Compare commits
66
Commits
122a03b23d
..
v1.4.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93571d9181 | ||
|
|
89af9876ae | ||
|
|
0471e0ca40 | ||
|
|
38207d2272 | ||
|
|
add9d8e0cd | ||
|
|
edc60582ec | ||
|
|
ccb7cafc68 | ||
|
|
0183bfb58c | ||
|
|
830d1e360c | ||
|
|
04728d7d94 | ||
|
|
e62ffed2b1 | ||
|
|
9d37043b3e | ||
|
|
6858cd064d | ||
|
|
f99670ceaa | ||
|
|
75b0e68b85 | ||
|
|
4a341331e2 | ||
|
|
422f2b6bcf | ||
|
|
d4021114cd | ||
|
|
fd6dfbe5b0 | ||
|
|
573d2f46c4 | ||
|
|
ef39674194 | ||
|
|
9973849408 | ||
|
|
d9db268b06 | ||
|
|
129c34b002 | ||
|
|
ebf30a679e | ||
|
|
09a8dd183d | ||
|
|
057c878831 | ||
|
|
43cbfa07f5 | ||
|
|
0e0967795e | ||
|
|
68b5372415 | ||
|
|
9e8f196b20 | ||
|
|
b8f0af9ef5 | ||
|
|
24e2bc33cf | ||
|
|
a7df91b92b | ||
|
|
0eb0188ba7 | ||
|
|
e2f595d558 | ||
|
|
18082d0df1 | ||
|
|
640502d5a8 | ||
|
|
270f9d88b3 | ||
|
|
6a0e61d415 | ||
|
|
92e3b41468 | ||
|
|
7d852419b5 | ||
|
|
9066433c29 | ||
|
|
c81a6e05cd | ||
|
|
0a9bdf08f6 | ||
|
|
2b74a9b21f | ||
|
|
b5a5138569 | ||
|
|
26423187d3 | ||
|
|
a94f78d090 | ||
|
|
14c4227292 | ||
|
|
fc3e1dd003 | ||
|
|
5090ddab6c | ||
|
|
3633882d6c | ||
|
|
ae27a097b9 | ||
|
|
5fdff5664f | ||
|
|
f8bea78db5 | ||
|
|
48bec4cc03 | ||
|
|
bfe88d2673 | ||
|
|
0d587d1154 | ||
|
|
67aba17173 | ||
|
|
45c12fc5ce | ||
|
|
3da8228068 | ||
|
|
974e886742 | ||
|
|
1f3f52d225 | ||
|
|
85347597cc | ||
|
|
bc04ee7bd2 |
@@ -39,29 +39,11 @@ jobs:
|
|||||||
# would always fail (no lockfile to lock against on a fresh runner).
|
# would always fail (no lockfile to lock against on a fresh runner).
|
||||||
- run: cargo test
|
- run: cargo test
|
||||||
|
|
||||||
# crates.io publish is an INDEPENDENT job: it serves EXTERNAL consumers only.
|
# NOTE: there is no crates.io publish job. libfreemkv is git-tag-only
|
||||||
# The freemkv binaries no longer depend on it (they git-tag-pin libfreemkv via
|
# (`package.publish = false` — it git-deps the firmware crate freemkv-unlock,
|
||||||
# a committed [patch.crates-io]), so this publish runs in parallel with their
|
# which never ships to crates.io). Every consumer git-tag-pins libfreemkv via
|
||||||
# release builds rather than gating them. It `needs: [verify, test]` so a
|
# a committed [patch.crates-io]; the git tag itself IS the release artifact.
|
||||||
# failing test suite still blocks publication to crates.io — external
|
# A `cargo publish` here fails hard on `publish = false`, so it was removed.
|
||||||
# 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 }}
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
# Only needs `verify`; the GitHub Release can be cut as soon as the version
|
# Only needs `verify`; the GitHub Release can be cut as soon as the version
|
||||||
|
|||||||
+208
-5
@@ -1,5 +1,208 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- **AACS 2.1 (FMTS) is a first-class disc format.** FMTS discs are detected,
|
||||||
|
labeled, and scanned as their own format rather than misread as plain UHD. The
|
||||||
|
forensic variant segments are located from `IndividualSegment.tbl` and the
|
||||||
|
`SegmentKey.tbl` container is parsed; the bulk of the title decrypts with the
|
||||||
|
unit key as usual, and the forensic segments (for which no segment-key source
|
||||||
|
exists yet) are skipped as expected loss, so a 2.1 disc rips mostly-complete
|
||||||
|
instead of failing outright.
|
||||||
|
- **AACS 2.1 variant Media Key chain runs end to end.** The variant media key is
|
||||||
|
derived as a clean Processing-Key to media-key primitive, with the record
|
||||||
|
layout pinned against reference variant MKBs — the per-slot `C` block from the
|
||||||
|
`0x0c` cvalue table, the `VARIANTS` table plus trailing nonce at `0x2d`, and
|
||||||
|
`VKD` at `0x2f` — so a genuine variant MKB resolves through the ladder.
|
||||||
|
- **Partial HD-DVD support.** HD-DVD is detected as its own format and its
|
||||||
|
`HVDVD_TS` `.evo` clips mux through the pipeline: EVO video is demuxed from the
|
||||||
|
MPEG program stream, including VC-1 titles carried on extended stream id `0xFD`
|
||||||
|
(real selector in the PES `stream_id_extension`), with the VC-1 access units
|
||||||
|
reframed so each I-frame keeps its preceding sequence and entry-point headers.
|
||||||
|
Title composition is heuristic for now (authoritative program-chain parsing is
|
||||||
|
planned), so a disc that authors two distinct features under the layer-break
|
||||||
|
naming may present them as one title.
|
||||||
|
- **Display-order timestamps for program-stream H.264 / VC-1 / HEVC.** A program
|
||||||
|
stream stamps a PES PTS only once per GOP; the parsers now reconstruct a
|
||||||
|
display-order PTS per frame from the coded picture type and the sparse anchor
|
||||||
|
(duration self-calibrated from anchor spacing), so a decoder no longer sees
|
||||||
|
colliding DTS. Gated to the program-stream path — the BD/UHD transport path
|
||||||
|
(per-frame PTS) is unchanged.
|
||||||
|
- **Stream-label parsers: reader-backed detection and a menu-language fallback.**
|
||||||
|
Label detection can inspect a jar's contents, so vendor parsers claim only
|
||||||
|
their own discs; a new last-resort parser reads menu-artwork languages.
|
||||||
|
- **keydb round-trips AACS 2.0 host certs** (the `HC2` line) so a load/save cycle
|
||||||
|
no longer drops v2 host credentials.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **MPEG-2 reassembles through the shared `AuAssembler`.** The MPEG-2 parser's
|
||||||
|
hand-rolled PES buffer and offset-keyed mark queues are replaced by the same
|
||||||
|
access-unit assembler the H.264/HEVC/VC-1 parsers use (in a new MPEG-2 mode);
|
||||||
|
the GOP-buffered `temporal_reference` reorder and PTS origin-locking are
|
||||||
|
unchanged, so DVD output is identical.
|
||||||
|
- **Generic per-scheme recovery seam.** Decrypt-miss handling is now a
|
||||||
|
scheme-neutral seam the input stream installs (no recovery, or an AACS
|
||||||
|
fresh-key fetch), with CSS self-recovering separately from the data itself. An
|
||||||
|
undecryptable unit is counted the same whatever the scheme, so the separate
|
||||||
|
"undecryptable" loss bucket folds into one loss count.
|
||||||
|
- **`aacs` module reorganized.** The former god-module is split into
|
||||||
|
`media_key` / `volume_key` / `inf` / `resolve` / `mkb` / `crypto` / `content`,
|
||||||
|
the `boil` veneer is removed, and module paths (not a `mod.rs` facade) are the
|
||||||
|
public API.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Main title is chosen by largest physical size, not clip count**, so a
|
||||||
|
chapter-per-clip disc (e.g. Fast & Furious) is no longer mis-ranked behind a
|
||||||
|
virtual composite.
|
||||||
|
- **A fresh-rip ISO `sync_all` failure is no longer swallowed**: `is_regular` is
|
||||||
|
read from the open file handle instead of a pre-create `metadata(path)` that
|
||||||
|
always failed on a path that does not exist yet.
|
||||||
|
- **A transient CLIP-info parse failure no longer suppresses a clip's extents**
|
||||||
|
for a later playlist item that references the same clip.
|
||||||
|
- **Reverify downgrades that fail to persist are logged, not swallowed**, so a
|
||||||
|
bad unit cannot be silently mismarked good on resume.
|
||||||
|
- **The CLI sanitizes on-disc metadata** (title, volume label, playlist, stream
|
||||||
|
labels) before printing, so a crafted disc cannot inject terminal escape
|
||||||
|
sequences.
|
||||||
|
- **keydb entry validation matches the parser exactly** — a `0x` line counts only
|
||||||
|
with a ` = ` — so content that parses to zero usable entries can no longer be
|
||||||
|
saved as valid.
|
||||||
|
- **autorip** recovers a poisoned config lock in the rip thread instead of
|
||||||
|
panicking it, and corrects the resume pass count.
|
||||||
|
- Criterion stream numbering (a map value of 0 no longer shadows stream 1); AACS
|
||||||
|
resolve classifies a media-keys-only source missing the VID as "VID
|
||||||
|
unavailable"; a dropped partial PES flags a discontinuity; the no-demuxer path
|
||||||
|
detects an early consumer disconnect.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- **Decrypt thread count is resolved once** and cached off the per-buffer hot
|
||||||
|
path (the env var and `available_parallelism` are no longer probed per call).
|
||||||
|
|
||||||
## [1.2.2] — 2026-07-04
|
## [1.2.2] — 2026-07-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -39,7 +242,7 @@
|
|||||||
|
|
||||||
- **Processing-Key resolution is ~15× faster on UHD.** A Processing Key is the
|
- **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
|
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
|
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
|
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
|
drops from ~37 s to ~2.4 s; the SD tree walk now lives solely in the device-key
|
||||||
@@ -272,7 +475,7 @@ consumers are the in-tree toolchain crates.
|
|||||||
|
|
||||||
- **Post-read decrypt-verify gate.** Every AACS unit read off the disc is now
|
- **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
|
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
|
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
|
decrypts is treated exactly like a bad read — re-read by
|
||||||
the patch pass, terminal loss only if truly unrecoverable — closing the
|
the patch pass, terminal loss only if truly unrecoverable — closing the
|
||||||
@@ -293,7 +496,7 @@ consumers are the in-tree toolchain crates.
|
|||||||
|
|
||||||
- **AACS decrypt acceptance is now standards-strict.** A key is accepted only
|
- **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
|
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.
|
could coincidentally restore enough syncs to pass and silently corrupt a unit.
|
||||||
- keydb download/save moved out of the library into freemkv-keysources;
|
- keydb download/save moved out of the library into freemkv-keysources;
|
||||||
libfreemkv no longer has any keydb I/O (it already held no keys).
|
libfreemkv no longer has any keydb I/O (it already held no keys).
|
||||||
@@ -301,7 +504,7 @@ consumers are the in-tree toolchain crates.
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **AACS content-certificate bus-encryption flag read from the wrong bit.** The
|
- **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
|
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
|
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
|
obtained. Also corrected the cc_id offset (byte 14) and the AACS2 type marker
|
||||||
@@ -601,7 +804,7 @@ hardening.
|
|||||||
|
|
||||||
- **Keyless DVD/CSS title-key recovery.** A CSS-protected DVD decrypts with no
|
- **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
|
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
|
validated by descrambling a sector and confirming the known plaintext
|
||||||
reappears, so a wrong key fails cleanly instead of producing silent garbage
|
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
|
(`src/css/stevenson.rs`). `Disc::scan_image` recovers the same title key from
|
||||||
|
|||||||
+1
-1
@@ -24,4 +24,4 @@ cargo test
|
|||||||
|
|
||||||
## License
|
## 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
@@ -1,9 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "1.2.2"
|
version = "1.4.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
license = "AGPL-3.0-only"
|
license = "MIT"
|
||||||
description = "Open source raw disc access library for optical drives"
|
description = "Open source raw disc access library for optical drives"
|
||||||
repository = "https://github.com/freemkv/libfreemkv"
|
repository = "https://github.com/freemkv/libfreemkv"
|
||||||
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
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
|
# 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
|
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||||
freemkv-unlock = { path = "../freemkv-unlock" }
|
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.4" }
|
||||||
num-bigint = "0.4"
|
num-bigint = "0.4"
|
||||||
num-traits = "0.2"
|
num-traits = "0.2"
|
||||||
num-integer = "0.1"
|
num-integer = "0.1"
|
||||||
@@ -36,6 +36,12 @@ rand = "0.8"
|
|||||||
cmac = "0.7"
|
cmac = "0.7"
|
||||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
base64 = "0.22.1"
|
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
|
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||||
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
MIT License
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
Copyright (C) 2026 FreeMKV Contributors
|
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
it under the terms of the GNU Affero General Public License as published
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
by the Free Software Foundation, version 3 of the License.
|
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,
|
The above copyright notice and this permission notice shall be included in all
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
copies or substantial portions of the Software.
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
# libfreemkv
|
# libfreemkv
|
||||||
|
|
||||||
@@ -190,4 +190,4 @@ Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/fre
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
AGPL-3.0-only
|
MIT
|
||||||
|
|||||||
@@ -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.
|
material is compiled in; DVD CSS player keys are the only compiled-in keys.
|
||||||
|
|
||||||
**Repository:** <https://github.com/freemkv/libfreemkv>
|
**Repository:** <https://github.com/freemkv/libfreemkv>
|
||||||
**License:** AGPL-3.0-only
|
**License:** MIT
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+603
-726
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -69,7 +69,7 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
|||||||
///
|
///
|
||||||
/// The Media Key Variant chain uses AES-G to derive both the variant
|
/// The Media Key Variant chain uses AES-G to derive both the variant
|
||||||
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
|
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
|
||||||
/// (`Kvu = AES-G(Km, VID)`). See [`super::keys::derive_vuk`] for the
|
/// (`Kvu = AES-G(Km, VID)`). See [`super::derive::derive_vuk`] for the
|
||||||
/// classical VUK form — the math is identical, this exposes it as a
|
/// classical VUK form — the math is identical, this exposes it as a
|
||||||
/// neutral primitive for the variant chain.
|
/// neutral primitive for the variant chain.
|
||||||
pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
||||||
@@ -89,7 +89,7 @@ pub(crate) const AESG3_SEED: [u8; 16] = [
|
|||||||
/// left=`D(k,s0)⊕s0` inc 0, pk=`D(k,s0+1)⊕(s0+1)` inc 1, right=`D(k,s0+2)⊕(s0+2)` inc 2).
|
/// left=`D(k,s0)⊕s0` inc 0, pk=`D(k,s0+1)⊕(s0+1)` inc 1, right=`D(k,s0+2)⊕(s0+2)` inc 2).
|
||||||
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
|
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
|
||||||
///
|
///
|
||||||
/// Shared with [`super::variants`] (its variant chain runs the same SD
|
/// Shared with [`super::variant`] (its variant chain runs the same SD
|
||||||
/// tree); a single definition keeps the two walks byte-identical.
|
/// tree); a single definition keeps the two walks byte-identical.
|
||||||
pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||||
let mut seed = AESG3_SEED;
|
let mut seed = AESG3_SEED;
|
||||||
|
|||||||
+18
-18
@@ -10,8 +10,8 @@ use super::types::*;
|
|||||||
///
|
///
|
||||||
/// A Processing Key is **terminal**: it is the key at its Subset-Difference
|
/// 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
|
/// 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
|
/// tried *directly* against the MKB cvalue tables (no tree descent) — the
|
||||||
/// libaacs `_calc_mk_pks` (iterate PKs × cvalues). On a large AACS 2.x UHD MKB
|
/// 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
|
/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node
|
||||||
/// label and walking the tree.
|
/// label and walking the tree.
|
||||||
///
|
///
|
||||||
@@ -105,7 +105,7 @@ pub(crate) fn validate_processing_key(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute v_mask from a UV value. [C] §3.2.3. Shared with [`super::variants`].
|
/// Compute v_mask from a UV value. [C] §3.2.3. Shared with [`super::variant`].
|
||||||
pub(super) fn calc_v_mask(uv: u32) -> u32 {
|
pub(super) fn calc_v_mask(uv: u32) -> u32 {
|
||||||
let mut v_mask: u32 = 0xFFFF_FFFF;
|
let mut v_mask: u32 = 0xFFFF_FFFF;
|
||||||
while (uv & !v_mask) == 0 && v_mask != 0 {
|
while (uv & !v_mask) == 0 && v_mask != 0 {
|
||||||
@@ -115,7 +115,7 @@ pub(super) fn calc_v_mask(uv: u32) -> u32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Derive processing key from device key using subset-difference tree traversal.
|
/// Derive processing key from device key using subset-difference tree traversal.
|
||||||
/// [C] §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variants`].
|
/// [C] §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variant`].
|
||||||
pub(super) fn calc_pk_from_dk(
|
pub(super) fn calc_pk_from_dk(
|
||||||
dk: &[u8; 16],
|
dk: &[u8; 16],
|
||||||
uv: u32,
|
uv: u32,
|
||||||
@@ -203,13 +203,14 @@ pub fn derive_media_key_and_pk_from_dk(
|
|||||||
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
||||||
let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value
|
let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value
|
||||||
|
|
||||||
if u_mask_shift & 0xC0 != 0 {
|
// `num_uvs` was computed via `take_while(.. c[0] & 0xC0 == 0)`, so
|
||||||
break; // device revoked
|
// every iterated slot already has its revoked-marker bits clear — no
|
||||||
}
|
// inner `& 0xC0` re-check is needed (it would be unreachable).
|
||||||
// Shifts of 32..=63 (0x20..=0x3F pass the 0xC0 mask above) would
|
//
|
||||||
// panic in debug / wrap to a wrong mask in release. The MKB byte
|
// Shifts of 32..=63 (0x20..=0x3F) have those bits clear but would
|
||||||
// is disc-controlled, so a crafted/corrupt MKB must not crash the
|
// panic in debug / wrap to a wrong mask in release. The MKB byte is
|
||||||
// ripper: skip an out-of-range slot rather than `<<` it.
|
// disc-controlled, so a crafted/corrupt MKB must not crash the ripper:
|
||||||
|
// skip an out-of-range slot rather than `<<` it.
|
||||||
if u_mask_shift >= 32 {
|
if u_mask_shift >= 32 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -366,6 +367,7 @@ pub(crate) fn resolve_dk_node(
|
|||||||
/// independent reproduction harnesses (e.g. `examples/prove_hkd_aacs.rs`) can
|
/// independent reproduction harnesses (e.g. `examples/prove_hkd_aacs.rs`) can
|
||||||
/// exercise the exact same parser + verify primitives the production walk uses.
|
/// exercise the exact same parser + verify primitives the production walk uses.
|
||||||
/// These are thin wrappers — no new logic.
|
/// These are thin wrappers — no new logic.
|
||||||
|
#[doc(hidden)]
|
||||||
pub mod probe {
|
pub mod probe {
|
||||||
use super::super::crypto::aes_ecb_decrypt;
|
use super::super::crypto::aes_ecb_decrypt;
|
||||||
|
|
||||||
@@ -461,7 +463,7 @@ pub enum KeyCandidate {
|
|||||||
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
/// 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
|
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
|
||||||
/// its declared CPS-unit number); the caller runs
|
/// 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`.
|
/// disc. Rungs above the candidate are `None`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ResolvedChain {
|
pub struct ResolvedChain {
|
||||||
@@ -485,7 +487,7 @@ pub struct ResolvedChain {
|
|||||||
///
|
///
|
||||||
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
|
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
|
||||||
/// `unit_keys` against a real encrypted unit with
|
/// `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
|
/// 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
|
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
||||||
@@ -503,7 +505,8 @@ pub fn resolve_candidate(
|
|||||||
let version = mkb_type(mkb)
|
let version = mkb_type(mkb)
|
||||||
.map(|t| t.generation())
|
.map(|t| t.generation())
|
||||||
.unwrap_or(AacsVersion::V10);
|
.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() {
|
if ukf.encrypted_keys.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -610,10 +613,7 @@ mod resolve_candidate_tests {
|
|||||||
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
|
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_candidate_uk_is_itself() {
|
fn resolve_candidate_uk_is_itself() {
|
||||||
let uk = UnitKey {
|
let uk = UnitKey::new(2, [0x9u8; 16]);
|
||||||
idx: 2,
|
|
||||||
key: [0x9u8; 16],
|
|
||||||
};
|
|
||||||
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal");
|
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal");
|
||||||
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||||
assert!(r.vuk.is_none() && r.mk.is_none());
|
assert!(r.vuk.is_none() && r.mk.is_none());
|
||||||
|
|||||||
@@ -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
@@ -121,7 +121,7 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
|||||||
return None;
|
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
|
// 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,
|
// 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.
|
// 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.
|
/// MKB disc structure format code.
|
||||||
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
||||||
|
|
||||||
@@ -261,10 +342,10 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
|||||||
return None;
|
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)
|
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||||||
// [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`)
|
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
|
||||||
// [14..20] cc_id (6 bytes) (libaacs: `p + 14`)
|
// [14..20] cc_id (6 bytes) (`p + 14`)
|
||||||
let version = if data[0] == 0x00 {
|
let version = if data[0] == 0x00 {
|
||||||
AacsVersion::V10
|
AacsVersion::V10
|
||||||
} else {
|
} else {
|
||||||
@@ -283,3 +364,94 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
|||||||
version,
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+124
-14
@@ -2,10 +2,36 @@
|
|||||||
//!
|
//!
|
||||||
//! The MKB record format (framing walker, the `MkbRecord` view, record-body
|
//! The MKB record format (framing walker, the `MkbRecord` view, record-body
|
||||||
//! finders), the MKBType / AACS-generation classification, and MKB-file
|
//! finders), the MKBType / AACS-generation classification, and MKB-file
|
||||||
//! utilities (content length, trimming, version). Consolidated here from the
|
//! utilities (content length, trimming, version). Consolidated here so the one
|
||||||
//! former `keys.rs` / `variant.rs` so the one place that understands MKB bytes
|
//! place that understands MKB bytes is `mkb`. Some duplicate record finders
|
||||||
//! is `mkb`. A follow-up collapses the remaining duplicate finders (see the
|
//! still live side by side pending a follow-up that collapses them.
|
||||||
//! private refactor notes) — for now both dialects live here side by side.
|
|
||||||
|
// ── MKB record types ([C] Chapter 3) ──────────────────────────────────────
|
||||||
|
// The ONE canonical set. Every record-type comparison in the `aacs` module
|
||||||
|
// references these, so a type byte is never a bare literal scattered across
|
||||||
|
// files (the `0x0c` variant-data record in particular used to appear in several
|
||||||
|
// hand-rolled forms).
|
||||||
|
|
||||||
|
/// Type-and-Version — carries the 32-bit MKBType / AACS generation.
|
||||||
|
pub(crate) const REC_TYPE_AND_VERSION: u8 = 0x10;
|
||||||
|
/// Subset-Difference index — the per-slot `(u_mask_shift, uv)` table.
|
||||||
|
pub(crate) const REC_SUBSET_DIFFERENCE: u8 = 0x04;
|
||||||
|
/// Media Key Data — the classical (1.0 / 2.0) per-subset cvalue table.
|
||||||
|
pub(crate) const REC_MEDIA_KEY_DATA: u8 = 0x05;
|
||||||
|
/// Explicit Subset-Difference — the smaller cvalue table some MKBs use.
|
||||||
|
pub(crate) const REC_EXPLICIT_SUBSET_DIFF: u8 = 0x07;
|
||||||
|
/// Media Key Variant Data (AACS 2.1) — the per-subset-difference `C` table
|
||||||
|
/// (one 16-byte C per slot); the `Kmp` step reads C from HERE, not `0x2d`.
|
||||||
|
pub(crate) const REC_MEDIA_KEY_VARIANT_DATA: u8 = 0x0c;
|
||||||
|
/// Variant Data + Nonce (AACS 2.1) — the `VARIANTS[uv]` table (leading bytes)
|
||||||
|
/// with the 16-byte `Kvn` Nonce at the tail.
|
||||||
|
pub(crate) const REC_VARIANT_DATA_AND_NONCE: u8 = 0x2d;
|
||||||
|
/// Variant Key Data table (AACS 2.1) — 65,535×16, indexed by the resolved VKD index.
|
||||||
|
pub(crate) const REC_VKD_TABLE: u8 = 0x2f;
|
||||||
|
/// Verify-Media-Key — AACS 1.0.
|
||||||
|
pub(crate) const REC_VERIFY_MEDIA_KEY_V1: u8 = 0x81;
|
||||||
|
/// Verify-Media-Key — AACS 2.x.
|
||||||
|
pub(crate) const REC_VERIFY_MEDIA_KEY_V2: u8 = 0x86;
|
||||||
|
|
||||||
/// A single MKB record produced by [`walk_mkb`].
|
/// A single MKB record produced by [`walk_mkb`].
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -41,7 +67,7 @@ pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
|
|||||||
/// then the body — stopping at the `00 000000` end marker or a
|
/// then the body — stopping at the `00 000000` end marker or a
|
||||||
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
|
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
|
||||||
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
|
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
|
||||||
/// MKB record walk in `aacs::keys` are built on this, so the framing rules — and
|
/// MKB record walk in `aacs::resolve`/`aacs::derive` are built on this, so the framing rules — and
|
||||||
/// any future fix to them — live in exactly one place (they had drifted across
|
/// any future fix to them — live in exactly one place (they had drifted across
|
||||||
/// six hand-rolled copies).
|
/// six hand-rolled copies).
|
||||||
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
|
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
|
||||||
@@ -131,11 +157,13 @@ impl AacsVersion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
/// 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]> {
|
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
|
// 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.
|
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
|
||||||
let found = mkb_records(mkb).find(|&(_, rt, len)| (rt == 0x81 || rt == 0x86) && len >= 20);
|
let found = mkb_records(mkb).find(|&(_, rt, len)| {
|
||||||
|
(rt == REC_VERIFY_MEDIA_KEY_V1 || rt == REC_VERIFY_MEDIA_KEY_V2) && len >= 20
|
||||||
|
});
|
||||||
match found {
|
match found {
|
||||||
Some((o, rec_type, rec_len)) => {
|
Some((o, rec_type, rec_len)) => {
|
||||||
let mut dv = [0u8; 16];
|
let mut dv = [0u8; 16];
|
||||||
@@ -170,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
|
/// 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
|
/// 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
|
/// 5-byte Subset-Difference index entries in record `0x04` — the standard AACS
|
||||||
/// libaacs, whose `mkb_cvalues()` reads `0x05` and `mkb_subdiff_records()`
|
/// MKB layout (`0x05` cvalues 1:1 with the `0x04` subset-difference index).
|
||||||
/// reads `0x04`.
|
|
||||||
///
|
///
|
||||||
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
|
/// 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
|
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
|
||||||
@@ -240,7 +267,7 @@ pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
|||||||
// Type-and-Version record (0x10): version is the BE u32 at body offset 4
|
// Type-and-Version record (0x10): version is the BE u32 at body offset 4
|
||||||
// (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version).
|
// (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version).
|
||||||
mkb_records(mkb)
|
mkb_records(mkb)
|
||||||
.find(|&(_, rt, len)| rt == 0x10 && len >= 12)
|
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 12)
|
||||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
|
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,10 +280,10 @@ pub const MKB_TYPE_4_PRERECORDED: u32 = 0x0004_1003;
|
|||||||
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
|
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
|
||||||
pub const MKB_TYPE_10_CLASS_II: u32 = 0x000A_1003;
|
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;
|
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;
|
pub const MKB_21_CATEGORY_C: u32 = 0x4815_1003;
|
||||||
|
|
||||||
/// The AACS MKB Type field, decoded.
|
/// The AACS MKB Type field, decoded.
|
||||||
@@ -309,7 +336,7 @@ pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
|
|||||||
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
|
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
|
||||||
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
|
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
|
||||||
mkb_records(mkb)
|
mkb_records(mkb)
|
||||||
.find(|&(_, rt, len)| rt == 0x10 && len >= 8)
|
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 8)
|
||||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]]))
|
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,3 +350,86 @@ pub fn mkb_type(mkb: &[u8]) -> Option<MkbType> {
|
|||||||
pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
|
pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
|
||||||
mkb_type(mkb).map(MkbType::is_uhd)
|
mkb_type(mkb).map(MkbType::is_uhd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// One MKB record: 1 type byte + big-endian 24-bit total length + body.
|
||||||
|
fn rec(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||||
|
let len = 4 + body.len();
|
||||||
|
let mut v = vec![rec_type, (len >> 16) as u8, (len >> 8) as u8, len as u8];
|
||||||
|
v.extend_from_slice(body);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type-and-Version record (0x10): body = 4-byte MKBType + 4-byte version.
|
||||||
|
fn type_and_version(mkb_type: u32, version: u32) -> Vec<u8> {
|
||||||
|
let mut body = mkb_type.to_be_bytes().to_vec();
|
||||||
|
body.extend_from_slice(&version.to_be_bytes());
|
||||||
|
rec(REC_TYPE_AND_VERSION, &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walker_frames_records_and_stops_at_end_marker() {
|
||||||
|
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 77);
|
||||||
|
mkb.extend(rec(REC_VKD_TABLE, &[0xAA; 16]));
|
||||||
|
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||||
|
mkb.extend(rec(0x99, &[0xFF; 8])); // must NOT be walked (past the marker)
|
||||||
|
|
||||||
|
let recs = walk_mkb(&mkb);
|
||||||
|
assert_eq!(recs.len(), 2, "walk stops at the 00 000000 end marker");
|
||||||
|
assert_eq!(recs[0].rec_type, REC_TYPE_AND_VERSION);
|
||||||
|
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||||
|
assert_eq!(recs[1].body, vec![0xAA; 16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walker_stops_on_malformed_or_out_of_bounds_length() {
|
||||||
|
// A record whose declared length runs past the buffer end must terminate
|
||||||
|
// the walk rather than panic or read OOB.
|
||||||
|
let mkb = vec![REC_VKD_TABLE, 0x00, 0xFF, 0xFF, 0x01, 0x02]; // len=0xFFFF, only 6 bytes
|
||||||
|
assert!(
|
||||||
|
walk_mkb(&mkb).is_empty(),
|
||||||
|
"over-long record yields no records"
|
||||||
|
);
|
||||||
|
// A sub-4 length (shorter than the header itself) is also rejected.
|
||||||
|
let short = vec![REC_VKD_TABLE, 0x00, 0x00, 0x02];
|
||||||
|
assert!(walk_mkb(&short).is_empty(), "sub-4 length is rejected");
|
||||||
|
// A truncated header (< 4 bytes) yields nothing.
|
||||||
|
assert!(walk_mkb(&[0x10, 0x00]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mkb_type_and_version_decode_from_the_type_record() {
|
||||||
|
let mut mkb = type_and_version(MKB_21_CATEGORY_C, 100);
|
||||||
|
mkb.extend([0x00, 0x00, 0x00, 0x00]);
|
||||||
|
assert_eq!(mkb_type_raw(&mkb), Some(MKB_21_CATEGORY_C));
|
||||||
|
assert_eq!(mkb_version(&mkb), Some(100));
|
||||||
|
assert_eq!(mkb_is_uhd(&mkb), Some(true), "2.1 Category C is UHD");
|
||||||
|
|
||||||
|
let bd = type_and_version(MKB_TYPE_4_PRERECORDED, 68);
|
||||||
|
assert_eq!(
|
||||||
|
mkb_is_uhd(&bd),
|
||||||
|
Some(false),
|
||||||
|
"AACS 1.0 prerecorded is not UHD"
|
||||||
|
);
|
||||||
|
// No Type record → None (not a panic, not a fabricated value).
|
||||||
|
assert_eq!(mkb_version(&rec(REC_VKD_TABLE, &[0; 16])), None);
|
||||||
|
assert_eq!(mkb_type_raw(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trim_mkb_keeps_only_the_framed_records() {
|
||||||
|
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 1);
|
||||||
|
let content_len = mkb.len(); // the single framed record, no end marker
|
||||||
|
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||||
|
mkb.extend([0xDE; 4096]); // trailing padding past the end marker
|
||||||
|
let trimmed = trim_mkb(mkb);
|
||||||
|
assert_eq!(
|
||||||
|
trimmed.len(),
|
||||||
|
content_len,
|
||||||
|
"trim keeps the framed records, dropping the end marker and padding"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+64
-13
@@ -22,25 +22,36 @@
|
|||||||
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
|
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
|
||||||
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
|
//! - `[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).
|
//! - `[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
|
//! - `[RE]` — reverse-engineered from real discs, cited only where the public
|
||||||
//! is silent (the `0x86` verify record and the Category-C MKBType names).
|
//! spec is silent (the `0x86` verify record and the Category-C MKB type values).
|
||||||
|
|
||||||
pub mod content;
|
pub mod content;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod derive;
|
pub mod derive;
|
||||||
pub mod host_certs;
|
pub mod host_certs;
|
||||||
|
pub mod index_select;
|
||||||
pub mod inf;
|
pub mod inf;
|
||||||
pub mod mkb;
|
pub mod mkb;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod resolve;
|
pub mod resolve;
|
||||||
|
pub mod segment;
|
||||||
|
pub mod segment_key;
|
||||||
pub mod trace;
|
pub mod trace;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod variant;
|
pub mod variant;
|
||||||
|
|
||||||
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
|
/// On-disc UDF paths to the AACS key-input files.
|
||||||
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
|
///
|
||||||
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
|
/// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps the
|
||||||
/// or changing a fallback in one place can then never silently diverge 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.
|
/// 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: &str = "/AACS/Unit_Key_RO.inf";
|
||||||
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
||||||
@@ -48,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_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
||||||
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||||||
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.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
|
// The module structure IS the public API — consumers import from the owning
|
||||||
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
||||||
@@ -58,7 +109,7 @@ pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
|||||||
// content-decrypt entry points that downstream key-source crates import through
|
// content-decrypt entry points that downstream key-source crates import through
|
||||||
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
|
// 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.
|
// 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 derive::derive_vuk;
|
||||||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||||||
|
|
||||||
@@ -68,7 +119,7 @@ mod tests {
|
|||||||
//! Touching one representative item per module keeps these as a
|
//! Touching one representative item per module keeps these as a
|
||||||
//! compile-time contract that the module paths stay stable.
|
//! 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::inf::{disc_hash, disc_hash_hex};
|
||||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||||
use super::variant::is_variant_mkb;
|
use super::variant::is_variant_mkb;
|
||||||
@@ -96,15 +147,15 @@ mod tests {
|
|||||||
fn public_helpers_are_callable_by_module_path() {
|
fn public_helpers_are_callable_by_module_path() {
|
||||||
// Touch a representative function from each module so a dropped/renamed
|
// Touch a representative function from each module so a dropped/renamed
|
||||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
// 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 _ = mkb_content_len(&[]);
|
||||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||||
let _ = super::derive::resolve_candidate(
|
let _ = super::derive::resolve_candidate(
|
||||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey {
|
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
|
||||||
idx: 0,
|
|
||||||
key: [0u8; 16],
|
|
||||||
}),
|
|
||||||
&[],
|
&[],
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ use super::types::{DeviceKey, DiscEntry, HostCert};
|
|||||||
/// Source of AACS key material.
|
/// Source of AACS key material.
|
||||||
///
|
///
|
||||||
/// Implementors return raw material only — the resolver in
|
/// Implementors return raw material only — the resolver in
|
||||||
/// `aacs::keys` owns all the crypto (DK→PK walking, PK validation,
|
/// `aacs::resolve` and `aacs::derive` own the crypto (DK→PK walking, PK validation,
|
||||||
/// MK→VUK→TK derivation). See module docs for method semantics.
|
/// MK→VUK→TK derivation). See module docs for method semantics.
|
||||||
pub trait KeyProvider: Send + Sync {
|
pub trait KeyProvider: Send + Sync {
|
||||||
/// Device keys (top-of-tree, walked by the resolver).
|
/// Device keys (top-of-tree, walked by the resolver).
|
||||||
|
|||||||
+21
-16
@@ -8,7 +8,7 @@ use super::mkb::*;
|
|||||||
//
|
//
|
||||||
// Canonical form is `<category>1003` (low 16 bits `0x1003` is a fixed marker).
|
// 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,
|
// 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 ───────────────────────────────────────────────
|
// ── Full VUK resolution chain ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -106,8 +106,12 @@ pub fn resolve_keys_with_reason(
|
|||||||
pub(crate) fn classify_resolve_failure(ctx: &ResolveContext<'_>) -> ResolveFailure {
|
pub(crate) fn classify_resolve_failure(ctx: &ResolveContext<'_>) -> ResolveFailure {
|
||||||
let has_vid = *ctx.volume_id != [0u8; 16];
|
let has_vid = *ctx.volume_id != [0u8; 16];
|
||||||
let providers = super::provider::Providers(ctx.providers);
|
let providers = super::provider::Providers(ctx.providers);
|
||||||
let has_derivation_material =
|
// Media keys are also derivation material: with an MK you can derive the VUK
|
||||||
!providers.device_keys().is_empty() || !providers.processing_keys().is_empty();
|
// once you have the VID, so a media-keys-only provider that is merely missing
|
||||||
|
// the VID is VidUnavailable, not NoMaterial.
|
||||||
|
let has_derivation_material = !providers.device_keys().is_empty()
|
||||||
|
|| !providers.processing_keys().is_empty()
|
||||||
|
|| !providers.media_keys().is_empty();
|
||||||
if !has_vid && has_derivation_material {
|
if !has_vid && has_derivation_material {
|
||||||
ResolveFailure::VidUnavailable
|
ResolveFailure::VidUnavailable
|
||||||
} else {
|
} else {
|
||||||
@@ -152,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
|
/// equivalent of path 2 — there's no host-side PK derivation against a
|
||||||
/// Variant MKB.)
|
/// Variant MKB.)
|
||||||
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
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 hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||||
let bus_encryption = ctx
|
let bus_encryption = ctx
|
||||||
.content_cert
|
.content_cert
|
||||||
@@ -276,8 +280,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
|||||||
.map(|cc| cc.bus_encryption)
|
.map(|cc| cc.bus_encryption)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Parse Unit_Key_RO.inf at the version-appropriate stride.
|
// Parse the disc's title-key file (BD/UHD Unit_Key_RO.inf at the
|
||||||
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
|
// 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 hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||||
let has_vid = *ctx.volume_id != [0u8; 16];
|
let has_vid = *ctx.volume_id != [0u8; 16];
|
||||||
@@ -786,7 +791,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validate_processing_key_round_trip_with_nonzero_uv() {
|
fn validate_processing_key_round_trip_with_nonzero_uv() {
|
||||||
// Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the
|
// 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:
|
// recovers mk. Catches the bugs that landed pre-fix:
|
||||||
// * uv XOR step was missing → mk wrong whenever uv != 0
|
// * uv XOR step was missing → mk wrong whenever uv != 0
|
||||||
// * AES-128E + 12-zero check instead of AES-128D + magic
|
// * AES-128E + 12-zero check instead of AES-128D + magic
|
||||||
@@ -840,8 +845,8 @@ mod tests {
|
|||||||
// ── MKB cvalue-record selection (issue #259 / #281) ─────────────────
|
// ── MKB cvalue-record selection (issue #259 / #281) ─────────────────
|
||||||
//
|
//
|
||||||
// The cvalue (Media Key Data) table is record 0x05; the
|
// The cvalue (Media Key Data) table is record 0x05; the
|
||||||
// Subset-Difference index is record 0x04. This matches libaacs
|
// Subset-Difference index is record 0x04 (the standard AACS MKB layout:
|
||||||
// (`mkb_cvalues` → 0x05, `mkb_subdiff_records` → 0x04). Record 0x07
|
// 0x05 = cvalues, 0x04 = subset-difference index). Record 0x07
|
||||||
// (Explicit Subset-Difference Record) is NOT the cvalue table. On real
|
// (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
|
// 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
|
// table is large (181270 entries, 1:1 with 0x04). An earlier
|
||||||
@@ -1260,7 +1265,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn test_content_cert_parse() {
|
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.
|
// BIT 7 of byte 1, cc_id at bytes 14..20.
|
||||||
let mut data = vec![0u8; 20];
|
let mut data = vec![0u8; 20];
|
||||||
data[0] = 0x00; // AACS 1.0
|
data[0] = 0x00; // AACS 1.0
|
||||||
@@ -1341,7 +1346,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn stride_v10_is_48_v20_is_64_and_picks_distinct_keys() {
|
fn stride_v10_is_48_v20_is_64_and_picks_distinct_keys() {
|
||||||
// AACS 1.0 stride = 48, AACS 2.0/2.1 stride = 64 (keys.rs:30-35).
|
// AACS 1.0 stride = 48, AACS 2.0/2.1 stride = 64 (aacs/inf.rs).
|
||||||
// Lay keys at 64-byte stride. Parsing at V20 stride must pick exactly
|
// Lay keys at 64-byte stride. Parsing at V20 stride must pick exactly
|
||||||
// those keys; parsing the SAME bytes at V10 (48) stride would read the
|
// those keys; parsing the SAME bytes at V10 (48) stride would read the
|
||||||
// wrong (intermediate) bytes for key 2 onward — proving the stride
|
// wrong (intermediate) bytes for key 2 onward — proving the stride
|
||||||
@@ -1443,7 +1448,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_unit_key_ro_cps_unit_numbers_are_1_based() {
|
fn parse_unit_key_ro_cps_unit_numbers_are_1_based() {
|
||||||
// The disc's CPS unit numbers are emitted as (i+1) — keys.rs:162.
|
// The disc's CPS unit numbers are emitted as (i+1) — aacs/inf.rs.
|
||||||
let data = build_unit_key_ro(3, 48);
|
let data = build_unit_key_ro(3, 48);
|
||||||
let p = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
|
let p = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1456,7 +1461,7 @@ mod tests {
|
|||||||
// [20..22] first_play, [22..24] top_menu, [24..26] num_titles, then
|
// [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
|
// 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
|
// 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
|
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[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
|
data[22..24].copy_from_slice(&2u16.to_be_bytes()); // top_menu CPS 2
|
||||||
@@ -1576,7 +1581,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_content_cert_extracts_cc_id_and_nonzero_type_is_v20() {
|
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.
|
// non-0x00 type → V20.
|
||||||
let mut data = vec![0u8; 20];
|
let mut data = vec![0u8; 20];
|
||||||
data[0] = 0x10; // AACS2 type marker → V20
|
data[0] = 0x10; // AACS2 type marker → V20
|
||||||
@@ -1589,7 +1594,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_content_cert_bus_encryption_reads_bit7() {
|
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.
|
// clear → false; bit7 set → true. Pins the bit, not a truthiness of the byte.
|
||||||
let mut data = vec![0u8; 20];
|
let mut data = vec![0u8; 20];
|
||||||
data[1] = 0x7F; // bits 0..6 set, bit 7 clear
|
data[1] = 0x7F; // bits 0..6 set, bit 7 clear
|
||||||
@@ -1932,7 +1937,7 @@ mod tests {
|
|||||||
//
|
//
|
||||||
// The rc.6 E7017/E7022 split is also exercised end-to-end through the
|
// The rc.6 E7017/E7022 split is also exercised end-to-end through the
|
||||||
// `ensure_decryptable` gate in `disc/mod.rs`. These tests pin the
|
// `ensure_decryptable` gate in `disc/mod.rs`. These tests pin the
|
||||||
// *classifier* directly at the keys.rs seam and cover the branches the
|
// *classifier* directly at the aacs::resolve seam and cover the branches the
|
||||||
// gate test does not: VID-present (must never be VidUnavailable), the
|
// gate test does not: VID-present (must never be VidUnavailable), the
|
||||||
// processing-keys-only material path, and the version dispatch / Ok path.
|
// processing-keys-only material path, and the version dispatch / Ok path.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! 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 index | u16 flag (= 1)
|
||||||
|
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||||
|
//! ```
|
||||||
|
//! `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 the forensic index keys.
|
||||||
|
///
|
||||||
|
/// `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
|
||||||
|
/// ([`Error::FmtsKeyMissing`]) — the same policy as a missing unit key, so a
|
||||||
|
/// forensic-holed rip is refused rather than produced. No segment-key source
|
||||||
|
/// exists yet, so `true` is the only value under which a 2.1 disc rips at all;
|
||||||
|
/// flip to `false` once segment keys can be sourced and a partial rip should be
|
||||||
|
/// refused. Hardcoded on purpose — not a user setting.
|
||||||
|
///
|
||||||
|
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
|
||||||
|
pub const BYPASS_FMTS_KEY: bool = false;
|
||||||
|
|
||||||
|
/// One forensic segment: the inclusive source-packet range it occupies in the
|
||||||
|
/// FMTS clip.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Segment {
|
||||||
|
/// 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).
|
||||||
|
pub end_spn: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Segment {
|
||||||
|
/// Source-packet count in this (inclusive) segment.
|
||||||
|
pub fn packet_count(&self) -> u32 {
|
||||||
|
self.end_spn
|
||||||
|
.saturating_sub(self.start_spn)
|
||||||
|
.saturating_add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte offset of the segment start within the clip (`start_spn * 192`).
|
||||||
|
pub fn start_byte(&self) -> u64 {
|
||||||
|
self.start_spn as u64 * SOURCE_PACKET_LEN
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte length of the segment (`packet_count * 192`).
|
||||||
|
pub fn byte_len(&self) -> u64 {
|
||||||
|
self.packet_count() as u64 * SOURCE_PACKET_LEN
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when source packet `spn` falls inside this segment.
|
||||||
|
pub fn contains_spn(&self, spn: u32) -> bool {
|
||||||
|
spn >= self.start_spn && spn <= self.end_spn
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the inclusive source-packet span `[first, last]` overlaps this
|
||||||
|
/// segment. Used to decide whether an aligned unit (which spans several
|
||||||
|
/// packets) touches the segment at all, not just whether one packet does.
|
||||||
|
pub fn overlaps_spn(&self, first: u32, last: u32) -> bool {
|
||||||
|
first <= self.end_spn && last >= self.start_spn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source packets spanned by one AACS aligned unit: `6144 / 192 = 32`.
|
||||||
|
pub const PACKETS_PER_UNIT: u32 =
|
||||||
|
(crate::aacs::content::ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32;
|
||||||
|
|
||||||
|
/// Byte offset within the clip of a clip-relative 2048-byte sector `lba`. The
|
||||||
|
/// FMTS decode reads the clip file directly, so `lba` 0 is the clip's first
|
||||||
|
/// byte and this offset lines up with the source-packet grid the segment map
|
||||||
|
/// uses.
|
||||||
|
pub fn lba_byte_offset(lba: u32) -> u64 {
|
||||||
|
lba as u64 * 2048
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The forensic segment an AACS aligned unit belongs to, if any, given the
|
||||||
|
/// 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 **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
|
||||||
|
/// caller falls back to the normal unit-key fetch.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// forensic; on the observed disc segments are unit-aligned, but the span test
|
||||||
|
/// does not rely on that.
|
||||||
|
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 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
|
||||||
|
/// yielding bogus ranges.
|
||||||
|
pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||||
|
if tbl.len() < 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let count = u16::from_be_bytes([tbl[4], tbl[5]]) as usize;
|
||||||
|
let record_size = u16::from_be_bytes([tbl[6], tbl[7]]) as usize;
|
||||||
|
if record_size != SEGMENT_RECORD_LEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if 8usize.checked_add(count.checked_mul(record_size)?)? > tbl.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut segments = Vec::with_capacity(count);
|
||||||
|
for i in 0..count {
|
||||||
|
let o = 8 + i * record_size;
|
||||||
|
// 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 {
|
||||||
|
index,
|
||||||
|
start_spn,
|
||||||
|
end_spn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 `(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
|
||||||
|
v.extend_from_slice(&(recs.len() as u16).to_be_bytes()); // count
|
||||||
|
v.extend_from_slice(&(SEGMENT_RECORD_LEN as u16).to_be_bytes()); // record_size
|
||||||
|
for &(n, s, e) in recs {
|
||||||
|
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // marker
|
||||||
|
v.extend_from_slice(&n.to_be_bytes());
|
||||||
|
v.extend_from_slice(&1u16.to_be_bytes()); // flag
|
||||||
|
v.extend_from_slice(&s.to_be_bytes());
|
||||||
|
v.extend_from_slice(&e.to_be_bytes());
|
||||||
|
}
|
||||||
|
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 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),
|
||||||
|
(3, 1051840, 1054399),
|
||||||
|
]);
|
||||||
|
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||||
|
assert_eq!(segs.len(), 3);
|
||||||
|
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);
|
||||||
|
assert_eq!(segs[0].byte_len(), 2560 * 192);
|
||||||
|
assert_eq!(segs[0].start_byte(), 343680 * 192);
|
||||||
|
assert!(segs[0].contains_spn(345000));
|
||||||
|
assert!(!segs[0].contains_spn(343679));
|
||||||
|
assert!(!segs[0].contains_spn(346240));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_wrong_record_size() {
|
||||||
|
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||||
|
tbl[6..8].copy_from_slice(&20u16.to_be_bytes()); // record_size != 16
|
||||||
|
assert!(parse_individual_segments(&tbl).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_truncated_and_overrun() {
|
||||||
|
assert!(parse_individual_segments(&[0u8; 4]).is_none()); // < header
|
||||||
|
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||||
|
tbl[4..6].copy_from_slice(&99u16.to_be_bytes()); // claims 99 recs, has 1
|
||||||
|
assert!(parse_individual_segments(&tbl).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_table_is_empty_not_none() {
|
||||||
|
let tbl = build_tbl(&[]);
|
||||||
|
assert_eq!(parse_individual_segments(&tbl), Some(Vec::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packets_per_unit_is_thirty_two() {
|
||||||
|
// 6144-byte aligned unit / 192-byte source packet.
|
||||||
|
assert_eq!(PACKETS_PER_UNIT, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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 = 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]
|
||||||
|
fn unit_outside_every_segment_is_unit_key_miss() {
|
||||||
|
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!(segment_for_unit(&segs, off).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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 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 = 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!(segment_for_unit(&segs, before).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||||
|
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lba_maps_to_the_packet_grid() {
|
||||||
|
// A unit is 3 sectors (6144 bytes) = 32 packets. Clip-relative LBA 3 is
|
||||||
|
// the second aligned unit, which starts at packet 32.
|
||||||
|
let off = lba_byte_offset(3);
|
||||||
|
assert_eq!(off / SOURCE_PACKET_LEN, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
//! AACS 2.1 FMTS forensic segment keys, `AACS/SegmentKeyNNNNN.tbl`.
|
||||||
|
//!
|
||||||
|
//! One file per CPS unit (`SegmentKey00001.tbl`, ...). It is the on-disc key
|
||||||
|
//! store for the forensic variant segments mapped by [`super::segment`]. A
|
||||||
|
//! device does not read a segment key directly. It derives a **16-bit variant
|
||||||
|
//! selector** from the Media Key Variant chain (see [`super::variant`]) and uses
|
||||||
|
//! that selector to index this table, which is how the device's position in the
|
||||||
|
//! key tree decides which variant it can decrypt (the traitor-tracing link).
|
||||||
|
//!
|
||||||
|
//! Container format (confirmed against a retail AACS 2.1 disc):
|
||||||
|
//! ```text
|
||||||
|
//! header (8 bytes): u32 tag | u16 index_space | u16 record_size
|
||||||
|
//! record[index_space] (record_size bytes each)
|
||||||
|
//! ```
|
||||||
|
//! On the reference disc: `index_space` = `0xffff` (the full 16-bit selector
|
||||||
|
//! space, 65536 records), `record_size` = `0x0218` = 536. Total
|
||||||
|
//! `8 + 65536 * 536 = 35,127,304` bytes, which matches the file exactly. Each
|
||||||
|
//! record begins with an 8-byte sub-header, then 528 bytes of encrypted key
|
||||||
|
//! material.
|
||||||
|
//!
|
||||||
|
//! **Not yet reversed:** the internal layout of a record's 528-byte payload, and
|
||||||
|
//! how it maps onto the segments of [`super::segment`]. One numeric coincidence
|
||||||
|
//! worth noting for whoever cracks it: the reference disc has 792 segments and
|
||||||
|
//! `528 = 33 * 16`, with `792 = 24 * 33`, so `33` appears on both sides. Until
|
||||||
|
//! the mapping and the key derivation are pinned, this module exposes only the
|
||||||
|
//! confirmed container: locate the record for a given 16-bit selector.
|
||||||
|
|
||||||
|
/// Bytes of the fixed file header.
|
||||||
|
pub const HEADER_LEN: usize = 8;
|
||||||
|
|
||||||
|
/// The on-disc segment-key table container. Borrows the file bytes; a record is
|
||||||
|
/// looked up by the 16-bit variant selector.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct SegmentKeyTable<'a> {
|
||||||
|
data: &'a [u8],
|
||||||
|
/// Number of records (the selector index space, e.g. 65536).
|
||||||
|
count: usize,
|
||||||
|
/// Bytes per record (e.g. 536).
|
||||||
|
record_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SegmentKeyTable<'a> {
|
||||||
|
/// Parse and validate the container header against the buffer length.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the buffer is too small, or the declared
|
||||||
|
/// `count * record_size` (plus header) does not match the buffer, so a
|
||||||
|
/// truncated or foreign table degrades to "no segment keys" rather than
|
||||||
|
/// handing back bogus records. `index_space` of `0xffff` is read as the full
|
||||||
|
/// 65536-entry space (a device selector is a full 16-bit value).
|
||||||
|
pub fn parse(data: &'a [u8]) -> Option<Self> {
|
||||||
|
if data.len() < HEADER_LEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let index_space = u16::from_be_bytes([data[4], data[5]]);
|
||||||
|
let record_size = u16::from_be_bytes([data[6], data[7]]) as usize;
|
||||||
|
// 0xffff means the full 16-bit selector space (65536 records).
|
||||||
|
let count = if index_space == 0xffff {
|
||||||
|
0x1_0000
|
||||||
|
} else {
|
||||||
|
index_space as usize
|
||||||
|
};
|
||||||
|
if record_size == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body = count.checked_mul(record_size)?;
|
||||||
|
if HEADER_LEN.checked_add(body)? != data.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
data,
|
||||||
|
count,
|
||||||
|
record_size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of records (the selector index space).
|
||||||
|
pub fn record_count(&self) -> usize {
|
||||||
|
self.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes per record.
|
||||||
|
pub fn record_size(&self) -> usize {
|
||||||
|
self.record_size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The raw record for a 16-bit variant `selector`, including its 8-byte
|
||||||
|
/// sub-header. `None` if the selector is past the table (only possible when
|
||||||
|
/// `index_space` was not the full 16-bit space).
|
||||||
|
pub fn record(&self, selector: u16) -> Option<&'a [u8]> {
|
||||||
|
let idx = selector as usize;
|
||||||
|
if idx >= self.count {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let start = HEADER_LEN + idx * self.record_size;
|
||||||
|
self.data.get(start..start + self.record_size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The encrypted key payload for a selector: the record with its 8-byte
|
||||||
|
/// sub-header stripped. The internal layout of these bytes is not yet
|
||||||
|
/// reversed (see module docs).
|
||||||
|
pub fn record_payload(&self, selector: u16) -> Option<&'a [u8]> {
|
||||||
|
self.record(selector).and_then(|r| r.get(HEADER_LEN..))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Build a container with `record_size` and the given `index_space`, filling
|
||||||
|
/// each record with a distinguishable byte so lookups can be checked.
|
||||||
|
fn build(index_space: u16, record_size: u16) -> Vec<u8> {
|
||||||
|
let count = if index_space == 0xffff {
|
||||||
|
0x1_0000
|
||||||
|
} else {
|
||||||
|
index_space as usize
|
||||||
|
};
|
||||||
|
let mut v = Vec::with_capacity(HEADER_LEN + count * record_size as usize);
|
||||||
|
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // tag
|
||||||
|
v.extend_from_slice(&index_space.to_be_bytes());
|
||||||
|
v.extend_from_slice(&record_size.to_be_bytes());
|
||||||
|
for i in 0..count {
|
||||||
|
let mut rec = vec![(i & 0xff) as u8; record_size as usize];
|
||||||
|
// sub-header, as seen on disc
|
||||||
|
rec[..8].copy_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||||
|
v.extend_from_slice(&rec);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_retail_container_geometry() {
|
||||||
|
// The real disc: 0xffff index space, 536-byte records, 35,127,304 total.
|
||||||
|
let data = build(0xffff, 536);
|
||||||
|
assert_eq!(
|
||||||
|
data.len(),
|
||||||
|
35_127_304,
|
||||||
|
"matches the retail file size exactly"
|
||||||
|
);
|
||||||
|
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||||
|
assert_eq!(t.record_count(), 65_536);
|
||||||
|
assert_eq!(t.record_size(), 536);
|
||||||
|
let rec = t.record(0x1234).expect("record");
|
||||||
|
assert_eq!(rec.len(), 536);
|
||||||
|
assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||||
|
assert_eq!(t.record_payload(0x1234).unwrap().len(), 528);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn small_index_space_bounds_lookups() {
|
||||||
|
let data = build(4, 32);
|
||||||
|
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||||
|
assert_eq!(t.record_count(), 4);
|
||||||
|
assert!(t.record(3).is_some());
|
||||||
|
assert!(t.record(4).is_none(), "selector past the table is None");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_size_mismatch_and_truncation() {
|
||||||
|
assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none());
|
||||||
|
let mut data = build(4, 32);
|
||||||
|
data.truncate(data.len() - 1); // body no longer matches header
|
||||||
|
assert!(SegmentKeyTable::parse(&data).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,41 @@ pub struct ProcessingKey(pub [u8; 16]);
|
|||||||
pub struct UnitKey {
|
pub struct UnitKey {
|
||||||
pub idx: u32,
|
pub idx: u32,
|
||||||
pub key: [u8; 16],
|
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.
|
/// A per-disc entry from the key database.
|
||||||
|
|||||||
+222
-75
@@ -3,8 +3,9 @@
|
|||||||
//! On AACS 2.1 the Media Key derivation gains a second stage on top of
|
//! On AACS 2.1 the Media Key derivation gains a second stage on top of
|
||||||
//! the classical subset-difference walk. The classical walk yields a
|
//! the classical subset-difference walk. The classical walk yields a
|
||||||
//! Media Key Precursor (Kmp) rather than the final Media Key; the
|
//! Media Key Precursor (Kmp) rather than the final Media Key; the
|
||||||
//! Precursor combines with disc-supplied Variant Key Data (VKD) and the
|
//! Precursor combines with disc-supplied Variant Key Data (VKD) and a
|
||||||
//! fixed Key Correction Data (KCD) constant to produce the Media Key.
|
//! per-licensee Key Correction Data (KCD) constant to produce the Media
|
||||||
|
//! Key.
|
||||||
//!
|
//!
|
||||||
//! The entry point is [`derive_media_key_variant`] — a `Kp -> Km`
|
//! The entry point is [`derive_media_key_variant`] — a `Kp -> Km`
|
||||||
//! derivation. Deriving `Kp` itself from device keys (DK -> PK) is the
|
//! derivation. Deriving `Kp` itself from device keys (DK -> PK) is the
|
||||||
@@ -27,14 +28,16 @@
|
|||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! **Status.** The record layout is pinned against real variant MKBs:
|
//! **Status.** The record layout is pinned against real variant MKBs:
|
||||||
//! `variants_for_uv` reads the `VARIANTS[uv]` table from `0x2d`, `C` from
|
//! `C` is the per-slot block of the `0x0c` cvalue table (indexed by the
|
||||||
//! the `0x2d` head, `VKD` from `0x2f`, and the Nonce from the `0x2d`
|
//! matched subset-difference — NOT the `0x2d` head), `VARIANTS[uv]` is the
|
||||||
//! tail. The one input still missing is a covering 2.1 Processing Key to
|
//! `0x2d` VARIANTS table (leading `body-16` bytes, Nonce at the `0x2d`
|
||||||
//! run the chain end-to-end against the `0x86` Verify-Media-Key record —
|
//! tail), and `VKD` is `0x2f`. Two inputs still block an end-to-end run
|
||||||
//! which would also confirm the last layout picks (the 16-bit `Kvn` width
|
//! against the `0x86` Verify-Media-Key record: the real per-licensee KCD
|
||||||
//! vs. a narrower spec value, and Nonce head-vs-tail). Until then the
|
//! (see [`KEY_CORRECTION_DATA`] — not coded, per-manufacturer), and a
|
||||||
//! final verify gate rejects any wrong pick, so a bad key is never
|
//! covering 2.1 Processing Key. Either one missing yields a wrong `Km`
|
||||||
//! emitted — only an error.
|
//! that the final verify gate rejects, so a bad key is never emitted —
|
||||||
|
//! only an error. (A covering key would also confirm the last layout
|
||||||
|
//! picks: the 16-bit `Kvn` width and Nonce head-vs-tail.)
|
||||||
//!
|
//!
|
||||||
//! Two condition bits on `Kmp[15]` route off the default KCD path (Soft
|
//! Two condition bits on `Kmp[15]` route off the default KCD path (Soft
|
||||||
//! Correction and Online Challenge); the chain does not model those modes
|
//! Correction and Online Challenge); the chain does not model those modes
|
||||||
@@ -51,12 +54,25 @@ use super::crypto::{aes_ecb_decrypt, aes_g};
|
|||||||
use super::mkb::*;
|
use super::mkb::*;
|
||||||
use super::types::DeviceKey;
|
use super::types::DeviceKey;
|
||||||
|
|
||||||
|
// The MKB record types this chain selects — `REC_MEDIA_KEY_VARIANT_DATA`
|
||||||
|
// (`0x0c`, the per-slot C table), `REC_VARIANT_DATA_AND_NONCE` (`0x2d`, VARIANTS
|
||||||
|
// + tail Nonce), `REC_VKD_TABLE` (`0x2f`), the subset-difference / cvalue records
|
||||||
|
// (`0x04` / `0x05` / `0x07`), and the verify records (`0x81` / `0x86`) — are the
|
||||||
|
// canonical set in [`super::mkb`], in scope here via the `use super::mkb::*` glob.
|
||||||
|
|
||||||
// ── Public constants ──────────────────────────────────────────────────────
|
// ── Public constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// AACS 2.1 Key Correction Data — a fixed algorithm constant.
|
/// AACS 2.1 Key Correction Data — a zero placeholder, NOT real key material.
|
||||||
const KEY_CORRECTION_DATA: [u8; 16] = [
|
///
|
||||||
0x3b, 0x62, 0x8a, 0x78, 0x29, 0x00, 0xca, 0x2f, 0xdb, 0xe7, 0x7a, 0x49, 0xfe, 0x22, 0xd6, 0x6e,
|
/// **KCD is PER-LICENSEE** (per player manufacturer) — there is no single
|
||||||
];
|
/// universal value. libfreemkv compiles in no AACS key material (keydb.cfg is
|
||||||
|
/// the single source of truth), so this stays all-zero: the chain's SHAPE still
|
||||||
|
/// runs, but on a real variant disc the derivation yields a wrong Media Key that
|
||||||
|
/// the final Verify-Media-Key gate rejects. The variant chain therefore cannot
|
||||||
|
/// complete on a real disc today — a key-acquisition gap, not a code gap. If a
|
||||||
|
/// real per-licensee KCD is ever available it must come from keydb.cfg, never a
|
||||||
|
/// compiled constant.
|
||||||
|
const KEY_CORRECTION_DATA: [u8; 16] = [0u8; 16];
|
||||||
|
|
||||||
// ── MKB record walking ────────────────────────────────────────────────────
|
// ── MKB record walking ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -69,38 +85,41 @@ const KEY_CORRECTION_DATA: [u8; 16] = [
|
|||||||
/// The earlier `0x82`/`0x83` guess was speculative and never appeared in any
|
/// The earlier `0x82`/`0x83` guess was speculative and never appeared in any
|
||||||
/// real MKB.
|
/// real MKB.
|
||||||
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
|
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
|
||||||
records.iter().any(|r| matches!(r.rec_type, 0x2d | 0x2f))
|
records
|
||||||
|
.iter()
|
||||||
|
.any(|r| matches!(r.rec_type, REC_VARIANT_DATA_AND_NONCE | REC_VKD_TABLE))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Body of the Encrypted Media Key Variant Data record (type `0x2d`).
|
/// Body of the `0x2d` record: the `VARIANTS` table followed by the trailing
|
||||||
///
|
/// 16-byte `Kvn` Nonce. Measured `46_100*2 + 16 = 92_216` on Zombieland v70 and
|
||||||
/// Confirmed against a live variant MKB as the `0x2d` record (92,220 bytes on
|
/// `92_220` on Stand By Me v70 — in both, the leading `body.len() - 16` bytes are
|
||||||
/// the reference disc — not a clean multiple of 16, so it is a structured /
|
/// the big-endian `u16` `VARIANTS` table (one per subset-difference) and the last
|
||||||
/// count-prefixed record, not a flat C-block table). The exact per-uv C
|
/// 16 bytes are the Nonce, with NO leading header. This does NOT hold the C used
|
||||||
/// selection is the one sub-field still unconfirmed without a real disc+key to
|
/// for `Kmp` — that is the per-slot block in `0x0c`
|
||||||
/// test against; the chain currently consumes the leading 16-byte block.
|
/// ([`REC_MEDIA_KEY_VARIANT_DATA`]). Both [`variant_nonce`] and
|
||||||
/// `pub(crate)` until that offset is pinned.
|
/// [`variants_for_uv`] read this body.
|
||||||
pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
|
pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||||
records
|
records
|
||||||
.iter()
|
.iter()
|
||||||
.find(|r| r.rec_type == 0x2d)
|
.find(|r| r.rec_type == REC_VARIANT_DATA_AND_NONCE)
|
||||||
.map(|r| r.body.as_slice())
|
.map(|r| r.body.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)`.
|
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)` — the trailing 16 bytes of the
|
||||||
|
/// `0x2d` record ([`variant_data_record`]).
|
||||||
///
|
///
|
||||||
/// **UNCONFIRMED source.** The `0x2d` Encrypted-Media-Key-Variant-Data record is
|
/// The Nonce-at-tail placement is consistent across both reference MKBs (the
|
||||||
/// the most likely home for a per-disc nonce, so this reads its trailing 16
|
/// leading `body-16` bytes form the `VARIANTS` table exactly), but head-vs-tail
|
||||||
/// bytes. Confirming this (vs. a fixed slice elsewhere in `0x2d`) needs a
|
/// is only truly pinned by running the full chain against the `0x86` verify with
|
||||||
/// covering key to run the whole chain against the `0x86` verify; until then a
|
/// a covering key. Until then a wrong nonce can only fail that final gate, never
|
||||||
/// wrong nonce can only fail that final gate, never emit a bad key.
|
/// emit a bad key.
|
||||||
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||||
let r = records.iter().find(|r| r.rec_type == 0x2d)?;
|
let body = variant_data_record(records)?;
|
||||||
if r.body.len() < 16 {
|
if body.len() < 16 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut out = [0u8; 16];
|
let mut out = [0u8; 16];
|
||||||
out.copy_from_slice(&r.body[r.body.len() - 16..]);
|
out.copy_from_slice(&body[body.len() - 16..]);
|
||||||
Some(out)
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,14 +131,14 @@ pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
|||||||
pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
|
pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||||
records
|
records
|
||||||
.iter()
|
.iter()
|
||||||
.find(|r| r.rec_type == 0x2f && !r.body.is_empty() && r.body.len() % 16 == 0)
|
.find(|r| r.rec_type == REC_VKD_TABLE && !r.body.is_empty() && r.body.len() % 16 == 0)
|
||||||
.map(|r| r.body.as_slice())
|
.map(|r| r.body.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
|
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
|
||||||
|
|
||||||
// `calc_v_mask` and `calc_pk_from_dk` (and the AES-G3 seed step they ride
|
// `calc_v_mask` and `calc_pk_from_dk` (and the AES-G3 seed step they ride
|
||||||
// on) are shared with the classical walk in [`super::keys`] — a single
|
// on) are shared with the classical walk in [`super::derive`] — a single
|
||||||
// definition keeps the variant SD tree byte-identical to the classical one.
|
// definition keeps the variant SD tree byte-identical to the classical one.
|
||||||
// (`aesg3` itself is imported separately in the test module.)
|
// (`aesg3` itself is imported separately in the test module.)
|
||||||
use super::derive::{calc_pk_from_dk, calc_v_mask};
|
use super::derive::{calc_pk_from_dk, calc_v_mask};
|
||||||
@@ -140,9 +159,10 @@ pub struct ProcessingKeyMatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||||
let r = records
|
let r = records.iter().find(|r| {
|
||||||
.iter()
|
(r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2)
|
||||||
.find(|r| (r.rec_type == 0x81 || r.rec_type == 0x86) && r.body.len() >= 16)?;
|
&& r.body.len() >= 16
|
||||||
|
})?;
|
||||||
let mut out = [0u8; 16];
|
let mut out = [0u8; 16];
|
||||||
out.copy_from_slice(&r.body[..16]);
|
out.copy_from_slice(&r.body[..16]);
|
||||||
Some(out)
|
Some(out)
|
||||||
@@ -152,7 +172,7 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
|||||||
/// `device_keys` covers. Returns `None` if no DK walks any uv.
|
/// `device_keys` covers. Returns `None` if no DK walks any uv.
|
||||||
///
|
///
|
||||||
/// This is the AACS-2.1 **variant** walk; the classical walk lives in
|
/// This is the AACS-2.1 **variant** walk; the classical walk lives in
|
||||||
/// [`super::keys::derive_media_key_and_pk_from_dk`]. The two are kept
|
/// [`super::derive::derive_media_key_and_pk_from_dk`]. The two are kept
|
||||||
/// separate on purpose and select MKB records in DELIBERATELY different
|
/// separate on purpose and select MKB records in DELIBERATELY different
|
||||||
/// order:
|
/// order:
|
||||||
///
|
///
|
||||||
@@ -161,7 +181,7 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
|||||||
/// small `0x07` Explicit-Subset-Difference record carries the
|
/// small `0x07` Explicit-Subset-Difference record carries the
|
||||||
/// cvalue the Precursor chain consumes, whereas a classical UHD MKB
|
/// cvalue the Precursor chain consumes, whereas a classical UHD MKB
|
||||||
/// keeps its 1:1 cvalue table in the large `0x05` record (see the
|
/// keeps its 1:1 cvalue table in the large `0x05` record (see the
|
||||||
/// note on [`super::keys::probe::mkb_cvalues`]). They must NOT be
|
/// note on [`super::derive::probe::mkb_cvalues`]). They must NOT be
|
||||||
/// unified to one order — each is correct for its own MKB shape.
|
/// unified to one order — each is correct for its own MKB shape.
|
||||||
/// - finders: this walk operates on parsed [`MkbRecord`]s (needed
|
/// - finders: this walk operates on parsed [`MkbRecord`]s (needed
|
||||||
/// because the variant chain also reads `0x2d`/`0x2f`); the
|
/// because the variant chain also reads `0x2d`/`0x2f`); the
|
||||||
@@ -176,14 +196,14 @@ pub fn walk_processing_key(
|
|||||||
device_keys: &[DeviceKey],
|
device_keys: &[DeviceKey],
|
||||||
) -> Option<ProcessingKeyMatch> {
|
) -> Option<ProcessingKeyMatch> {
|
||||||
let mk_dv = mkb_find_mk_dv(records)?;
|
let mk_dv = mkb_find_mk_dv(records)?;
|
||||||
let uvs = mkb_find_body(records, 0x04)?;
|
let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
|
||||||
// Variant cvalue source: a real variant MKB carries its per-uv cvalue table
|
// Variant cvalue source: a real variant MKB carries its per-uv cvalue table
|
||||||
// in record `0x0c` (confirmed 46,101×16, one per `0x04` subset-difference
|
// in record `0x0c` (confirmed 46,101×16, one per `0x04` subset-difference
|
||||||
// slot). Fall back to `0x07`/`0x05` for the synthetic fixtures and any MKB
|
// slot). Fall back to `0x07`/`0x05` for the synthetic fixtures and any MKB
|
||||||
// shape that keeps its cvalues there.
|
// shape that keeps its cvalues there.
|
||||||
let cvalues = mkb_find_body(records, 0x0c)
|
let cvalues = mkb_find_body(records, REC_MEDIA_KEY_VARIANT_DATA)
|
||||||
.or_else(|| mkb_find_body(records, 0x07))
|
.or_else(|| mkb_find_body(records, REC_EXPLICIT_SUBSET_DIFF))
|
||||||
.or_else(|| mkb_find_body(records, 0x05))?;
|
.or_else(|| mkb_find_body(records, REC_MEDIA_KEY_DATA))?;
|
||||||
|
|
||||||
let num_uvs = uvs
|
let num_uvs = uvs
|
||||||
.chunks(5)
|
.chunks(5)
|
||||||
@@ -201,14 +221,11 @@ pub fn walk_processing_key(
|
|||||||
// parse stops, no inner re-check needed.
|
// parse stops, no inner re-check needed.
|
||||||
let u_mask_shift = uvs[5 * uvs_idx];
|
let u_mask_shift = uvs[5 * uvs_idx];
|
||||||
|
|
||||||
if u_mask_shift & 0xC0 != 0 {
|
// 0x20..=0x3F (32..=63) have their revoked-marker bits clear (so they
|
||||||
break;
|
// pass the take_while above) but are out of range for a u32 shift.
|
||||||
}
|
// `wrapping_shl` would silently compute shift % 32 (e.g. 32 → no shift
|
||||||
// 0x20..=0x3F (32..=63) pass the 0xC0 revoked-marker check but are
|
// → 0xFFFF_FFFF), matching a wrong uv slot and deriving a wrong key.
|
||||||
// out of range for a u32 shift. `wrapping_shl` would silently
|
// Disc-controlled byte: skip the slot instead.
|
||||||
// compute shift % 32 (e.g. 32 → no shift → 0xFFFF_FFFF), matching a
|
|
||||||
// wrong uv slot and deriving a wrong key. Disc-controlled byte:
|
|
||||||
// skip the slot instead.
|
|
||||||
if u_mask_shift >= 32 {
|
if u_mask_shift >= 32 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -343,10 +360,18 @@ impl std::error::Error for MediaKeyVariantError {}
|
|||||||
/// yield a wrong `Km`, which the final Verify-Media-Key gate rejects (never a
|
/// yield a wrong `Km`, which the final Verify-Media-Key gate rejects (never a
|
||||||
/// silent bad key).
|
/// silent bad key).
|
||||||
fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
|
fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
|
||||||
let body = records.iter().find(|r| r.rec_type == 0x2d)?.body.as_slice();
|
let body = variant_data_record(records)?;
|
||||||
|
// The VARIANTS table is the leading bytes; the 16-byte Kvn Nonce is packed at
|
||||||
|
// the TAIL (see [`variant_nonce`]). Bound the read to the table region so a
|
||||||
|
// near-end slot can never read Nonce bytes as a VARIANTS entry. NO leading
|
||||||
|
// header (measured: Zombieland v70 `0x2d` body = 46_100*2 + 16 = 92_216).
|
||||||
|
const NONCE: usize = 16;
|
||||||
|
let table_len = body.len().checked_sub(NONCE)?;
|
||||||
let off = sd_slot_index.checked_mul(2)?;
|
let off = sd_slot_index.checked_mul(2)?;
|
||||||
let bytes = body.get(off..off + 2)?;
|
if off + 2 > table_len {
|
||||||
Some(u16::from_be_bytes([bytes[0], bytes[1]]))
|
return None;
|
||||||
|
}
|
||||||
|
Some(u16::from_be_bytes([body[off], body[off + 1]]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enumerate the `(uv, slot_index)` pairs of a variant MKB's subset-difference
|
/// Enumerate the `(uv, slot_index)` pairs of a variant MKB's subset-difference
|
||||||
@@ -354,7 +379,7 @@ fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
|
|||||||
/// to index cvalues. Factored out so a bare Processing Key (which arrives without
|
/// to index cvalues. Factored out so a bare Processing Key (which arrives without
|
||||||
/// its slot) can be tried against each slot.
|
/// its slot) can be tried against each slot.
|
||||||
fn variant_uv_slots(records: &[MkbRecord]) -> Option<Vec<(u32, usize)>> {
|
fn variant_uv_slots(records: &[MkbRecord]) -> Option<Vec<(u32, usize)>> {
|
||||||
let uvs = mkb_find_body(records, 0x04)?;
|
let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut idx = 0usize;
|
let mut idx = 0usize;
|
||||||
while (idx + 1) * 5 <= uvs.len() {
|
while (idx + 1) * 5 <= uvs.len() {
|
||||||
@@ -381,7 +406,11 @@ struct VariantMkb<'a> {
|
|||||||
records: &'a [MkbRecord],
|
records: &'a [MkbRecord],
|
||||||
nonce: [u8; 16],
|
nonce: [u8; 16],
|
||||||
vkd_table: &'a [u8],
|
vkd_table: &'a [u8],
|
||||||
c_block: [u8; 16],
|
/// The per-subset-difference Encrypted-Media-Key-Variant-Data (C) table from
|
||||||
|
/// record `0x0c` — one 16-byte C per slot. C for slot `i` is
|
||||||
|
/// `cvalues[i*16..][..16]`, the SAME source/index [`walk_processing_key`]
|
||||||
|
/// uses. (NOT `0x2d`, which is VARIANTS + Nonce.)
|
||||||
|
cvalues: &'a [u8],
|
||||||
mk_dv: [u8; 16],
|
mk_dv: [u8; 16],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,8 +424,21 @@ fn variant_km_for_slot(
|
|||||||
uv: u32,
|
uv: u32,
|
||||||
slot_index: usize,
|
slot_index: usize,
|
||||||
) -> Result<[u8; 16], MediaKeyVariantError> {
|
) -> Result<[u8; 16], MediaKeyVariantError> {
|
||||||
|
// C for THIS subset-difference: the slot's 16-byte block in the `0x0c`
|
||||||
|
// Encrypted-Media-Key-Variant-Data table (same index that selected the
|
||||||
|
// cvalue in `walk_processing_key`). `0x2d` is VARIANTS + Nonce, not C.
|
||||||
|
let cv_off = slot_index
|
||||||
|
.checked_mul(16)
|
||||||
|
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||||
|
let c_slice = m
|
||||||
|
.cvalues
|
||||||
|
.get(cv_off..cv_off + 16)
|
||||||
|
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||||
|
let mut c_block = [0u8; 16];
|
||||||
|
c_block.copy_from_slice(c_slice);
|
||||||
|
|
||||||
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
|
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
|
||||||
let mut kmp = aes_ecb_decrypt(kp, &m.c_block);
|
let mut kmp = aes_ecb_decrypt(kp, &c_block);
|
||||||
let uv_bytes = uv.to_be_bytes();
|
let uv_bytes = uv.to_be_bytes();
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
kmp[12 + i] ^= uv_bytes[i];
|
kmp[12 + i] ^= uv_bytes[i];
|
||||||
@@ -476,19 +518,21 @@ pub fn derive_media_key_variant(
|
|||||||
}
|
}
|
||||||
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||||
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||||
let c_value = variant_data_record(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
// C for the Kmp step is the per-subset-difference `0x0c` table (one 16-byte
|
||||||
if c_value.len() < 16 {
|
// C per slot) — the SAME source and index `walk_processing_key` uses. `0x2d`
|
||||||
return Err(MediaKeyVariantError::MkbIncomplete);
|
// holds VARIANTS + Nonce, NOT C. Fall back to `0x07`/`0x05` for the synthetic
|
||||||
}
|
// fixtures that keep a single cvalue there.
|
||||||
let mut c_block = [0u8; 16];
|
let cvalues = mkb_find_body(mkb_records, REC_MEDIA_KEY_VARIANT_DATA)
|
||||||
c_block.copy_from_slice(&c_value[..16]);
|
.or_else(|| mkb_find_body(mkb_records, REC_EXPLICIT_SUBSET_DIFF))
|
||||||
|
.or_else(|| mkb_find_body(mkb_records, REC_MEDIA_KEY_DATA))
|
||||||
|
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||||
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||||
let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||||
let m = VariantMkb {
|
let m = VariantMkb {
|
||||||
records: mkb_records,
|
records: mkb_records,
|
||||||
nonce,
|
nonce,
|
||||||
vkd_table,
|
vkd_table,
|
||||||
c_block,
|
cvalues,
|
||||||
mk_dv,
|
mk_dv,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -509,10 +553,83 @@ pub fn derive_media_key_variant(
|
|||||||
Err(correction.unwrap_or(MediaKeyVariantError::ProcessingKeyUnavailable))
|
Err(correction.unwrap_or(MediaKeyVariantError::ProcessingKeyUnavailable))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run the variant chain from a caller-supplied Processing Key and EXPLICIT
|
||||||
|
/// per-slot inputs — the harness entry that tries a captured `Kp` against known
|
||||||
|
/// slot material, bypassing both the device-key walk and the on-MKB
|
||||||
|
/// `VARIANTS[uv]` lookup. The caller supplies the `0x0c` C block, the slot's
|
||||||
|
/// subset-difference number `uv`, and its `VARIANTS[uv]`; the MKB supplies the
|
||||||
|
/// Nonce, the VKD table, and the Verify-Media-Key value.
|
||||||
|
///
|
||||||
|
/// Returns `(Km, Kvu)`. The terminal Verify-Media-Key gate is identical to
|
||||||
|
/// [`derive_media_key_variant`], so a wrong `c_block` / `uv` / `variants_uv`
|
||||||
|
/// returns [`MediaKeyVariantError::MediaKeyVerifyFailed`] rather than a bogus
|
||||||
|
/// key. The soft-correction / online-challenge bits on `Kmp[15]` are classified
|
||||||
|
/// the same way, so a slot needing an out-of-band correction path is
|
||||||
|
/// distinguishable from a non-matching input.
|
||||||
|
///
|
||||||
|
/// (Note the KCD caveat on [`KEY_CORRECTION_DATA`]: without the real per-licensee
|
||||||
|
/// KCD this fails the verify gate on a real disc — a key-acquisition gap.)
|
||||||
|
pub fn media_key_variant_from_kp(
|
||||||
|
kp: &[u8; 16],
|
||||||
|
c_block: &[u8; 16],
|
||||||
|
uv: u32,
|
||||||
|
variants_uv: u16,
|
||||||
|
mkb_records: &[MkbRecord],
|
||||||
|
vid: &[u8; 16],
|
||||||
|
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
|
||||||
|
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||||
|
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||||
|
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||||
|
|
||||||
|
// Kmp = AES-128D(Kp, C) XOR uv.
|
||||||
|
let mut kmp = aes_ecb_decrypt(kp, c_block);
|
||||||
|
let uv_bytes = uv.to_be_bytes();
|
||||||
|
for i in 0..4 {
|
||||||
|
kmp[12 + i] ^= uv_bytes[i];
|
||||||
|
}
|
||||||
|
if kmp[15] & 0b0000_0010 != 0 {
|
||||||
|
return Err(MediaKeyVariantError::SoftCorrectionRequired);
|
||||||
|
}
|
||||||
|
if kmp[15] & 0b0000_0100 != 0 {
|
||||||
|
return Err(MediaKeyVariantError::OnlineChallengeRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kpnew = Kmp XOR KCD.
|
||||||
|
let mut kpnew = [0u8; 16];
|
||||||
|
for i in 0..16 {
|
||||||
|
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kvn = AES-G(Kp, Nonce) & 0xFFFF; VKD_idx = Kvn XOR VARIANTS[uv].
|
||||||
|
let kvn_block = aes_g(kp, &nonce);
|
||||||
|
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
|
||||||
|
let vkd_idx = kvn ^ variants_uv;
|
||||||
|
let off = (vkd_idx as usize) * 16;
|
||||||
|
if off + 16 > vkd_table.len() {
|
||||||
|
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
|
||||||
|
}
|
||||||
|
let mut vkd = [0u8; 16];
|
||||||
|
vkd.copy_from_slice(&vkd_table[off..off + 16]);
|
||||||
|
|
||||||
|
// Km = AES-128D(Kpnew, VKD) XOR uv, then the authoritative Verify-Media-Key gate.
|
||||||
|
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
|
||||||
|
for i in 0..4 {
|
||||||
|
km[12 + i] ^= uv_bytes[i];
|
||||||
|
}
|
||||||
|
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||||
|
if aes_ecb_decrypt(&km, &mk_dv)[..8] != VERIFY_MAGIC {
|
||||||
|
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kvu = AES-G(Km, VID).
|
||||||
|
let kvu = aes_g(&km, vid);
|
||||||
|
Ok((km, kvu))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
// These three live in `super::keys` now (consolidated SD-walk helpers);
|
// These three live in `super::derive` now (consolidated SD-walk helpers);
|
||||||
// `use super::*` does not re-export the parent module's private `use`
|
// `use super::*` does not re-export the parent module's private `use`
|
||||||
// imports, so pull them in directly for the tests below.
|
// imports, so pull them in directly for the tests below.
|
||||||
use super::super::crypto::aesg3;
|
use super::super::crypto::aesg3;
|
||||||
@@ -710,21 +827,24 @@ mod tests {
|
|||||||
aes_d_result[15] ^= 0x02;
|
aes_d_result[15] ^= 0x02;
|
||||||
let c_block = aes_ecb_encrypt(&kp, &aes_d_result);
|
let c_block = aes_ecb_encrypt(&kp, &aes_d_result);
|
||||||
|
|
||||||
// cvalues record (0x07): one 16-byte cvalue. The walker
|
// cvalues record (0x07): the per-SD C the chain reads for `Kmp`. This
|
||||||
// indexes it for the magic-check step; on a variant MKB the
|
// fixture has no `0x0c`, so both the walk and the chain fall back to
|
||||||
// magic check fails but `variant_present` is true so the
|
// `0x07` — plant the computed `c_block` HERE so `AES-D(Kp, C) XOR uv ==
|
||||||
// walker still returns the match. Content is don't-care.
|
// Kmp` and the chosen `kmp15` bit lands. On a variant MKB the per-match
|
||||||
|
// magic check fails, but `variant_present` is true, so the walk still
|
||||||
|
// returns the match.
|
||||||
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
|
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
|
||||||
mkb.extend_from_slice(&[0xAB; 16]);
|
mkb.extend_from_slice(&c_block);
|
||||||
|
|
||||||
// Verify Media Key (0x86): body content is don't-care.
|
// Verify Media Key (0x86): body content is don't-care.
|
||||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||||
mkb.extend_from_slice(&[0xCD; 16]);
|
mkb.extend_from_slice(&[0xCD; 16]);
|
||||||
|
|
||||||
// 0x2d record: Encrypted Media Key Variant Data — C (head 16) then a
|
// 0x2d record: VARIANTS table (head, don't-care for these tests) then the
|
||||||
// trailing 16-byte Nonce (variant_nonce reads the tail), 32-byte body.
|
// trailing 16-byte Nonce (`variant_nonce` reads the tail), 32-byte body.
|
||||||
|
// (C is NOT here — it is the `0x07`/`0x0c` cvalue above.)
|
||||||
mkb.extend_from_slice(&[0x2d, 0x00, 0x00, 0x24]);
|
mkb.extend_from_slice(&[0x2d, 0x00, 0x00, 0x24]);
|
||||||
mkb.extend_from_slice(&c_block);
|
mkb.extend_from_slice(&[0x11; 16]);
|
||||||
mkb.extend_from_slice(&[0x77; 16]);
|
mkb.extend_from_slice(&[0x77; 16]);
|
||||||
|
|
||||||
// 0x2f record: Variant Key Data table — one 16-byte entry.
|
// 0x2f record: Variant Key Data table — one 16-byte entry.
|
||||||
@@ -769,7 +889,7 @@ mod tests {
|
|||||||
fn mkb_records_matches_walk_mkb_framing() {
|
fn mkb_records_matches_walk_mkb_framing() {
|
||||||
// The lazy `mkb_records` iterator and the owning `walk_mkb` must agree on
|
// The lazy `mkb_records` iterator and the owning `walk_mkb` must agree on
|
||||||
// (offset, type, len) for every record — they share the one framing
|
// (offset, type, len) for every record — they share the one framing
|
||||||
// walker, and every keys.rs MKB walk now relies on this equivalence.
|
// walker, and every aacs::resolve/derive MKB walk now relies on this equivalence.
|
||||||
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
|
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
|
||||||
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 1, 2, 3, 4]);
|
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 1, 2, 3, 4]);
|
||||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0xFF]); // terminator + trailing
|
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0xFF]); // terminator + trailing
|
||||||
@@ -1023,4 +1143,31 @@ mod tests {
|
|||||||
let codes: HashSet<String> = cases.iter().map(|e| e.to_string()).collect();
|
let codes: HashSet<String> = cases.iter().map(|e| e.to_string()).collect();
|
||||||
assert_eq!(codes.len(), cases.len(), "all error codes must be unique");
|
assert_eq!(codes.len(), cases.len(), "all error codes must be unique");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `media_key_variant_from_kp` runs the full chain from explicit inputs and
|
||||||
|
/// classifies the `Kmp[15]` soft-correction bit. A `c_block` chosen so
|
||||||
|
/// `AES-D(Kp, C) == Kmp` with bit `0x02` set (uv=0) must surface
|
||||||
|
/// `SoftCorrectionRequired` before it touches the VKD / verify steps —
|
||||||
|
/// proving the explicit-input entry runs the same chain and gates.
|
||||||
|
#[test]
|
||||||
|
fn media_key_variant_from_kp_classifies_soft_correction() {
|
||||||
|
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||||
|
let kp = [0x11u8; 16];
|
||||||
|
// Plant Kmp[15]=0x02 (soft-correction) with uv=0 so Kmp == AES-D(kp, C).
|
||||||
|
let mut target_kmp = [0x00u8; 16];
|
||||||
|
target_kmp[15] = 0x02;
|
||||||
|
let c_block = aes_ecb_encrypt(&kp, &target_kmp);
|
||||||
|
// Minimal variant MKB: 0x2d (16-byte body = tail Nonce), 0x2f (one VKD
|
||||||
|
// entry), 0x86 (Verify-Media-Key).
|
||||||
|
let mut mkb = vec![0x2d, 0x00, 0x00, 0x14];
|
||||||
|
mkb.extend_from_slice(&[0x99; 16]);
|
||||||
|
mkb.extend_from_slice(&[0x2f, 0x00, 0x00, 0x14]);
|
||||||
|
mkb.extend_from_slice(&[0xBB; 16]);
|
||||||
|
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||||
|
mkb.extend_from_slice(&[0xCD; 16]);
|
||||||
|
let recs = walk_mkb(&mkb);
|
||||||
|
let err = media_key_variant_from_kp(&kp, &c_block, 0, 0, &recs, &[0u8; 16])
|
||||||
|
.expect_err("soft-correction bit → classified, not a key");
|
||||||
|
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -236,7 +236,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
|
/// 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:
|
/// clpi_parse.c:
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
@@ -394,7 +394,7 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
|||||||
return Ok((Vec::new(), Vec::new()));
|
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]
|
// stream_PID: 16 bits → ep_map[2..4]
|
||||||
// reserved: 10 bits ┐
|
// reserved: 10 bits ┐
|
||||||
// EP_stream_type: 4 bits │ ep_map[4..14] = 80 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
|
// 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)>.
|
/// Build a ProgramInfo section. `streams` = Vec<(pid, sci_bytes)>.
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ pub mod coding_type {
|
|||||||
pub const MPEG2_VIDEO: u8 = 0x02;
|
pub const MPEG2_VIDEO: u8 = 0x02;
|
||||||
/// H.264 / AVC video (ISO/IEC 13818-1 Table 2-34).
|
/// H.264 / AVC video (ISO/IEC 13818-1 Table 2-34).
|
||||||
pub const H264: u8 = 0x1B;
|
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).
|
/// HEVC / H.265 video (ISO/IEC 13818-1 Table 2-34, 2015 amendment).
|
||||||
pub const HEVC: u8 = 0x24;
|
pub const HEVC: u8 = 0x24;
|
||||||
/// SMPTE VC-1 video (BD-ROM convention, ISO user-private range).
|
/// SMPTE VC-1 video (BD-ROM convention, ISO user-private range).
|
||||||
|
|||||||
+120
-134
@@ -1,58 +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:
|
//! The algorithm is the one recovered and published in Frank A. Stevenson's
|
||||||
//! - LFSR1: 17-bit state (9-bit lo + 8-bit hi register, seeded from
|
//! 1999 cryptanalysis ("Cryptanalysis of Contents Scrambling System") and
|
||||||
//! key[0..2]), driven by TAB2/TAB3
|
//! described in the open CSS literature. It is implemented here from that public
|
||||||
//! - LFSR0: 24-bit feedback register (seeded from key[2..5] XOR seed[2..5],
|
//! description; its constants (see [`super::tables`]) are the cipher's own
|
||||||
//! masked to 0xFFFFFF), driven by a feedback polynomial through TAB4
|
//! 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.
|
//! The cipher uses two table-driven linear-feedback circuits:
|
||||||
//! Content descrambling computes plain = TAB1[cipher] ^ keystream — a TAB1
|
//! - **LFSR1** — a 17-bit register (a 9-bit and an 8-bit half seeded from
|
||||||
//! substitution of each ciphertext byte followed by an XOR with the keystream
|
//! `key[0..2] XOR seed[0..2]`), stepped through `TAB2`/`TAB3`/`TAB5`.
|
||||||
//! (NOT a plain XOR; the cipher is not its own inverse).
|
//! - **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).
|
//! Each output byte is the sum-with-carry of the two register outputs. A body
|
||||||
//! Tables: CSS specification constants.
|
//! 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};
|
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||||
|
|
||||||
/// Descramble a CSS-encrypted DVD sector in place.
|
/// Descramble a CSS-encrypted DVD sector in place.
|
||||||
///
|
///
|
||||||
/// Exact port of libdvdcss `dvdcss_unscramble` (css.c). The two content
|
/// The two feedback registers are seeded **directly** from
|
||||||
/// LFSRs are seeded **directly** from `title_key XOR sector_seed` — there is
|
/// `title_key XOR sector_seed` (bytes `0x54..0x59`) — there is no title-key
|
||||||
/// no `decrypt_key` mangling on this path (that is the disc/title-key
|
/// mangling on the content path (that belongs to the disc/title-key hierarchy,
|
||||||
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
|
/// not the sector cipher). Only the body, bytes `0x80..0x800`, is transformed:
|
||||||
/// `*p = TAB1[*p] ^ (i_t5 & 0xff)`.
|
/// `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`.
|
||||||
///
|
///
|
||||||
/// The scramble flag at byte 0x14 (bits 4-5) indicates encryption. Like
|
/// The scramble flag at byte `0x14` (bits 4-5) marks an encrypted sector. This
|
||||||
/// libdvdcss, the flag byte is NOT modified here — the caller treats a
|
/// routine CLEARS that flag after unscrambling, so a descrambled sector reads as
|
||||||
/// nonzero `sector[0x14] & 0x30` as "needs unscrambling" and the descramble
|
/// `sector[0x14] & 0x30 == 0`; callers and tests use that to tell it from
|
||||||
/// is its own inverse, so re-running it on plaintext would re-scramble.
|
/// ciphertext, and re-running descramble on an already-cleared sector is a no-op
|
||||||
/// (freemkv historically cleared the flag; we keep clearing it so callers
|
/// (the flag guard below skips it). Clearing does not affect the recovered body.
|
||||||
/// and the existing tests can distinguish a descrambled sector. This does
|
|
||||||
/// not affect the recovered body.)
|
|
||||||
///
|
///
|
||||||
/// No-op (returns without modifying `sector`) in two cases:
|
/// No-op (returns without modifying `sector`) in two cases:
|
||||||
/// - `sector.len() < 2048`: the encrypted region (0x80..0x800) is not
|
/// - `sector.len() < 2048`: the encrypted region (`0x80..0x800`) is not fully
|
||||||
/// fully present. Callers chunk by 2048, so a trailing partial chunk is
|
/// present. Callers chunk by 2048, so a trailing partial chunk is left
|
||||||
/// left untouched. The `debug_assert!` flags this misuse in debug/test
|
/// untouched. The `debug_assert!` flags this misuse in debug/test builds; a
|
||||||
/// builds; a DVD sector is always exactly 2048 bytes.
|
/// DVD sector is always exactly 2048 bytes.
|
||||||
/// - scramble flags are zero: the sector is not CSS-encrypted.
|
/// - 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]) {
|
pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
sector.len() >= 2048,
|
sector.len() >= 2048,
|
||||||
@@ -62,102 +50,103 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// libdvdcss: `if( !(p_sec[0x14] & 0x30) ) return;`
|
// Not scrambled (flag bits 4-5 clear) → nothing to do.
|
||||||
if sector[0x14] & 0x30 == 0 {
|
if sector[0x14] & 0x30 == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// LFSR1: seeded directly from (key ^ seed) — NO decrypt_key.
|
// LFSR1 halves, seeded from (key ^ seed) bytes 0-1. The 9-bit half carries a
|
||||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
// set bit 8 (`| 0x100`) as its running marker.
|
||||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) 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;
|
||||||
|
|
||||||
// LFSR0 (i_t3): 24-bit feedback register seeded from the remaining three
|
// LFSR0 (24-bit), seeded from the remaining three key/seed bytes, then
|
||||||
// key/seed bytes, then transformed `i_t3 = i_t3*2 + 8 - (i_t3 & 7)`.
|
// pre-conditioned `r0 = r0*2 + 8 - (r0 & 7)`.
|
||||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
let mut r0: u32 = (((title_key[2] as u32)
|
||||||
| ((title_key[3] as u32) << 8)
|
| ((title_key[3] as u32) << 8)
|
||||||
| ((title_key[4] as u32) << 16))
|
| ((title_key[4] as u32) << 16))
|
||||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||||
& 0xFF_FFFF;
|
& 0xFF_FFFF;
|
||||||
let i_t4_seed = i_t3 & 7;
|
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
|
||||||
|
|
||||||
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) {
|
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||||
// Advance LFSR1.
|
// Step LFSR1: its output byte `o1`.
|
||||||
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||||
i_t2 = i_t1 >> 1;
|
r1b = r1a >> 1;
|
||||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
r1a = ((r1a & 1) << 8) ^ o1;
|
||||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
o1 = TAB5[o1 as usize] as u32;
|
||||||
|
|
||||||
// Advance LFSR0 (i_t3) and fold both outputs into i_t5.
|
// Step LFSR0: its output byte `o0`.
|
||||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||||
i_t3 = (i_t3 << 8) | i_t6;
|
r0 = (r0 << 8) | o0;
|
||||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
o0 = TAB4[o0 as usize] as u32;
|
||||||
i_t5 += i_t6 + i_t4;
|
|
||||||
|
|
||||||
*byte = TAB1[*byte as usize] ^ (i_t5 & 0xFF) as u8;
|
// Combine (sum with carry) and recover the plaintext byte.
|
||||||
i_t5 >>= 8;
|
acc += o0 + o1;
|
||||||
|
*byte = TAB1[*byte as usize] ^ (acc & 0xFF) as u8;
|
||||||
|
acc >>= 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// libdvdcss leaves byte 0x14 untouched; freemkv clears the scramble bits
|
// Clear the scramble bits so downstream code and tests can tell a sector was
|
||||||
// so downstream code and tests can tell a sector was descrambled.
|
// descrambled; bits 6-7 of byte 0x14 are preserved.
|
||||||
sector[0x14] &= 0xCF;
|
sector[0x14] &= 0xCF;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
|
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
|
||||||
/// CSS ciphertext under `title_key`.
|
/// CSS ciphertext under `title_key`.
|
||||||
///
|
///
|
||||||
/// Descramble computes `plain = TAB1[cipher] ^ (i_t5 & 0xff)`, so the
|
/// Descramble computes `plain = TAB1[cipher] ^ (keystream & 0xff)`, so the
|
||||||
/// inverse is `cipher = TAB1_INV[plain ^ (i_t5 & 0xff)]` with the identical
|
/// inverse is `cipher = TAB1_INV[plain ^ (keystream & 0xff)]` with the identical
|
||||||
/// LFSR keystream. The keystream derivation is byte-for-byte the same as
|
/// keystream. The keystream derivation is the same as [`descramble_sector`];
|
||||||
/// `descramble_sector` (libdvdcss `dvdcss_unscramble`); only the final
|
/// only the final substitution differs. Bytes `0x80..0x800` are rewritten in
|
||||||
/// substitution differs. Bytes 0x80..0x800 are rewritten in place; the
|
/// place; the scramble flag is set to `0x10` so a subsequent descramble runs.
|
||||||
/// scramble flag is set to 0x10 so a subsequent descramble runs.
|
|
||||||
///
|
///
|
||||||
/// Not on any production read path — it exists so the key-recovery tests
|
/// Not on any production read path — it exists so the key-recovery tests (and
|
||||||
/// (and any caller that needs to produce a known CSS-encrypted sector) can
|
/// any caller that needs a known CSS-encrypted sector) can build genuine
|
||||||
/// build genuine ciphertext rather than approximating it.
|
/// ciphertext rather than approximating it.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||||
if sector.len() < 2048 {
|
if sector.len() < 2048 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
let mut r0: u32 = (((title_key[2] as u32)
|
||||||
| ((title_key[3] as u32) << 8)
|
| ((title_key[3] as u32) << 8)
|
||||||
| ((title_key[4] as u32) << 16))
|
| ((title_key[4] as u32) << 16))
|
||||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||||
& 0xFF_FFFF;
|
& 0xFF_FFFF;
|
||||||
let i_t4_seed = i_t3 & 7;
|
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
|
||||||
|
|
||||||
let mut i_t5: u32 = 0;
|
let mut acc: u32 = 0;
|
||||||
|
|
||||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
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;
|
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||||
i_t2 = i_t1 >> 1;
|
r1b = r1a >> 1;
|
||||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
r1a = ((r1a & 1) << 8) ^ o1;
|
||||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
o1 = TAB5[o1 as usize] as u32;
|
||||||
|
|
||||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||||
i_t3 = (i_t3 << 8) | i_t6;
|
r0 = (r0 << 8) | o0;
|
||||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
o0 = TAB4[o0 as usize] as u32;
|
||||||
i_t5 += i_t6 + i_t4;
|
acc += o0 + o1;
|
||||||
|
|
||||||
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
|
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
|
||||||
*byte = (*TAB1_INV)[(*byte ^ (i_t5 & 0xFF) as u8) as usize];
|
*byte = (*TAB1_INV)[(*byte ^ (acc & 0xFF) as u8) as usize];
|
||||||
i_t5 >>= 8;
|
acc >>= 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the sector scrambled so the descrambler will process it.
|
// Mark the sector scrambled so the descrambler will process it.
|
||||||
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
|
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a
|
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a bijection on
|
||||||
/// bijection on 0..256, so `TAB1_INV[TAB1[x]] == x`.
|
/// `0..256`, so `TAB1_INV[TAB1[x]] == x`.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
|
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
|
||||||
let mut inv = [0u8; 256];
|
let mut inv = [0u8; 256];
|
||||||
@@ -181,14 +170,15 @@ mod tests {
|
|||||||
assert_eq!(sector, original);
|
assert_eq!(sector, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cross-check `descramble_sector` against the EXACT output of libdvdcss
|
/// Regression vector: the deterministic output of the CSS content cipher for
|
||||||
/// `dvdcss_unscramble` (css.c) for a fixed sector, computed from the
|
/// a fixed key/seed/body. The value is generated by this implementation and
|
||||||
/// reference C semantics with the reference tables. Pins the content
|
/// is self-consistent with the scramble/descramble round-trip below — any
|
||||||
/// cipher to libdvdcss byte-for-byte.
|
/// 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.
|
/// key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA.
|
||||||
#[test]
|
#[test]
|
||||||
fn descramble_matches_libdvdcss_unscramble_vector() {
|
fn descramble_produces_the_reference_css_vector() {
|
||||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
let mut sector = vec![0xAAu8; 2048];
|
let mut sector = vec![0xAAu8; 2048];
|
||||||
sector[0x14] = 0x30;
|
sector[0x14] = 0x30;
|
||||||
@@ -200,12 +190,12 @@ mod tests {
|
|||||||
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
|
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
|
||||||
0xA0, 0x31
|
0xA0, 0x31
|
||||||
],
|
],
|
||||||
"descramble body head must match libdvdcss dvdcss_unscramble"
|
"descramble body head must match the reference CSS vector"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
§or[0x7F8..0x800],
|
§or[0x7F8..0x800],
|
||||||
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
|
&[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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,8 +231,8 @@ mod tests {
|
|||||||
|
|
||||||
/// Test 2: descramble inverts scramble over the body.
|
/// Test 2: descramble inverts scramble over the body.
|
||||||
///
|
///
|
||||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to
|
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to the
|
||||||
/// the ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
/// ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||||
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
|
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
|
||||||
/// with the same key must reproduce the original body exactly.
|
/// with the same key must reproduce the original body exactly.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -279,9 +269,9 @@ mod tests {
|
|||||||
|
|
||||||
/// css_tab1_relationship
|
/// css_tab1_relationship
|
||||||
///
|
///
|
||||||
/// Verify the structure of TAB1: it is a substitution table used in
|
/// Verify the structure of TAB1: it is a substitution table used in key
|
||||||
/// key mangling. Check that no two inputs map to the same output
|
/// mangling. Check that no two inputs map to the same output (TAB1 is a
|
||||||
/// (TAB1 is a permutation of 0..255).
|
/// permutation of 0..255).
|
||||||
#[test]
|
#[test]
|
||||||
fn css_tab1_is_permutation() {
|
fn css_tab1_is_permutation() {
|
||||||
let mut seen = [false; 256];
|
let mut seen = [false; 256];
|
||||||
@@ -332,8 +322,8 @@ mod tests {
|
|||||||
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
|
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
|
||||||
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
|
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
|
||||||
///
|
///
|
||||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live
|
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live in
|
||||||
/// in bits 4-5; the masked value 0 means not scrambled.
|
/// bits 4-5; the masked value 0 means not scrambled.
|
||||||
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
|
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
|
||||||
/// seen as scrambled and the body would change.
|
/// seen as scrambled and the body would change.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -352,11 +342,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Each individual scramble bit (4 and 5) independently marks the sector
|
/// Each individual scramble bit (4 and 5) independently marks the sector as
|
||||||
/// as encrypted: 0x10 and 0x20 must both trigger descrambling.
|
/// encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||||
///
|
///
|
||||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both
|
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both nonzero.
|
||||||
/// nonzero.
|
|
||||||
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
|
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
|
||||||
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
|
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -381,8 +370,8 @@ mod tests {
|
|||||||
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
|
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
|
||||||
///
|
///
|
||||||
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
|
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
|
||||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved
|
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved high
|
||||||
/// high bits assert fails.
|
/// bits assert fails.
|
||||||
#[test]
|
#[test]
|
||||||
fn descramble_clear_preserves_high_bits_of_0x14() {
|
fn descramble_clear_preserves_high_bits_of_0x14() {
|
||||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||||
@@ -398,11 +387,11 @@ mod tests {
|
|||||||
|
|
||||||
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
|
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
|
||||||
|
|
||||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80
|
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80 (the
|
||||||
/// (the header) must NOT be modified by the keystream — except byte 0x14
|
/// header) must NOT be modified by the keystream — except byte 0x14 whose
|
||||||
/// whose flag is cleared. In particular the sector-seed bytes 0x54..0x59
|
/// flag is cleared. In particular the sector-seed bytes 0x54..0x59 (which
|
||||||
/// (which live inside the header) must survive untouched, since the
|
/// live inside the header) must survive untouched, since the descrambler
|
||||||
/// descrambler reads them but never writes them.
|
/// reads them but never writes them.
|
||||||
///
|
///
|
||||||
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
|
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
|
||||||
/// 128..2048 only.
|
/// 128..2048 only.
|
||||||
@@ -429,16 +418,15 @@ mod tests {
|
|||||||
assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive");
|
assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a
|
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a prefix.
|
||||||
/// prefix. With a constant body and constant key, the keystream is
|
/// With a constant body and constant key, the keystream is non-degenerate
|
||||||
/// non-degenerate enough that the very last sector byte (index 2047) is
|
/// enough that the very last sector byte (index 2047) is altered. This guards
|
||||||
/// altered. This guards the loop bound `.take(2048)` against an
|
/// the loop bound `.take(2048)` against an off-by-one that would leave the
|
||||||
/// off-by-one that would leave the final byte(s) scrambled.
|
/// final byte(s) scrambled.
|
||||||
///
|
///
|
||||||
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
|
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
|
||||||
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
|
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
|
||||||
/// assert fires (keystream byte for the last position is verified nonzero
|
/// assert fires (this body is all-zero so any keystream XOR shows).
|
||||||
/// below by the round-trip, and this body is all-zero so any XOR shows).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descramble_covers_final_body_byte() {
|
fn descramble_covers_final_body_byte() {
|
||||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
@@ -447,9 +435,7 @@ mod tests {
|
|||||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||||
descramble_sector(&key, &mut sector);
|
descramble_sector(&key, &mut sector);
|
||||||
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
|
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
|
||||||
// the keystream reaches the final byte. (If the last keystream byte
|
// the keystream reaches the final byte.
|
||||||
// happened to be 0 this could be a flaky test, so assert the run-end
|
|
||||||
// region as a whole differs from zero.)
|
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
§or[2040..2048],
|
§or[2040..2048],
|
||||||
&[0u8; 8][..],
|
&[0u8; 8][..],
|
||||||
@@ -458,13 +444,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Descramble is keyed by `title_key XOR seed`: two different title keys
|
/// Descramble is keyed by `title_key XOR seed`: two different title keys
|
||||||
/// produce two different bodies for the same scrambled input. A cipher
|
/// produce two different bodies for the same scrambled input. A cipher that
|
||||||
/// that ignored the title key (or mixed it in wrongly) would yield
|
/// ignored the title key (or mixed it in wrongly) would yield identical
|
||||||
/// identical output — silent wrong-key decryption.
|
/// output — silent wrong-key decryption.
|
||||||
///
|
///
|
||||||
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
|
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
|
||||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both
|
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both keys
|
||||||
/// keys give the same body, assert fires.
|
/// give the same body, assert fires.
|
||||||
#[test]
|
#[test]
|
||||||
fn descramble_output_depends_on_title_key() {
|
fn descramble_output_depends_on_title_key() {
|
||||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||||
|
|||||||
+47
-1
@@ -46,7 +46,7 @@ pub struct CssState {
|
|||||||
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
|
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
|
||||||
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
|
/// 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 /
|
/// (bytes 0x00..0x80) often ends in a short-period repeating run (stuffing /
|
||||||
/// constant fill); the attack assumes that run continues across the 0x80
|
/// constant fill); the attack assumes that run continues across the 0x80
|
||||||
/// boundary into the encrypted region, giving the known plaintext the 2^16
|
/// boundary into the encrypted region, giving the known plaintext the 2^16
|
||||||
@@ -271,6 +271,52 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
|
|||||||
lfsr::descramble_sector(&state.title_key, sector);
|
lfsr::descramble_sector(&state.title_key, sector);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 (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
|
||||||
|
/// the CSS primitives and runs inside `decrypt::decrypt_sectors` (a public,
|
||||||
|
/// self-contained CSS decrypt), NOT at the post-decrypt recovery seam that AACS
|
||||||
|
/// key-fetch and FMTS segment-skip use (those consume external inputs).
|
||||||
|
///
|
||||||
|
/// The clear header (`<0x80`) is never scrambled, so its periodic crib predicts
|
||||||
|
/// the plaintext at `0x80`. Descramble with the cached key; if the crib fails to
|
||||||
|
/// reappear the key region changed (or the primed key was wrong) — restore the
|
||||||
|
/// ciphertext, re-crack from this very sector, and descramble again. A crib-less
|
||||||
|
/// sector (no periodic run) can be neither validated nor cracked, so it rides the
|
||||||
|
/// cached key — correct, because it lives in the same region as the nearby crib
|
||||||
|
/// sector that set the cache.
|
||||||
|
pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) {
|
||||||
|
for chunk in buf.chunks_mut(2048) {
|
||||||
|
if chunk.len() < 2048 || !is_scrambled(chunk) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let crib = stevenson::attack_crib(chunk);
|
||||||
|
// Snapshot the ciphertext (chunk is exactly 2048 here) only when there is
|
||||||
|
// a crib to validate against, so the common cache-hit path costs no
|
||||||
|
// per-sector heap allocation.
|
||||||
|
let mut original = [0u8; 2048];
|
||||||
|
if crib.is_some() {
|
||||||
|
original.copy_from_slice(chunk);
|
||||||
|
}
|
||||||
|
lfsr::descramble_sector(title_key, chunk);
|
||||||
|
if let Some(crib) = crib {
|
||||||
|
if chunk[0x80..0x80 + 10] != crib[..] {
|
||||||
|
// Cached key is stale for this region — restore the ciphertext and
|
||||||
|
// crack this sector's own key.
|
||||||
|
chunk.copy_from_slice(&original);
|
||||||
|
if let Some(fresh) = stevenson::crack_title_key(chunk) {
|
||||||
|
*title_key = fresh;
|
||||||
|
}
|
||||||
|
lfsr::descramble_sector(title_key, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a sector has the CSS scramble flag set.
|
/// Check if a sector has the CSS scramble flag set.
|
||||||
///
|
///
|
||||||
/// This is the RAW flag test — bits 4-5 of the sub-header byte 0x14 — used by
|
/// This is the RAW flag test — bits 4-5 of the sub-header byte 0x14 — used by
|
||||||
|
|||||||
+42
-61
@@ -1,48 +1,37 @@
|
|||||||
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
|
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
|
||||||
//! (1999), ported exactly from libdvdcss `RecoverTitleKey` + `AttackPattern`
|
//! (1999), implemented from his published cryptanalysis ("Cryptanalysis of
|
||||||
//! (css.c).
|
//! 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
|
||||||
//! Recovers the 5-byte CSS title key from a single scrambled DVD sector with
|
//! only known plaintext. Implemented from that public description; nothing here
|
||||||
//! no player keys and no disc-key crack, using only known plaintext.
|
//! is copied or translated from any particular CSS software.
|
||||||
//!
|
//!
|
||||||
//! # The cipher this attacks
|
//! # The cipher this attacks
|
||||||
//!
|
//!
|
||||||
//! The content descrambler ([`super::lfsr::descramble_sector`], = libdvdcss
|
//! The content descrambler ([`super::lfsr::descramble_sector`]) seeds its two
|
||||||
//! `dvdcss_unscramble`) seeds its two LFSRs **directly** from
|
//! LFSRs **directly** from `key = title_key XOR sector_seed` (seed =
|
||||||
//! `key = title_key XOR sector_seed` (seed = `sector[0x54..0x59]`):
|
//! `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
|
||||||
//! ```text
|
//! byte recovered as `plain = TAB1[cipher] ^ (keystream & 0xff)`. There is no
|
||||||
//! i_t1 = (key[0] ^ sec[0x54]) | 0x100; // LFSR1 low (9-bit)
|
//! title-key mangling on the content path, so the recovery is a single inversion
|
||||||
//! i_t2 = key[1] ^ sec[0x55]; // LFSR1 high
|
//! of the sector cipher.
|
||||||
//! 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 attack
|
//! # The attack
|
||||||
//!
|
//!
|
||||||
//! 1. **Known plaintext → keystream.** Because the descramble applies TAB1
|
//! 1. **Known plaintext → keystream.** Because descramble applies TAB1 to the
|
||||||
//! to the ciphertext, the per-byte keystream is
|
//! ciphertext, the per-byte keystream is `TAB1[cipher[i]] ^ plain[i]`.
|
||||||
//! `buf[i] = TAB1[cipher[i]] ^ plain[i]` (matching libdvdcss
|
|
||||||
//! `RecoverTitleKey`'s `p_buffer`).
|
|
||||||
//! 2. **Brute the 16-bit LFSR1 seed.** For each of 2^16 seeds, run LFSR1
|
//! 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
|
//! forward; for the first four steps deduce the LFSR0 output bytes from the
|
||||||
//! the keystream (carry-tracked), reconstructing `i_t3`. For the next six
|
//! keystream (carry-tracked), reconstructing LFSR0's state. For the next six
|
||||||
//! steps clock LFSR0 normally and check it reproduces the keystream — a
|
//! steps clock LFSR0 normally and check it reproduces the keystream — a wrong
|
||||||
//! wrong LFSR1 seed fails fast.
|
//! LFSR1 seed fails fast.
|
||||||
//! 3. **Back-clock LFSR0.** Run four backward `i_t3` steps (each a 256-way
|
//! 3. **Back-clock LFSR0.** Run four backward steps (each a 256-way search for
|
||||||
//! search for the byte shifted in) to reach the initial state, then undo
|
//! the byte shifted in) to reach the initial state, then undo the
|
||||||
//! `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
//! `r0*2 + 8 - (r0 & 7)` pre-conditioning to recover key[2..5].
|
||||||
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]` (plain XOR —
|
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]`.
|
||||||
//! the descramble seeds directly, so there is no inversion).
|
|
||||||
//!
|
//!
|
||||||
//! `AttackPattern` finds known plaintext for step 1: the longest periodic
|
//! Known plaintext for step 1 comes from the longest periodic run in the
|
||||||
//! run in the cleartext `sec[0x00..0x80]`, assumed to continue into the
|
//! cleartext `sec[0x00..0x80]`, assumed to continue into the encrypted region at
|
||||||
//! encrypted region at 0x80.
|
//! 0x80.
|
||||||
|
|
||||||
use super::lfsr::descramble_sector;
|
use super::lfsr::descramble_sector;
|
||||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
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 SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
||||||
const FLAG_BYTE: usize = 0x14;
|
const FLAG_BYTE: usize = 0x14;
|
||||||
|
|
||||||
/// RecoverTitleKey: recover the title key from cipher + known plaintext.
|
/// Recover the title key from cipher + known plaintext (the core of Stevenson's
|
||||||
///
|
/// attack). `crypted` is the ciphertext starting at sector byte 0x80;
|
||||||
/// Exact port of libdvdcss `RecoverTitleKey` (css.c). `crypted` is the
|
/// `decrypted` is the matching known plaintext; `seed` is `sector[0x54..0x59]`.
|
||||||
/// ciphertext starting at sector byte 0x80; `decrypted` is the matching
|
/// On success returns the recovered 5-byte title key; `None` if no LFSR seed
|
||||||
/// known plaintext; `seed` is `sector[0x54..0x59]`. On success returns the
|
/// reproduces the keystream.
|
||||||
/// 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
|
/// At least 10 bytes of `crypted`/`decrypted` are required (the cipher is
|
||||||
/// iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).
|
/// 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]
|
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AttackPattern: find a repeating pattern just before the encrypted region
|
/// Find a repeating pattern just before the encrypted region and assume the
|
||||||
/// and assume the plaintext at 0x80 continues it.
|
/// plaintext at 0x80 continues it — the known-plaintext step of Stevenson's
|
||||||
///
|
/// attack. Scans cleartext `sec[0x00..0x80]` for the longest run that repeats
|
||||||
/// Functionally-equivalent port of libdvdcss `AttackPattern` (css.c) — finds the
|
/// with a cycle length in 2..0x2F. If the run is long enough (`plen > 3` and at
|
||||||
/// same periodic cribs on real DVD data, though its byte-comparison anchor
|
/// least two full cycles), the known plaintext at 0x80 is taken to be the
|
||||||
/// differs from the C on phase-misaligned runs. Scans cleartext
|
/// periodic run continuing forward, and [`recover_title_key_from_plain`] is
|
||||||
/// `sec[0x00..0x80]` for the longest run that repeats with a cycle length in
|
/// applied.
|
||||||
/// 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]> {
|
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||||
if sector.len() < SECTOR_BYTES {
|
if sector.len() < SECTOR_BYTES {
|
||||||
return None;
|
return None;
|
||||||
@@ -260,10 +244,7 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inner body of [`crack_title_key`] — the actual AttackPattern search. Split
|
/// Crib: the predicted 10-byte plaintext at byte 0x80.
|
||||||
/// out so the public entry point can wall-clock the whole attempt for the
|
|
||||||
/// runaway guard without threading a timer through every return path.
|
|
||||||
/// AttackPattern crib: the predicted 10-byte plaintext at byte 0x80.
|
|
||||||
///
|
///
|
||||||
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
|
/// 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
|
/// run that repeats with a cycle length in 2..0x2F. If the run is long enough
|
||||||
@@ -369,7 +350,7 @@ mod tests {
|
|||||||
|
|
||||||
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
|
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
|
||||||
/// in a periodic run that continues into the encrypted region — the case
|
/// 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(
|
fn synth_periodic_sector(
|
||||||
title_key: &[u8; 5],
|
title_key: &[u8; 5],
|
||||||
seed: &[u8; 5],
|
seed: &[u8; 5],
|
||||||
@@ -382,7 +363,7 @@ mod tests {
|
|||||||
// (RUN_START..0x80) and continuing into the encrypted region. This
|
// (RUN_START..0x80) and continuing into the encrypted region. This
|
||||||
// mirrors a real VOB: a periodic data run just before the scrambled
|
// 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
|
// 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.
|
// offset 0 so the run is consistent across the 0x80 boundary.
|
||||||
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
|
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
|
||||||
// — enough for >=2 cycles of every tested period (<=19).
|
// — enough for >=2 cycles of every tested period (<=19).
|
||||||
@@ -469,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
|
/// no plaintext supplied — recovers a round-tripping key when the
|
||||||
/// cleartext ends in a periodic run that continues into 0x80.
|
/// cleartext ends in a periodic run that continues into 0x80.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -491,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
|
/// a known body, hand back the keystream-derived key, and the recovered
|
||||||
/// key (XOR-back included) reproduces the plaintext.
|
/// key (XOR-back included) reproduces the plaintext.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+11
-12
@@ -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,
|
0xb7, 0xf7, 0xbf, 0xa2, 0xe7, 0xa7, 0xef, 0xf2, 0xba, 0xfa, 0xb2, 0xaf, 0xea, 0xaa, 0xe2, 0xff,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Table 2: LFSR1 high-byte feedback permutation.
|
/// Table 2: LFSR1 high-byte feedback permutation — a fixed constant of the CSS
|
||||||
///
|
/// cipher (per the published algorithm).
|
||||||
/// Byte-identical to libdvdcss `p_css_tab2` (csstables.h).
|
|
||||||
pub const TAB2: [u8; 256] = [
|
pub const TAB2: [u8; 256] = [
|
||||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09, 0x08, 0x0b, 0x0a, 0x0d, 0x0c, 0x0f, 0x0e,
|
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,
|
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,
|
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
|
/// It is the 8-value block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step
|
||||||
/// block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step indexes this
|
/// indexes this table with the 9-bit low register (0x100..=0x1FF), but only the
|
||||||
/// table with the 9-bit low register (0x100..=0x1FF), but only the low 3
|
/// low 3 bits select the output — the high bits are ignored, hence the constant
|
||||||
/// 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
|
/// blocks. The 512-entry width simply lets the 9-bit index be used without
|
||||||
/// masking.
|
/// masking.
|
||||||
pub const TAB3: [u8; 512] = [
|
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]
|
/// BASE = [0x00,0x24,0x49,0x6d,0x92,0xb6,0xdb,0xff]
|
||||||
/// repeated 64 times — `TAB3[i] == BASE[i & 7]`. The high bits of the
|
/// 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
|
/// 9-bit index do not affect the output (the LFSR1 step indexes with the
|
||||||
/// with the full 9-bit low register but only `& 7` matters). This pins
|
/// full 9-bit low register but only `& 7` matters). This pins all 512
|
||||||
/// all 512 entries to the published table.
|
/// entries to the published cipher's table.
|
||||||
///
|
///
|
||||||
/// Mutation: flip any single byte in the TAB3 literal -> the formula
|
/// Mutation: flip any single byte in the TAB3 literal -> the formula
|
||||||
/// check fails at that index.
|
/// check fails at that index.
|
||||||
|
|||||||
+463
-148
@@ -123,17 +123,24 @@ pub fn decrypt_threads() -> usize {
|
|||||||
if explicit > 0 {
|
if explicit > 0 {
|
||||||
return explicit;
|
return explicit;
|
||||||
}
|
}
|
||||||
let env = std::env::var("FREEMKV_THREADS")
|
// Resolve the `FREEMKV_THREADS` env var + `available_parallelism()` ONCE and
|
||||||
.ok()
|
// cache it — this runs on the per-buffer decrypt hot path, and a getenv +
|
||||||
.and_then(|v| v.parse::<usize>().ok())
|
// String alloc + parallelism syscall per call is pure overhead. The explicit
|
||||||
.unwrap_or(0);
|
// `set_decrypt_threads` override above still takes effect dynamically.
|
||||||
if env > 0 {
|
static DEFAULT_THREADS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||||
return env.min(MAX_THREADS);
|
*DEFAULT_THREADS.get_or_init(|| {
|
||||||
}
|
let env = std::env::var("FREEMKV_THREADS")
|
||||||
let cores = std::thread::available_parallelism()
|
.ok()
|
||||||
.map(|n| n.get())
|
.and_then(|v| v.parse::<usize>().ok())
|
||||||
.unwrap_or(2);
|
.unwrap_or(0);
|
||||||
cores.clamp(1, MAX_THREADS)
|
if env > 0 {
|
||||||
|
return env.min(MAX_THREADS);
|
||||||
|
}
|
||||||
|
let cores = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(2);
|
||||||
|
cores.clamp(1, MAX_THREADS)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolved decryption state from disc scanning.
|
/// Resolved decryption state from disc scanning.
|
||||||
@@ -143,10 +150,15 @@ pub fn decrypt_threads() -> usize {
|
|||||||
pub enum DecryptKeys {
|
pub enum DecryptKeys {
|
||||||
/// No encryption on this disc.
|
/// No encryption on this disc.
|
||||||
None,
|
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 {
|
Aacs {
|
||||||
unit_keys: Vec<(u32, [u8; 16])>,
|
unit_keys: Vec<(u32, [u8; 16])>,
|
||||||
read_data_key: Option<[u8; 16]>,
|
read_data_key: Option<[u8; 16]>,
|
||||||
|
format: crate::disc::ContentFormat,
|
||||||
},
|
},
|
||||||
/// CSS (DVD). Title key for sector descrambling.
|
/// CSS (DVD). Title key for sector descrambling.
|
||||||
Css { title_key: [u8; 5] },
|
Css { title_key: [u8; 5] },
|
||||||
@@ -159,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.
|
/// Decrypt a buffer of sectors in-place.
|
||||||
///
|
///
|
||||||
/// For AACS: processes in 6144-byte aligned units (3 sectors).
|
/// For AACS: processes in 6144-byte aligned units (3 sectors).
|
||||||
@@ -173,14 +362,16 @@ impl DecryptKeys {
|
|||||||
/// Returns `Err` if decryption was expected but keys are missing or invalid.
|
/// Returns `Err` if decryption was expected but keys are missing or invalid.
|
||||||
/// Never produces silently corrupted output.
|
/// Never produces silently corrupted output.
|
||||||
///
|
///
|
||||||
/// On success returns the number of bytes belonging to scrambled AACS units
|
/// Pure decrypt: every encrypted unit has a key APPLIED in place and the
|
||||||
/// that **no available key could decrypt** — those units are restored to their
|
/// plaintext is left as-is — this function applies NO policy (it never restores
|
||||||
/// original encrypted bytes (so a clear nav-file is never corrupted), but for
|
/// ciphertext, nulls, or re-fetches). On success it returns the number of bytes
|
||||||
/// genuine encrypted content this is silent data loss the downstream TS
|
/// belonging to units a key was applied to but that did NOT reassemble to clean
|
||||||
/// assembler will drop without a sync. The decrypt-on-read decorator folds this
|
/// MPEG-TS ("unverified"). "Did a key open it to clean TS?" is a key-SELECTION /
|
||||||
/// count into the mux loss accounting so a partial key failure can't be reported
|
/// read-VERIFY signal, NOT a "did we decrypt?" verdict — a correct key can
|
||||||
/// as a perfect rip. `0` for `None` / `Css` and for any AACS buffer where every
|
/// decrypt content whose encoding is broken. The caller decides what an
|
||||||
/// scrambled unit decrypted.
|
/// 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(
|
pub fn decrypt_sectors(
|
||||||
buf: &mut [u8],
|
buf: &mut [u8],
|
||||||
keys: &mut DecryptKeys,
|
keys: &mut DecryptKeys,
|
||||||
@@ -197,7 +388,8 @@ pub fn decrypt_sectors(
|
|||||||
/// no TS sync, which would otherwise be mistaken for ciphertext). `base_lba` is
|
/// 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.
|
/// 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(
|
pub fn decrypt_sectors_in_content(
|
||||||
buf: &mut [u8],
|
buf: &mut [u8],
|
||||||
keys: &mut DecryptKeys,
|
keys: &mut DecryptKeys,
|
||||||
@@ -233,6 +425,7 @@ fn decrypt_sectors_impl(
|
|||||||
DecryptKeys::Aacs {
|
DecryptKeys::Aacs {
|
||||||
unit_keys,
|
unit_keys,
|
||||||
read_data_key,
|
read_data_key,
|
||||||
|
format,
|
||||||
} => {
|
} => {
|
||||||
// Validate that unit_key_idx is in-range before doing anything else.
|
// Validate that unit_key_idx is in-range before doing anything else.
|
||||||
// This preserves the existing contract: an out-of-range explicit index
|
// This preserves the existing contract: an out-of-range explicit index
|
||||||
@@ -241,8 +434,13 @@ fn decrypt_sectors_impl(
|
|||||||
return Err(crate::error::Error::DecryptFailed);
|
return Err(crate::error::Error::DecryptFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip CPS-unit IDs — the decrypt primitives only want the raw key bytes.
|
// Container of this disc's content — the key SELECTOR (`is_clean`)
|
||||||
let raw_keys: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect();
|
// 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 rdk: Option<[u8; 16]> = *read_data_key;
|
||||||
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
|
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
|
||||||
// AACS decrypts whole 6144-byte aligned units. The live mux path
|
// AACS decrypts whole 6144-byte aligned units. The live mux path
|
||||||
@@ -265,7 +463,7 @@ fn decrypt_sectors_impl(
|
|||||||
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
||||||
// the highway path's Error::ExtentNotUnitAligned policy.
|
// 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
|
// buffer shorter than a full unit, so it cannot judge a partial. We
|
||||||
// instead apply the same TS-sync-intactness test it uses internally
|
// instead apply the same TS-sync-intactness test it uses internally
|
||||||
// (ts_sync_count vs ts_packet_total) directly to the available
|
// (ts_sync_count vs ts_packet_total) directly to the available
|
||||||
@@ -286,10 +484,14 @@ fn decrypt_sectors_impl(
|
|||||||
Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges),
|
Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges),
|
||||||
None => true,
|
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 partial = &buf[buf.len() - partial_len..];
|
||||||
let packets = aacs::content::ts_packet_total(partial);
|
if !aacs::content::is_clean(partial, format) {
|
||||||
if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 {
|
|
||||||
return Err(crate::error::Error::DecryptFailed);
|
return Err(crate::error::Error::DecryptFailed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,27 +515,34 @@ fn decrypt_sectors_impl(
|
|||||||
// accounting so a partial key failure isn't reported as a clean rip.
|
// accounting so a partial key failure isn't reported as a clean rip.
|
||||||
let dropped_bytes = AtomicUsize::new(0);
|
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
|
// 1. Try the cached key index first (avoids scanning all keys on the
|
||||||
// common case where a disc run uses one CPS unit throughout).
|
// common case where a disc run uses one CPS unit throughout).
|
||||||
// 2. On miss, try every key in order (multi-CPS-unit discs).
|
// 2. On miss, try every key in order (multi-CPS-unit discs).
|
||||||
// 3. Accept the first key whose output passes the TS-sync verify.
|
// 3. Select the first key whose output passes the TS-sync verify.
|
||||||
// 4. Only restore-to-original if NO key validates (non-m2ts unit or
|
// 4. If NONE yields clean TS, keep the applied-key plaintext anyway
|
||||||
// genuine decrypt failure). See test
|
// (a key WAS applied — bad TS is the caller's/muxer's concern) and
|
||||||
// `nav_file_unit_survives_decrypt_attempt`.
|
// 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
|
// 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
|
// must happen first — it's a shared layer on top that is key-independent
|
||||||
// across all CPS units on the disc.
|
// across all CPS units on the disc.
|
||||||
let decrypt_one = |chunk: &mut [u8]| {
|
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;
|
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
|
// Bus-decrypt (AACS 2.0) in place first — a shared layer under every
|
||||||
// in-place when there is no bus layer.
|
// CPS unit key. Whatever we do below operates on the bus-clear bytes.
|
||||||
if let Some(ref rdk_key) = rdk {
|
if let Some(ref rdk_key) = rdk {
|
||||||
aacs::content::decrypt_bus(chunk, rdk_key);
|
aacs::content::decrypt_bus(chunk, rdk_key);
|
||||||
}
|
}
|
||||||
@@ -342,29 +551,68 @@ fn decrypt_sectors_impl(
|
|||||||
// back to the full list skipping the hint.
|
// back to the full list skipping the hint.
|
||||||
let hint = last_key_idx.load(Ordering::Relaxed);
|
let hint = last_key_idx.load(Ordering::Relaxed);
|
||||||
let try_order =
|
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 {
|
for idx in try_order {
|
||||||
if let Some(key) = raw_keys.get(idx) {
|
if let Some((_, key)) = unit_keys.get(idx) {
|
||||||
// Work on a per-key copy so a failing attempt doesn't
|
scratch.copy_from_slice(chunk);
|
||||||
// clobber the bus-decrypted base we'll retry on.
|
aacs::content::decrypt_unit(scratch, key);
|
||||||
let mut attempt: Vec<u8> = chunk.to_vec();
|
if aacs::content::is_clean(scratch, format) {
|
||||||
if aacs::content::decrypt_unit(&mut attempt, key) {
|
chunk.copy_from_slice(scratch);
|
||||||
chunk.copy_from_slice(&attempt);
|
|
||||||
last_key_idx.store(idx, Ordering::Relaxed);
|
last_key_idx.store(idx, Ordering::Relaxed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if first_idx.is_none() {
|
||||||
|
first_idx = Some(idx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No key validated — restore the original encrypted bytes and
|
// No key yielded clean structure. Keep the first-tried key's
|
||||||
// tally the loss. The unit is flagged encrypted (we only reach
|
// plaintext (the pool is non-empty past the guard, so `first_idx` is
|
||||||
// here past the CPI gate) but no key applied: genuine encrypted
|
// always `Some`) and tally the unit as unverified. Never restore
|
||||||
// content with a missing/wrong sub-key. We always tally; the mux
|
// ciphertext; that is a caller concern, threaded through the recovery
|
||||||
// read path treats
|
// ciphertext, not this seam.
|
||||||
// the count as loss (its extents are real content), while
|
if let Some(idx) = first_idx {
|
||||||
// metadata-probe callers that don't install a loss sink ignore it.
|
aacs::content::decrypt_unit(chunk, &unit_keys[idx].1);
|
||||||
chunk.copy_from_slice(&original);
|
}
|
||||||
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -401,11 +649,14 @@ fn decrypt_sectors_impl(
|
|||||||
// back to the serial path rather than panic.
|
// back to the serial path rather than panic.
|
||||||
match decrypt_pool() {
|
match decrypt_pool() {
|
||||||
Some(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(|| {
|
pool.install(|| {
|
||||||
chunks.into_par_iter().enumerate().for_each(|(idx, chunk)| {
|
buf.par_chunks_mut(unit_len)
|
||||||
process(idx, chunk);
|
.enumerate()
|
||||||
});
|
.for_each(|(idx, chunk)| {
|
||||||
|
process(idx, chunk);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -418,49 +669,13 @@ fn decrypt_sectors_impl(
|
|||||||
dropped_bytes.into_inner()
|
dropped_bytes.into_inner()
|
||||||
}
|
}
|
||||||
DecryptKeys::Css { title_key } => {
|
DecryptKeys::Css { title_key } => {
|
||||||
// CSS has no supplied key list: the ONLY source of a title key is
|
// CSS SELF-recovers: the title key changes per VOB region and is
|
||||||
// cracking the data, and the key changes per VTS/VOB region. So
|
// re-cracked constantly, but always FROM THE DATA ITSELF — no external
|
||||||
// `title_key` is a CACHE of the last crack, not a fixed disc key —
|
// input. So the whole descramble-and-rekey is self-contained here (see
|
||||||
// applying it blindly across a region boundary descrambles with the
|
// `css::descramble_region`), and CSS does not need the post-decrypt
|
||||||
// wrong key (valid headers, garbage payload). Validate it on every
|
// recovery seam that AACS key-fetch / FMTS segment-skip use (those DO
|
||||||
// scrambled sector and re-crack on a miss (libdvdcss's on-demand
|
// consume external inputs a `decrypt_sectors` caller cannot supply).
|
||||||
// per-region rekey; the same validate-then-rekey shape the AACS arm
|
css::descramble_region(buf, title_key);
|
||||||
// above uses, but re-cracking instead of picking from a list).
|
|
||||||
//
|
|
||||||
// The clear header (<0x80) is never scrambled, so its periodic crib
|
|
||||||
// predicts the plaintext at 0x80. Descramble with the cached key; if
|
|
||||||
// the crib fails to reappear the key region changed (or the primed
|
|
||||||
// key was wrong) — restore the ciphertext, re-crack from this very
|
|
||||||
// sector, and descramble again. A crib-less sector (no periodic run)
|
|
||||||
// can be neither validated nor cracked, so it rides the cached key —
|
|
||||||
// correct, because it lives in the same region as the nearby crib
|
|
||||||
// sector that set the cache.
|
|
||||||
for chunk in buf.chunks_mut(2048) {
|
|
||||||
if chunk.len() < 2048 || !css::is_scrambled(chunk) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let crib = css::stevenson::attack_crib(chunk);
|
|
||||||
// Snapshot the ciphertext into a stack buffer (chunk is exactly
|
|
||||||
// 2048 here — guaranteed by the `< 2048` continue above) only
|
|
||||||
// when there's a crib to validate against, so the common
|
|
||||||
// cache-hit path costs no per-sector heap allocation.
|
|
||||||
let mut original = [0u8; 2048];
|
|
||||||
if crib.is_some() {
|
|
||||||
original.copy_from_slice(chunk);
|
|
||||||
}
|
|
||||||
css::lfsr::descramble_sector(title_key, chunk);
|
|
||||||
if let Some(crib) = crib {
|
|
||||||
if chunk[0x80..0x80 + 10] != crib[..] {
|
|
||||||
// Cached key is stale for this region — restore the
|
|
||||||
// ciphertext and crack this sector's own key.
|
|
||||||
chunk.copy_from_slice(&original);
|
|
||||||
if let Some(fresh) = css::stevenson::crack_title_key(chunk) {
|
|
||||||
*title_key = fresh;
|
|
||||||
}
|
|
||||||
css::lfsr::descramble_sector(title_key, chunk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -471,13 +686,15 @@ fn decrypt_sectors_impl(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here
|
/// Regression for the 0.18.1 nav-file scramble bug, modern form. A non-m2ts
|
||||||
/// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled
|
/// unit (here an MPLS file: starts "MPLS", whose byte-0 'M'=0x4D coincidentally
|
||||||
/// under `ts_sync_destroyed`, gets AES-decrypted with the unit key, fails
|
/// sets the CPI bits, so it reads as encrypted) must never be scrambled by a
|
||||||
/// the TS-sync verification, and must be restored to its original bytes —
|
/// decrypt attempt. The decrypter applies NO policy and no longer restores — so
|
||||||
/// not left scrambled.
|
/// 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]
|
#[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];
|
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
unit[0] = b'M';
|
unit[0] = b'M';
|
||||||
unit[1] = b'P';
|
unit[1] = b'P';
|
||||||
@@ -491,11 +708,14 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
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!(
|
assert_eq!(
|
||||||
unit, snapshot,
|
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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,6 +780,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
|
|
||||||
@@ -593,6 +814,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
||||||
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
|
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
|
||||||
@@ -611,6 +833,7 @@ mod tests {
|
|||||||
let mut keys_g = DecryptKeys::Aacs {
|
let mut keys_g = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut keys_u = keys_g.clone();
|
let mut keys_u = keys_g.clone();
|
||||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
@@ -661,6 +884,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
let mut buf = original.clone();
|
let mut buf = original.clone();
|
||||||
@@ -679,6 +903,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
let mut buf = original.clone();
|
let mut buf = original.clone();
|
||||||
@@ -713,6 +938,83 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a Stevenson-crackable scrambled CSS sector for `title_key` (mirrors
|
||||||
|
/// `crackable_sector` in the css::mod tests): a periodic run in the clear
|
||||||
|
/// header continues past 0x80 into the encrypted region, so
|
||||||
|
/// `stevenson::crack_title_key` recovers the key. Distinct `seed` values give
|
||||||
|
/// two sectors different cribs, standing in for two VOB regions.
|
||||||
|
fn crackable_css_sector(title_key: &[u8; 5], seed: &[u8; 5]) -> Vec<u8> {
|
||||||
|
const RUN_START: usize = 0x59;
|
||||||
|
const SEED_OFFSET: usize = 0x54;
|
||||||
|
const PERIOD: usize = 8;
|
||||||
|
let mut plaintext = vec![0u8; 2048];
|
||||||
|
plaintext[0x00..0x04].copy_from_slice(&css::PACK_START);
|
||||||
|
plaintext[0x14] = 0x10; // scramble flag
|
||||||
|
let pat: Vec<u8> = (0..PERIOD)
|
||||||
|
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
|
||||||
|
.collect();
|
||||||
|
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
|
||||||
|
*b = pat[i % PERIOD];
|
||||||
|
}
|
||||||
|
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||||
|
css::lfsr::scramble_sector(title_key, &mut plaintext);
|
||||||
|
plaintext
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CHARACTERIZATION (recovery refactor safety net): the CSS arm's per-region
|
||||||
|
/// re-crack (the `title_key` cache is stale for a new VOB region → restore
|
||||||
|
/// ciphertext, `crack_title_key` this sector, re-descramble). Two crackable
|
||||||
|
/// sectors scrambled under DIFFERENT keys sit back-to-back; the cache is
|
||||||
|
/// primed to the FIRST key. Sector 0 rides the cache (crib matches); sector 1
|
||||||
|
/// must trip the crib mismatch and re-crack to its own key. Both must land
|
||||||
|
/// correct plaintext, and the cache must end on region 1's key.
|
||||||
|
///
|
||||||
|
/// This behaviour currently lives inline in `decrypt_sectors` (the `Css`
|
||||||
|
/// arm). It is the delicate logic the recovery refactor will move to the
|
||||||
|
/// input-stream seam, so it must stay green byte-for-byte across that move.
|
||||||
|
#[test]
|
||||||
|
fn css_region_change_recracks_the_title_key() {
|
||||||
|
let key_a = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let key_b = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE];
|
||||||
|
let sector_a = crackable_css_sector(&key_a, &[0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||||
|
let sector_b = crackable_css_sector(&key_b, &[0x09, 0x08, 0x07, 0x06, 0x05]);
|
||||||
|
|
||||||
|
// Expected plaintext bodies: each sector descrambled under its true key.
|
||||||
|
let mut plain_a = sector_a.clone();
|
||||||
|
css::lfsr::descramble_sector(&key_a, &mut plain_a);
|
||||||
|
let mut plain_b = sector_b.clone();
|
||||||
|
css::lfsr::descramble_sector(&key_b, &mut plain_b);
|
||||||
|
|
||||||
|
let mut buf = Vec::with_capacity(4096);
|
||||||
|
buf.extend_from_slice(§or_a);
|
||||||
|
buf.extend_from_slice(§or_b);
|
||||||
|
|
||||||
|
// Cache primed to region A's key (as if A was the last crack). CSS
|
||||||
|
// descramble-and-rekey lives in `css::descramble_region` (the recovery
|
||||||
|
// seam calls it); the region change must re-crack region B's key.
|
||||||
|
let mut ended = key_a;
|
||||||
|
css::descramble_region(&mut buf, &mut ended);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
&buf[0x80..2048],
|
||||||
|
&plain_a[0x80..2048],
|
||||||
|
"sector 0 rides the cached key (crib matches, no re-crack)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
&buf[2048 + 0x80..4096],
|
||||||
|
&plain_b[0x80..2048],
|
||||||
|
"sector 1 re-cracks its own region key and descrambles correctly"
|
||||||
|
);
|
||||||
|
// The cache must have advanced to a key that descrambles region B.
|
||||||
|
let mut check_b = sector_b.clone();
|
||||||
|
css::lfsr::descramble_sector(&ended, &mut check_b);
|
||||||
|
assert_eq!(
|
||||||
|
&check_b[0x80..2048],
|
||||||
|
&plain_b[0x80..2048],
|
||||||
|
"the ended cache key must round-trip region B's body"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Mixed 3-unit buffer: only the in-content SCRAMBLED unit is counted; an
|
/// Mixed 3-unit buffer: only the in-content SCRAMBLED unit is counted; an
|
||||||
/// in-content CLEAR unit and an out-of-content SCRAMBLED unit are both skipped.
|
/// in-content CLEAR unit and an out-of-content SCRAMBLED unit are both skipped.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -720,6 +1022,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let u = aacs::content::ALIGNED_UNIT_LEN;
|
let u = aacs::content::ALIGNED_UNIT_LEN;
|
||||||
let mut buf = vec![0u8; 3 * u];
|
let mut buf = vec![0u8; 3 * u];
|
||||||
@@ -737,6 +1040,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
||||||
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
|
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
|
||||||
@@ -756,6 +1060,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
|
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
|
||||||
// content → the partial must be tolerated (Ok), not DecryptFailed.
|
// content → the partial must be tolerated (Ok), not DecryptFailed.
|
||||||
@@ -778,6 +1083,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
|
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
|
||||||
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
@@ -803,6 +1109,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
|
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
|
||||||
let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
@@ -825,6 +1132,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
|
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
|
||||||
@@ -838,6 +1146,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
|
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
|
||||||
let snapshot = buf.clone();
|
let snapshot = buf.clone();
|
||||||
@@ -879,6 +1188,7 @@ mod tests {
|
|||||||
DecryptKeys::Aacs {
|
DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0; 16])],
|
unit_keys: vec![(0, [0; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
}
|
}
|
||||||
.is_encrypted()
|
.is_encrypted()
|
||||||
);
|
);
|
||||||
@@ -912,11 +1222,12 @@ mod tests {
|
|||||||
/// fixed wrong key -> the body no longer matches the plaintext.
|
/// fixed wrong key -> the body no longer matches the plaintext.
|
||||||
#[test]
|
#[test]
|
||||||
fn css_descrambles_with_title_key() {
|
fn css_descrambles_with_title_key() {
|
||||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
let mut title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||||
let (mut sector, plaintext) = make_css_sector(&title_key, &seed, 0xA5);
|
let (mut sector, plaintext) = make_css_sector(&title_key, &seed, 0xA5);
|
||||||
let mut keys = DecryptKeys::Css { title_key };
|
// CSS descramble lives in `css::descramble_region` (the recovery seam
|
||||||
decrypt_sectors(&mut sector, &mut keys, 0).expect("CSS decrypt is Ok");
|
// calls it); `decrypt_sectors` only flags CSS sectors for recovery.
|
||||||
|
css::descramble_region(&mut sector, &mut title_key);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
§or[0x80..2048],
|
§or[0x80..2048],
|
||||||
&plaintext[0x80..2048],
|
&plaintext[0x80..2048],
|
||||||
@@ -945,8 +1256,8 @@ mod tests {
|
|||||||
let (s1, p1) = make_css_sector(&title_key, &[0x66, 0x77, 0x88, 0x99, 0xAA], 0xC3);
|
let (s1, p1) = make_css_sector(&title_key, &[0x66, 0x77, 0x88, 0x99, 0xAA], 0xC3);
|
||||||
let mut buf = s0;
|
let mut buf = s0;
|
||||||
buf.extend_from_slice(&s1);
|
buf.extend_from_slice(&s1);
|
||||||
let mut keys = DecryptKeys::Css { title_key };
|
let mut title_key = title_key;
|
||||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("CSS multi-sector decrypt is Ok");
|
css::descramble_region(&mut buf, &mut title_key);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
&buf[0x80..2048],
|
&buf[0x80..2048],
|
||||||
&p0[0x80..2048],
|
&p0[0x80..2048],
|
||||||
@@ -1024,8 +1335,8 @@ mod tests {
|
|||||||
buf.extend_from_slice(&s1);
|
buf.extend_from_slice(&s1);
|
||||||
|
|
||||||
// Cache primed to key_a only — exactly what the one-shot scan crack yields.
|
// Cache primed to key_a only — exactly what the one-shot scan crack yields.
|
||||||
let mut keys = DecryptKeys::Css { title_key: key_a };
|
let mut title_key = key_a;
|
||||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("CSS multi-region decrypt is Ok");
|
css::descramble_region(&mut buf, &mut title_key);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
&buf[0x80..2048],
|
&buf[0x80..2048],
|
||||||
@@ -1038,13 +1349,10 @@ mod tests {
|
|||||||
"region B sector must descramble after the path re-cracks its own key"
|
"region B sector must descramble after the path re-cracks its own key"
|
||||||
);
|
);
|
||||||
// The cache must have advanced to region B's key.
|
// The cache must have advanced to region B's key.
|
||||||
match keys {
|
assert_eq!(
|
||||||
DecryptKeys::Css { title_key } => assert_eq!(
|
title_key, key_b,
|
||||||
title_key, key_b,
|
"cache must hold region B's key after the rekey"
|
||||||
"cache must hold region B's key after the rekey"
|
);
|
||||||
),
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The CSS path leaves UNSCRAMBLED sectors (flag clear) byte-for-byte
|
/// The CSS path leaves UNSCRAMBLED sectors (flag clear) byte-for-byte
|
||||||
@@ -1095,6 +1403,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
let err = decrypt_sectors(&mut buf, &mut keys, 5)
|
let err = decrypt_sectors(&mut buf, &mut keys, 5)
|
||||||
@@ -1115,6 +1424,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![],
|
unit_keys: vec![],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
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");
|
let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error");
|
||||||
@@ -1189,13 +1499,14 @@ mod tests {
|
|||||||
let mut unit = clear_ts_unit();
|
let mut unit = clear_ts_unit();
|
||||||
aacs_encrypt_unit_for_test(&mut unit, &key1);
|
aacs_encrypt_unit_for_test(&mut unit, &key1);
|
||||||
assert!(
|
assert!(
|
||||||
aacs::content::ts_sync_destroyed(&unit),
|
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
|
||||||
"encrypted unit must look scrambled before decrypt"
|
"encrypted unit must look scrambled before decrypt"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, key0), (1, key1)], // two CPS units
|
unit_keys: vec![(0, key0), (1, key1)], // two CPS units
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Call with the default hint (idx 0) — the fix must fall back to key1.
|
// Call with the default hint (idx 0) — the fix must fall back to key1.
|
||||||
@@ -1203,7 +1514,7 @@ mod tests {
|
|||||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
|
||||||
|
|
||||||
assert!(
|
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)"
|
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
|
||||||
);
|
);
|
||||||
// Every sync position must carry 0x47.
|
// Every sync position must carry 0x47.
|
||||||
@@ -1230,11 +1541,12 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, key)],
|
unit_keys: vec![(0, key)],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = unit;
|
let mut buf = unit;
|
||||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
|
||||||
assert!(
|
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"
|
"single-key disc: TS syncs must be restored"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1244,21 +1556,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression for the silent partial-decrypt-loss defect: a scrambled AACS
|
/// A unit no supplied key opens to clean TS is still DECRYPTED in place (the
|
||||||
/// unit that NO supplied key can decrypt is restored to its original
|
/// key is applied — decryption ran; a broken result is bad data, not a decrypt
|
||||||
/// ciphertext (so a clear nav-file is never corrupted) AND `decrypt_sectors`
|
/// failure) and NEVER restored to ciphertext. `decrypt_sectors` still returns
|
||||||
/// returns the unit's byte length as the dropped count. Before the fix this
|
/// the unit's byte length as the UNVERIFIED count — the read-verify signal the
|
||||||
/// returned `()` and the still-encrypted bytes flowed downstream to be
|
/// sweep/patch caller consumes (the mux ignores it and passes the bytes to the
|
||||||
/// silently dropped by the TS assembler with zero loss accounting — a rip
|
/// muxer). This is the single decrypt authority applying no policy.
|
||||||
/// missing real content reported `lost_video_secs=0` and passed the abort
|
|
||||||
/// gate even under `abort_on_lost_secs=0`.
|
|
||||||
///
|
///
|
||||||
/// Grounding: the `dropped_bytes.fetch_add(chunk.len(), …)` on the
|
/// Grounding: `dropped_bytes.fetch_add(chunk.len(), …)` in `decrypt_one`, and
|
||||||
/// no-key-validated restore path; the function returns that tally.
|
/// the removal of the `copy_from_slice(&original)` restore.
|
||||||
/// Mutation: drop the `fetch_add` (or return a constant 0) → dropped == 0,
|
/// Mutation: re-add the restore → `buf == ciphertext`, this fails.
|
||||||
/// this fails.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aacs_undecryptable_unit_reports_dropped_bytes() {
|
fn aacs_undecryptable_unit_is_decrypted_not_restored() {
|
||||||
let real_key = [0x33u8; 16];
|
let real_key = [0x33u8; 16];
|
||||||
let wrong_key = [0x44u8; 16]; // not the encrypting key
|
let wrong_key = [0x44u8; 16]; // not the encrypting key
|
||||||
|
|
||||||
@@ -1267,26 +1576,27 @@ mod tests {
|
|||||||
aacs_encrypt_unit_for_test(&mut unit, &real_key);
|
aacs_encrypt_unit_for_test(&mut unit, &real_key);
|
||||||
let ciphertext = unit.clone();
|
let ciphertext = unit.clone();
|
||||||
assert!(
|
assert!(
|
||||||
aacs::content::ts_sync_destroyed(&unit),
|
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
|
||||||
"encrypted unit must look scrambled going in"
|
"encrypted unit must look scrambled going in"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, wrong_key)],
|
unit_keys: vec![(0, wrong_key)],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = unit;
|
let mut buf = unit;
|
||||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0)
|
let unverified =
|
||||||
.expect("undecryptable unit is not a hard error");
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("applying a key is never a hard error");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dropped,
|
unverified,
|
||||||
aacs::content::ALIGNED_UNIT_LEN,
|
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,
|
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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1317,22 +1627,26 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, key)],
|
unit_keys: vec![(0, key)],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok");
|
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dropped,
|
dropped,
|
||||||
aacs::content::ALIGNED_UNIT_LEN,
|
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!(
|
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"
|
"the decryptable unit must come out clear"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_ne!(
|
||||||
&buf[aacs::content::ALIGNED_UNIT_LEN..],
|
&buf[aacs::content::ALIGNED_UNIT_LEN..],
|
||||||
&unit_b_ciphertext[..],
|
&unit_b_ciphertext[..],
|
||||||
"the undecryptable unit must be restored to ciphertext"
|
"the unverified unit is DECRYPTED in place (key applied), never restored to ciphertext"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1346,6 +1660,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, key)],
|
unit_keys: vec![(0, key)],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = unit;
|
let mut buf = unit;
|
||||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
|
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
|
||||||
|
|||||||
+134
-256
@@ -6,6 +6,18 @@ use crate::mpls;
|
|||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
use crate::udf;
|
use crate::udf;
|
||||||
|
|
||||||
|
/// Stream-file extensions probed for a BD-family playlist clip, in priority
|
||||||
|
/// order. A clip is normally `.m2ts`; AACS 2.1 (FMTS) discs name the main feature
|
||||||
|
/// `.fmts` (an M2TS transport stream plus forensic variant segments) and 3D discs
|
||||||
|
/// use `.ssif`. `.m2ts` is tried first, so a normal clip is unaffected — the
|
||||||
|
/// fallback only runs when `.m2ts` is absent (exactly when `file_extents` errors).
|
||||||
|
///
|
||||||
|
/// Scope: these are all variants that live in `BDMV/STREAM/` and are reached
|
||||||
|
/// through an MPLS playlist. HD-DVD's `.evo` does NOT belong here — HD-DVD is a
|
||||||
|
/// different tree (`HVDVD_TS/`) with `.XPL` playlists and needs its own
|
||||||
|
/// enumerator (a peer to `parse_playlist`), not another extension in this list.
|
||||||
|
const CLIP_STREAM_EXTS: [&str; 3] = ["m2ts", "fmts", "ssif"];
|
||||||
|
|
||||||
impl Disc {
|
impl Disc {
|
||||||
/// Scan Blu-ray titles from MPLS playlists.
|
/// Scan Blu-ray titles from MPLS playlists.
|
||||||
pub(super) fn scan_bluray_titles(
|
pub(super) fn scan_bluray_titles(
|
||||||
@@ -60,6 +72,11 @@ impl Disc {
|
|||||||
// Parse each clip for size, duration, and sector extents
|
// Parse each clip for size, duration, and sector extents
|
||||||
let mut extents = Vec::new();
|
let mut extents = Vec::new();
|
||||||
let mut total_size: u64 = 0;
|
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());
|
let mut clips = Vec::with_capacity(parsed.play_items.len());
|
||||||
// BD playlists legally reference the same .m2ts clip_id from
|
// BD playlists legally reference the same .m2ts clip_id from
|
||||||
// multiple PlayItems (multi-angle, seamless splits, looped
|
// multiple PlayItems (multi-angle, seamless splits, looped
|
||||||
@@ -73,22 +90,49 @@ impl Disc {
|
|||||||
for play_item in &parsed.play_items {
|
for play_item in &parsed.play_items {
|
||||||
let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0;
|
let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0;
|
||||||
let mut pkt_count: u32 = 0;
|
let mut pkt_count: u32 = 0;
|
||||||
let first_ref = seen_clips.insert(play_item.clip_id.clone());
|
|
||||||
|
|
||||||
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
|
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
|
||||||
if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) {
|
if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) {
|
||||||
if let Ok(clip_info) = clpi::parse(&clpi_data) {
|
if let Ok(clip_info) = clpi::parse(&clpi_data) {
|
||||||
pkt_count = clip_info.source_packet_count;
|
pkt_count = clip_info.source_packet_count;
|
||||||
|
|
||||||
|
// Mark the clip seen ONLY after its .clpi parses — a transient
|
||||||
|
// read/parse failure on the first PlayItem referencing a clip
|
||||||
|
// must not permanently suppress its extents/size for a later
|
||||||
|
// PlayItem referencing the same clip that succeeds.
|
||||||
|
let first_ref = seen_clips.insert(play_item.clip_id.clone());
|
||||||
|
|
||||||
// Only fetch/push the physical extents and add to the
|
// Only fetch/push the physical extents and add to the
|
||||||
// total size the first time this clip_id is seen.
|
// total size the first time this clip_id is seen.
|
||||||
if first_ref {
|
if first_ref {
|
||||||
total_size += pkt_count as u64 * 192;
|
total_size += pkt_count as u64 * 192;
|
||||||
|
|
||||||
// Get m2ts file extents from UDF allocation descriptors.
|
// Get stream file extents from UDF allocation descriptors.
|
||||||
// Dual-layer discs split files across layers — UDF knows the real layout.
|
// Dual-layer discs split files across layers — UDF knows the real layout.
|
||||||
let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id);
|
//
|
||||||
if let Ok(file_exts) = udf_fs.file_extents(reader, &m2ts_path) {
|
// The clip's stream file is normally `.m2ts`, but AACS 2.1
|
||||||
|
// (FMTS) discs name the main feature `.fmts` and 3D discs
|
||||||
|
// 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.
|
||||||
|
// 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 {
|
for (lba, sectors) in file_exts {
|
||||||
if sectors > 0 && lba > 0 {
|
if sectors > 0 && lba > 0 {
|
||||||
extents.push(Extent {
|
extents.push(Extent {
|
||||||
@@ -112,7 +156,7 @@ impl Disc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build streams from STN table
|
// Build streams from STN table
|
||||||
let streams: Vec<Stream> = parsed
|
let mut streams: Vec<Stream> = parsed
|
||||||
.streams
|
.streams
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|s| {
|
.filter_map(|s| {
|
||||||
@@ -206,6 +250,42 @@ impl Disc {
|
|||||||
})
|
})
|
||||||
.collect();
|
.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
|
// Convert marks to chapters. mark_type == 1 is an entry-mark
|
||||||
// (chapter); type 2 is a link point and type 0 is reserved, so
|
// (chapter); type 2 is a link point and type 0 is reserved, so
|
||||||
// neither is a chapter.
|
// neither is a chapter.
|
||||||
@@ -314,256 +394,7 @@ impl Disc {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::sector::SectorSource;
|
use crate::udf::fixture::*;
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// In-memory disc backing store
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
/// In-memory SectorSource backed by an absolute-LBA → 2048-byte
|
|
||||||
/// sector map. Unmapped sectors read as zeroes (matches a freshly
|
|
||||||
/// formatted region). Mirrors the `MapReader` used in `udf.rs`
|
|
||||||
/// tests so fixtures are byte-for-byte interoperable.
|
|
||||||
struct MemDisc {
|
|
||||||
sectors: HashMap<u32, [u8; 2048]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MemDisc {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
sectors: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
|
||||||
self.sectors.insert(lba, data);
|
|
||||||
}
|
|
||||||
/// Write arbitrary-length bytes starting at `lba`, splitting across
|
|
||||||
/// consecutive 2048-byte sectors (zero-padded last sector).
|
|
||||||
fn put_bytes(&mut self, lba: u32, bytes: &[u8]) {
|
|
||||||
for (i, chunk) in bytes.chunks(2048).enumerate() {
|
|
||||||
let mut s = [0u8; 2048];
|
|
||||||
s[..chunk.len()].copy_from_slice(chunk);
|
|
||||||
self.put(lba + i as u32, s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SectorSource for MemDisc {
|
|
||||||
fn read_sectors(
|
|
||||||
&mut self,
|
|
||||||
lba: u32,
|
|
||||||
count: u16,
|
|
||||||
buf: &mut [u8],
|
|
||||||
_recovery: bool,
|
|
||||||
) -> crate::error::Result<usize> {
|
|
||||||
let need = count as usize * 2048;
|
|
||||||
for i in 0..count as u32 {
|
|
||||||
let off = i as usize * 2048;
|
|
||||||
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
|
|
||||||
buf[off..off + 2048].copy_from_slice(&s);
|
|
||||||
}
|
|
||||||
Ok(need)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// UDF image builder — produces a disc image `udf::read_filesystem`
|
|
||||||
// can navigate. All field offsets are cited from ECMA-167 / the
|
|
||||||
// exact bytes `udf.rs::read_filesystem` reads.
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Fixed layout. PART_START == META_START so file LBAs (physical
|
|
||||||
/// partition relative) and ICB/dir LBAs (metadata relative) share
|
|
||||||
/// one address space — both resolve to abs = PART_START + lba. This
|
|
||||||
/// keeps fixtures small; `read_filesystem` takes the single-partition
|
|
||||||
/// path (num_partition_maps == 1) so no metadata-partition file is
|
|
||||||
/// needed.
|
|
||||||
const PART_START: u32 = 2000;
|
|
||||||
|
|
||||||
/// One file's on-disc placement: metadata LBA of its ICB, the LBA of
|
|
||||||
/// its (single contiguous) data extent, byte length, and whether the
|
|
||||||
/// ICB encodes its allocation descriptor as a Long AD (16-byte, the
|
|
||||||
/// real BD-ROM .m2ts layout) vs Short AD (8-byte).
|
|
||||||
struct FileSpec {
|
|
||||||
name: String,
|
|
||||||
icb_lba: u32,
|
|
||||||
data_lba: u32,
|
|
||||||
size: u32,
|
|
||||||
long_ad: bool,
|
|
||||||
/// Optional explicit file contents written at `data_lba`.
|
|
||||||
contents: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A directory node for the builder: its ICB LBA, the LBA where its
|
|
||||||
/// FID list lives, child files, and child subdirectories.
|
|
||||||
struct DirSpec {
|
|
||||||
name: String,
|
|
||||||
icb_lba: u32,
|
|
||||||
dir_data_lba: u32,
|
|
||||||
files: Vec<FileSpec>,
|
|
||||||
subdirs: Vec<DirSpec>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build an Extended File Entry ICB (tag 266) with one allocation
|
|
||||||
/// descriptor. Offsets per `udf.rs`: tag@0, ICB-tag flags@34,
|
|
||||||
/// info_length(u64)@56, l_ea@208, l_ad@212, ADs@216.
|
|
||||||
fn build_file_icb(size: u32, data_lba: u32, long_ad: bool) -> [u8; 2048] {
|
|
||||||
let mut s = [0u8; 2048];
|
|
||||||
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
|
|
||||||
if long_ad {
|
|
||||||
// ICB Tag flags low 3 bits = 1 → Long AD (16-byte stride).
|
|
||||||
s[34..36].copy_from_slice(&1u16.to_le_bytes());
|
|
||||||
}
|
|
||||||
s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length
|
|
||||||
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
|
|
||||||
let ad_size: u32 = if long_ad { 16 } else { 8 };
|
|
||||||
s[212..216].copy_from_slice(&ad_size.to_le_bytes()); // l_ad
|
|
||||||
// Short/Long AD share length(4)@216 | lba(4)@220. extent_type 0
|
|
||||||
// (recorded) is top 2 bits = 0, so raw == len.
|
|
||||||
s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes());
|
|
||||||
s[220..224].copy_from_slice(&data_lba.to_le_bytes());
|
|
||||||
// Long AD's part_ref(2)@224 + impl_use(6)@226 stay zero.
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a directory ICB (tag 266) whose single short AD points at the
|
|
||||||
/// directory's FID data.
|
|
||||||
fn build_dir_icb(dir_data_lba: u32, dir_data_len: u32) -> [u8; 2048] {
|
|
||||||
build_file_icb(dir_data_len, dir_data_lba, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append one File Identifier Descriptor (tag 257) to `buf`.
|
|
||||||
/// Layout per `read_directory`: tag@0, file_chars@18, l_fi@19,
|
|
||||||
/// ICB long_ad extent_location(LBA)@24, l_iu(u16)@36, name@(38+l_iu).
|
|
||||||
/// Name uses UDF compression-id 8 (8-bit ASCII), so the on-disc name
|
|
||||||
/// field is `[0x08, ascii_bytes...]` and l_fi = 1 + ascii.len().
|
|
||||||
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
|
||||||
let start = buf.len();
|
|
||||||
let name_field: Vec<u8> = if is_parent {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
let mut v = vec![0x08u8];
|
|
||||||
v.extend_from_slice(name.as_bytes());
|
|
||||||
v
|
|
||||||
};
|
|
||||||
let l_fi = name_field.len();
|
|
||||||
let mut fid = vec![0u8; 38];
|
|
||||||
fid[0..2].copy_from_slice(&257u16.to_le_bytes()); // FID tag
|
|
||||||
let mut file_chars = 0u8;
|
|
||||||
if is_dir {
|
|
||||||
file_chars |= 0x02;
|
|
||||||
}
|
|
||||||
if is_parent {
|
|
||||||
file_chars |= 0x08;
|
|
||||||
}
|
|
||||||
fid[18] = file_chars;
|
|
||||||
fid[19] = l_fi as u8;
|
|
||||||
// ICB long_ad: extent_location LBA at offset 24.
|
|
||||||
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes());
|
|
||||||
// l_iu (u16) at offset 36 = 0.
|
|
||||||
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
|
|
||||||
buf.extend_from_slice(&fid);
|
|
||||||
buf.extend_from_slice(&name_field);
|
|
||||||
// Pad to 4-byte alignment (FID stride = (38 + l_iu + l_fi + 3) & !3).
|
|
||||||
let used = buf.len() - start;
|
|
||||||
let pad = (used + 3) & !3;
|
|
||||||
buf.resize(start + pad, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recursively lay a DirSpec (and children) into the MemDisc, writing
|
|
||||||
/// directory ICBs, FID lists, file ICBs, and file data.
|
|
||||||
fn lay_dir(disc: &mut MemDisc, dir: &DirSpec) {
|
|
||||||
let mut fids = Vec::new();
|
|
||||||
// Parent entry first (file_chars bit 0x08) — skipped by the parser
|
|
||||||
// but present on real discs.
|
|
||||||
push_fid(&mut fids, "", dir.icb_lba, true, true);
|
|
||||||
for f in &dir.files {
|
|
||||||
push_fid(&mut fids, &f.name, f.icb_lba, false, false);
|
|
||||||
disc.put(
|
|
||||||
PART_START + f.icb_lba,
|
|
||||||
build_file_icb(f.size, f.data_lba, f.long_ad),
|
|
||||||
);
|
|
||||||
if !f.contents.is_empty() {
|
|
||||||
disc.put_bytes(PART_START + f.data_lba, &f.contents);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for sub in &dir.subdirs {
|
|
||||||
push_fid(&mut fids, &sub.name, sub.icb_lba, true, false);
|
|
||||||
}
|
|
||||||
disc.put(
|
|
||||||
PART_START + dir.icb_lba,
|
|
||||||
build_dir_icb(dir.dir_data_lba, fids.len() as u32),
|
|
||||||
);
|
|
||||||
disc.put_bytes(PART_START + dir.dir_data_lba, &fids);
|
|
||||||
for sub in &dir.subdirs {
|
|
||||||
lay_dir(disc, sub);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the static UDF anchor/VDS/FSD structure so `read_filesystem`
|
|
||||||
/// reaches `root_icb_lba`. Single partition map → metadata_start ==
|
|
||||||
/// partition_start == PART_START.
|
|
||||||
fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) {
|
|
||||||
// AVDP at sector 256, tag 2 (ECMA-167 §10.2).
|
|
||||||
let mut avdp = [0u8; 2048];
|
|
||||||
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
|
|
||||||
disc.put(256, avdp);
|
|
||||||
|
|
||||||
// Partition Descriptor (tag 5) at sector 32: partition_start@188.
|
|
||||||
let mut pd = [0u8; 2048];
|
|
||||||
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
|
|
||||||
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
|
|
||||||
disc.put(32, pd);
|
|
||||||
|
|
||||||
// Logical Volume Descriptor (tag 6) at sector 33:
|
|
||||||
// num_partition_maps(u32)@268 = 1 (single map → no metadata part).
|
|
||||||
let mut lvd = [0u8; 2048];
|
|
||||||
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
|
|
||||||
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
|
|
||||||
disc.put(33, lvd);
|
|
||||||
|
|
||||||
// Terminating Descriptor (tag 8) at sector 34 → ends VDS scan.
|
|
||||||
let mut td = [0u8; 2048];
|
|
||||||
td[0..2].copy_from_slice(&8u16.to_le_bytes());
|
|
||||||
disc.put(34, td);
|
|
||||||
|
|
||||||
// File Set Descriptor (tag 256) at metadata_start (== PART_START):
|
|
||||||
// root-dir ICB LBA at offset 404 (long_ad extent_location).
|
|
||||||
let mut fsd = [0u8; 2048];
|
|
||||||
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
|
|
||||||
fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes());
|
|
||||||
disc.put(PART_START, fsd);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn file(name: &str, icb_lba: u32, data_lba: u32, size: u32, long_ad: bool) -> FileSpec {
|
|
||||||
FileSpec {
|
|
||||||
name: name.to_string(),
|
|
||||||
icb_lba,
|
|
||||||
data_lba,
|
|
||||||
size,
|
|
||||||
long_ad,
|
|
||||||
contents: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn file_with(
|
|
||||||
name: &str,
|
|
||||||
icb_lba: u32,
|
|
||||||
data_lba: u32,
|
|
||||||
contents: Vec<u8>,
|
|
||||||
long_ad: bool,
|
|
||||||
) -> FileSpec {
|
|
||||||
FileSpec {
|
|
||||||
name: name.to_string(),
|
|
||||||
icb_lba,
|
|
||||||
data_lba,
|
|
||||||
size: contents.len() as u32,
|
|
||||||
long_ad,
|
|
||||||
contents,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// MPLS builder (BD-ROM PlayList spec). Mirrors the layout the
|
// MPLS builder (BD-ROM PlayList spec). Mirrors the layout the
|
||||||
// `mpls::parse` consumer reads (header@0, PlayList@playlist_start,
|
// `mpls::parse` consumer reads (header@0, PlayList@playlist_start,
|
||||||
@@ -797,13 +628,29 @@ mod tests {
|
|||||||
u32, /*packets*/
|
u32, /*packets*/
|
||||||
u32, /*data_lba*/
|
u32, /*data_lba*/
|
||||||
)],
|
)],
|
||||||
|
) -> udf::UdfFs {
|
||||||
|
make_bdmv_fs_ext(disc, clips, "m2ts")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`make_bdmv_fs`] but the STREAM file carries `stream_ext` instead of
|
||||||
|
/// `.m2ts` (e.g. "fmts" for an AACS 2.1 feature clip, "ssif" for 3D) — drives
|
||||||
|
/// the [`CLIP_STREAM_EXTS`] fallback in `parse_playlist`.
|
||||||
|
fn make_bdmv_fs_ext(
|
||||||
|
disc: &mut MemDisc,
|
||||||
|
clips: &[(
|
||||||
|
&str,
|
||||||
|
u32, /*sectors*/
|
||||||
|
u32, /*packets*/
|
||||||
|
u32, /*data_lba*/
|
||||||
|
)],
|
||||||
|
stream_ext: &str,
|
||||||
) -> udf::UdfFs {
|
) -> udf::UdfFs {
|
||||||
// Layout LBAs: pick widely separated values to avoid collisions.
|
// Layout LBAs: pick widely separated values to avoid collisions.
|
||||||
let mut stream_files = Vec::new();
|
let mut stream_files = Vec::new();
|
||||||
let mut clipinf_files = Vec::new();
|
let mut clipinf_files = Vec::new();
|
||||||
let mut icb = 100u32;
|
let mut icb = 100u32;
|
||||||
for (name, sectors, packets, data_lba) in clips {
|
for (name, sectors, packets, data_lba) in clips {
|
||||||
let m2ts = format!("{name}.m2ts");
|
let m2ts = format!("{name}.{stream_ext}");
|
||||||
// Size in bytes — file_extents derives sectors via div_ceil(2048).
|
// Size in bytes — file_extents derives sectors via div_ceil(2048).
|
||||||
let size = sectors * 2048;
|
let size = sectors * 2048;
|
||||||
stream_files.push(file(&m2ts, icb, *data_lba, size, true));
|
stream_files.push(file(&m2ts, icb, *data_lba, size, true));
|
||||||
@@ -881,6 +728,37 @@ mod tests {
|
|||||||
assert_eq!(t.clips[0].source_packets, 4000);
|
assert_eq!(t.clips[0].source_packets, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AACS 2.1: the feature clip is `00001.fmts`, NOT `.m2ts`. The
|
||||||
|
/// [`CLIP_STREAM_EXTS`] fallback in `parse_playlist` must still resolve the
|
||||||
|
/// physical extent — before the fix the hard-coded `.m2ts` path errored,
|
||||||
|
/// yielding empty extents (a silent empty rip and 0 encrypted samples for key
|
||||||
|
/// resolution). Size still comes from the `.clpi`, which parses regardless.
|
||||||
|
#[test]
|
||||||
|
fn parse_playlist_fmts_clip_resolves_extent() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
// Only a .fmts stream exists for clip 00001 (no .m2ts on disc).
|
||||||
|
let udf = make_bdmv_fs_ext(&mut disc, &[("00001", 1000, 4000, 5000)], "fmts");
|
||||||
|
let mpls = build_mpls(
|
||||||
|
&[PiSpec {
|
||||||
|
clip_id: *b"00001",
|
||||||
|
in_time: 0,
|
||||||
|
out_time: 60 * 45000,
|
||||||
|
}],
|
||||||
|
(0, 0, 0, 0, 0, 0, 0, 0),
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||||
|
assert_eq!(t.size_bytes, 4000 * 192, "size from .clpi source packets");
|
||||||
|
assert_eq!(
|
||||||
|
t.extents.len(),
|
||||||
|
1,
|
||||||
|
"the .fmts extent must be resolved via fallback"
|
||||||
|
);
|
||||||
|
assert_eq!(t.extents[0].start_lba, PART_START + 5000);
|
||||||
|
assert_eq!(t.extents[0].sector_count, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/// THE 0.31.0 DEDUP PATH. A playlist that references the SAME clip_id
|
/// THE 0.31.0 DEDUP PATH. A playlist that references the SAME clip_id
|
||||||
/// from multiple PlayItems (seamless split / looped segment) must count
|
/// from multiple PlayItems (seamless split / looped segment) must count
|
||||||
/// the physical extents and packet bytes EXACTLY ONCE — mux reads
|
/// the physical extents and packet bytes EXACTLY ONCE — mux reads
|
||||||
|
|||||||
+8
-12
@@ -111,13 +111,13 @@ impl AacsCertUnlocker<'_> {
|
|||||||
// through method calls, so clone the (cheap) identity first.
|
// through method calls, so clone the (cheap) identity first.
|
||||||
let drive_id = session.drive_id.clone();
|
let drive_id = session.drive_id.clone();
|
||||||
let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs);
|
let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs);
|
||||||
let unlocked = crate::unlock_bridge::run_unlockers(
|
let (_, unlock_res) = crate::unlock_bridge::run_bus(
|
||||||
session.scsi_mut(),
|
session.scsi_mut(),
|
||||||
&drive_id,
|
&drive_id,
|
||||||
freemkv_unlock::DiscKind::Aacs,
|
freemkv_unlock::DiscKind::Aacs,
|
||||||
&fu_certs,
|
&fu_certs,
|
||||||
)
|
);
|
||||||
.map_err(CertUnlockFailure::Unlock)?;
|
let unlocked = unlock_res.map_err(CertUnlockFailure::Unlock)?;
|
||||||
// The cert handshake yields a VID on success; its absence is VidUnavailable.
|
// The cert handshake yields a VID on success; its absence is VidUnavailable.
|
||||||
let Some(volume_id) = unlocked.vid else {
|
let Some(volume_id) = unlocked.vid else {
|
||||||
return Err(CertUnlockFailure::Unlock(UnlockError::VidUnavailable));
|
return Err(CertUnlockFailure::Unlock(UnlockError::VidUnavailable));
|
||||||
@@ -311,15 +311,11 @@ impl Disc {
|
|||||||
) -> Result<AacsState> {
|
) -> Result<AacsState> {
|
||||||
use crate::aacs;
|
use crate::aacs;
|
||||||
|
|
||||||
let uk_ro_data = udf_fs
|
let uk_ro_data =
|
||||||
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
|
aacs::read_first(aacs::UNIT_KEY_RO_PATHS, |p| udf_fs.read_file(reader, p))?;
|
||||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
|
|
||||||
.map_err(|_| Error::AacsNoKeys)?;
|
|
||||||
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
||||||
|
|
||||||
let cc = udf_fs
|
let cc = aacs::read_first(aacs::CONTENT_CERT_PATHS, |p| udf_fs.read_file(reader, p))
|
||||||
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
|
|
||||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
|
||||||
.ok()
|
.ok()
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(aacs::inf::parse_content_cert);
|
.and_then(aacs::inf::parse_content_cert);
|
||||||
@@ -572,7 +568,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A content certificate: type byte@0 (0x00 = V10, else V20),
|
/// A content certificate: type byte@0 (0x00 = V10, else V20),
|
||||||
/// bus_encryption bit7@1, cc_id@14..20 (aacs/keys.rs parse_content_cert,
|
/// bus_encryption bit7@1, cc_id@14..20 (aacs/inf.rs parse_content_cert,
|
||||||
/// which requires ≥20 bytes and reads the bus flag from `data[1] >> 7`).
|
/// which requires ≥20 bytes and reads the bus flag from `data[1] >> 7`).
|
||||||
fn build_content_cert(cert_type: u8, bus_encryption: bool) -> Vec<u8> {
|
fn build_content_cert(cert_type: u8, bus_encryption: bool) -> Vec<u8> {
|
||||||
let mut v = vec![0u8; 20];
|
let mut v = vec![0u8; 20];
|
||||||
@@ -584,7 +580,7 @@ mod tests {
|
|||||||
/// An MKB with one Type-and-Version record (type 0x10) carrying the
|
/// An MKB with one Type-and-Version record (type 0x10) carrying the
|
||||||
/// version as BE u32 at record offset 8, followed by a recorded EOF
|
/// version as BE u32 at record offset 8, followed by a recorded EOF
|
||||||
/// record then trailing zero padding. mkb_content_len walks records
|
/// record then trailing zero padding. mkb_content_len walks records
|
||||||
/// and stops at the first padding (type 0) byte (aacs/keys.rs).
|
/// and stops at the first padding (type 0) byte (aacs/inf.rs).
|
||||||
fn build_mkb(version: u32, pad_to: usize) -> Vec<u8> {
|
fn build_mkb(version: u32, pad_to: usize) -> Vec<u8> {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
// Type 0x10 record, length 16 (>= 12 so version is read).
|
// Type 0x10 record, length 16 (>= 12 so version is read).
|
||||||
|
|||||||
+77
-92
@@ -22,7 +22,6 @@ use crate::sector::{DecryptingSectorSource, SectorSource};
|
|||||||
use crate::udf::{self, DirEntry, UdfFs};
|
use crate::udf::{self, DirEntry, UdfFs};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
|
|
||||||
use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
||||||
/// AACS aligned unit = 3 sectors / 6144 bytes. Content reads are issued in
|
/// AACS aligned unit = 3 sectors / 6144 bytes. Content reads are issued in
|
||||||
@@ -66,10 +65,9 @@ pub struct FileResult {
|
|||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
/// Bytes written that decrypted cleanly.
|
/// Bytes written that decrypted cleanly.
|
||||||
pub bytes_good: u64,
|
pub bytes_good: u64,
|
||||||
/// Bytes lost to unreadable sectors (zero-filled holes).
|
/// Bytes lost — unreadable sectors AND undecryptable units both land here
|
||||||
|
/// (extract fails a bad decrypt loud, so it is zero-filled like a bad sector).
|
||||||
pub bytes_unreadable: u64,
|
pub bytes_unreadable: u64,
|
||||||
/// Bytes lost to undecryptable AACS/CSS units (still ciphertext / dropped).
|
|
||||||
pub bytes_undecryptable: u64,
|
|
||||||
/// True when the file was fully written (renamed from `.partial`).
|
/// True when the file was fully written (renamed from `.partial`).
|
||||||
pub complete: bool,
|
pub complete: bool,
|
||||||
}
|
}
|
||||||
@@ -81,10 +79,8 @@ pub struct ExtractResult {
|
|||||||
pub files: Vec<FileResult>,
|
pub files: Vec<FileResult>,
|
||||||
/// Aggregate good bytes across all files.
|
/// Aggregate good bytes across all files.
|
||||||
pub bytes_good: u64,
|
pub bytes_good: u64,
|
||||||
/// Aggregate unreadable (bad-sector) bytes.
|
/// Aggregate lost bytes — bad sectors AND undecryptable units (one bucket).
|
||||||
pub bytes_unreadable: u64,
|
pub bytes_unreadable: u64,
|
||||||
/// Aggregate undecryptable (decrypt-loss) bytes.
|
|
||||||
pub bytes_undecryptable: u64,
|
|
||||||
/// True when every file completed and no loss was recorded.
|
/// True when every file completed and no loss was recorded.
|
||||||
pub complete: bool,
|
pub complete: bool,
|
||||||
/// True when the run stopped early on an interrupt / progress halt.
|
/// True when the run stopped early on an interrupt / progress halt.
|
||||||
@@ -92,11 +88,10 @@ pub struct ExtractResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ExtractResult {
|
impl ExtractResult {
|
||||||
/// Total bytes lost (unreadable + undecryptable). A non-zero value means
|
/// Total bytes lost. A non-zero value means the extraction is holed; the CLI
|
||||||
/// the extraction is holed; the CLI exits non-zero so a script can re-run
|
/// exits non-zero so a script can re-run through the `iso://` multipass path.
|
||||||
/// through the `iso://` multipass path.
|
|
||||||
pub fn bytes_lost(&self) -> u64 {
|
pub fn bytes_lost(&self) -> u64 {
|
||||||
self.bytes_unreadable + self.bytes_undecryptable
|
self.bytes_unreadable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +192,6 @@ impl Disc {
|
|||||||
// borrowing wrapper (so the caller keeps `reader`), swap keys per CSS
|
// borrowing wrapper (so the caller keeps `reader`), swap keys per CSS
|
||||||
// VTS group via `set_keys`; AACS/None keep `base_keys` throughout.
|
// VTS group via `set_keys`; AACS/None keep `base_keys` throughout.
|
||||||
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
|
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
|
||||||
let decrypt_loss = dec.decrypt_loss();
|
|
||||||
|
|
||||||
let mut result = ExtractResult::default();
|
let mut result = ExtractResult::default();
|
||||||
let total_bytes = required;
|
let total_bytes = required;
|
||||||
@@ -232,26 +226,15 @@ impl Disc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acquire (rather than Relaxed) on these per-file delta loads:
|
// A unit that fails to decrypt fails the read loud (extract runs
|
||||||
// `extract_tree` drives `dec` single-threaded so there is no race
|
// non-tolerate), so extract_one_file already zero-filled it and
|
||||||
// today, and Acquire costs nothing on x86. Note this is only half
|
// counted it in bytes_unreadable — one 'lost' bucket covers both
|
||||||
// the synchronisation: the paired counter store
|
// media damage and decrypt failure.
|
||||||
// (sector/decrypting.rs `fetch_add`) is Relaxed, so an Acquire
|
let (fr, halted) =
|
||||||
// load alone does NOT yet establish a happens-before edge. Before
|
|
||||||
// file extraction is parallelised, upgrade that store to Release
|
|
||||||
// (or stronger) so the delta cannot read a stale counter.
|
|
||||||
let before_loss = decrypt_loss.load(Ordering::Acquire);
|
|
||||||
let (mut fr, halted) =
|
|
||||||
extract_one_file(&mut dec, dest, pf, total_bytes, &mut done_bytes, opts)?;
|
extract_one_file(&mut dec, dest, pf, total_bytes, &mut done_bytes, opts)?;
|
||||||
let after_loss = decrypt_loss.load(Ordering::Acquire);
|
|
||||||
fr.bytes_undecryptable = after_loss.saturating_sub(before_loss);
|
|
||||||
fr.bytes_good = fr.bytes_good.saturating_sub(fr.bytes_undecryptable);
|
|
||||||
|
|
||||||
result.bytes_good = result.bytes_good.saturating_add(fr.bytes_good);
|
result.bytes_good = result.bytes_good.saturating_add(fr.bytes_good);
|
||||||
result.bytes_unreadable = result.bytes_unreadable.saturating_add(fr.bytes_unreadable);
|
result.bytes_unreadable = result.bytes_unreadable.saturating_add(fr.bytes_unreadable);
|
||||||
result.bytes_undecryptable = result
|
|
||||||
.bytes_undecryptable
|
|
||||||
.saturating_add(fr.bytes_undecryptable);
|
|
||||||
result.files.push(fr);
|
result.files.push(fr);
|
||||||
if halted {
|
if halted {
|
||||||
result.halted = true;
|
result.halted = true;
|
||||||
@@ -261,7 +244,6 @@ impl Disc {
|
|||||||
|
|
||||||
result.complete = !result.halted
|
result.complete = !result.halted
|
||||||
&& result.bytes_unreadable == 0
|
&& result.bytes_unreadable == 0
|
||||||
&& result.bytes_undecryptable == 0
|
|
||||||
&& result.files.iter().all(|f| f.complete);
|
&& result.files.iter().all(|f| f.complete);
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -306,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
|
/// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an
|
||||||
/// inner source for its lifetime while the caller keeps the underlying
|
/// inner source for its lifetime while the caller keeps the underlying
|
||||||
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
|
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
|
||||||
@@ -492,7 +417,6 @@ fn extract_one_file<S: SectorSource>(
|
|||||||
path: pf.host_rel.clone(),
|
path: pf.host_rel.clone(),
|
||||||
bytes_good: 0,
|
bytes_good: 0,
|
||||||
bytes_unreadable: 0,
|
bytes_unreadable: 0,
|
||||||
bytes_undecryptable: 0,
|
|
||||||
complete: false,
|
complete: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -851,8 +775,11 @@ mod tests {
|
|||||||
|
|
||||||
struct MemDisc {
|
struct MemDisc {
|
||||||
sectors: HashMap<u32, [u8; 2048]>,
|
sectors: HashMap<u32, [u8; 2048]>,
|
||||||
/// Absolute LBAs that fail to read (bad-sector fixture).
|
/// Absolute LBAs that fail to read (bad-sector fixture → DiscRead).
|
||||||
bad: std::collections::HashSet<u32>,
|
bad: std::collections::HashSet<u32>,
|
||||||
|
/// Absolute LBAs whose read fails to DECRYPT (no/wrong key fixture →
|
||||||
|
/// DecryptFailed), exercising the undecryptable-unit loss path.
|
||||||
|
decrypt_fail: std::collections::HashSet<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemDisc {
|
impl MemDisc {
|
||||||
@@ -860,6 +787,7 @@ mod tests {
|
|||||||
Self {
|
Self {
|
||||||
sectors: HashMap::new(),
|
sectors: HashMap::new(),
|
||||||
bad: std::collections::HashSet::new(),
|
bad: std::collections::HashSet::new(),
|
||||||
|
decrypt_fail: std::collections::HashSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
||||||
@@ -891,6 +819,9 @@ mod tests {
|
|||||||
sense: None,
|
sense: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if self.decrypt_fail.contains(&(lba + i)) {
|
||||||
|
return Err(Error::DecryptFailed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for i in 0..count as u32 {
|
for i in 0..count as u32 {
|
||||||
let off = i as usize * 2048;
|
let off = i as usize * 2048;
|
||||||
@@ -1412,6 +1343,64 @@ mod tests {
|
|||||||
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
|
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An UNDECRYPTABLE unit (DecryptFailed — wrong/missing key) is zero-filled
|
||||||
|
/// and counted as loss through the public API exactly like a bad sector:
|
||||||
|
/// the recovery-seam consolidation folded the old bytes_undecryptable bucket
|
||||||
|
/// into bytes_unreadable, and the run must still report complete == false and
|
||||||
|
/// bytes_lost() > 0 (this gates the CLI exit code / multipass re-run).
|
||||||
|
#[test]
|
||||||
|
fn undecryptable_unit_holes_file_and_accounts_loss() {
|
||||||
|
let good = vec![0x55u8; 4 * 2048];
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![DirSpec {
|
||||||
|
name: "BDMV".to_string(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![DirSpec {
|
||||||
|
name: "STREAM".to_string(),
|
||||||
|
icb_lba: 22,
|
||||||
|
dir_data_lba: 23,
|
||||||
|
files: vec![file("00001.m2ts", 24, 5000, good.clone(), true)],
|
||||||
|
subdirs: vec![],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let mut disc = build_disc(root);
|
||||||
|
// The whole extent fails to decrypt (no/wrong key) rather than to read.
|
||||||
|
for i in 0..4u32 {
|
||||||
|
disc.decrypt_fail.insert(PART_START + 5000 + i);
|
||||||
|
}
|
||||||
|
let out = TmpDir::new("decryptfail");
|
||||||
|
let res = clear_disc()
|
||||||
|
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
|
||||||
|
.expect("extract does not abort on an undecryptable unit");
|
||||||
|
let got = read_out(out.path(), "BDMV/STREAM/00001.m2ts").expect("file written");
|
||||||
|
assert_eq!(
|
||||||
|
got.len(),
|
||||||
|
good.len(),
|
||||||
|
"holed file still sized to declared size"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
got.iter().all(|&b| b == 0),
|
||||||
|
"undecryptable range zero-filled"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!res.complete,
|
||||||
|
"an undecryptable unit makes the rip incomplete"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
res.bytes_lost() > 0,
|
||||||
|
"decrypt loss counted, not reported clean"
|
||||||
|
);
|
||||||
|
assert_eq!(res.bytes_unreadable, good.len() as u64);
|
||||||
|
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
|
||||||
|
}
|
||||||
|
|
||||||
/// Path sanitization rejects a host-illegal component in a disc file name.
|
/// Path sanitization rejects a host-illegal component in a disc file name.
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_rejects_illegal_component() {
|
fn sanitize_rejects_illegal_component() {
|
||||||
@@ -1585,10 +1574,6 @@ mod tests {
|
|||||||
res.bytes_unreadable, 0,
|
res.bytes_unreadable, 0,
|
||||||
"per-extent unit base must keep the second extent off the hole path"
|
"per-extent unit base must keep the second extent off the hole path"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
res.bytes_undecryptable, 0,
|
|
||||||
"clear units decrypt-restore clean"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
res.complete,
|
res.complete,
|
||||||
"a clean multi-extent AACS file extracts complete"
|
"a clean multi-extent AACS file extracts complete"
|
||||||
|
|||||||
+1395
File diff suppressed because it is too large
Load Diff
+401
-192
@@ -10,15 +10,15 @@
|
|||||||
|
|
||||||
mod bluray;
|
mod bluray;
|
||||||
mod dvd;
|
mod dvd;
|
||||||
pub mod dvd_audio_probe;
|
pub(crate) mod dvd_audio_probe;
|
||||||
mod encrypt;
|
mod encrypt;
|
||||||
mod extract;
|
mod extract;
|
||||||
|
mod hddvd;
|
||||||
pub mod mapfile;
|
pub mod mapfile;
|
||||||
mod patch;
|
mod patch;
|
||||||
pub mod read_error;
|
pub mod read_error;
|
||||||
mod section_recover;
|
mod section_recover;
|
||||||
mod sweep;
|
mod sweep;
|
||||||
pub mod verify;
|
|
||||||
|
|
||||||
use crate::drive::{Drive, extract_scsi_context};
|
use crate::drive::{Drive, extract_scsi_context};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
@@ -84,7 +84,8 @@ pub struct Disc {
|
|||||||
pub enum ContentFormat {
|
pub enum ContentFormat {
|
||||||
/// Blu-ray BD Transport Stream (192-byte packets)
|
/// Blu-ray BD Transport Stream (192-byte packets)
|
||||||
BdTs,
|
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,
|
MpegPs,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +94,16 @@ pub enum ContentFormat {
|
|||||||
pub enum DiscFormat {
|
pub enum DiscFormat {
|
||||||
/// 4K UHD Blu-ray (HEVC 2160p)
|
/// 4K UHD Blu-ray (HEVC 2160p)
|
||||||
Uhd,
|
Uhd,
|
||||||
|
/// UHD Blu-ray with AACS 2.1 FMTS content — the main feature is a `.fmts`
|
||||||
|
/// clip (M2TS transport stream plus interleaved forensic variant segments).
|
||||||
|
/// A BD-tree disc (enumerated by [`Disc::scan_bluray_titles`]); distinct
|
||||||
|
/// from [`DiscFormat::Uhd`] only in the container + AACS generation.
|
||||||
|
Fmts,
|
||||||
/// Standard Blu-ray (1080p/1080i)
|
/// Standard Blu-ray (1080p/1080i)
|
||||||
BluRay,
|
BluRay,
|
||||||
|
/// HD-DVD — `HVDVD_TS/` tree with `.evo` (Enhanced VOB, MPEG program stream)
|
||||||
|
/// clips. A tree-level peer of DVD/BD, enumerated by its own scanner.
|
||||||
|
HdDvd,
|
||||||
/// DVD
|
/// DVD
|
||||||
Dvd,
|
Dvd,
|
||||||
/// Unknown
|
/// Unknown
|
||||||
@@ -207,6 +216,22 @@ pub struct VideoStream {
|
|||||||
pub measured_cicp: Option<MeasuredCicp>,
|
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
|
/// Measured CICP colour signalling read directly from a video elementary stream
|
||||||
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
|
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -802,7 +827,11 @@ impl Codec {
|
|||||||
use crate::consts::coding_type as c;
|
use crate::consts::coding_type as c;
|
||||||
match ct {
|
match ct {
|
||||||
c::HEVC => Codec::Hevc,
|
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::VC1 => Codec::Vc1,
|
||||||
c::MPEG2_VIDEO => Codec::Mpeg2,
|
c::MPEG2_VIDEO => Codec::Mpeg2,
|
||||||
c::TRUEHD => Codec::TrueHd,
|
c::TRUEHD => Codec::TrueHd,
|
||||||
@@ -1481,13 +1510,10 @@ impl Disc {
|
|||||||
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
|
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
|
||||||
|
|
||||||
let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
|
let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
|
||||||
let format = if udf_fs.find_dir("/BDMV").is_some() {
|
// Authoritative here — the same MKB-driven detector the full scan uses
|
||||||
DiscFormat::BluRay // full scan distinguishes UHD vs BD
|
// (no titles needed: BD/UHD/FMTS come from the MKB generation). It no
|
||||||
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
|
// longer defaults to BluRay or defers UHD/FMTS to the full scan.
|
||||||
DiscFormat::Dvd
|
let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
|
||||||
} else {
|
|
||||||
DiscFormat::Unknown
|
|
||||||
};
|
|
||||||
let encrypted =
|
let encrypted =
|
||||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||||
@@ -1597,12 +1623,11 @@ impl Disc {
|
|||||||
// detection needs the read, the read needs auth, auth needs detection.
|
// detection needs the read, the read needs auth, auth needs detection.
|
||||||
// The handshake is itself the detector: on a non-CSS (unencrypted) DVD
|
// 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
|
// 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
|
// in the clear. This block is DVD-only: gate on `DiscFormat::Dvd`, NOT
|
||||||
// through the AACS handshake above and never reaches here.
|
// `content_format == MpegPs` — HD-DVD `.evo` is ALSO MPEG-PS but is AACS,
|
||||||
if disc.css.is_none()
|
// not CSS, so it must never enter the CSS/REPORT-KEY handshake (it goes
|
||||||
&& disc.content_format == ContentFormat::MpegPs
|
// through the AACS path above). BD/UHD are MPEG-TS and never reach here.
|
||||||
&& !disc.titles.is_empty()
|
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
|
// CSS title keys are per-VTS, and ONLY the scrambled movie content
|
||||||
// carries a non-zero key. Menu / VMG / logo cells (often the
|
// carries a non-zero key. Menu / VMG / logo cells (often the
|
||||||
// low-LBA first extent) return a ZERO title key over REPORT KEY —
|
// low-LBA first extent) return a ZERO title key over REPORT KEY —
|
||||||
@@ -1644,12 +1669,13 @@ impl Disc {
|
|||||||
// key). Any failure is non-fatal: continue to the crack, which
|
// key). Any failure is non-fatal: continue to the crack, which
|
||||||
// simply finds nothing if the drive kept the sectors gated.
|
// simply finds nothing if the drive kept the sectors gated.
|
||||||
let drive_id = session.drive_id.clone();
|
let drive_id = session.drive_id.clone();
|
||||||
if let Err(e) = crate::unlock_bridge::run_unlockers(
|
let (_, css_unlock_res) = crate::unlock_bridge::run_bus(
|
||||||
session.scsi_mut(),
|
session.scsi_mut(),
|
||||||
&drive_id,
|
&drive_id,
|
||||||
freemkv_unlock::DiscKind::Css,
|
freemkv_unlock::DiscKind::Css,
|
||||||
&[],
|
&[],
|
||||||
) {
|
);
|
||||||
|
if let Err(e) = css_unlock_res {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::scan",
|
target: "freemkv::scan",
|
||||||
outcome = ?e,
|
outcome = ?e,
|
||||||
@@ -1717,10 +1743,13 @@ impl Disc {
|
|||||||
// pre-decrypted one. A pre-decrypted image has its scramble flags clear,
|
// 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
|
// so `crack_key` finds no crackable sector and the disc stays in the
|
||||||
// clear. AACS images go through KEYDB VUK lookup, not here.
|
// clear. AACS images go through KEYDB VUK lookup, not here.
|
||||||
if disc.css.is_none()
|
//
|
||||||
&& disc.content_format == ContentFormat::MpegPs
|
// Gate on `DiscFormat::Dvd`, NOT `content_format == MpegPs`: HD-DVD
|
||||||
&& !disc.titles.is_empty()
|
// `.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
|
let main_extents = match disc
|
||||||
.titles
|
.titles
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1772,10 +1801,9 @@ impl Disc {
|
|||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
udf_fs: &udf::UdfFs,
|
udf_fs: &udf::UdfFs,
|
||||||
) -> Result<(Vec<u8>, Vec<u8>, u8)> {
|
) -> Result<(Vec<u8>, Vec<u8>, u8)> {
|
||||||
let inf = udf_fs
|
let inf = crate::aacs::read_first(crate::aacs::UNIT_KEY_RO_PATHS, |p| {
|
||||||
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
|
udf_fs.read_file(reader, p)
|
||||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
|
})?;
|
||||||
.map_err(|_| Error::AacsNoKeys)?;
|
|
||||||
let mkb = Self::read_mkb_content(reader, udf_fs)?;
|
let mkb = Self::read_mkb_content(reader, udf_fs)?;
|
||||||
let version = Self::read_aacs_version(reader, udf_fs);
|
let version = Self::read_aacs_version(reader, udf_fs);
|
||||||
Ok((inf, mkb, version))
|
Ok((inf, mkb, version))
|
||||||
@@ -1793,12 +1821,12 @@ impl Disc {
|
|||||||
/// mis-strided title keys (silent wrong unit keys), so a missing cert must
|
/// mis-strided title keys (silent wrong unit keys), so a missing cert must
|
||||||
/// not quietly pick the V10 stride for a UHD disc.
|
/// not quietly pick the V10 stride for a UHD disc.
|
||||||
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
|
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
|
||||||
match udf_fs
|
match crate::aacs::read_first(crate::aacs::CONTENT_CERT_PATHS, |p| {
|
||||||
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
|
udf_fs.read_file(reader, p)
|
||||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(crate::aacs::inf::parse_content_cert)
|
.and_then(crate::aacs::inf::parse_content_cert)
|
||||||
{
|
{
|
||||||
Some(c) => c.version.major(),
|
Some(c) => c.version.major(),
|
||||||
None => {
|
None => {
|
||||||
@@ -1829,10 +1857,9 @@ impl Disc {
|
|||||||
const MAX_BYTES: usize = 64 * 1024 * 1024;
|
const MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||||
let mut want = START_BYTES;
|
let mut want = START_BYTES;
|
||||||
loop {
|
loop {
|
||||||
let buf = udf_fs
|
let buf = crate::aacs::read_first(crate::aacs::MKB_PATHS, |p| {
|
||||||
.read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want)
|
udf_fs.read_file_prefix(reader, p, want)
|
||||||
.or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want))
|
})?;
|
||||||
.map_err(|_| Error::AacsNoKeys)?;
|
|
||||||
let n = crate::aacs::mkb::mkb_content_len(&buf);
|
let n = crate::aacs::mkb::mkb_content_len(&buf);
|
||||||
// `n` strictly inside `buf` => the record walk reached the padding
|
// `n` strictly inside `buf` => the record walk reached the padding
|
||||||
// boundary (full content captured). `buf` shorter than `want` =>
|
// boundary (full content captured). `buf` shorter than `want` =>
|
||||||
@@ -1906,12 +1933,20 @@ impl Disc {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
|
// 3. Titles + container — dispatched by on-disc tree. HD-DVD and DVD are
|
||||||
|
// tree-level peers, each with its own enumerator; FMTS shares the BD
|
||||||
|
// tree (a `.fmts` stream variant). Disc FORMAT is a separate axis
|
||||||
|
// derived below from the AACS MKB generation, not the tree.
|
||||||
let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
|
let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
|
||||||
(
|
(
|
||||||
Self::scan_bluray_titles(reader, &udf_fs),
|
Self::scan_bluray_titles(reader, &udf_fs),
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
)
|
)
|
||||||
|
} else if udf_fs.find_dir("/HVDVD_TS").is_some() {
|
||||||
|
(
|
||||||
|
Self::scan_hddvd_titles(reader, &udf_fs),
|
||||||
|
ContentFormat::MpegPs,
|
||||||
|
)
|
||||||
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
|
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
|
||||||
(
|
(
|
||||||
Self::scan_dvd_titles(reader, &udf_fs),
|
Self::scan_dvd_titles(reader, &udf_fs),
|
||||||
@@ -1935,8 +1970,11 @@ impl Disc {
|
|||||||
crate::labels::apply(reader, &udf_fs, &mut titles);
|
crate::labels::apply(reader, &udf_fs, &mut titles);
|
||||||
crate::labels::fill_defaults(&mut titles);
|
crate::labels::fill_defaults(&mut titles);
|
||||||
|
|
||||||
// 5. Derive format, layers, region
|
// 5. Format (AACS MKB generation → BD/UHD/FMTS; tree → HD-DVD/DVD) and
|
||||||
let format = Self::detect_format(&titles);
|
// layers. Region coding is not yet decoded from the disc — every disc
|
||||||
|
// reports Region-free for now (correct for all UHD; a stub for
|
||||||
|
// region-locked BD/DVD until region detection is implemented).
|
||||||
|
let format = Self::detect_disc_format(reader, &udf_fs, &titles);
|
||||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||||
let region = DiscRegion::Free;
|
let region = DiscRegion::Free;
|
||||||
|
|
||||||
@@ -2025,10 +2063,10 @@ impl Disc {
|
|||||||
/// 1. Real titles (`size_bytes ≤ capacity_bytes`) before virtual
|
/// 1. Real titles (`size_bytes ≤ capacity_bytes`) before virtual
|
||||||
/// composites. The capacity check is a hard "physically
|
/// composites. The capacity check is a hard "physically
|
||||||
/// possible data on this disc" gate.
|
/// possible data on this disc" gate.
|
||||||
/// 2. Among real titles, fewer clips first. A 1-clip playlist is
|
/// 2. Among real titles, LARGEST physical size first — the main
|
||||||
/// the canonical main feature; multi-clip playlists are either
|
/// feature is the biggest real title on the disc. (This replaced
|
||||||
/// chapter-stitched (small count) or virtual composites
|
/// the old clip-count ordering, which mis-ranked chapter-per-clip
|
||||||
/// (large count). Fewer wins.
|
/// discs like Fast & Furious.)
|
||||||
/// 3. Tiebreak on longer duration first.
|
/// 3. Tiebreak on longer duration first.
|
||||||
///
|
///
|
||||||
/// **Effect on non-branching discs:** unchanged — the main movie
|
/// **Effect on non-branching discs:** unchanged — the main movie
|
||||||
@@ -2040,17 +2078,27 @@ impl Disc {
|
|||||||
b: &DiscTitle,
|
b: &DiscTitle,
|
||||||
capacity_bytes: u64,
|
capacity_bytes: u64,
|
||||||
) -> std::cmp::Ordering {
|
) -> std::cmp::Ordering {
|
||||||
|
// A title bigger than the whole disc is a "play-all" composite artifact
|
||||||
|
// (its declared size double-counts clips shared with other playlists) —
|
||||||
|
// demote it below any real single title.
|
||||||
let a_oversize = a.size_bytes > capacity_bytes;
|
let a_oversize = a.size_bytes > capacity_bytes;
|
||||||
let b_oversize = b.size_bytes > capacity_bytes;
|
let b_oversize = b.size_bytes > capacity_bytes;
|
||||||
a_oversize
|
a_oversize
|
||||||
.cmp(&b_oversize)
|
.cmp(&b_oversize)
|
||||||
.then_with(|| a.clips.len().cmp(&b.clips.len()))
|
// PRIMARY: largest physical size = the main feature. Robust where
|
||||||
|
// duration and clip-count are not — a decoy "play-all" playlist runs
|
||||||
|
// long (e.g. 1h31m) but is tiny (0.4 GB of reused/junk clips), and the
|
||||||
|
// real feature is often chaptered into MANY clips (one per chapter),
|
||||||
|
// which the old clip-count-ascending key wrongly demoted below 1-clip
|
||||||
|
// bonus reels. Validated across 23 UHD/BD discs — fixes F9 / Fast Five
|
||||||
|
// / Fast & Furious 6 / Furious 7 (feature was ranked ~#13–36), no
|
||||||
|
// regressions on the 19 already correct.
|
||||||
|
.then_with(|| b.size_bytes.cmp(&a.size_bytes))
|
||||||
|
// Tiebreak for equal-size twins: longer duration, then richer audio —
|
||||||
|
// the same feature authored as sibling playlists (a full-audio main
|
||||||
|
// vs an audio-reduced twin, e.g. Fight Club's 00800 [DTS-HD MA + 13
|
||||||
|
// tracks] vs 00004 [stereo AC-3 only]). Prefer lossless-multichannel.
|
||||||
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
|
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
|
||||||
// Same length + clip count = the same feature authored as multiple
|
|
||||||
// playlists (a full-audio main vs an audio-reduced twin, e.g. Fight
|
|
||||||
// Club's 00800 [DTS-HD MA + 13 tracks] vs 00004 [stereo AC-3 only]).
|
|
||||||
// Prefer the richer audio so we never rip a stereo-only variant over
|
|
||||||
// the lossless-multichannel main feature.
|
|
||||||
.then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a)))
|
.then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2086,6 +2134,55 @@ impl Disc {
|
|||||||
(lossless, max_ch, count)
|
(lossless, max_ch, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The disc format, from the two on-disc axes:
|
||||||
|
/// * **tree** → HD-DVD (`HVDVD_TS/`) and DVD (`VIDEO_TS/`) are tree-level
|
||||||
|
/// peers with their own enumerators;
|
||||||
|
/// * **AACS MKB generation** → within the BD tree (`BDMV/`), the MKB Type
|
||||||
|
/// record decides BD (1.0) / UHD (2.0) / FMTS (2.1). This is the
|
||||||
|
/// authoritative, cheap signal (the Type record is the first bytes of
|
||||||
|
/// `MKB_RO.inf`) — reusing [`crate::aacs::mkb::mkb_type`] /
|
||||||
|
/// [`crate::aacs::mkb::MkbType::generation`], not a filesystem heuristic.
|
||||||
|
///
|
||||||
|
/// An unencrypted / MKB-less BD tree falls back to video resolution (still a
|
||||||
|
/// BD-tree disc, so never below [`DiscFormat::BluRay`]).
|
||||||
|
fn detect_disc_format(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
udf_fs: &crate::udf::UdfFs,
|
||||||
|
titles: &[DiscTitle],
|
||||||
|
) -> DiscFormat {
|
||||||
|
use crate::aacs::mkb::{AacsVersion, mkb_type};
|
||||||
|
// Tree priority MUST match the title-scan dispatch (BDMV → HVDVD_TS →
|
||||||
|
// VIDEO_TS): otherwise a disc carrying two trees would be classified as
|
||||||
|
// one format but enumerated as another (e.g. BD titles tagged HdDvd).
|
||||||
|
if udf_fs.find_dir("/BDMV").is_some() {
|
||||||
|
// Only the Type-and-Version record (first record) is needed.
|
||||||
|
if let Ok(mkb) = udf_fs.read_file_prefix(reader, "/AACS/MKB_RO.inf", 64) {
|
||||||
|
match mkb_type(&mkb).map(|t| t.generation()) {
|
||||||
|
Some(AacsVersion::V21) => return DiscFormat::Fmts,
|
||||||
|
Some(AacsVersion::V20) => return DiscFormat::Uhd,
|
||||||
|
Some(AacsVersion::V10) => return DiscFormat::BluRay,
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unencrypted / unreadable MKB: refine by resolution, but a BD-tree
|
||||||
|
// disc is never below Blu-ray — only UHD can promote it. detect_format
|
||||||
|
// is a general resolution classifier that can return Dvd for an SD
|
||||||
|
// bonus/menu title, which must NOT tag a BDMV disc as DVD (that
|
||||||
|
// mis-sizes the ECC-block sweep). Clamp anything but UHD up to BluRay.
|
||||||
|
return match Self::detect_format(titles) {
|
||||||
|
DiscFormat::Uhd => DiscFormat::Uhd,
|
||||||
|
_ => DiscFormat::BluRay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if udf_fs.find_dir("/HVDVD_TS").is_some() {
|
||||||
|
return DiscFormat::HdDvd;
|
||||||
|
}
|
||||||
|
if udf_fs.find_dir("/VIDEO_TS").is_some() {
|
||||||
|
return DiscFormat::Dvd;
|
||||||
|
}
|
||||||
|
DiscFormat::Unknown
|
||||||
|
}
|
||||||
|
|
||||||
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
|
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
|
||||||
for title in titles.iter().take(3) {
|
for title in titles.iter().take(3) {
|
||||||
for stream in &title.streams {
|
for stream in &title.streams {
|
||||||
@@ -2200,12 +2297,15 @@ fn aligned_unit_keys_validate(
|
|||||||
unit_keys: &[(u32, [u8; 16])],
|
unit_keys: &[(u32, [u8; 16])],
|
||||||
read_data_key: Option<&[u8; 16]>,
|
read_data_key: Option<&[u8; 16]>,
|
||||||
samples: &[Vec<u8>],
|
samples: &[Vec<u8>],
|
||||||
|
format: ContentFormat,
|
||||||
) -> bool {
|
) -> 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
|
let scrambled: Vec<&[u8]> = samples
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.as_slice())
|
.map(|s| s.as_slice())
|
||||||
.filter(|s| aacs_unit_needs_decrypt(s))
|
.filter(|s| aacs_unit_needs_decrypt(s, format))
|
||||||
.collect();
|
.collect();
|
||||||
if scrambled.is_empty() {
|
if scrambled.is_empty() {
|
||||||
return true; // nothing to disprove against — accept
|
return true; // nothing to disprove against — accept
|
||||||
@@ -2227,7 +2327,13 @@ fn aligned_unit_keys_validate(
|
|||||||
hb.tick_cpu(tried, total);
|
hb.tick_cpu(tried, total);
|
||||||
tried += 1;
|
tried += 1;
|
||||||
probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
|
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;
|
covered = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2254,6 +2360,7 @@ impl Disc {
|
|||||||
crate::decrypt::DecryptKeys::Aacs {
|
crate::decrypt::DecryptKeys::Aacs {
|
||||||
unit_keys: aacs.unit_keys.clone(),
|
unit_keys: aacs.unit_keys.clone(),
|
||||||
read_data_key: aacs.read_data_key,
|
read_data_key: aacs.read_data_key,
|
||||||
|
format: self.content_format,
|
||||||
}
|
}
|
||||||
} else if let Some(ref css) = self.css {
|
} else if let Some(ref css) = self.css {
|
||||||
crate::decrypt::DecryptKeys::Css {
|
crate::decrypt::DecryptKeys::Css {
|
||||||
@@ -2308,18 +2415,23 @@ impl Disc {
|
|||||||
/// that honestly (`AACS: no`), and on a *stock* drive that fell back to the
|
/// that honestly (`AACS: no`), and on a *stock* drive that fell back to the
|
||||||
/// cert route it reports `LibreDrive: no, AACS: yes` — the real diagnostic.
|
/// cert route it reports `LibreDrive: no, AACS: yes` — the real diagnostic.
|
||||||
pub fn unlocker_matrix(&self, drive: &crate::Drive) -> Vec<(&'static str, bool)> {
|
pub fn unlocker_matrix(&self, drive: &crate::Drive) -> Vec<(&'static str, bool)> {
|
||||||
// LibreDrive firmware-unlocked the drive iff a drive unlocker actually
|
// The drive-prep unlocker that actually ran (recorded on init):
|
||||||
// succeeded at init (name recorded only on success).
|
// "LibreDrive" (MediaTek) or "Renesas" — mutually exclusive per drive.
|
||||||
let ld_worked = drive.unlocker_name().is_some();
|
let prep = drive.unlocker_name();
|
||||||
|
// Only LibreDrive (MediaTek) removes AACS bus encryption AT THE DRIVE;
|
||||||
|
// Renesas unlocks features but leaves the bus to the cert.
|
||||||
|
let ld_removed_bus = prep == Some("LibreDrive");
|
||||||
crate::unlock_bridge::unlocker_names()
|
crate::unlock_bridge::unlocker_names()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|name| {
|
.map(|name| {
|
||||||
let did_work = match name {
|
let did_work = match name {
|
||||||
// The drive firmware unlock ran and succeeded.
|
// Each firmware unlocker did work iff it was the one that ran.
|
||||||
"LibreDrive" => ld_worked,
|
"LibreDrive" => ld_removed_bus,
|
||||||
// The AACS host-cert route removed the bus ONLY when LibreDrive
|
"Renesas" => prep == Some("Renesas"),
|
||||||
// didn't (stock drive) AND AACS state was actually obtained.
|
// The AACS host-cert route removed the bus ONLY when the
|
||||||
"AACS" => self.aacs.is_some() && !ld_worked,
|
// firmware didn't (stock or Renesas drive) AND AACS state was
|
||||||
|
// actually obtained.
|
||||||
|
"AACS" => self.aacs.is_some() && !ld_removed_bus,
|
||||||
// The CSS handshake/crack succeeded → title keys recovered.
|
// The CSS handshake/crack succeeded → title keys recovered.
|
||||||
"CSS" => self.css.is_some(),
|
"CSS" => self.css.is_some(),
|
||||||
// A newly-registered unlocker with no runtime signal wired
|
// A newly-registered unlocker with no runtime signal wired
|
||||||
@@ -2565,13 +2677,15 @@ impl Disc {
|
|||||||
aacs.unit_keys = keys;
|
aacs.unit_keys = keys;
|
||||||
aacs.key_source = KeyOrigin::ExternalUk;
|
aacs.key_source = KeyOrigin::ExternalUk;
|
||||||
} else if self.encrypted && self.css.is_none() {
|
} else if self.encrypted && self.css.is_none() {
|
||||||
|
// FMTS is AACS 2.1, a UHD-family (bus-encrypted) format — not BD.
|
||||||
|
let uhd_family = matches!(self.format, DiscFormat::Uhd | DiscFormat::Fmts);
|
||||||
self.aacs = Some(AacsState {
|
self.aacs = Some(AacsState {
|
||||||
version: if self.format == DiscFormat::Uhd {
|
version: if uhd_family {
|
||||||
crate::aacs::mkb::AACS_MAJOR_UHD
|
crate::aacs::mkb::AACS_MAJOR_UHD
|
||||||
} else {
|
} else {
|
||||||
crate::aacs::mkb::AACS_MAJOR_BD
|
crate::aacs::mkb::AACS_MAJOR_BD
|
||||||
},
|
},
|
||||||
bus_encryption: self.format == DiscFormat::Uhd,
|
bus_encryption: uhd_family,
|
||||||
mkb_version: None,
|
mkb_version: None,
|
||||||
disc_hash: String::new(),
|
disc_hash: String::new(),
|
||||||
key_source: KeyOrigin::ExternalUk,
|
key_source: KeyOrigin::ExternalUk,
|
||||||
@@ -2714,7 +2828,12 @@ impl Disc {
|
|||||||
// de-scramble it. With no samples (or only clear ones) there is nothing
|
// 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
|
// to disprove against, so the key is accepted as-is — keeping the
|
||||||
// sample-less paths (resume / mapfile cache) byte-for-byte unchanged.
|
// 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);
|
return Err(crate::error::Error::AacsKeyRejected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2947,10 +3066,6 @@ impl Disc {
|
|||||||
progress: opts.progress,
|
progress: opts.progress,
|
||||||
halt: opts.halt.clone(),
|
halt: opts.halt.clone(),
|
||||||
key_fetch: opts.key_fetch.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)?;
|
let pr = self.patch(reader, path, &patch_opts)?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -3005,27 +3120,15 @@ impl Disc {
|
|||||||
// A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without
|
// A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without
|
||||||
// `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext.
|
// `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext.
|
||||||
//
|
//
|
||||||
// A NON-decrypting MULTIPASS sweep (`!opts.decrypt && skip_on_error`, the
|
// Every other sweep (`!opts.decrypt`: the autorip / `--multipass` path and
|
||||||
// autorip / `--multipass` path) writes the ISO as CIPHERTEXT, but we
|
// plain `--raw`) writes the ISO as CIPHERTEXT verbatim — keys = `None`, a
|
||||||
// still resolve the keys and VERIFY each unit on a scratch copy: a unit
|
// pure pass-through. Bad sectors are found by PHYSICAL read success (a SCSI
|
||||||
// that won't decrypt fails the read (`DECRYPT_VERIFY_READ`) exactly like
|
// read error → skip / NonTrimmed → patch re-read), NOT by decrypt structure.
|
||||||
// a SCSI error, and flows into the SAME read-error recovery (skip /
|
// (The old decrypt-VERIFY read gate — which mis-aligned the disc-absolute
|
||||||
// NonTrimmed / patch). This is the one spot that makes "a read succeeded"
|
// unit grid against clip-file-anchored AACS units and false-failed good
|
||||||
// mean "read AND decrypts" — everything downstream is unchanged. With no
|
// clips like Dunkirk's orphan-CPS clip — was removed. There is no scratch
|
||||||
// usable AACS keys (no keydb) it degrades to a plain pass-through.
|
// verify and no post-sweep clip-anchored pass; decryptability is proven at
|
||||||
//
|
// mux time, not at capture time.)
|
||||||
// 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.
|
|
||||||
let keys = if opts.decrypt {
|
let keys = if opts.decrypt {
|
||||||
self.decrypt_keys()
|
self.decrypt_keys()
|
||||||
} else {
|
} else {
|
||||||
@@ -3051,22 +3154,6 @@ impl Disc {
|
|||||||
};
|
};
|
||||||
let reader = &mut reader;
|
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.
|
// Mapfile: load if resuming, else wipe + recreate.
|
||||||
let mapfile_path = self.mapfile_for(path);
|
let mapfile_path = self.mapfile_for(path);
|
||||||
// covers_disc reconciliation. A resume against a mapfile whose total
|
// covers_disc reconciliation. A resume against a mapfile whose total
|
||||||
@@ -3158,25 +3245,37 @@ impl Disc {
|
|||||||
// ISO file: if resuming and mapfile has Finished ranges, open existing;
|
// ISO file: if resuming and mapfile has Finished ranges, open existing;
|
||||||
// otherwise create fresh and pre-size to total_bytes (sparse holes for
|
// otherwise create fresh and pre-size to total_bytes (sparse holes for
|
||||||
// non-tried regions).
|
// non-tried regions).
|
||||||
let is_regular = std::fs::metadata(path)
|
//
|
||||||
.map(|m| m.file_type().is_file())
|
// `is_regular` MUST be read from the OPEN file handle, not from
|
||||||
.unwrap_or(false);
|
// `metadata(path)` — on a fresh rip the path does not exist yet, so a
|
||||||
let file = if resume
|
// pre-create `metadata(path)` always fails (is_regular=false), which both
|
||||||
|
// skips the pre-size AND makes `SweepSink::close` swallow a real
|
||||||
|
// `sync_all()` failure on the just-written ISO as if it were /dev/null.
|
||||||
|
let (file, is_regular) = if resume
|
||||||
&& std::fs::metadata(path)
|
&& std::fs::metadata(path)
|
||||||
.map(|m| m.len() > 0)
|
.map(|m| m.len() > 0)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
std::fs::OpenOptions::new()
|
let f = std::fs::OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
.open(path)
|
.open(path)
|
||||||
.map_err(|e| Error::IoError { source: e })?
|
.map_err(|e| Error::IoError { source: e })?;
|
||||||
|
let reg = f
|
||||||
|
.metadata()
|
||||||
|
.map(|m| m.file_type().is_file())
|
||||||
|
.unwrap_or(false);
|
||||||
|
(f, reg)
|
||||||
} else {
|
} else {
|
||||||
let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
|
let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
|
||||||
if is_regular {
|
let reg = f
|
||||||
|
.metadata()
|
||||||
|
.map(|m| m.file_type().is_file())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if reg {
|
||||||
f.set_len(total_bytes)
|
f.set_len(total_bytes)
|
||||||
.map_err(|e| Error::IoError { source: e })?;
|
.map_err(|e| Error::IoError { source: e })?;
|
||||||
}
|
}
|
||||||
f
|
(f, reg)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrap the raw `File` in our bounded-cache `WritebackFile`
|
// Wrap the raw `File` in our bounded-cache `WritebackFile`
|
||||||
@@ -3345,18 +3444,6 @@ impl Disc {
|
|||||||
// The consumer thread sees decrypted bytes; the
|
// The consumer thread sees decrypted bytes; the
|
||||||
// pre-0.18 inline decrypt_sectors call lived here.
|
// 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
|
// Move the batch into the channel via fresh
|
||||||
// owned Vec. The producer's `buf` is reused
|
// owned Vec. The producer's `buf` is reused
|
||||||
// for the next read.
|
// for the next read.
|
||||||
@@ -3365,26 +3452,6 @@ impl Disc {
|
|||||||
producer_err = Some(consumer_gone());
|
producer_err = Some(consumer_gone());
|
||||||
break 'outer;
|
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);
|
bytes_done = bytes_done.saturating_add(block_bytes);
|
||||||
pos += block_bytes;
|
pos += block_bytes;
|
||||||
}
|
}
|
||||||
@@ -3871,18 +3938,6 @@ pub struct PatchOptions<'a> {
|
|||||||
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N
|
/// 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.
|
/// recover an orphan CPS unit's key when re-reading its bad range.
|
||||||
pub key_fetch: Option<crate::sector::KeyFetch>,
|
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`].
|
/// Result returned by [`Disc::patch`].
|
||||||
@@ -3971,8 +4026,10 @@ const MIN_BATCH_SECTORS: u16 = 3;
|
|||||||
|
|
||||||
pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 {
|
pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 {
|
||||||
match format {
|
match format {
|
||||||
DiscFormat::Uhd | DiscFormat::BluRay => 32,
|
// BD-family 64 KiB ECC block (32 × 2048). FMTS is a UHD BD disc.
|
||||||
DiscFormat::Dvd => 16,
|
DiscFormat::Uhd | DiscFormat::Fmts | DiscFormat::BluRay => 32,
|
||||||
|
// 32 KiB ECC block (16 × 2048) — DVD and HD-DVD.
|
||||||
|
DiscFormat::Dvd | DiscFormat::HdDvd => 16,
|
||||||
DiscFormat::Unknown => 32,
|
DiscFormat::Unknown => 32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4388,14 +4445,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-branching disc: longest 1-clip title is the movie. Sort
|
/// Non-branching disc: largest title is the movie. With realistic sizes
|
||||||
/// must not change behaviour — the existing "duration descending"
|
/// (bytes track duration for same-codec content) size-first yields the same
|
||||||
/// expectation holds when no titles overflow capacity.
|
/// ranking as duration — biggest/longest feature, then extra, then menu.
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_order_preserves_natural_ranking_on_normal_disc() {
|
fn canonical_order_preserves_natural_ranking_on_normal_disc() {
|
||||||
const CAPACITY: u64 = 60_000_000_000;
|
const CAPACITY: u64 = 60_000_000_000;
|
||||||
let mut titles = vec![
|
let mut titles = vec![
|
||||||
title_with("00100.mpls", 600.0, 5_000_000_000, 1), // 10 min menu
|
title_with("00100.mpls", 600.0, 500_000_000, 1), // 10 min menu (small)
|
||||||
title_with("00800.mpls", 7320.0, 55_000_000_000, 1), // 2h02m main feature
|
title_with("00800.mpls", 7320.0, 55_000_000_000, 1), // 2h02m main feature
|
||||||
title_with("00200.mpls", 1800.0, 2_000_000_000, 1), // 30 min extra
|
title_with("00200.mpls", 1800.0, 2_000_000_000, 1), // 30 min extra
|
||||||
];
|
];
|
||||||
@@ -4429,21 +4486,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tiebreak: equal duration + equal capacity-validity → fewer
|
|
||||||
/// clips wins. A chapter-stitched 3-clip movie should beat a
|
|
||||||
/// 50-clip virtual composite of the same duration.
|
|
||||||
#[test]
|
|
||||||
fn canonical_order_fewer_clips_wins_tiebreak() {
|
|
||||||
const CAPACITY: u64 = 100_000_000_000;
|
|
||||||
let mut titles = vec![
|
|
||||||
title_with("00050.mpls", 7200.0, 50_000_000_000, 50),
|
|
||||||
title_with("00800.mpls", 7200.0, 50_000_000_000, 3),
|
|
||||||
];
|
|
||||||
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
|
|
||||||
assert_eq!(titles[0].playlist, "00800.mpls");
|
|
||||||
assert_eq!(titles[1].playlist, "00050.mpls");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_format_uhd() {
|
fn detect_format_uhd() {
|
||||||
let titles = vec![title_with_video(Codec::Hevc, Resolution::R2160p)];
|
let titles = vec![title_with_video(Codec::Hevc, Resolution::R2160p)];
|
||||||
@@ -4468,6 +4510,132 @@ mod tests {
|
|||||||
assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown);
|
assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An AACS MKB Type-and-Version record (0x10) carrying `raw_type` — the only
|
||||||
|
/// record [`Disc::detect_disc_format`] reads to decide BD/UHD/FMTS.
|
||||||
|
fn mkb_type_record(raw_type: u32) -> Vec<u8> {
|
||||||
|
let mut v = vec![0x10, 0x00, 0x00, 0x0c]; // record type 0x10, rec_len 12
|
||||||
|
v.extend_from_slice(&raw_type.to_be_bytes()); // MKBType @ body offset 0
|
||||||
|
v.extend_from_slice(&0u32.to_be_bytes()); // version @ body offset 4
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FORMAT derives from the AACS MKB generation, not the tree or filesystem:
|
||||||
|
/// 2.1 → FMTS, 2.0 → UHD, 1.0 → BD — all from the MKB Type record.
|
||||||
|
#[test]
|
||||||
|
fn detect_format_from_mkb_generation() {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
for (raw, expected) in [
|
||||||
|
(0x4815_1003u32, DiscFormat::Fmts),
|
||||||
|
(0x4814_1003u32, DiscFormat::Uhd),
|
||||||
|
(0x0004_1003u32, DiscFormat::BluRay),
|
||||||
|
] {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![
|
||||||
|
DirSpec {
|
||||||
|
name: "BDMV".into(),
|
||||||
|
icb_lba: 12,
|
||||||
|
dir_data_lba: 13,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![],
|
||||||
|
},
|
||||||
|
DirSpec {
|
||||||
|
name: "AACS".into(),
|
||||||
|
icb_lba: 14,
|
||||||
|
dir_data_lba: 15,
|
||||||
|
files: vec![file_with(
|
||||||
|
"MKB_RO.inf",
|
||||||
|
16,
|
||||||
|
5000,
|
||||||
|
mkb_type_record(raw),
|
||||||
|
true,
|
||||||
|
)],
|
||||||
|
subdirs: vec![],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
build_udf_skeleton(&mut disc, 10);
|
||||||
|
lay_dir(&mut disc, &root);
|
||||||
|
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||||
|
assert_eq!(
|
||||||
|
Disc::detect_disc_format(&mut disc, &udf, &[]),
|
||||||
|
expected,
|
||||||
|
"MKB type {raw:#010x}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HD-DVD is a tree-level format — recognized from `HVDVD_TS/`, no MKB.
|
||||||
|
#[test]
|
||||||
|
fn detect_format_hddvd_from_tree() {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![DirSpec {
|
||||||
|
name: "HVDVD_TS".into(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
build_udf_skeleton(&mut disc, 10);
|
||||||
|
lay_dir(&mut disc, &root);
|
||||||
|
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||||
|
assert_eq!(
|
||||||
|
Disc::detect_disc_format(&mut disc, &udf, &[]),
|
||||||
|
DiscFormat::HdDvd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Title selection is by largest physical size, NOT clip count or duration.
|
||||||
|
/// Real-disc shape (Fast Five): a 57 GB / 11-clip feature must outrank both a
|
||||||
|
/// small 1-clip bonus reel and a long-but-tiny decoy "play-all" (91 reused
|
||||||
|
/// clips, 1h31m, 0.4 GB). The old clip-count-ascending key put the bonus t1.
|
||||||
|
#[test]
|
||||||
|
fn canonical_title_order_picks_largest_feature() {
|
||||||
|
fn title_sized(size_bytes: u64, duration_secs: f64, n_clips: usize) -> DiscTitle {
|
||||||
|
DiscTitle {
|
||||||
|
playlist: String::new(),
|
||||||
|
playlist_id: 0,
|
||||||
|
duration_secs,
|
||||||
|
size_bytes,
|
||||||
|
clips: (0..n_clips)
|
||||||
|
.map(|i| Clip {
|
||||||
|
clip_id: format!("{i:05}"),
|
||||||
|
in_time: 0,
|
||||||
|
out_time: 0,
|
||||||
|
duration_secs: 0.0,
|
||||||
|
source_packets: 0,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
streams: Vec::new(),
|
||||||
|
chapters: Vec::new(),
|
||||||
|
extents: Vec::new(),
|
||||||
|
content_format: ContentFormat::BdTs,
|
||||||
|
codec_privates: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let capacity = 66_000_000_000u64;
|
||||||
|
let feature = title_sized(57_000_000_000, 7860.0, 11); // 2h11m, 11 chapters
|
||||||
|
let bonus = title_sized(1_200_000_000, 600.0, 1); // 10m, 1 clip
|
||||||
|
let decoy = title_sized(400_000_000, 5460.0, 91); // 1h31m but tiny (reused)
|
||||||
|
let mut v = vec![bonus, decoy, feature];
|
||||||
|
v.sort_by(|a, b| Disc::canonical_title_order(a, b, capacity));
|
||||||
|
assert_eq!(
|
||||||
|
v[0].size_bytes, 57_000_000_000,
|
||||||
|
"the largest real title is the main feature"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn content_format_default_bdts() {
|
fn content_format_default_bdts() {
|
||||||
let t = title_with_video(Codec::H264, Resolution::R1080p);
|
let t = title_with_video(Codec::H264, Resolution::R1080p);
|
||||||
@@ -4616,6 +4784,7 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::Aacs {
|
crate::decrypt::DecryptKeys::Aacs {
|
||||||
unit_keys,
|
unit_keys,
|
||||||
read_data_key,
|
read_data_key,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(unit_keys, uk, "injected UK must be the decrypt 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");
|
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
|
||||||
@@ -4632,6 +4801,25 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inject_unit_keys_labels_fmts_as_uhd_family() {
|
||||||
|
// FMTS is AACS 2.1 — a UHD-family, bus-encrypted format. Injecting a UK
|
||||||
|
// on an FMTS disc must synthesize the UHD version + bus encryption, not
|
||||||
|
// mislabel it AACS 1.0 / bus-off (which would break FMTS decryption on
|
||||||
|
// the mapfile-recovered-UK path).
|
||||||
|
let mut disc = make_test_disc(1000, "FMTS");
|
||||||
|
disc.format = DiscFormat::Fmts;
|
||||||
|
disc.encrypted = true;
|
||||||
|
disc.inject_unit_keys(vec![(0u32, [0x22u8; 16])]);
|
||||||
|
let aacs = disc.aacs.as_ref().expect("aacs state synthesized");
|
||||||
|
assert_eq!(
|
||||||
|
aacs.version,
|
||||||
|
crate::aacs::mkb::AACS_MAJOR_UHD,
|
||||||
|
"FMTS is AACS 2.x (UHD major), not BD"
|
||||||
|
);
|
||||||
|
assert!(aacs.bus_encryption, "FMTS is bus-encrypted like UHD");
|
||||||
|
}
|
||||||
|
|
||||||
/// Build an AacsState carrying the given unit keys (other fields are inert
|
/// Build an AacsState carrying the given unit keys (other fields are inert
|
||||||
/// defaults — these tests only exercise the unit-key/decrypt-keys plumbing).
|
/// defaults — these tests only exercise the unit-key/decrypt-keys plumbing).
|
||||||
fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState {
|
fn aacs_with(unit_keys: Vec<(u32, [u8; 16])>) -> AacsState {
|
||||||
@@ -5116,14 +5304,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_key_validation_gates_on_real_ciphertext() {
|
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
|
// No samples -> nothing to disprove against -> accept (sample-less paths
|
||||||
// like resume / mapfile must be unaffected).
|
// like resume / mapfile must be unaffected).
|
||||||
assert!(super::aligned_unit_keys_validate(
|
assert!(super::aligned_unit_keys_validate(
|
||||||
&[(0, [0x11u8; 16])],
|
&[(0, [0x11u8; 16])],
|
||||||
None,
|
None,
|
||||||
&[]
|
&[],
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
|
|
||||||
// A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
|
// A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
|
||||||
@@ -5134,18 +5323,22 @@ mod tests {
|
|||||||
clear[off] = 0x47;
|
clear[off] = 0x47;
|
||||||
off += 192;
|
off += 192;
|
||||||
}
|
}
|
||||||
assert!(!ts_sync_destroyed(&clear));
|
assert!(crate::aacs::content::is_clean(
|
||||||
|
&clear,
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
assert!(super::aligned_unit_keys_validate(
|
assert!(super::aligned_unit_keys_validate(
|
||||||
&[(0, [0x11u8; 16])],
|
&[(0, [0x11u8; 16])],
|
||||||
None,
|
None,
|
||||||
&[clear.clone()]
|
&[clear.clone()],
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
|
|
||||||
// A genuinely scrambled unit the RIGHT key restores to clear TS.
|
// A genuinely scrambled unit the RIGHT key restores to clear TS.
|
||||||
let uk = [0x5au8; 16];
|
let uk = [0x5au8; 16];
|
||||||
let enc = encrypt_unit_for_test(&clear, &uk);
|
let enc = encrypt_unit_for_test(&clear, &uk);
|
||||||
assert!(
|
assert!(
|
||||||
ts_sync_destroyed(&enc),
|
!crate::aacs::content::is_clean(&enc, crate::disc::ContentFormat::BdTs),
|
||||||
"encrypted unit must read scrambled"
|
"encrypted unit must read scrambled"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -5153,16 +5346,23 @@ mod tests {
|
|||||||
assert!(super::aligned_unit_keys_validate(
|
assert!(super::aligned_unit_keys_validate(
|
||||||
&[(7, uk)],
|
&[(7, uk)],
|
||||||
None,
|
None,
|
||||||
&[enc.clone()]
|
&[enc.clone()],
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
// Wrong key -> cannot de-scramble a scrambled sample -> reject.
|
// Wrong key -> cannot de-scramble a scrambled sample -> reject.
|
||||||
assert!(!super::aligned_unit_keys_validate(
|
assert!(!super::aligned_unit_keys_validate(
|
||||||
&[(7, [0x00u8; 16])],
|
&[(7, [0x00u8; 16])],
|
||||||
None,
|
None,
|
||||||
&[enc.clone()]
|
&[enc.clone()],
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
// Empty key set against a scrambled sample -> reject.
|
// 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]
|
#[test]
|
||||||
@@ -5174,7 +5374,7 @@ mod tests {
|
|||||||
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
|
// 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
|
// ISO/MKV with no error surfaced. The gate must now reject a key set
|
||||||
// that leaves any scrambled sample uncovered.
|
// 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 clear = vec![0u8; ALIGNED_UNIT_LEN];
|
||||||
let mut off = 4;
|
let mut off = 4;
|
||||||
@@ -5187,8 +5387,14 @@ mod tests {
|
|||||||
let uk1 = [0x22u8; 16];
|
let uk1 = [0x22u8; 16];
|
||||||
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
|
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
|
||||||
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
|
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
|
||||||
assert!(ts_sync_destroyed(&sample0));
|
assert!(!crate::aacs::content::is_clean(
|
||||||
assert!(ts_sync_destroyed(&sample1));
|
&sample0,
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
assert!(!crate::aacs::content::is_clean(
|
||||||
|
&sample1,
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
let samples = vec![sample0.clone(), sample1.clone()];
|
let samples = vec![sample0.clone(), sample1.clone()];
|
||||||
|
|
||||||
@@ -5197,21 +5403,24 @@ mod tests {
|
|||||||
assert!(!super::aligned_unit_keys_validate(
|
assert!(!super::aligned_unit_keys_validate(
|
||||||
&[(0, uk0)],
|
&[(0, uk0)],
|
||||||
None,
|
None,
|
||||||
&samples
|
&samples,
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
|
|
||||||
// Complete key set (both CPS units) -> accept.
|
// Complete key set (both CPS units) -> accept.
|
||||||
assert!(super::aligned_unit_keys_validate(
|
assert!(super::aligned_unit_keys_validate(
|
||||||
&[(0, uk0), (1, uk1)],
|
&[(0, uk0), (1, uk1)],
|
||||||
None,
|
None,
|
||||||
&samples
|
&samples,
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
|
|
||||||
// Order-independent: covering key present anywhere in the set is fine.
|
// Order-independent: covering key present anywhere in the set is fine.
|
||||||
assert!(super::aligned_unit_keys_validate(
|
assert!(super::aligned_unit_keys_validate(
|
||||||
&[(1, uk1), (0, uk0)],
|
&[(1, uk1), (0, uk0)],
|
||||||
None,
|
None,
|
||||||
&samples
|
&samples,
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-88
@@ -405,9 +405,15 @@ pub(super) fn compute_initial_state(
|
|||||||
bad_ranges.reverse();
|
bad_ranges.reverse();
|
||||||
}
|
}
|
||||||
let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum();
|
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)
|
let is_regular = std::fs::metadata(path)
|
||||||
.map(|m| m.file_type().is_file())
|
.map(|m| m.file_type().is_file())
|
||||||
.unwrap_or(false);
|
.unwrap_or(true);
|
||||||
Ok((
|
Ok((
|
||||||
map,
|
map,
|
||||||
initial_stats,
|
initial_stats,
|
||||||
@@ -473,9 +479,7 @@ pub(super) fn recovery_read<R: SectorSource + ?Sized>(
|
|||||||
/// gets recorded NonTrimmed. Pure data structure — no I/O — so each phase
|
/// gets recorded NonTrimmed. Pure data structure — no I/O — so each phase
|
||||||
/// helper is unit-testable by asserting the residual `SubRanges`.
|
/// helper is unit-testable by asserting the residual `SubRanges`.
|
||||||
///
|
///
|
||||||
/// Foundation for the phased `recover_section` orchestrator; not yet wired
|
/// The residue tracker used by the phased `recover_section` orchestrator.
|
||||||
/// into the live loop (see the deferral note in the #50 work).
|
|
||||||
#[cfg_attr(not(test), allow(dead_code))]
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
pub(super) struct SubRanges {
|
pub(super) struct SubRanges {
|
||||||
/// (pos, len) pairs, sorted by pos, non-overlapping, all non-zero len.
|
/// (pos, len) pairs, sorted by pos, non-overlapping, all non-zero len.
|
||||||
@@ -1249,7 +1253,24 @@ impl Disc {
|
|||||||
pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 {
|
pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 {
|
||||||
let map = match mapfile::Mapfile::load(mapfile_path) {
|
let map = match mapfile::Mapfile::load(mapfile_path) {
|
||||||
Ok(m) => m,
|
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(&[
|
let bad_ranges = map.ranges_with(&[
|
||||||
mapfile::SectorStatus::NonTrimmed,
|
mapfile::SectorStatus::NonTrimmed,
|
||||||
@@ -1301,33 +1322,13 @@ impl Disc {
|
|||||||
let bytes_good_before = initial_stats.bytes_good;
|
let bytes_good_before = initial_stats.bytes_good;
|
||||||
let bytes_good_start = bytes_good_before;
|
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
|
// Decrypt-aware read — symmetric with `Disc::sweep`. A decrypting patch
|
||||||
// (`opts.decrypt`) decrypts in place (plaintext ISO). A NON-decrypting
|
// (`opts.decrypt`) decrypts in place (plaintext ISO); a NON-decrypting
|
||||||
// patch (the multipass / `--raw --multipass` path) resolves the keys and
|
// patch (the multipass / `--raw --multipass` path) copies ciphertext
|
||||||
// VERIFIES each unit on a scratch copy: a re-read that STILL won't decrypt
|
// verbatim (keys = `None` → pass-through). Bad sectors are found by
|
||||||
// fails the read (`DECRYPT_VERIFY_READ`) and stays NonTrimmed, so the
|
// PHYSICAL read success, not by decrypt structure: a re-read that returns
|
||||||
// retry loop keeps re-reading it "until it decrypts or retries exhaust"
|
// good bytes recovers the range; a read that errors leaves it NonTrimmed
|
||||||
// exactly as for a SCSI read error — and a unit that DOES decrypt on a
|
// for the next pass. (The old decrypt-VERIFY read gate was removed.)
|
||||||
// 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.
|
|
||||||
let keys = if opts.decrypt {
|
let keys = if opts.decrypt {
|
||||||
self.decrypt_keys()
|
self.decrypt_keys()
|
||||||
} else {
|
} else {
|
||||||
@@ -1437,55 +1438,7 @@ impl Disc {
|
|||||||
// sink's summary. `close` failing on a regular-file sync_all is
|
// sink's summary. `close` failing on a regular-file sync_all is
|
||||||
// surfaced here as `Error::IoError`, matching pre-split
|
// surfaced here as `Error::IoError`, matching pre-split
|
||||||
// behaviour.
|
// behaviour.
|
||||||
let mut summary = pipe.finish()?;
|
let 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 {
|
|
||||||
let _ = m.record(
|
|
||||||
lba as u64 * 2048,
|
|
||||||
cnt as u64 * 2048,
|
|
||||||
mapfile::SectorStatus::NonTrimmed,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let _ = m.flush();
|
|
||||||
// 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 outcome = build_outcome(
|
let outcome = build_outcome(
|
||||||
&state,
|
&state,
|
||||||
@@ -1547,14 +1500,13 @@ mod tests {
|
|||||||
assert!(on("true"));
|
assert!(on("true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport failure (status=0xFF, USB-bridge crash) must be recognised by
|
/// Transport failure (status=0xFF, USB-bridge crash) must be recognised and
|
||||||
/// the gate `handle_read_failure` now checks FIRST, so it aborts the pass
|
/// abort the pass, rather than being treated as an ordinary bad sector and
|
||||||
/// (wedged_exit + BreakOuter) instead of treating the bridge crash as an
|
/// hammering the crashed device for up to the per-range watchdog budget. The
|
||||||
/// ordinary bad sector and hammering the crashed device for up to the
|
/// transport-failure classification predicate is not unit-testable in
|
||||||
/// per-range watchdog budget. `handle_read_failure` is not unit-testable in
|
/// isolation, so this guards the predicate the production early-return keys
|
||||||
/// isolation, so this guards the classification predicate the production
|
/// off, and the contrast that an ordinary read error is NOT misclassified as
|
||||||
/// early-return keys off, and the contrast that an ordinary read error is
|
/// a transport failure.
|
||||||
/// NOT misclassified as a transport failure.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transport_failure_is_recognised_for_patch_abort() {
|
fn transport_failure_is_recognised_for_patch_abort() {
|
||||||
use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE;
|
use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE;
|
||||||
|
|||||||
+6
-18
@@ -196,22 +196,10 @@ impl ReadCtx {
|
|||||||
/// threshold is loose so we don't bail too early on a range that
|
/// threshold is loose so we don't bail too early on a range that
|
||||||
/// has scattered good sectors mixed in.
|
/// has scattered good sectors mixed in.
|
||||||
///
|
///
|
||||||
/// `damage_threshold_pct = 6` mirrors `disc/patch.rs`'s
|
/// `damage_threshold_pct = 6` is looser than Pass 1 (12%): Pass N triggers
|
||||||
/// `PASSN_DAMAGE_THRESHOLD_PCT`. Pass N triggers the damage-skip
|
/// the damage-skip at half Pass 1 density because the patch loop exists to chip
|
||||||
/// at half the density Pass 1 uses (Pass 1 = 12%) because the
|
/// away at bad ranges, so being more eager to skip clustered bad sectors
|
||||||
/// patch loop's whole job is to chip away at bad ranges — being
|
/// converges faster on the recoverable good sectors inside a range.
|
||||||
/// more eager to skip clustered bad sectors converges faster on
|
|
||||||
/// the recoverable good sectors inside a range. The patch-side
|
|
||||||
/// `compute_damage_skip` reads its threshold directly from
|
|
||||||
/// `PASSN_DAMAGE_THRESHOLD_PCT`, which is an alias for this crate's
|
|
||||||
/// `PATCH_DAMAGE_THRESHOLD_PCT`, so the two are always in sync.
|
|
||||||
/// The patch loop's damage-skip is not yet unified with `handle_read_error`'s
|
|
||||||
/// jump path. (v0.20.8 unification attempt found the unification
|
|
||||||
/// itself blocked on the size-aware `range_remaining/4` cap that
|
|
||||||
/// lives in `compute_damage_skip` but not in
|
|
||||||
/// `handle_read_error::JumpAhead` — see
|
|
||||||
/// `tests/passn_handler_ab.rs` for the A/B fixture that pins
|
|
||||||
/// the divergence point.)
|
|
||||||
pub fn for_patch(batch: u16) -> Self {
|
pub fn for_patch(batch: u16) -> Self {
|
||||||
Self {
|
Self {
|
||||||
batch,
|
batch,
|
||||||
@@ -443,8 +431,8 @@ const WEDGE_ABORT_THRESHOLD: u64 = 16;
|
|||||||
const WEDGE_PASS_N_SKIP_SECTORS: u64 = 64;
|
const WEDGE_PASS_N_SKIP_SECTORS: u64 = 64;
|
||||||
|
|
||||||
/// Single source of truth for the Pass-N damage-window threshold.
|
/// Single source of truth for the Pass-N damage-window threshold.
|
||||||
/// Both [`ReadCtx::for_patch`] and `disc::patch::compute_damage_skip`
|
/// [`ReadCtx::for_patch`] reads this constant for the Pass-N damage-skip
|
||||||
/// reference this constant so the two damage-skip paths cannot drift.
|
/// threshold.
|
||||||
///
|
///
|
||||||
/// 6% means: with a 16-entry sliding window, the damage-skip fires
|
/// 6% means: with a 16-entry sliding window, the damage-skip fires
|
||||||
/// once 1 out of 16 recent reads has failed. Pass 1 uses a 12%
|
/// once 1 out of 16 recent reads has failed. Pass 1 uses a 12%
|
||||||
|
|||||||
@@ -66,14 +66,6 @@ pub(super) enum WorkItem {
|
|||||||
/// tell them apart without parsing a flag.
|
/// tell them apart without parsing a flag.
|
||||||
GapFill { pos: u64, len: u64 },
|
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
|
/// Producer wants the latest mapfile stats for the progress
|
||||||
/// callback. Consumer responds on `prog_tx` with a fresh
|
/// callback. Consumer responds on `prog_tx` with a fresh
|
||||||
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
||||||
@@ -182,12 +174,6 @@ impl Sink<WorkItem> for SweepSink {
|
|||||||
}
|
}
|
||||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
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 => {
|
WorkItem::StatsRequest => {
|
||||||
let stats = self.map.stats();
|
let stats = self.map.stats();
|
||||||
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
|
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
|
||||||
|
|||||||
-1022
File diff suppressed because it is too large
Load Diff
+7
-8
@@ -433,15 +433,14 @@ impl Drive {
|
|||||||
// that used to sit here was the v1.0.0-rc.1 regression — it skipped the
|
// that used to sit here was the v1.0.0-rc.1 regression — it skipped the
|
||||||
// drive-prep for DVD, leaving DVDs riplocked at stock speed.
|
// drive-prep for DVD, leaving DVDs riplocked at stock speed.
|
||||||
self.init_ran = true;
|
self.init_ran = true;
|
||||||
let r: Result<()> = match crate::unlock_bridge::run_unlockers(
|
let (matched, unlock_res) =
|
||||||
self.scsi.as_mut(),
|
crate::unlock_bridge::run_features(self.scsi.as_mut(), &self.drive_id);
|
||||||
&self.drive_id,
|
let r: Result<()> = match unlock_res {
|
||||||
freemkv_unlock::DiscKind::Unknown,
|
|
||||||
&[],
|
|
||||||
) {
|
|
||||||
Ok(unlocked) => {
|
Ok(unlocked) => {
|
||||||
self.unlocker_name =
|
// Record WHICH drive-prep unlocker actually ran — "LibreDrive"
|
||||||
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
|
// (MediaTek) or "Renesas" — not the ld-only identity lookup, so a
|
||||||
|
// Renesas drive reports itself honestly rather than as nothing.
|
||||||
|
self.unlocker_name = Some(matched.to_string());
|
||||||
// Stash the OEM Volume ID the unlocker returned for the AACS
|
// Stash the OEM Volume ID the unlocker returned for the AACS
|
||||||
// handshake phase (do_handshake reads it via `oem_vid()`). A
|
// handshake phase (do_handshake reads it via `oem_vid()`). A
|
||||||
// drive-prep unlocker always carries a VID; guard anyway.
|
// drive-prep unlocker always carries a VID; guard anyway.
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Byte layout follows the DVD-Video specification (VMGI/VTSI headers,
|
//! Byte layout follows the DVD-Video specification (VMGI/VTSI headers,
|
||||||
//! PGC/cell tables, PCI/HLI button packets); the VM command decoder is
|
//! 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
|
//! Current contents: [`vmcmd`] — the VM command decoder (proven against the
|
||||||
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
|
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
|
||||||
|
|||||||
+5
-5
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! An 8-byte navigation command as found in PGC command tables (pre/post/cell)
|
//! 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
|
//! 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
|
//! 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
|
//! 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;
|
const JP_CALL_SS: u8 = 8;
|
||||||
|
|
||||||
// Link (type 1, direct=0) sub-commands. NOTE: sub-op 0 is NOP/no-link and 1 is
|
// 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_SUB: u8 = 1;
|
||||||
const LK_PGCN: u8 = 4;
|
const LK_PGCN: u8 = 4;
|
||||||
const LK_PTTN: u8 = 5;
|
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
|
((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
|
// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The
|
||||||
// operand *offsets* differ by command family.
|
// operand *offsets* differ by command family.
|
||||||
//
|
//
|
||||||
@@ -205,7 +205,7 @@ pub fn decode(b: &[u8; 8]) -> Command {
|
|||||||
let cmd = b[1] & 0x0F;
|
let cmd = b[1] & 0x0F;
|
||||||
|
|
||||||
// Compare predicate, with the operand layout for this command family
|
// 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) {
|
let compare = match (typ, direct) {
|
||||||
(TYPE_SPECIAL, _) => if_v1(b),
|
(TYPE_SPECIAL, _) => if_v1(b),
|
||||||
(TYPE_LINK_JUMP, 1) => if_v2(b), // jump
|
(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]
|
#[test]
|
||||||
fn link_subop_zero_is_nop_one_is_linksub() {
|
fn link_subop_zero_is_nop_one_is_linksub() {
|
||||||
assert_eq!(decode(&h("2000000000000000")).instr, Instr::Nop);
|
assert_eq!(decode(&h("2000000000000000")).instr, Instr::Nop);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ pub const E_NO_DISC_KEY: u16 = 7022;
|
|||||||
pub const E_CSS_KEY_MISSING: u16 = 7023;
|
pub const E_CSS_KEY_MISSING: u16 = 7023;
|
||||||
pub const E_AACS_NO_HOST_CERT: u16 = 7024;
|
pub const E_AACS_NO_HOST_CERT: u16 = 7024;
|
||||||
pub const E_AACS_BUS_KEY_UNAVAILABLE: u16 = 7025;
|
pub const E_AACS_BUS_KEY_UNAVAILABLE: u16 = 7025;
|
||||||
|
pub const E_FMTS_KEY_MISSING: u16 = 7026;
|
||||||
|
|
||||||
// Keydb (8xxx)
|
// Keydb (8xxx)
|
||||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||||
@@ -360,6 +361,15 @@ pub enum Error {
|
|||||||
/// time and no handshake runs.
|
/// time and no handshake runs.
|
||||||
AacsBusKeyUnavailable,
|
AacsBusKeyUnavailable,
|
||||||
|
|
||||||
|
/// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment
|
||||||
|
/// (variant) key is available to open them, and `BYPASS_FMTS_KEY` is `false`
|
||||||
|
/// (strict mode). Raised UPFRONT — before the mux — exactly like a missing
|
||||||
|
/// unit key, so a 2.1 disc that would rip with holes is refused rather than
|
||||||
|
/// silently producing a forensic-holed output. When `BYPASS_FMTS_KEY` is
|
||||||
|
/// `true` (the default today) this is never raised: the bulk decodes with the
|
||||||
|
/// unit key and the forensic segments are skipped as expected loss.
|
||||||
|
FmtsKeyMissing,
|
||||||
|
|
||||||
// Keydb (8xxx)
|
// Keydb (8xxx)
|
||||||
KeydbConnect {
|
KeydbConnect {
|
||||||
host: String,
|
host: String,
|
||||||
@@ -573,6 +583,7 @@ impl Error {
|
|||||||
Error::CssKeyMissing => E_CSS_KEY_MISSING,
|
Error::CssKeyMissing => E_CSS_KEY_MISSING,
|
||||||
Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT,
|
Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT,
|
||||||
Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE,
|
Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE,
|
||||||
|
Error::FmtsKeyMissing => E_FMTS_KEY_MISSING,
|
||||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||||
@@ -1209,6 +1220,7 @@ mod tests {
|
|||||||
E_CSS_KEY_MISSING,
|
E_CSS_KEY_MISSING,
|
||||||
E_AACS_NO_HOST_CERT,
|
E_AACS_NO_HOST_CERT,
|
||||||
E_AACS_BUS_KEY_UNAVAILABLE,
|
E_AACS_BUS_KEY_UNAVAILABLE,
|
||||||
|
E_FMTS_KEY_MISSING,
|
||||||
E_KEYDB_CONNECT,
|
E_KEYDB_CONNECT,
|
||||||
E_KEYDB_HTTP,
|
E_KEYDB_HTTP,
|
||||||
E_KEYDB_INVALID,
|
E_KEYDB_INVALID,
|
||||||
|
|||||||
+8
-8
@@ -58,7 +58,7 @@ pub struct DvdTitle {
|
|||||||
pub struct DvdCell {
|
pub struct DvdCell {
|
||||||
pub first_sector: u32,
|
pub first_sector: u32,
|
||||||
pub last_sector: u32,
|
pub last_sector: u32,
|
||||||
/// Raw cell-category byte at `cell_playback + 0` (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
|
/// Packs block_mode (bits 7-6), block_type (bits 5-4), seamless_play
|
||||||
/// (bit 3), interleaved (bit 2), stc_discontinuity (bit 1),
|
/// (bit 3), interleaved (bit 2), stc_discontinuity (bit 1),
|
||||||
/// seamless_angle (bit 0). Carried so the extent builder can recognise
|
/// 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
|
/// 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),
|
/// MSB-first: `block_mode`(7-6), `block_type`(5-4), `seamless_play`(3),
|
||||||
/// `interleaved`(2), `stc_discontinuity`(1), `seamless_angle`(0). (The real
|
/// `interleaved`(2), `stc_discontinuity`(1), `seamless_angle`(0). (The real
|
||||||
/// `cell_type` is a karaoke-only field in byte 1, not used here.)
|
/// `cell_type` is a karaoke-only field in byte 1, not used here.)
|
||||||
@@ -93,7 +93,7 @@ pub struct CellCategory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn decode(raw: u8) -> Self {
|
||||||
CellCategory {
|
CellCategory {
|
||||||
block_mode: (raw >> 6) & 0x03,
|
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
|
// 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.
|
// values they point to are per-disc.
|
||||||
const VTSTT_VOBS_OFFSET: usize = 0xC4; // VTS title VOBS start sector (feature)
|
const VTSTT_VOBS_OFFSET: usize = 0xC4; // VTS title VOBS start sector (feature)
|
||||||
const VTS_PGCIT_OFFSET: usize = 0xCC; // VTS_PGCIT sector pointer
|
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),
|
_ => 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 {
|
let sample_rate = match sample_rate_flag {
|
||||||
0 => 48000,
|
0 => 48000,
|
||||||
1 => 96000,
|
1 => 96000,
|
||||||
@@ -1071,7 +1071,7 @@ mod tests {
|
|||||||
/// reads with — a co-edit of constant + helper would silently re-introduce
|
/// 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
|
/// 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
|
/// `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
|
/// display_aspect[3-2] permitted_df[1-0]) — no `v_atr_byte`. If the parser's
|
||||||
/// bit positions drift, these fail.
|
/// bit positions drift, these fail.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1238,7 +1238,7 @@ mod tests {
|
|||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// Added hardening tests. Grounded in the DVD-Video IFO spec
|
// 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),
|
/// 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),
|
/// block_type (5-4), seamless_play (3), interleaved (2),
|
||||||
/// stc_discontinuity (1), seamless_angle (0).
|
/// stc_discontinuity (1), seamless_angle (0).
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
|||||||
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
||||||
/// cache stays bounded the same way the write side does.
|
/// cache stays bounded the same way the write side does.
|
||||||
///
|
///
|
||||||
/// 32 MiB is the empirically tuned value on the rip1 test bed (single
|
/// 32 MiB is the empirically tuned value on a 7200rpm HDD via SATA:
|
||||||
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the
|
/// smaller windows (8 / 16 MiB) shorten the
|
||||||
/// kernel-readahead overlap and slow the producer; larger windows
|
/// kernel-readahead overlap and slow the producer; larger windows
|
||||||
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
||||||
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
||||||
|
|||||||
+8
-2
@@ -449,7 +449,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
|||||||
} else {
|
} else {
|
||||||
// Benign per-item OK: trace-level (L4) only; the
|
// Benign per-item OK: trace-level (L4) only; the
|
||||||
// apply-side rolling summary carries throughput.
|
// apply-side rolling summary carries throughput.
|
||||||
tracing::trace!("Pipeline send: OK in {:.3}ms", elapsed.as_micros());
|
tracing::trace!(
|
||||||
|
"Pipeline send: OK in {:.3}ms",
|
||||||
|
elapsed.as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -464,7 +467,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
|||||||
std::any::type_name::<I>()
|
std::any::type_name::<I>()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("Pipeline send: failed after {:.3}ms", elapsed.as_micros());
|
tracing::debug!(
|
||||||
|
"Pipeline send: failed after {:.3}ms",
|
||||||
|
elapsed.as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e.0)
|
Err(e.0)
|
||||||
|
|||||||
+239
-24
@@ -23,6 +23,64 @@ use crate::aacs::types::{UnitKey, Vid};
|
|||||||
use crate::disc::Key;
|
use crate::disc::Key;
|
||||||
use crate::error::Error;
|
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;
|
||||||
|
|
||||||
|
/// A set of encrypted content-unit samples PROVEN to carry at least
|
||||||
|
/// [`MIN_SAMPLE_UNITS`] units — the online `/decode` request's proof-of-ownership.
|
||||||
|
///
|
||||||
|
/// "Parse, don't validate": the only constructor, [`DecodeSampleSet::new`], returns
|
||||||
|
/// `None` for an under-sized slice, so an online key request simply *cannot be built*
|
||||||
|
/// from too few samples. The runtime `len() < MIN_SAMPLE_UNITS` check that used to
|
||||||
|
/// live at the request site (and was silently forgotten by an under-sampling caller,
|
||||||
|
/// reading as "key service down") becomes a compile-time obligation: a request builder
|
||||||
|
/// that takes `&DecodeSampleSet` can never receive an unchecked `Vec`.
|
||||||
|
///
|
||||||
|
/// The *count* enforced here is a runtime property of the disc (how many encrypted
|
||||||
|
/// units it yields); the *requested* count is a caller-side compile-time constant that
|
||||||
|
/// callers pin to `MIN_SAMPLE_UNITS` (see e.g. autorip's `SAMPLE_UNITS`). Together the
|
||||||
|
/// two make under-sampling unrepresentable at the request boundary.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DecodeSampleSet(Vec<Vec<u8>>);
|
||||||
|
|
||||||
|
impl DecodeSampleSet {
|
||||||
|
/// Wrap `units` iff it carries at least [`MIN_SAMPLE_UNITS`] samples; `None`
|
||||||
|
/// otherwise (the caller then skips the online source rather than sending an
|
||||||
|
/// ambiguous request). This is the sole way to obtain a `DecodeSampleSet`.
|
||||||
|
pub fn new(units: Vec<Vec<u8>>) -> Option<Self> {
|
||||||
|
(units.len() >= MIN_SAMPLE_UNITS).then_some(Self(units))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The proven-sufficient samples. Guaranteed `>= MIN_SAMPLE_UNITS` in length.
|
||||||
|
pub fn units(&self) -> &[Vec<u8>] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of samples — always `>= MIN_SAMPLE_UNITS`.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always `false` (a `DecodeSampleSet` never holds fewer than `MIN_SAMPLE_UNITS`);
|
||||||
|
/// provided so the type satisfies the usual `len`/`is_empty` pairing.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
/// 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
|
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
||||||
/// structures a source or key server may key on.
|
/// structures a source or key server may key on.
|
||||||
@@ -77,7 +135,7 @@ pub trait ResolveCtx {
|
|||||||
fn mkb(&self) -> Result<&[u8], Error>;
|
fn mkb(&self) -> Result<&[u8], Error>;
|
||||||
/// The disc's encrypted title keys, parsed from `Unit_Key_RO.inf` the same
|
/// The disc's encrypted title keys, parsed from `Unit_Key_RO.inf` the same
|
||||||
/// way the library's resolver parses them ([`crate::aacs::inf::parse_unit_key_ro`]),
|
/// way the library's resolver parses them ([`crate::aacs::inf::parse_unit_key_ro`]),
|
||||||
/// in on-disc order. Feed straight into [`crate::aacs::boil::uk_from_vuk`].
|
/// in on-disc order. Feed straight into [`crate::aacs::derive::decrypt_unit_key`].
|
||||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error>;
|
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error>;
|
||||||
/// Up to `n` encrypted on-disc content sample units, for a source that
|
/// Up to `n` encrypted on-disc content sample units, for a source that
|
||||||
/// validates a candidate server-side against real ciphertext.
|
/// validates a candidate server-side against real ciphertext.
|
||||||
@@ -167,8 +225,8 @@ impl ResolveCtx for DiscInputsCtx<'_> {
|
|||||||
/// holds, orchestrates the derivation down to Unit Keys using the library's
|
/// holds, orchestrates the derivation down to Unit Keys using the library's
|
||||||
/// boil-down crypto primitives — never re-implementing AES. A source that holds
|
/// boil-down crypto primitives — never re-implementing AES. A source that holds
|
||||||
/// pre-decrypted Unit Keys returns them directly; one that holds a VUK calls
|
/// pre-decrypted Unit Keys returns them directly; one that holds a VUK calls
|
||||||
/// [`crate::aacs::boil::uk_from_vuk`]; one that holds device keys calls
|
/// [`crate::aacs::derive::decrypt_unit_key`]; one that holds device keys calls
|
||||||
/// [`crate::aacs::boil::mk_from_dk`] → [`crate::aacs::boil::vuk_from_mk`] → `uk_from_vuk`.
|
/// [`crate::aacs::derive::derive_media_key_from_dk`] → [`crate::aacs::derive::derive_vuk`] → `decrypt_unit_key`.
|
||||||
///
|
///
|
||||||
/// Returning an empty `Vec` means "no key for this disc from this source"; an
|
/// Returning an empty `Vec` means "no key for this disc from this source"; an
|
||||||
/// `Err` means the source itself failed (I/O, parse, network). The caller
|
/// `Err` means the source itself failed (I/O, parse, network). The caller
|
||||||
@@ -222,7 +280,7 @@ pub fn resolve_and_apply(
|
|||||||
/// success — so a wrong/partial key set is rejected and the loop continues.
|
/// success — so a wrong/partial key set is rejected and the loop continues.
|
||||||
///
|
///
|
||||||
/// CPS-unit numbering: a source returns Unit Keys carrying the POSITIONAL index
|
/// CPS-unit numbering: a source returns Unit Keys carrying the POSITIONAL index
|
||||||
/// from [`crate::aacs::boil::uk_from_vuk`]; the library's canonical CPS-unit number is
|
/// from [`crate::aacs::derive::decrypt_unit_key`]; the library's canonical CPS-unit number is
|
||||||
/// `position + 1` (matching [`crate::aacs::inf::parse_unit_key_ro`]'s `(i + 1)`), so
|
/// `position + 1` (matching [`crate::aacs::inf::parse_unit_key_ro`]'s `(i + 1)`), so
|
||||||
/// the committed `AacsState.unit_keys` is byte-identical to the library-resolved
|
/// the committed `AacsState.unit_keys` is byte-identical to the library-resolved
|
||||||
/// path. The number is cosmetic for descramble (the decrypt path strips it and
|
/// path. The number is cosmetic for descramble (the decrypt path strips it and
|
||||||
@@ -318,7 +376,28 @@ pub fn key_fetch(
|
|||||||
inputs: DiscInputs,
|
inputs: DiscInputs,
|
||||||
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
||||||
) -> crate::sector::KeyFetch {
|
) -> 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]> {
|
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 sources = make_sources();
|
||||||
let mut di = inputs.clone();
|
let mut di = inputs.clone();
|
||||||
di.samples = samples.to_vec();
|
di.samples = samples.to_vec();
|
||||||
@@ -327,10 +406,15 @@ pub fn key_fetch(
|
|||||||
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
|
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
|
||||||
// 48-byte stride — hardcoding the V20 stride here corrupted them.
|
// 48-byte stride — hardcoding the V20 stride here corrupted them.
|
||||||
let ctx = DiscInputsCtx::new(&di);
|
let ctx = DiscInputsCtx::new(&di);
|
||||||
fetch_unit_keys(&sources, &ctx)
|
let keys: Vec<[u8; 16]> = fetch_unit_keys(&sources, &ctx)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|u| u.key)
|
.map(|u| u.key)
|
||||||
.collect()
|
.collect();
|
||||||
|
cache
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.insert(fp, keys.clone());
|
||||||
|
keys
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,18 +428,24 @@ pub fn key_fetch(
|
|||||||
/// `start_lba`), which the library owns. A key source is *handed* these bytes
|
/// `start_lba`), which the library owns. A key source is *handed* these bytes
|
||||||
/// via `DiscInputs.samples`; it never reads the disc itself.
|
/// via `DiscInputs.samples`; it never reads the disc itself.
|
||||||
///
|
///
|
||||||
/// "Encrypted" is decided by [`crate::aacs::content::ts_sync_destroyed`] — the SAME
|
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
|
||||||
/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear
|
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
|
||||||
/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a
|
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
|
||||||
/// clear unit proves nothing, so this collects only scrambled ones — probing
|
/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant
|
||||||
/// several points spread across EACH extent so a title whose encrypted body
|
/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
|
||||||
/// starts late (or whose midpoint lands in clear nav) still yields samples.
|
/// unit sent to a key server yields nothing to validate against — the "0
|
||||||
|
/// encrypted units" rejection. A clip opens with clear navigation units (PAT/PMT,
|
||||||
|
/// menus) whose CPI is clear; only CPI-flagged content units are collected —
|
||||||
|
/// probing several points spread across EACH extent so a title whose encrypted
|
||||||
|
/// body starts late (or whose midpoint lands in clear nav) still yields samples.
|
||||||
|
/// CPI is read at each extent's `start_lba` (clip-file-anchored), so byte 0 is a
|
||||||
|
/// real unit start and the flag is meaningful.
|
||||||
pub fn read_encrypted_units(
|
pub fn read_encrypted_units(
|
||||||
reader: &mut dyn crate::sector::SectorSource,
|
reader: &mut dyn crate::sector::SectorSource,
|
||||||
title: &crate::disc::DiscTitle,
|
title: &crate::disc::DiscTitle,
|
||||||
n: usize,
|
n: usize,
|
||||||
) -> Vec<Vec<u8>> {
|
) -> Vec<Vec<u8>> {
|
||||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
|
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||||
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
|
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
|
||||||
// Probe several evenly-spaced points across EACH extent rather than only the
|
// Probe several evenly-spaced points across EACH extent rather than only the
|
||||||
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
|
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
|
||||||
@@ -401,7 +491,7 @@ pub fn read_encrypted_units(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let u = &buf[o..o + ALIGNED_UNIT_LEN];
|
let u = &buf[o..o + ALIGNED_UNIT_LEN];
|
||||||
if ts_sync_destroyed(u) {
|
if aacs_unit_encrypted(u, title.content_format) {
|
||||||
out.push(u.to_vec());
|
out.push(u.to_vec());
|
||||||
if out.len() >= n {
|
if out.len() >= n {
|
||||||
return out;
|
return out;
|
||||||
@@ -419,6 +509,46 @@ mod tests {
|
|||||||
use crate::aacs::types::UnitKey;
|
use crate::aacs::types::UnitKey;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
fn units(n: usize) -> Vec<Vec<u8>> {
|
||||||
|
(0..n).map(|i| vec![i as u8; 4]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DecodeSampleSet: the online request can't be built under-sized ─────────
|
||||||
|
|
||||||
|
/// Fewer than MIN_SAMPLE_UNITS → no set. Mutation: accepting a short slice
|
||||||
|
/// resurrects the exact autorip bug (a 4-sample request silently skipped /
|
||||||
|
/// read as "service down").
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_rejects_under_min() {
|
||||||
|
for n in 0..MIN_SAMPLE_UNITS {
|
||||||
|
assert!(
|
||||||
|
DecodeSampleSet::new(units(n)).is_none(),
|
||||||
|
"{n} samples (< {MIN_SAMPLE_UNITS}) must not build a DecodeSampleSet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exactly the minimum, and above it, construct — and expose all samples.
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_accepts_min_and_above() {
|
||||||
|
let exact = DecodeSampleSet::new(units(MIN_SAMPLE_UNITS)).expect("min builds");
|
||||||
|
assert_eq!(exact.len(), MIN_SAMPLE_UNITS);
|
||||||
|
assert_eq!(exact.units().len(), MIN_SAMPLE_UNITS);
|
||||||
|
assert!(!exact.is_empty());
|
||||||
|
|
||||||
|
let more = DecodeSampleSet::new(units(MIN_SAMPLE_UNITS + 5)).expect("above min builds");
|
||||||
|
assert_eq!(more.len(), MIN_SAMPLE_UNITS + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wrapped units round-trip byte-for-byte (the request carries exactly what
|
||||||
|
/// was gathered — no reordering/truncation).
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_preserves_units() {
|
||||||
|
let raw = units(MIN_SAMPLE_UNITS);
|
||||||
|
let set = DecodeSampleSet::new(raw.clone()).unwrap();
|
||||||
|
assert_eq!(set.units(), raw.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
// ── KeySource default-method behaviour ────────────────────────────────────
|
// ── KeySource default-method behaviour ────────────────────────────────────
|
||||||
|
|
||||||
/// KeySource::host_certs() defaults to empty regardless of the MKB argument.
|
/// KeySource::host_certs() defaults to empty regardless of the MKB argument.
|
||||||
@@ -557,10 +687,7 @@ mod tests {
|
|||||||
struct HasKey([u8; 16]);
|
struct HasKey([u8; 16]);
|
||||||
impl KeySource for HasKey {
|
impl KeySource for HasKey {
|
||||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||||
Ok(vec![UnitKey {
|
Ok(vec![UnitKey::new(0, self.0)])
|
||||||
idx: 0,
|
|
||||||
key: self.0,
|
|
||||||
}])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,10 +733,7 @@ mod tests {
|
|||||||
if let Ok(s) = ctx.samples(8) {
|
if let Ok(s) = ctx.samples(8) {
|
||||||
self.seen.lock().unwrap().extend(s);
|
self.seen.lock().unwrap().extend(s);
|
||||||
}
|
}
|
||||||
Ok(vec![UnitKey {
|
Ok(vec![UnitKey::new(0, self.key)])
|
||||||
idx: 0,
|
|
||||||
key: self.key,
|
|
||||||
}])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,7 +770,7 @@ mod tests {
|
|||||||
/// finds the early scrambled band.
|
/// finds the early scrambled band.
|
||||||
#[test]
|
#[test]
|
||||||
fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() {
|
fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() {
|
||||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
|
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
|
|
||||||
@@ -716,7 +840,98 @@ mod tests {
|
|||||||
"the probe-spread must sample the early scrambled band the midpoint misses"
|
"the probe-spread must sample the early scrambled band the midpoint misses"
|
||||||
);
|
);
|
||||||
for s in &samples {
|
for s in &samples {
|
||||||
assert!(ts_sync_destroyed(s), "every sample is a scrambled unit");
|
assert!(
|
||||||
|
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
|
||||||
|
"every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the
|
||||||
|
/// `ts_sync_destroyed` heuristic. Half the units are sync-destroyed but
|
||||||
|
/// CPI-CLEAR (`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely
|
||||||
|
/// lack TS syncs; the old sampler collected these and the key server rejected
|
||||||
|
/// the POST as "0 encrypted units". `read_encrypted_units` must skip them and
|
||||||
|
/// return ONLY CPI-flagged units. A regression to `ts_sync_destroyed` would
|
||||||
|
/// collect the CPI-clear units too and fail the `& 0xC0` assertion.
|
||||||
|
#[test]
|
||||||
|
fn read_encrypted_units_selects_by_cpi_not_ts_sync() {
|
||||||
|
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::sector::SectorSource;
|
||||||
|
|
||||||
|
// Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47).
|
||||||
|
// Odd units: CPI-set (byte0 = 0xC0) with a scrambled body.
|
||||||
|
// `ts_sync_destroyed` is TRUE for BOTH; `aacs_unit_encrypted` only odd.
|
||||||
|
struct MixSource {
|
||||||
|
ext_start: u32,
|
||||||
|
total_units: u32,
|
||||||
|
}
|
||||||
|
impl SectorSource for MixSource {
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
self.ext_start + self.total_units * ALIGNED_UNIT_SECTORS + 64
|
||||||
|
}
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
lba: u32,
|
||||||
|
count: u16,
|
||||||
|
buf: &mut [u8],
|
||||||
|
_r: bool,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let bytes = count as usize * 2048;
|
||||||
|
for (i, chunk) in buf[..bytes].chunks_mut(ALIGNED_UNIT_LEN).enumerate() {
|
||||||
|
if chunk.len() < ALIGNED_UNIT_LEN {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let abs = (lba - self.ext_start) / ALIGNED_UNIT_SECTORS + i as u32;
|
||||||
|
if abs % 2 == 0 {
|
||||||
|
chunk.fill(0x11); // CPI-clear (0x11 & 0xC0 == 0), no TS sync
|
||||||
|
} else {
|
||||||
|
chunk.fill(0xAB); // scrambled body (no TS sync)
|
||||||
|
chunk[0] = 0xC0; // CPI set -> encrypted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_units = 400u32;
|
||||||
|
let ext_start = 500u32;
|
||||||
|
let mut src = MixSource {
|
||||||
|
ext_start,
|
||||||
|
total_units,
|
||||||
|
};
|
||||||
|
let title = crate::disc::DiscTitle {
|
||||||
|
playlist: String::new(),
|
||||||
|
playlist_id: 0,
|
||||||
|
duration_secs: 0.0,
|
||||||
|
size_bytes: 0,
|
||||||
|
clips: Vec::new(),
|
||||||
|
streams: Vec::new(),
|
||||||
|
chapters: Vec::new(),
|
||||||
|
extents: vec![crate::disc::Extent {
|
||||||
|
start_lba: ext_start,
|
||||||
|
sector_count: total_units * ALIGNED_UNIT_SECTORS,
|
||||||
|
}],
|
||||||
|
content_format: crate::disc::ContentFormat::BdTs,
|
||||||
|
codec_privates: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let samples = read_encrypted_units(&mut src, &title, 8);
|
||||||
|
assert!(
|
||||||
|
!samples.is_empty(),
|
||||||
|
"the CPI-flagged (odd) units must still be collected"
|
||||||
|
);
|
||||||
|
for s in &samples {
|
||||||
|
assert!(
|
||||||
|
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
|
||||||
|
"only CPI-flagged units are selected"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s[0] & 0xC0,
|
||||||
|
0xC0,
|
||||||
|
"a CPI-clear sync-destroyed unit must never be sampled"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-21
@@ -16,7 +16,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
|
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
|
||||||
/// inside a `/BDMV/JAR/*` archive.
|
/// inside a `/BDMV/JAR/*` archive.
|
||||||
pub fn detect(udf: &UdfFs) -> bool {
|
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||||
super::jar_file_exists(udf, "streamproperties.xml")
|
super::jar_file_exists(udf, "streamproperties.xml")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +77,18 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
|||||||
/// `apply_labels` matches on `(type, stream_number)`, so a collision
|
/// `apply_labels` matches on `(type, stream_number)`, so a collision
|
||||||
/// would mislabel tracks.)
|
/// would mislabel tracks.)
|
||||||
fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>) -> Vec<u16> {
|
fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>) -> Vec<u16> {
|
||||||
// Numbers already claimed by the map, per type.
|
// Numbers already claimed by the map, per type. A map value of 0 is NOT a
|
||||||
|
// claim: apply_labels binds on 1-based stream numbers, so 0 is unmatchable.
|
||||||
|
// Treat 0 as "unmapped" here (defense in depth — parse_playback_config also
|
||||||
|
// filters it) so such a stream gets a real synthesized number instead of an
|
||||||
|
// orphan 0 that collides with / shadows a genuine stream 1.
|
||||||
let mut taken_audio: Vec<u16> = Vec::new();
|
let mut taken_audio: Vec<u16> = Vec::new();
|
||||||
let mut taken_sub: Vec<u16> = Vec::new();
|
let mut taken_sub: Vec<u16> = Vec::new();
|
||||||
for info in infos {
|
for info in infos {
|
||||||
if let Some(&n) = stream_map.get(&info.id) {
|
if let Some(&n) = stream_map.get(&info.id) {
|
||||||
|
if n == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match info.stream_type {
|
match info.stream_type {
|
||||||
StreamLabelType::Audio => taken_audio.push(n),
|
StreamLabelType::Audio => taken_audio.push(n),
|
||||||
StreamLabelType::Subtitle => taken_sub.push(n),
|
StreamLabelType::Subtitle => taken_sub.push(n),
|
||||||
@@ -94,8 +101,8 @@ fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>
|
|||||||
let mut out = Vec::with_capacity(infos.len());
|
let mut out = Vec::with_capacity(infos.len());
|
||||||
for info in infos {
|
for info in infos {
|
||||||
let n = match stream_map.get(&info.id).copied() {
|
let n = match stream_map.get(&info.id).copied() {
|
||||||
Some(n) => n,
|
Some(n) if n != 0 => n,
|
||||||
None => {
|
_ => {
|
||||||
let (idx, taken) = match info.stream_type {
|
let (idx, taken) = match info.stream_type {
|
||||||
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
|
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
|
||||||
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
|
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
|
||||||
@@ -277,27 +284,34 @@ mod tests {
|
|||||||
assert_eq!(nums[3], 2); // subtitle 2
|
assert_eq!(nums[3], 2); // subtitle 2
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec: map stream_num=0 is explicitly rejected (apply_labels uses 1-based).
|
/// Spec: a map value of 0 is unmatchable (apply_labels is 1-based), so
|
||||||
/// This is documented in parse_playback_config: `if stream_num != 0`.
|
/// assign_stream_numbers must treat it as unmapped and synthesize a real
|
||||||
/// Mutation: remove the `!= 0` guard → zero is stored in map.
|
/// 1-based number rather than emit an orphan 0.
|
||||||
|
/// Mutation: read the map value verbatim → stream_number 0 leaks out.
|
||||||
#[test]
|
#[test]
|
||||||
fn map_zero_stream_num_is_skipped() {
|
fn map_zero_stream_num_is_synthesized_not_emitted() {
|
||||||
// parse_playback_config skips zero; simulate that: the zero shouldn't
|
|
||||||
// end up in the map. We test assign_stream_numbers with a zero-containing
|
|
||||||
// map to verify it won't freeze the fallback counter at 1 forever.
|
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert("a0".to_string(), 0u16); // zero — per spec, was filtered by parse_playback_config
|
map.insert("a0".to_string(), 0u16); // 0 must not be treated as a claim
|
||||||
let infos = vec![info("a0", StreamLabelType::Audio)];
|
let infos = vec![info("a0", StreamLabelType::Audio)];
|
||||||
// If 0 IS in the map and assign_stream_numbers uses it, stream_number=0
|
|
||||||
// is not matchable (apply_labels is 1-based). The fallback counter
|
|
||||||
// would assign 1 instead. Test both paths:
|
|
||||||
let nums = assign_stream_numbers(&infos, &map);
|
let nums = assign_stream_numbers(&infos, &map);
|
||||||
// If the map has 0 for a0, assign_stream_numbers returns 0 (map wins).
|
// 0 is treated as unmapped → the fallback counter assigns 1.
|
||||||
// This is a known limitation — the guard lives in parse_playback_config.
|
assert_eq!(nums[0], 1);
|
||||||
// The test documents the ACTUAL behavior so a code change that introduces
|
}
|
||||||
// the guard in assign_stream_numbers would be caught.
|
|
||||||
// Current behavior: map wins → 0.
|
/// A stream genuinely mapped to 1 plus another stream whose map value is 0
|
||||||
assert_eq!(nums[0], 0);
|
/// must NOT both land on 1: the 0-stream is synthesized past the claimed 1.
|
||||||
|
#[test]
|
||||||
|
fn map_zero_does_not_collide_with_a_real_stream_one() {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert("real".to_string(), 1u16);
|
||||||
|
map.insert("bad".to_string(), 0u16);
|
||||||
|
let infos = vec![
|
||||||
|
info("real", StreamLabelType::Audio),
|
||||||
|
info("bad", StreamLabelType::Audio),
|
||||||
|
];
|
||||||
|
let nums = assign_stream_numbers(&infos, &map);
|
||||||
|
assert_eq!(nums[0], 1); // the genuinely-mapped stream keeps 1
|
||||||
|
assert_eq!(nums[1], 2); // the 0-stream is synthesized to the next free slot
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spec: collision-avoidance works across audio AND subtitle independently.
|
/// Spec: collision-avoidance works across audio AND subtitle independently.
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
/// Cheap signature check: a CTRM disc ships `menu_base.prop` and/or
|
/// Cheap signature check: a CTRM disc ships `menu_base.prop` and/or
|
||||||
/// `language_streams.txt` inside a `/BDMV/JAR/*` archive.
|
/// `language_streams.txt` inside a `/BDMV/JAR/*` archive.
|
||||||
pub fn detect(udf: &UdfFs) -> bool {
|
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||||
super::jar_file_exists(udf, "menu_base.prop")
|
super::jar_file_exists(udf, "menu_base.prop")
|
||||||
|| super::jar_file_exists(udf, "language_streams.txt")
|
|| super::jar_file_exists(udf, "language_streams.txt")
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-8
@@ -35,14 +35,16 @@ use crate::sector::SectorSource;
|
|||||||
use crate::udf::UdfFs;
|
use crate::udf::UdfFs;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// dbp detect can't peek inside a jar without a SectorSource (the
|
/// The real dbp signal is the `com/dbp/` package prefix inside a top-level
|
||||||
/// trait function only takes `&UdfFs`), so we trigger on the cheap
|
/// jar's central directory. With a reader in `detect`, we check that directly
|
||||||
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every
|
/// (a cheap central-directory scan, no class decode) so this parser claims
|
||||||
/// BD-J disc, but parse() does the real `com/dbp/` check and
|
/// only dbp discs instead of firing on every BD-J disc. `parse()` repeats the
|
||||||
/// returns None on a mismatch — so this parser only ever consumes
|
/// check as belt-and-suspenders.
|
||||||
/// time on discs that fell through every earlier parser.
|
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||||
pub fn detect(udf: &UdfFs) -> bool {
|
jar::for_each_jar(reader, udf, |_entry, archive| {
|
||||||
jar::has_any_top_level_jar(udf)
|
jar::has_path_prefix(archive, "com/dbp/").then_some(())
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan every top-level `/BDMV/JAR/*.jar` for the dbp framework and
|
/// Scan every top-level `/BDMV/JAR/*.jar` for the dbp framework and
|
||||||
|
|||||||
@@ -64,11 +64,15 @@ use crate::sector::SectorSource;
|
|||||||
use crate::udf::UdfFs;
|
use crate::udf::UdfFs;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
pub fn detect(udf: &UdfFs) -> bool {
|
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||||
// Cheap pre-check at the dir level; the real signal is
|
// The real signal is `com/bydeluxe/` inside a top-level jar's central
|
||||||
// `com/bydeluxe/` inside any top-level jar's central directory,
|
// directory. With a reader in detect we check it directly (cheap
|
||||||
// which `parse()` confirms when given a `SectorSource`.
|
// central-directory scan, no bytecode walk) so this parser claims only
|
||||||
jar::has_any_top_level_jar(udf)
|
// Deluxe discs; `parse()` repeats the check.
|
||||||
|
jar::for_each_jar(reader, udf, |_entry, archive| {
|
||||||
|
jar::has_path_prefix(archive, "com/bydeluxe/").then_some(())
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||||
|
|||||||
@@ -26,21 +26,6 @@ const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024;
|
|||||||
/// etc.
|
/// etc.
|
||||||
pub type Jar = ZipArchive<Cursor<Vec<u8>>>;
|
pub type Jar = ZipArchive<Cursor<Vec<u8>>>;
|
||||||
|
|
||||||
/// True if `/BDMV/JAR/` contains at least one top-level `.jar` file
|
|
||||||
/// (not under a subdir). Used by `detect()` in parsers whose real
|
|
||||||
/// signal lives inside a jar — they can't open the jar without a
|
|
||||||
/// `SectorSource`, so they use this cheap pre-check and do the real
|
|
||||||
/// `com/<vendor>/` discriminator in `parse()`.
|
|
||||||
pub fn has_any_top_level_jar(udf: &UdfFs) -> bool {
|
|
||||||
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
jar_dir
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.any(|e| !e.is_dir && e.name.to_lowercase().ends_with(".jar"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open every top-level `*.jar` entry in `/BDMV/JAR/` and yield each
|
/// Open every top-level `*.jar` entry in `/BDMV/JAR/` and yield each
|
||||||
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
|
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
|
||||||
/// produces, or `None` if every jar was visited without a hit.
|
/// produces, or `None` if every jar was visited without a hit.
|
||||||
|
|||||||
+28
-15
@@ -20,6 +20,7 @@ pub(crate) mod jar;
|
|||||||
mod mpls_universal;
|
mod mpls_universal;
|
||||||
mod paramount;
|
mod paramount;
|
||||||
mod pixelogic;
|
mod pixelogic;
|
||||||
|
mod png_filenames;
|
||||||
pub(crate) mod text;
|
pub(crate) mod text;
|
||||||
pub mod vocab;
|
pub mod vocab;
|
||||||
pub(crate) mod xml;
|
pub(crate) mod xml;
|
||||||
@@ -91,7 +92,11 @@ pub enum LabelQualifier {
|
|||||||
// the registry picks the highest-confidence parse result, falling back
|
// the registry picks the highest-confidence parse result, falling back
|
||||||
// to array order on confidence ties.
|
// to array order on confidence ties.
|
||||||
|
|
||||||
type DetectFn = fn(&UdfFs) -> bool;
|
// `detect` takes the reader too, so a parser can look INSIDE a jar's central
|
||||||
|
// directory (real vendor-prefix / project-file check) rather than firing on
|
||||||
|
// "any jar present". Precise detection is what lets the registry scale to many
|
||||||
|
// parsers without cross-parser collisions.
|
||||||
|
type DetectFn = fn(&mut dyn SectorSource, &UdfFs) -> bool;
|
||||||
type ParseFn = fn(&mut dyn SectorSource, &UdfFs) -> Option<ParseResult>;
|
type ParseFn = fn(&mut dyn SectorSource, &UdfFs) -> Option<ParseResult>;
|
||||||
|
|
||||||
/// Per-parser claim of how reliable its output is. Used by the
|
/// Per-parser claim of how reliable its output is. Used by the
|
||||||
@@ -158,11 +163,11 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
|||||||
("criterion", criterion::detect, criterion::parse),
|
("criterion", criterion::detect, criterion::parse),
|
||||||
("pixelogic", pixelogic::detect, pixelogic::parse),
|
("pixelogic", pixelogic::detect, pixelogic::parse),
|
||||||
("ctrm", ctrm::detect, ctrm::parse),
|
("ctrm", ctrm::detect, ctrm::parse),
|
||||||
// dbp and deluxe both detect on "any top-level .jar in /BDMV/JAR/"
|
// dbp and deluxe now detect via the real `com/<vendor>/` central-directory
|
||||||
// (every BD-J disc trips that) and do the real vendor-prefix check
|
// prefix (reader-backed), so they claim only their own discs. Order between
|
||||||
// in parse(). Order between them is the tiebreaker on equal
|
// them is the tiebreaker on equal confidence; dbp goes first because its
|
||||||
// confidence; dbp goes first because its parse path is cheaper
|
// parse path is cheaper (constant-pool iteration vs. deluxe's bytecode
|
||||||
// (constant-pool iteration vs. deluxe's bytecode walking).
|
// walking).
|
||||||
("dbp", dbp::detect, dbp::parse),
|
("dbp", dbp::detect, dbp::parse),
|
||||||
("deluxe", deluxe::detect, deluxe::parse),
|
("deluxe", deluxe::detect, deluxe::parse),
|
||||||
// Universal MPLS fallback. Returns Confidence::Low so framework
|
// Universal MPLS fallback. Returns Confidence::Low so framework
|
||||||
@@ -176,6 +181,11 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
|||||||
mpls_universal::detect,
|
mpls_universal::detect,
|
||||||
mpls_universal::parse,
|
mpls_universal::parse,
|
||||||
),
|
),
|
||||||
|
// Menu-graphic filename language hints (Low). AFTER mpls_universal so the
|
||||||
|
// richer spec-derived floor wins the Low tie whenever it produces anything;
|
||||||
|
// this only becomes the chosen parser when even MPLS yields nothing but the
|
||||||
|
// menu artwork still names its languages. A last-resort language source.
|
||||||
|
("png_filenames", png_filenames::detect, png_filenames::parse),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Search disc for config files, extract labels, apply to streams.
|
/// Search disc for config files, extract labels, apply to streams.
|
||||||
@@ -521,7 +531,7 @@ fn generate_audio_label_inner(
|
|||||||
fn extract(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec<StreamLabel> {
|
fn extract(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||||
let mut best: Option<(&'static str, ParseResult)> = None;
|
let mut best: Option<(&'static str, ParseResult)> = None;
|
||||||
for (name, detect, parse) in PARSERS {
|
for (name, detect, parse) in PARSERS {
|
||||||
if !detect(udf) {
|
if !detect(reader, udf) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tracing::info!(parser = name, "label parser detected");
|
tracing::info!(parser = name, "label parser detected");
|
||||||
@@ -788,7 +798,7 @@ pub fn analyze(reader: &mut dyn SectorSource, udf: &UdfFs) -> LabelAnalysis {
|
|||||||
let mut all_results: Vec<(&'static str, ParseResult)> = Vec::new();
|
let mut all_results: Vec<(&'static str, ParseResult)> = Vec::new();
|
||||||
|
|
||||||
for (name, detect, parse) in PARSERS {
|
for (name, detect, parse) in PARSERS {
|
||||||
if !detect(udf) {
|
if !detect(reader, udf) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tracing::info!(parser = name, "label parser detected");
|
tracing::info!(parser = name, "label parser detected");
|
||||||
@@ -961,8 +971,9 @@ pub struct ChapterSummary {
|
|||||||
|
|
||||||
/// List filenames found under any `/BDMV/JAR/<x>/` subdirectory of
|
/// List filenames found under any `/BDMV/JAR/<x>/` subdirectory of
|
||||||
/// the disc. Deduped, sorted. Returns an empty vec if no JAR dir is
|
/// the disc. Deduped, sorted. Returns an empty vec if no JAR dir is
|
||||||
/// present.
|
/// present. `pub(crate)` so filename-based parsers (e.g. `png_filenames`)
|
||||||
fn jar_inventory(udf: &UdfFs) -> Vec<String> {
|
/// can scan menu-asset names without a reader.
|
||||||
|
pub(crate) fn jar_inventory(udf: &UdfFs) -> Vec<String> {
|
||||||
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
|
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -1042,12 +1053,14 @@ mod registry_tests {
|
|||||||
"dbp",
|
"dbp",
|
||||||
"deluxe",
|
"deluxe",
|
||||||
"mpls_universal",
|
"mpls_universal",
|
||||||
|
"png_filenames",
|
||||||
],
|
],
|
||||||
"PARSERS array order changed — confirm dbp + deluxe stay just \
|
"PARSERS array order changed — file-presence/reader-gated High \
|
||||||
before mpls_universal (loose detect, real check in parse), \
|
parsers (paramount/criterion/pixelogic/ctrm) stay first; dbp + \
|
||||||
stricter parsers (paramount/criterion/pixelogic/ctrm — all \
|
deluxe (now real com/<vendor>/ prefix detect) stay before \
|
||||||
file-presence gated detect) stay first, and mpls_universal \
|
mpls_universal; mpls_universal stays the universal Low fallback; \
|
||||||
stays LAST as the universal Low-confidence fallback."
|
png_filenames (Low, language-only hint) stays LAST so MPLS wins \
|
||||||
|
the Low tie whenever it produces anything."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use crate::udf::UdfFs;
|
|||||||
|
|
||||||
/// True iff `/BDMV/PLAYLIST/` exists and contains at least one
|
/// True iff `/BDMV/PLAYLIST/` exists and contains at least one
|
||||||
/// `.mpls` file. Cheap directory walk only — no sector reads.
|
/// `.mpls` file. Cheap directory walk only — no sector reads.
|
||||||
pub fn detect(udf: &UdfFs) -> bool {
|
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||||
let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else {
|
let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelT
|
|||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
use crate::udf::UdfFs;
|
use crate::udf::UdfFs;
|
||||||
|
|
||||||
pub fn detect(udf: &UdfFs) -> bool {
|
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||||
super::jar_file_exists(udf, "playlists.xml")
|
super::jar_file_exists(udf, "playlists.xml")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const REGIONS: &[&str] = &[
|
|||||||
"US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE",
|
"US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn detect(udf: &UdfFs) -> bool {
|
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||||
super::jar_file_exists(udf, "bluray_project.bin")
|
super::jar_file_exists(udf, "bluray_project.bin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! Menu-graphic filename language hints.
|
||||||
|
//!
|
||||||
|
//! Some BD-J discs encode per-language menu artwork with the language in the
|
||||||
|
//! filename, e.g. `Dune_UHD01_Eng_Composite1.png`,
|
||||||
|
//! `VForVendetta_UHD01_FRE_Composite2.png`. The `_UHD01_{LANG}_Composite`
|
||||||
|
//! marker is authored deliberately, so the set of `{LANG}` tokens is the set
|
||||||
|
//! of menu languages the disc ships.
|
||||||
|
//!
|
||||||
|
//! This is a language-only hint (no per-stream purpose/codec), so it runs at
|
||||||
|
//! [`Confidence::Low`] — it never displaces a real framework parser, and it
|
||||||
|
//! sits at the same tier as the MPLS floor. It is here so the pattern is a
|
||||||
|
//! first-class, testable parser that keeps picking up discs as the corpus
|
||||||
|
//! grows, rather than lost logic. Detection is precise: it fires only on the
|
||||||
|
//! `_UHD01_{LANG}_Composite` grammar with a `{LANG}` the vocab recognizes.
|
||||||
|
|
||||||
|
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab};
|
||||||
|
use crate::sector::SectorSource;
|
||||||
|
use crate::udf::UdfFs;
|
||||||
|
|
||||||
|
pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||||
|
super::jar_inventory(udf)
|
||||||
|
.iter()
|
||||||
|
.any(|f| filename_lang(f).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(_reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||||
|
let names = super::jar_inventory(udf);
|
||||||
|
let labels = labels_from_filenames(&names);
|
||||||
|
if labels.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Low: language-only, derived from menu-asset filenames. A real framework
|
||||||
|
// parser (and even the MPLS floor's per-stream data) is preferred; this is
|
||||||
|
// a hint of which languages the disc menus offer.
|
||||||
|
Some(ParseResult::low(labels))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One audio [`StreamLabel`] per distinct menu language found, in first-seen
|
||||||
|
/// order, numbered 1-based. Split out from `parse` so it is unit-testable
|
||||||
|
/// without a `UdfFs`.
|
||||||
|
fn labels_from_filenames(names: &[String]) -> Vec<StreamLabel> {
|
||||||
|
let mut seen: Vec<&'static str> = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
if let Some(code) = filename_lang(name) {
|
||||||
|
if !seen.contains(&code) {
|
||||||
|
seen.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, code)| StreamLabel {
|
||||||
|
stream_number: (i as u16).saturating_add(1),
|
||||||
|
stream_type: StreamLabelType::Audio,
|
||||||
|
language: code.to_string(),
|
||||||
|
name: String::new(),
|
||||||
|
purpose: LabelPurpose::Normal,
|
||||||
|
qualifier: LabelQualifier::None,
|
||||||
|
codec_hint: String::new(),
|
||||||
|
variant: String::new(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the ISO-639-2 language code from a `{title}_UHD01_{LANG}_Composite`
|
||||||
|
/// menu-graphic filename, or `None` if the name does not match the grammar or
|
||||||
|
/// carries a `{LANG}` the vocab does not recognize.
|
||||||
|
///
|
||||||
|
/// The `_UHD01_` marker plus the `_Composite` suffix keep this from firing on
|
||||||
|
/// unrelated PNGs (`KeyComposite4.png`, `LoadingComposite1.png` have no
|
||||||
|
/// `_UHD01_{LANG}_` segment).
|
||||||
|
fn filename_lang(name: &str) -> Option<&'static str> {
|
||||||
|
// Case-fold once; the marker/suffix are matched case-insensitively.
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
let marker = "_uhd01_";
|
||||||
|
let m = lower.find(marker)?;
|
||||||
|
let after = m + marker.len();
|
||||||
|
// The language token runs from `after` up to the next `_`.
|
||||||
|
let rest = &lower[after..];
|
||||||
|
let end = rest.find('_')?;
|
||||||
|
if !rest[end..].starts_with("_composite") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = &name[after..after + end];
|
||||||
|
vocab::menu_lang(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_confirmed_samples() {
|
||||||
|
assert_eq!(filename_lang("Dune_UHD01_Eng_Composite1.png"), Some("eng"));
|
||||||
|
assert_eq!(filename_lang("Dune_UHD01_Ger_Composite2.png"), Some("deu"));
|
||||||
|
assert_eq!(
|
||||||
|
filename_lang("VForVendetta_UHD01_FRE_Composite2.png"),
|
||||||
|
Some("fra")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_non_language_composites() {
|
||||||
|
assert_eq!(filename_lang("KeyComposite4.png"), None);
|
||||||
|
assert_eq!(filename_lang("LoadingComposite1.png"), None);
|
||||||
|
assert_eq!(
|
||||||
|
filename_lang("FourKWarningsComposite1_bt2020_HDR.png"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(filename_lang("Fast9_UPK75_Composite1.png"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_language_token_is_none() {
|
||||||
|
// A UHD01 marker but a token the vocab does not recognize must not
|
||||||
|
// produce a bogus language.
|
||||||
|
assert_eq!(filename_lang("Movie_UHD01_Zzz_Composite1.png"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedups_and_numbers_distinct_languages() {
|
||||||
|
let names = vec![
|
||||||
|
"Dune_UHD01_Eng_Composite1.png".to_string(),
|
||||||
|
"Dune_UHD01_Eng_Composite2.png".to_string(),
|
||||||
|
"Dune_UHD01_Ger_Composite1.png".to_string(),
|
||||||
|
"LoadingComposite1.png".to_string(),
|
||||||
|
];
|
||||||
|
let labels = labels_from_filenames(&names);
|
||||||
|
assert_eq!(labels.len(), 2);
|
||||||
|
assert_eq!(labels[0].language, "eng");
|
||||||
|
assert_eq!(labels[0].stream_number, 1);
|
||||||
|
assert_eq!(labels[1].language, "deu");
|
||||||
|
assert_eq!(labels[1].stream_number, 2);
|
||||||
|
assert!(
|
||||||
|
labels
|
||||||
|
.iter()
|
||||||
|
.all(|l| l.stream_type == StreamLabelType::Audio)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_matching_names_yields_empty() {
|
||||||
|
let names = vec![
|
||||||
|
"KeyComposite4.png".to_string(),
|
||||||
|
"disc.properties".to_string(),
|
||||||
|
];
|
||||||
|
assert!(labels_from_filenames(&names).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,49 @@ const BARE_LANGS: &[(&str, &str)] = &[
|
|||||||
("galician", "glg"),
|
("galician", "glg"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Map a short menu-graphic language token (as embedded in authoring
|
||||||
|
/// filenames like `Dune_UHD01_Eng_Composite1.png`) to an ISO-639-2/T code.
|
||||||
|
///
|
||||||
|
/// These filename tokens are compact 2/3-letter abbreviations, NOT the full
|
||||||
|
/// language names [`lang`] handles, so they get their own certain table.
|
||||||
|
/// Accepts the ISO-639-2/B spellings some tools emit (`ger`, `fre`, `chi`)
|
||||||
|
/// and normalizes them to the /T code the rest of the pipeline uses (`deu`,
|
||||||
|
/// `fra`, `zho`). Case-insensitive. Returns `None` for anything not in the
|
||||||
|
/// table — never guesses, so an unrecognized token drops rather than
|
||||||
|
/// mislabels.
|
||||||
|
pub fn menu_lang(token: &str) -> Option<&'static str> {
|
||||||
|
let t = token.trim().to_ascii_lowercase();
|
||||||
|
let code = match t.as_str() {
|
||||||
|
"eng" | "en" => "eng",
|
||||||
|
"ger" | "deu" | "de" => "deu",
|
||||||
|
"fre" | "fra" | "fr" => "fra",
|
||||||
|
"spa" | "es" => "spa",
|
||||||
|
"ita" | "it" => "ita",
|
||||||
|
"por" | "pt" => "por",
|
||||||
|
"jpn" | "jap" | "ja" => "jpn",
|
||||||
|
"kor" | "ko" => "kor",
|
||||||
|
"chi" | "zho" | "zh" => "zho",
|
||||||
|
"rus" | "ru" => "rus",
|
||||||
|
"dut" | "nld" | "nl" => "nld",
|
||||||
|
"pol" | "pl" => "pol",
|
||||||
|
"cze" | "ces" | "cs" => "ces",
|
||||||
|
"dan" | "da" => "dan",
|
||||||
|
"fin" | "fi" => "fin",
|
||||||
|
"nor" | "no" => "nor",
|
||||||
|
"swe" | "sv" => "swe",
|
||||||
|
"hun" | "hu" => "hun",
|
||||||
|
"gre" | "ell" | "el" => "ell",
|
||||||
|
"tur" | "tr" => "tur",
|
||||||
|
"ara" | "ar" => "ara",
|
||||||
|
"hin" | "hi" => "hin",
|
||||||
|
"tha" | "th" => "tha",
|
||||||
|
"ukr" | "uk" => "ukr",
|
||||||
|
"cat" | "ca" => "cat",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(code)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Purpose ──────────────────────────────────────────────────────────────────
|
// ── Purpose ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Classify a free-form English label string into a [`LabelPurpose`].
|
/// Classify a free-form English label string into a [`LabelPurpose`].
|
||||||
|
|||||||
+5
-1
@@ -187,7 +187,10 @@ pub use identity::DriveId;
|
|||||||
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
||||||
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
||||||
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
// 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 ─────────────────────────────────────────────────────────
|
// ─── 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
|
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
||||||
// `SectorSource` to get plaintext sectors out.
|
// `SectorSource` to get plaintext sectors out.
|
||||||
pub use mux::build_iso_pipeline;
|
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 scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
||||||
pub use sector::{
|
pub use sector::{
|
||||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
|
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
|
||||||
|
|||||||
@@ -0,0 +1,844 @@
|
|||||||
|
//! Access-unit assembly — a codec-parser helper.
|
||||||
|
//!
|
||||||
|
//! The contract a codec parser converts is `PES → access units (Frames)`. A
|
||||||
|
//! *transport* stream hands the parser one AU per PES for free (BD aligns one
|
||||||
|
//! access unit per PES; the TS demuxer reassembles to the
|
||||||
|
//! `payload_unit_start_indicator`). A *program* stream does not — the PS muxer
|
||||||
|
//! chops the elementary stream into fixed-size PES fragments with no AU
|
||||||
|
//! alignment, and only the first fragment of an AU carries a PTS. So a parser
|
||||||
|
//! that assumes one-AU-per-PES (h264/hevc/vc1, written against TS) mis-frames a
|
||||||
|
//! program stream, while `mpeg2` — the DVD/PS codec — must reassemble across PES.
|
||||||
|
//!
|
||||||
|
//! [`AuAssembler`] is that reassembly, factored out so EVERY program-stream video
|
||||||
|
//! parser shares one implementation instead of hand-rolling the buffer. The
|
||||||
|
//! h264/hevc/vc1 parsers ([`Mode::StartCode`] / [`Mode::Vc1`]) and the MPEG-2
|
||||||
|
//! parser ([`Mode::Mpeg2`], via [`AuAssembler::mpeg2`]) all drive it. It buffers
|
||||||
|
//! PES-fragment bytes and emits one AU per codec AU boundary, carrying the
|
||||||
|
//! AU-start timing/source forward. Since the boundary is a codec start code, it
|
||||||
|
//! lives with the codec parser (which picks the marker); only the generic
|
||||||
|
//! buffering + timing-carry is shared here.
|
||||||
|
//!
|
||||||
|
//! This is *inside* the parser, not a pipeline stage: the pipeline stays
|
||||||
|
//! `Demuxer → PES → Parser → Frames`, and the demuxer stays codec-agnostic. Every
|
||||||
|
//! stream a parser sees runs through one of these — self-framing codecs (MPEG-2,
|
||||||
|
//! audio) use [`Mode::Passthrough`] so the parser code path is uniform.
|
||||||
|
|
||||||
|
use crate::disc::Codec;
|
||||||
|
use crate::pes::SourcePos;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
/// Safety cap on a single in-progress access unit. A real coded picture is far
|
||||||
|
/// below this; a stream that never yields a second AU boundary is force-flushed
|
||||||
|
/// at the cap rather than buffering without bound on hostile/corrupt input.
|
||||||
|
const MAX_AU_BUFFER: usize = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Cap on buffered timing/discontinuity marks. A real access unit spans a few
|
||||||
|
/// hundred PES fragments at most; this bounds the mark deques so a run of
|
||||||
|
/// zero-length (or start-code-free) timed fragments — which grow no buffer bytes
|
||||||
|
/// and so never trip the `MAX_AU_BUFFER` mark-prune — cannot accumulate marks
|
||||||
|
/// without bound on hostile/corrupt disc input.
|
||||||
|
const MAX_MARKS: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// One AU-complete unit drained from the buffer: its elementary-stream bytes plus
|
||||||
|
/// the timing/source/discontinuity of the fragment that opened the AU.
|
||||||
|
pub(crate) struct AssembledAu {
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub pts: Option<i64>,
|
||||||
|
pub dts: Option<i64>,
|
||||||
|
pub source: Option<SourcePos>,
|
||||||
|
pub discontinuity: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VC-1 (SMPTE 421M Annex E) BDU start-code suffixes, `00 00 01 <type>`.
|
||||||
|
const VC1_FRAME: u8 = 0x0D; // coded picture
|
||||||
|
const VC1_ENTRY: u8 = 0x0E; // entry-point header
|
||||||
|
const VC1_SEQ: u8 = 0x0F; // sequence header
|
||||||
|
|
||||||
|
/// MPEG-2 (ISO/IEC 13818-2) start-code suffixes, `00 00 01 <type>`.
|
||||||
|
const MP2_PICTURE: u8 = 0x00; // picture_start_code
|
||||||
|
const MP2_SEQ: u8 = 0xB3; // sequence_header_code
|
||||||
|
const MP2_GOP: u8 = 0xB8; // group_start_code
|
||||||
|
|
||||||
|
/// How a stream's fragments become AU-complete units.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Mode {
|
||||||
|
/// Split the elementary stream on the codec's single AU-delimiter start code
|
||||||
|
/// `00 00 01 <marker>` (H.264 AUD `0x09`, HEVC AUD `0x46`). Every AU opens with
|
||||||
|
/// exactly that code, so a plain split is correct.
|
||||||
|
StartCode(u8),
|
||||||
|
/// VC-1 has no single AU delimiter: an access unit is a `[sequence header?]
|
||||||
|
/// [entry point?][frame][slices…]` group. The sequence-header (`0x0F`) and
|
||||||
|
/// entry-point (`0x0E`) BDUs precede the frame (`0x0D`) they belong to, so a
|
||||||
|
/// plain `0x0D` split would glue them onto the *previous* AU and strip every
|
||||||
|
/// I-frame of its headers. The boundary is instead the next `0x0F`/`0x0E`/`0x0D`
|
||||||
|
/// start code that follows a frame already seen in the current AU.
|
||||||
|
Vc1,
|
||||||
|
/// MPEG-2 access unit: `[sequence header?][GOP header?][picture][slices…]`.
|
||||||
|
/// Structurally identical to [`Mode::Vc1`] — the sequence (`0xB3`) and GOP
|
||||||
|
/// (`0xB8`) headers precede the picture (`0x00`) they introduce, so the
|
||||||
|
/// boundary is the next picture / sequence / GOP start code that follows a
|
||||||
|
/// picture already seen. Slice (`0x01..=0xAF`), extension (`0xB5`),
|
||||||
|
/// user-data (`0xB2`) and sequence-end (`0xB7`) codes are NOT boundaries.
|
||||||
|
Mpeg2,
|
||||||
|
/// The codec self-frames (MPEG-2 reassembles in its own parser; audio resyncs
|
||||||
|
/// on syncwords), so each fragment passes straight through as one unit. Lets
|
||||||
|
/// the caller run EVERY stream through an assembler with no per-codec branch.
|
||||||
|
Passthrough,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A timing/source mark taken at the absolute stream offset of a fragment that
|
||||||
|
/// carried it, so it survives `buf.drain(..)` and can be attributed to the AU
|
||||||
|
/// whose byte range contains it.
|
||||||
|
struct Mark {
|
||||||
|
off: u64,
|
||||||
|
pts: Option<i64>,
|
||||||
|
dts: Option<i64>,
|
||||||
|
source: Option<SourcePos>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reassembles PES fragments into AU-complete units. One per stream; stateful
|
||||||
|
/// across `push` calls.
|
||||||
|
pub(crate) struct AuAssembler {
|
||||||
|
mode: Mode,
|
||||||
|
/// Buffered elementary-stream bytes not yet emitted as a complete AU.
|
||||||
|
buf: Vec<u8>,
|
||||||
|
/// Absolute stream offset of `buf[0]`, so marks (taken at absolute offsets)
|
||||||
|
/// survive `buf.drain(..)`.
|
||||||
|
base: u64,
|
||||||
|
/// Timing/source marks, in fragment order.
|
||||||
|
marks: VecDeque<Mark>,
|
||||||
|
/// Absolute offsets of fragments flagged with an upstream discontinuity.
|
||||||
|
disc_marks: VecDeque<u64>,
|
||||||
|
/// Incremental boundary-scan cursor: the offset into `buf` up to which the
|
||||||
|
/// current AU has already been searched for its end without finding one. Each
|
||||||
|
/// `push` resumes the boundary search from here instead of rescanning the
|
||||||
|
/// whole buffer, so reassembling one AU split across N PES fragments costs
|
||||||
|
/// O(AU bytes) total, not O(AU bytes²/fragment). Reset to 0 whenever `buf[0]`
|
||||||
|
/// moves (an AU drained, or leading bytes dropped).
|
||||||
|
scan_pos: usize,
|
||||||
|
/// Whether the current AU has already contained a coded frame/picture — the
|
||||||
|
/// state the VC-1/MPEG-2 boundary rule carries across a resumed scan (their
|
||||||
|
/// boundary is "the next opener after a frame is already seen"). Meaningless
|
||||||
|
/// for `Mode::StartCode`. Reset with `scan_pos`.
|
||||||
|
seen_unit: bool,
|
||||||
|
/// Pre-sync opener-search cursor: the offset up to which the buffer has been
|
||||||
|
/// searched for the FIRST AU opener with none found. Resumes the opener scan
|
||||||
|
/// so a long run of junk with no start code (hostile/corrupt input) costs
|
||||||
|
/// O(bytes) total, not O(buffer) per push. Reset when `buf[0]` moves.
|
||||||
|
opener_pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuAssembler {
|
||||||
|
/// An assembler for `codec`. Video codecs whose parsers assume AU-complete PES
|
||||||
|
/// (H.264 / HEVC / VC-1) get a [`Mode::StartCode`] assembler; MPEG-2 (self-
|
||||||
|
/// reassembles) and audio/subtitle codecs (self-framing) get [`Mode::Passthrough`]
|
||||||
|
/// so callers can run every stream through this uniformly.
|
||||||
|
pub(crate) fn for_codec(codec: Codec) -> Self {
|
||||||
|
let mode = match codec {
|
||||||
|
Codec::H264 => Mode::StartCode(0x09), // access_unit_delimiter NAL (type 9)
|
||||||
|
Codec::Hevc => Mode::StartCode(0x46), // AUD NAL (type 35 → (35 << 1) = 0x46)
|
||||||
|
Codec::Vc1 => Mode::Vc1, // frame + preceding seq/entry headers
|
||||||
|
_ => Mode::Passthrough,
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
mode,
|
||||||
|
// Passthrough never writes `buf` (one fragment → one unit); only the
|
||||||
|
// reassembling modes need reserve. Avoids ~256 KiB per audio/subtitle
|
||||||
|
// stream (and every TS/BD stream, which never feeds the assembler).
|
||||||
|
buf: match mode {
|
||||||
|
Mode::Passthrough => Vec::new(),
|
||||||
|
_ => Vec::with_capacity(256 * 1024),
|
||||||
|
},
|
||||||
|
base: 0,
|
||||||
|
marks: VecDeque::new(),
|
||||||
|
disc_marks: VecDeque::new(),
|
||||||
|
scan_pos: 0,
|
||||||
|
seen_unit: false,
|
||||||
|
opener_pos: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An assembler that reassembles MPEG-2 access units. The MPEG-2 parser owns
|
||||||
|
/// one of these directly (rather than hand-rolling the buffer): the demux
|
||||||
|
/// layer runs MPEG-2 through [`Mode::Passthrough`] and hands each fragment to
|
||||||
|
/// the parser, which feeds them here to be reframed on picture boundaries.
|
||||||
|
pub(crate) fn mpeg2() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: Mode::Mpeg2,
|
||||||
|
buf: Vec::with_capacity(128 * 1024),
|
||||||
|
base: 0,
|
||||||
|
marks: VecDeque::new(),
|
||||||
|
disc_marks: VecDeque::new(),
|
||||||
|
scan_pos: 0,
|
||||||
|
seen_unit: false,
|
||||||
|
opener_pos: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one PES fragment the caller OWNS; return every AU now complete. For
|
||||||
|
/// a self-framing (`Passthrough`) stream the payload is MOVED straight into
|
||||||
|
/// the emitted unit with no copy — the common DVD/HD-DVD case (MPEG-2 video,
|
||||||
|
/// all audio). A buffering mode copies into `buf` exactly as [`Self::push`].
|
||||||
|
pub(crate) fn push_owned(
|
||||||
|
&mut self,
|
||||||
|
data: Vec<u8>,
|
||||||
|
pts: Option<i64>,
|
||||||
|
dts: Option<i64>,
|
||||||
|
source: Option<SourcePos>,
|
||||||
|
discontinuity: bool,
|
||||||
|
) -> Vec<AssembledAu> {
|
||||||
|
if matches!(self.mode, Mode::Passthrough) {
|
||||||
|
return vec![AssembledAu {
|
||||||
|
data,
|
||||||
|
pts,
|
||||||
|
dts,
|
||||||
|
source,
|
||||||
|
discontinuity,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
self.push(&data, pts, dts, source, discontinuity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one PES fragment (borrowed); return every AU that is now complete.
|
||||||
|
pub(crate) fn push(
|
||||||
|
&mut self,
|
||||||
|
data: &[u8],
|
||||||
|
pts: Option<i64>,
|
||||||
|
dts: Option<i64>,
|
||||||
|
source: Option<SourcePos>,
|
||||||
|
discontinuity: bool,
|
||||||
|
) -> Vec<AssembledAu> {
|
||||||
|
// Self-framing codecs pass through unchanged — one fragment, one unit,
|
||||||
|
// its own timing. (This is exactly today's behaviour for mpeg2/audio.)
|
||||||
|
if matches!(self.mode, Mode::Passthrough) {
|
||||||
|
return vec![AssembledAu {
|
||||||
|
data: data.to_vec(),
|
||||||
|
pts,
|
||||||
|
dts,
|
||||||
|
source,
|
||||||
|
discontinuity,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
let off = self.base + self.buf.len() as u64;
|
||||||
|
if pts.is_some() || dts.is_some() || source.is_some() {
|
||||||
|
self.marks.push_back(Mark {
|
||||||
|
off,
|
||||||
|
pts,
|
||||||
|
dts,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
// Backstop: the `buf`-size cap prunes marks only when bytes accumulate.
|
||||||
|
// A run of zero-length (or start-code-free) timed fragments grows no
|
||||||
|
// bytes, so bound the deque directly — drop the oldest (stalest) mark,
|
||||||
|
// which belongs to an already-emitted or lost AU. A real AU spans far
|
||||||
|
// fewer fragments than this cap.
|
||||||
|
if self.marks.len() > MAX_MARKS {
|
||||||
|
self.marks.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if discontinuity {
|
||||||
|
self.disc_marks.push_back(off);
|
||||||
|
if self.disc_marks.len() > MAX_MARKS {
|
||||||
|
self.disc_marks.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.buf.extend_from_slice(data);
|
||||||
|
self.drain(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit the trailing in-progress AU at end of stream (no following boundary).
|
||||||
|
pub(crate) fn flush(&mut self) -> Vec<AssembledAu> {
|
||||||
|
if matches!(self.mode, Mode::Passthrough) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.drain(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain(&mut self, force: bool) -> Vec<AssembledAu> {
|
||||||
|
if matches!(self.mode, Mode::Passthrough) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
loop {
|
||||||
|
// Locate the AU start code that opens the buffered run (resumes from
|
||||||
|
// opener_pos so an unsynced junk run is scanned once, not per push).
|
||||||
|
let Some(a0) = self.au_opener_resumable() else {
|
||||||
|
// No AU boundary buffered. Bound memory: drop all but a 3-byte
|
||||||
|
// tail (enough to catch a start-code prefix straddling the cut)
|
||||||
|
// once over the cap; otherwise wait for more data.
|
||||||
|
if self.buf.len() > MAX_AU_BUFFER {
|
||||||
|
let drop = self.buf.len() - 3;
|
||||||
|
self.buf.drain(..drop);
|
||||||
|
self.base += drop as u64;
|
||||||
|
self.reset_scan();
|
||||||
|
self.drop_marks_before(self.base);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if a0 > 0 {
|
||||||
|
// Leading bytes before the first AU boundary are a partial AU from
|
||||||
|
// before we synced (or junk) — discard them and any stale marks.
|
||||||
|
self.buf.drain(..a0);
|
||||||
|
self.base += a0 as u64;
|
||||||
|
self.reset_scan();
|
||||||
|
self.drop_marks_before(self.base);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The AU runs from here (buf[0]) to the NEXT AU boundary. The search
|
||||||
|
// resumes from `scan_pos` (bytes already searched with no boundary),
|
||||||
|
// so one AU spread across many fragments is scanned once, not per push.
|
||||||
|
let end = match self.au_boundary_resumable() {
|
||||||
|
Some(next) => next,
|
||||||
|
// No next boundary yet: on EOF (or over-cap backstop) the rest of
|
||||||
|
// the buffer is this AU; otherwise wait for more data.
|
||||||
|
None if force => self.buf.len(),
|
||||||
|
None if self.buf.len() > MAX_AU_BUFFER => self.buf.len(),
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
if end == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let end_abs = self.base + end as u64;
|
||||||
|
|
||||||
|
// The AU's own timing/source: take the FIRST Some of each field
|
||||||
|
// across every mark in this AU's range [base, end_abs), independently
|
||||||
|
// — one PES fragment may carry the source while a later fragment of
|
||||||
|
// the same AU carries the PTS (and vice versa), so reading only the
|
||||||
|
// front mark would drop the other field. This restores the semantics
|
||||||
|
// of the pre-consolidation separate pts/source mark deques.
|
||||||
|
let (mut pts, mut dts, mut source) = (None, None, None);
|
||||||
|
while self.marks.front().is_some_and(|m| m.off < end_abs) {
|
||||||
|
let m = self.marks.pop_front().unwrap();
|
||||||
|
pts = pts.or(m.pts);
|
||||||
|
dts = dts.or(m.dts);
|
||||||
|
source = source.or(m.source);
|
||||||
|
}
|
||||||
|
let mut discontinuity = false;
|
||||||
|
if self.disc_marks.front().is_some_and(|&o| o < end_abs) {
|
||||||
|
discontinuity = true;
|
||||||
|
}
|
||||||
|
while self.disc_marks.front().is_some_and(|&o| o < end_abs) {
|
||||||
|
self.disc_marks.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = self.buf[..end].to_vec();
|
||||||
|
self.buf.drain(..end);
|
||||||
|
self.base += end as u64;
|
||||||
|
self.reset_scan();
|
||||||
|
out.push(AssembledAu {
|
||||||
|
data,
|
||||||
|
pts,
|
||||||
|
dts,
|
||||||
|
source,
|
||||||
|
discontinuity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the incremental boundary-scan cursor. Called whenever `buf[0]` moves
|
||||||
|
/// (an AU drained, or leading bytes discarded) so the next scan starts fresh
|
||||||
|
/// from the new AU opener.
|
||||||
|
fn reset_scan(&mut self) {
|
||||||
|
self.scan_pos = 0;
|
||||||
|
self.seen_unit = false;
|
||||||
|
self.opener_pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate the first AU opener in `buf`, resuming the search from `opener_pos`
|
||||||
|
/// (bytes already searched with no opener) so a long unsynced run costs
|
||||||
|
/// O(bytes) total, not O(buffer) per push. Advances `opener_pos` on a miss.
|
||||||
|
fn au_opener_resumable(&mut self) -> Option<usize> {
|
||||||
|
match au_opener_from(self.mode, &self.buf, self.opener_pos) {
|
||||||
|
Some(o) => Some(o),
|
||||||
|
None => {
|
||||||
|
// Nothing yet; next call resumes here (back up 3 for a straddling
|
||||||
|
// start-code prefix). Never advance past what is searchable.
|
||||||
|
self.opener_pos = self.buf.len().saturating_sub(3).max(self.opener_pos);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the end of the AU that opens at `buf[0]`, resuming from `scan_pos`
|
||||||
|
/// (and, for VC-1/MPEG-2, the carried `seen_unit`) instead of rescanning the
|
||||||
|
/// whole buffer. On no boundary yet, advances `scan_pos`/`seen_unit` so the
|
||||||
|
/// next call continues where this one stopped. Equivalent result to a
|
||||||
|
/// from-scratch whole-buffer scan, but O(total AU bytes) across all pushes.
|
||||||
|
fn au_boundary_resumable(&mut self) -> Option<usize> {
|
||||||
|
match self.mode {
|
||||||
|
Mode::StartCode(marker) => {
|
||||||
|
// Stateless: the AU ends at the next delimiter after the opener at
|
||||||
|
// buf[0]. Resume from the furthest searched offset (never before 4,
|
||||||
|
// to skip the opening delimiter). find_start_code needs 4 bytes, so
|
||||||
|
// back up 3 to catch a code straddling the previous buffer end.
|
||||||
|
let from = self.scan_pos.max(4);
|
||||||
|
match find_start_code(&self.buf, from, marker) {
|
||||||
|
Some(e) => Some(e),
|
||||||
|
None => {
|
||||||
|
self.scan_pos = self.buf.len().saturating_sub(3).max(from);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode::Vc1 => self.scan_unit_boundary(VC1_FRAME, &[VC1_ENTRY, VC1_SEQ]),
|
||||||
|
Mode::Mpeg2 => self.scan_unit_boundary(MP2_PICTURE, &[MP2_SEQ, MP2_GOP]),
|
||||||
|
Mode::Passthrough => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumable form of the VC-1/MPEG-2 boundary rule: scan from `scan_pos`,
|
||||||
|
/// carrying `seen_unit`; the AU ends at the next `frame` / `header` start code
|
||||||
|
/// once a frame is already seen. Advances `scan_pos`/`seen_unit` when no
|
||||||
|
/// boundary is found so the next push continues, not restarts.
|
||||||
|
fn scan_unit_boundary(&mut self, frame: u8, headers: &[u8]) -> Option<usize> {
|
||||||
|
let buf = &self.buf;
|
||||||
|
let mut i = self.scan_pos;
|
||||||
|
let mut seen = self.seen_unit;
|
||||||
|
while i + 4 <= buf.len() {
|
||||||
|
if buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 {
|
||||||
|
let c = buf[i + 3];
|
||||||
|
let is_frame = c == frame;
|
||||||
|
if (is_frame || headers.contains(&c)) && i > 0 && seen {
|
||||||
|
// The AU ends at the next frame/header once a frame is seen.
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
if is_frame {
|
||||||
|
seen = true;
|
||||||
|
}
|
||||||
|
i += 4;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No boundary yet. Persist the scan state so the next append resumes here
|
||||||
|
// rather than rescanning from 0 (the i+=4 stride is preserved exactly).
|
||||||
|
self.scan_pos = i;
|
||||||
|
self.seen_unit = seen;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop_marks_before(&mut self, off: u64) {
|
||||||
|
while self.marks.front().is_some_and(|m| m.off < off) {
|
||||||
|
self.marks.pop_front();
|
||||||
|
}
|
||||||
|
while self.disc_marks.front().is_some_and(|&o| o < off) {
|
||||||
|
self.disc_marks.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offset of the start code that opens the next AU in `buf` (at or after 0), or
|
||||||
|
/// `None` if no AU-opening start code is buffered yet.
|
||||||
|
fn au_opener_from(mode: Mode, buf: &[u8], from: usize) -> Option<usize> {
|
||||||
|
match mode {
|
||||||
|
Mode::StartCode(marker) => find_start_code(buf, from, marker),
|
||||||
|
// Any of the three AU-opening BDU types opens a VC-1 access unit.
|
||||||
|
Mode::Vc1 => find_vc1_start(buf, from),
|
||||||
|
// A sequence header, GOP header, or picture opens an MPEG-2 access unit.
|
||||||
|
Mode::Mpeg2 => find_mpeg2_start(buf, from),
|
||||||
|
Mode::Passthrough => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the next `00 00 01 <marker>` start code at or after `from`.
|
||||||
|
fn find_start_code(buf: &[u8], from: usize, marker: u8) -> Option<usize> {
|
||||||
|
let mut i = from;
|
||||||
|
while i + 4 <= buf.len() {
|
||||||
|
if buf[i] == 0 && buf[i + 1] == 0 && buf[i + 2] == 1 && buf[i + 3] == marker {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the next VC-1 AU-opening BDU start code (`00 00 01` followed by a
|
||||||
|
/// sequence header, entry point, or frame) at or after `from`.
|
||||||
|
fn find_vc1_start(buf: &[u8], from: usize) -> Option<usize> {
|
||||||
|
let mut i = from;
|
||||||
|
while i + 4 <= buf.len() {
|
||||||
|
if buf[i] == 0
|
||||||
|
&& buf[i + 1] == 0
|
||||||
|
&& buf[i + 2] == 1
|
||||||
|
&& matches!(buf[i + 3], VC1_FRAME | VC1_ENTRY | VC1_SEQ)
|
||||||
|
{
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the next MPEG-2 AU-opening start code (`00 00 01` followed by a picture,
|
||||||
|
/// sequence header, or GOP header) at or after `from`.
|
||||||
|
fn find_mpeg2_start(buf: &[u8], from: usize) -> Option<usize> {
|
||||||
|
let mut i = from;
|
||||||
|
while i + 4 <= buf.len() {
|
||||||
|
if buf[i] == 0
|
||||||
|
&& buf[i + 1] == 0
|
||||||
|
&& buf[i + 2] == 1
|
||||||
|
&& matches!(buf[i + 3], MP2_PICTURE | MP2_SEQ | MP2_GOP)
|
||||||
|
{
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const AUD: &[u8] = &[0x00, 0x00, 0x01, 0x09]; // H.264 access-unit delimiter
|
||||||
|
|
||||||
|
fn au(payload: u8, len: usize) -> Vec<u8> {
|
||||||
|
let mut v = AUD.to_vec();
|
||||||
|
v.extend(std::iter::repeat_n(payload, len));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_framing_codecs_pass_through_each_fragment_unchanged() {
|
||||||
|
// MPEG-2 (self-reassembles in its parser) and audio (syncword resync) run
|
||||||
|
// through a Passthrough assembler: every fragment emerges immediately as
|
||||||
|
// one unit with its own timing — byte-identical to today's path.
|
||||||
|
for codec in [Codec::Mpeg2, Codec::Ac3Plus, Codec::Dts, Codec::Lpcm] {
|
||||||
|
let mut a = AuAssembler::for_codec(codec);
|
||||||
|
let out = a.push(&[1, 2, 3, 4], Some(42), None, None, false);
|
||||||
|
assert_eq!(
|
||||||
|
out.len(),
|
||||||
|
1,
|
||||||
|
"{codec:?} passes each fragment straight through"
|
||||||
|
);
|
||||||
|
assert_eq!(out[0].data, vec![1, 2, 3, 4]);
|
||||||
|
assert_eq!(out[0].pts, Some(42));
|
||||||
|
assert!(a.flush().is_empty(), "passthrough buffers nothing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_codecs_reassemble_across_fragments() {
|
||||||
|
// H.264 buffers: one fragment is NOT a complete AU on its own.
|
||||||
|
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||||
|
assert!(
|
||||||
|
a.push(&[0, 0, 1, 0x09, 0xAB], Some(1), None, None, false)
|
||||||
|
.is_empty(),
|
||||||
|
"holds an AU until the next boundary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_au_split_across_fragments_reassembles_with_start_pts() {
|
||||||
|
// A single AU (AUD + 100 bytes) arrives as three fragments; only the
|
||||||
|
// first carries a PTS. It must emit exactly ONE AU with that PTS.
|
||||||
|
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||||
|
let full = au(0xAB, 100);
|
||||||
|
assert!(
|
||||||
|
a.push(&full[..40], Some(9000), None, None, false)
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(a.push(&full[40..80], None, None, None, false).is_empty());
|
||||||
|
assert!(a.push(&full[80..], None, None, None, false).is_empty());
|
||||||
|
let out = a.flush();
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
out[0].pts,
|
||||||
|
Some(9000),
|
||||||
|
"AU carries its START pts, not 0/None"
|
||||||
|
);
|
||||||
|
assert_eq!(out[0].data, full);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_aus_emit_when_the_second_boundary_arrives() {
|
||||||
|
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||||
|
let au1 = au(0x11, 50);
|
||||||
|
let au2 = au(0x22, 60);
|
||||||
|
let mut buf = au1.clone();
|
||||||
|
buf.extend_from_slice(&au2);
|
||||||
|
// AU1 + AU2's opening AUD → AU1 completes, tagged pts1.
|
||||||
|
let out = a.push(&buf[..au1.len() + 4], Some(1000), None, None, false);
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].data, au1);
|
||||||
|
assert_eq!(out[0].pts, Some(1000));
|
||||||
|
a.push(&buf[au1.len() + 4..], None, None, None, false);
|
||||||
|
let out2 = a.flush();
|
||||||
|
assert_eq!(out2.len(), 1);
|
||||||
|
assert_eq!(out2[0].data, au2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn au_merges_pts_and_source_from_different_fragments() {
|
||||||
|
// One fragment of an AU may carry the source stamp while a later fragment
|
||||||
|
// of the SAME AU carries the PTS (each PES gets a source; only the anchor
|
||||||
|
// gets a PTS). The AU must keep BOTH — reading only the front mark would
|
||||||
|
// drop whichever field the first fragment lacked.
|
||||||
|
let src = crate::pes::SourcePos::at_byte(4242);
|
||||||
|
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||||
|
let full = au(0xAB, 80);
|
||||||
|
// Fragment 1: source only, no PTS.
|
||||||
|
assert!(a.push(&full[..30], None, None, Some(src), false).is_empty());
|
||||||
|
// Fragment 2 (same AU): PTS only, no source.
|
||||||
|
assert!(
|
||||||
|
a.push(&full[30..], Some(9000), None, None, false)
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
let out = a.flush();
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].pts, Some(9000), "PTS from the 2nd fragment retained");
|
||||||
|
assert_eq!(
|
||||||
|
out[0].source.map(|s| s.byte),
|
||||||
|
Some(4242),
|
||||||
|
"source from the 1st fragment retained"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discontinuity_flag_attaches_to_the_au_it_opens() {
|
||||||
|
// A discontinuity-flagged fragment opens AU2; that flag must land on AU2,
|
||||||
|
// not AU1 (the B1 resync gate keys off it).
|
||||||
|
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||||
|
let au1 = au(0x11, 30);
|
||||||
|
let au2 = au(0x22, 30);
|
||||||
|
a.push(&au1, Some(1), None, None, false);
|
||||||
|
// AU2 arrives flagged; its opening AUD completes AU1 first.
|
||||||
|
let out = a.push(&au2, Some(2), None, None, true);
|
||||||
|
assert_eq!(out.len(), 1, "AU1 completes when AU2's boundary arrives");
|
||||||
|
assert!(!out[0].discontinuity, "AU1 is NOT the discontinuity");
|
||||||
|
let out2 = a.flush();
|
||||||
|
assert_eq!(out2.len(), 1);
|
||||||
|
assert!(out2[0].discontinuity, "AU2 carries the discontinuity");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leading_bytes_before_first_au_are_discarded() {
|
||||||
|
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||||
|
let mut buf = vec![0xFF, 0xFF, 0xFF, 0xFF];
|
||||||
|
buf.extend_from_slice(&au(0x33, 20));
|
||||||
|
a.push(&buf, Some(500), None, None, false);
|
||||||
|
let out = a.flush();
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].data, au(0x33, 20), "leading junk dropped, AU intact");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── VC-1 AU grouping ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn bdu(ty: u8, payload: u8, len: usize) -> Vec<u8> {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01, ty];
|
||||||
|
v.extend(std::iter::repeat_n(payload, len));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vc1_i_frame_keeps_its_preceding_seq_and_entry_headers() {
|
||||||
|
// An I-frame AU is [seq 0x0F][entry 0x0E][frame 0x0D][slices]; a following
|
||||||
|
// P-frame is just [frame 0x0D][slices]. A plain 0x0D split would strand the
|
||||||
|
// seq/entry headers on the P-frame's AU — the decode bug. The VC-1 mode must
|
||||||
|
// group them with the I-frame that follows them.
|
||||||
|
let mut a = AuAssembler::for_codec(Codec::Vc1);
|
||||||
|
let mut iframe = bdu(VC1_SEQ, 0xAA, 8);
|
||||||
|
iframe.extend(bdu(VC1_ENTRY, 0xBB, 6));
|
||||||
|
iframe.extend(bdu(VC1_FRAME, 0xCC, 20)); // frame + slice bytes
|
||||||
|
let pframe = bdu(VC1_FRAME, 0xDD, 15);
|
||||||
|
|
||||||
|
// Feed the I-frame; it stays open until the P-frame's boundary arrives.
|
||||||
|
assert!(a.push(&iframe, Some(9000), None, None, false).is_empty());
|
||||||
|
let out = a.push(&pframe, Some(9376), None, None, false);
|
||||||
|
assert_eq!(out.len(), 1, "I-frame AU completes at the P-frame boundary");
|
||||||
|
assert_eq!(out[0].data, iframe, "I-frame AU retains seq+entry+frame");
|
||||||
|
assert_eq!(out[0].pts, Some(9000));
|
||||||
|
|
||||||
|
let tail = a.flush();
|
||||||
|
assert_eq!(tail.len(), 1);
|
||||||
|
assert_eq!(tail[0].data, pframe, "P-frame is its own AU");
|
||||||
|
assert_eq!(tail[0].pts, Some(9376));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vc1_consecutive_frames_split_one_per_au() {
|
||||||
|
// Back-to-back frames with no headers between them each form their own AU.
|
||||||
|
let mut a = AuAssembler::for_codec(Codec::Vc1);
|
||||||
|
let f1 = bdu(VC1_FRAME, 0x11, 30);
|
||||||
|
let f2 = bdu(VC1_FRAME, 0x22, 40);
|
||||||
|
let mut both = f1.clone();
|
||||||
|
both.extend_from_slice(&f2);
|
||||||
|
both.extend(bdu(VC1_FRAME, 0x33, 4)); // opening boundary of a 3rd frame
|
||||||
|
let out = a.push(&both, Some(1), None, None, false);
|
||||||
|
assert_eq!(out.len(), 2, "two complete frames emit");
|
||||||
|
assert_eq!(out[0].data, f1);
|
||||||
|
assert_eq!(out[1].data, f2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vc1_entry_point_without_seq_header_still_groups_with_frame() {
|
||||||
|
// Mid-GOP open points can carry an entry-point header with no sequence
|
||||||
|
// header; it must still attach to the frame that follows it.
|
||||||
|
let mut a = AuAssembler::for_codec(Codec::Vc1);
|
||||||
|
let mut au = bdu(VC1_ENTRY, 0xEE, 5);
|
||||||
|
au.extend(bdu(VC1_FRAME, 0xFF, 12));
|
||||||
|
let mut done = a.push(&au, Some(500), None, None, false);
|
||||||
|
// Next frame's opening boundary closes the entry+frame AU.
|
||||||
|
done.extend(a.push(&bdu(VC1_FRAME, 0x00, 4), None, None, None, false));
|
||||||
|
done.extend(a.flush());
|
||||||
|
assert_eq!(done.len(), 2);
|
||||||
|
assert_eq!(done[0].data, au, "entry+frame grouped");
|
||||||
|
assert_eq!(done[0].pts, Some(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MPEG-2 AU grouping ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mpeg2_keeps_seq_and_gop_headers_with_their_picture() {
|
||||||
|
// A GOP-opening AU is [seq 0xB3][gop 0xB8][picture 0x00][slices]; the next
|
||||||
|
// picture (no headers) is its own AU. The seq/GOP headers must stay with
|
||||||
|
// the picture they introduce, not glue onto the previous AU.
|
||||||
|
let mut a = AuAssembler::mpeg2();
|
||||||
|
let mut gop = bdu(MP2_SEQ, 0xAA, 10);
|
||||||
|
gop.extend(bdu(MP2_GOP, 0xBB, 8));
|
||||||
|
gop.extend(bdu(MP2_PICTURE, 0xCC, 20)); // picture + slice bytes
|
||||||
|
let pic2 = bdu(MP2_PICTURE, 0xDD, 15);
|
||||||
|
|
||||||
|
assert!(a.push(&gop, Some(9000), None, None, false).is_empty());
|
||||||
|
let out = a.push(&pic2, Some(9376), None, None, false);
|
||||||
|
assert_eq!(
|
||||||
|
out.len(),
|
||||||
|
1,
|
||||||
|
"first AU completes at the next picture boundary"
|
||||||
|
);
|
||||||
|
assert_eq!(out[0].data, gop, "AU retains seq + GOP + picture");
|
||||||
|
assert_eq!(out[0].pts, Some(9000));
|
||||||
|
|
||||||
|
let tail = a.flush();
|
||||||
|
assert_eq!(tail.len(), 1);
|
||||||
|
assert_eq!(tail[0].data, pic2, "second picture is its own AU");
|
||||||
|
assert_eq!(tail[0].pts, Some(9376));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mpeg2_slice_codes_are_not_au_boundaries() {
|
||||||
|
// Slice start codes (0x01..=0xAF) inside a picture must not split the AU.
|
||||||
|
let mut a = AuAssembler::mpeg2();
|
||||||
|
let mut pic = bdu(MP2_PICTURE, 0x11, 4);
|
||||||
|
pic.extend(bdu(0x01, 0x22, 10)); // slice 1
|
||||||
|
pic.extend(bdu(0xAF, 0x33, 10)); // slice 175 (max slice code)
|
||||||
|
let next = bdu(MP2_PICTURE, 0x44, 4); // opening boundary of the next AU
|
||||||
|
let out = a.push(&[pic.clone(), next].concat(), Some(1), None, None, false);
|
||||||
|
assert_eq!(out.len(), 1, "slices stay inside the one picture AU");
|
||||||
|
assert_eq!(out[0].data, pic, "AU spans the picture and all its slices");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mpeg2_reassembles_one_picture_split_across_fragments() {
|
||||||
|
// A picture split across three PES fragments; only the first carries a PTS.
|
||||||
|
let mut a = AuAssembler::mpeg2();
|
||||||
|
let full = bdu(MP2_PICTURE, 0xEE, 100);
|
||||||
|
assert!(a.push(&full[..40], Some(500), None, None, false).is_empty());
|
||||||
|
assert!(a.push(&full[40..80], None, None, None, false).is_empty());
|
||||||
|
assert!(a.push(&full[80..], None, None, None, false).is_empty());
|
||||||
|
let out = a.flush();
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].pts, Some(500), "AU carries its START pts");
|
||||||
|
assert_eq!(out[0].data, full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `stream` into fragments of `frag` bytes, push them through the given
|
||||||
|
/// assembler mode, and return the reassembled AU byte-payloads.
|
||||||
|
fn reassemble_with(mut a: AuAssembler, stream: &[u8], frag: usize) -> Vec<Vec<u8>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < stream.len() {
|
||||||
|
let end = (i + frag).min(stream.len());
|
||||||
|
for au in a.push(&stream[i..end], None, None, None, false) {
|
||||||
|
out.push(au.data);
|
||||||
|
}
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
for au in a.flush() {
|
||||||
|
out.push(au.data);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resumable_boundary_matches_from_scratch_across_all_fragmentations() {
|
||||||
|
// The incremental scan_pos cursor must produce byte-identical AUs to a
|
||||||
|
// whole-buffer rescan, at EVERY fragment granularity (this is what makes
|
||||||
|
// the O(n) resume equivalent to the old O(n^2) from-scratch scan). Build a
|
||||||
|
// multi-AU stream per codec, reassemble it fed 1 byte at a time up to
|
||||||
|
// whole, and require one canonical result.
|
||||||
|
let h264 = {
|
||||||
|
let mut s = au(0x11, 40); // AU1 (AUD + payload)
|
||||||
|
s.extend(au(0x22, 70)); // AU2
|
||||||
|
s.extend(au(0x33, 25)); // AU3
|
||||||
|
s
|
||||||
|
};
|
||||||
|
let vc1 = {
|
||||||
|
let mut s = bdu(VC1_SEQ, 0xAA, 8);
|
||||||
|
s.extend(bdu(VC1_ENTRY, 0xBB, 6));
|
||||||
|
s.extend(bdu(VC1_FRAME, 0xCC, 50)); // I-frame AU
|
||||||
|
s.extend(bdu(VC1_FRAME, 0xDD, 30)); // P-frame AU
|
||||||
|
s.extend(bdu(VC1_FRAME, 0xEE, 20)); // P-frame AU
|
||||||
|
s
|
||||||
|
};
|
||||||
|
let mpeg2 = {
|
||||||
|
let mut s = bdu(MP2_SEQ, 0xAA, 10);
|
||||||
|
s.extend(bdu(MP2_GOP, 0xBB, 8));
|
||||||
|
s.extend(bdu(MP2_PICTURE, 0xCC, 60)); // GOP-opening picture AU
|
||||||
|
s.extend(bdu(MP2_PICTURE, 0xDD, 40)); // picture AU
|
||||||
|
s
|
||||||
|
};
|
||||||
|
// (label, stream, assembler factory). MPEG-2 uses the dedicated mpeg2()
|
||||||
|
// assembler (Mode::Mpeg2); the AUD/VC-1 codecs use for_codec().
|
||||||
|
type MakeAsm = fn() -> AuAssembler;
|
||||||
|
let cases: [(&str, &[u8], MakeAsm); 3] = [
|
||||||
|
("h264", &h264, || AuAssembler::for_codec(Codec::H264)),
|
||||||
|
("vc1", &vc1, || AuAssembler::for_codec(Codec::Vc1)),
|
||||||
|
("mpeg2", &mpeg2, AuAssembler::mpeg2),
|
||||||
|
];
|
||||||
|
for (label, stream, make) in cases {
|
||||||
|
let whole = reassemble_with(make(), stream, stream.len());
|
||||||
|
assert!(!whole.is_empty(), "{label}: baseline produced AUs");
|
||||||
|
for frag in 1..=stream.len() {
|
||||||
|
let got = reassemble_with(make(), stream, frag);
|
||||||
|
assert_eq!(
|
||||||
|
got, whole,
|
||||||
|
"{label}: fragmented at {frag} differs from whole-buffer reassembly"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marks_deques_stay_bounded_on_zero_length_timed_fragments() {
|
||||||
|
// A run of zero-length fragments that each carry a PTS (or a
|
||||||
|
// discontinuity) grows no buffer bytes, so the buf-size cap never prunes
|
||||||
|
// the mark deques. The MAX_MARKS backstop must bound them regardless.
|
||||||
|
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||||
|
for i in 0..(MAX_MARKS * 2) {
|
||||||
|
a.push(&[], Some(i as i64), None, None, true);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
a.marks.len() <= MAX_MARKS,
|
||||||
|
"marks bounded at MAX_MARKS, got {}",
|
||||||
|
a.marks.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
a.disc_marks.len() <= MAX_MARKS,
|
||||||
|
"disc_marks bounded at MAX_MARKS, got {}",
|
||||||
|
a.disc_marks.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn over_cap_without_boundary_force_flushes() {
|
||||||
|
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||||
|
let big = au(0x44, MAX_AU_BUFFER + 16);
|
||||||
|
let emitted = a.push(&big, Some(1), None, None, false);
|
||||||
|
assert!(
|
||||||
|
!emitted.is_empty(),
|
||||||
|
"over-cap AU is force-flushed, not buffered forever"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-4
@@ -37,7 +37,7 @@ pub struct DtsParser {
|
|||||||
/// timestamp instead of the later PES's. Offsets are kept relative to the
|
/// timestamp instead of the later PES's. Offsets are kept relative to the
|
||||||
/// current `buf` start and rebased whenever bytes are drained from the
|
/// current `buf` start and rebased whenever bytes are drained from the
|
||||||
/// front.
|
/// front.
|
||||||
pts_marks: Vec<(usize, i64)>,
|
pts_marks: std::collections::VecDeque<(usize, i64)>,
|
||||||
/// The `front_pts` of the PREVIOUS emitted access unit. When the current
|
/// The `front_pts` of the PREVIOUS emitted access unit. When the current
|
||||||
/// AU's `front_pts` differs, it began a new PES → re-base to it. When it is
|
/// AU's `front_pts` differs, it began a new PES → re-base to it. When it is
|
||||||
/// unchanged, this AU shares the previous AU's PES → advance one frame
|
/// unchanged, this AU shares the previous AU's PES → advance one frame
|
||||||
@@ -62,7 +62,7 @@ impl DtsParser {
|
|||||||
Self {
|
Self {
|
||||||
buf: Vec::with_capacity(32768),
|
buf: Vec::with_capacity(32768),
|
||||||
pending_pts: 0,
|
pending_pts: 0,
|
||||||
pts_marks: Vec::new(),
|
pts_marks: std::collections::VecDeque::new(),
|
||||||
last_front_pts: PTS_UNSET,
|
last_front_pts: PTS_UNSET,
|
||||||
next_pts_ns: PTS_UNSET,
|
next_pts_ns: PTS_UNSET,
|
||||||
}
|
}
|
||||||
@@ -194,7 +194,7 @@ impl CodecParser for DtsParser {
|
|||||||
// discontinuity-carrying PES is a PUSI with a PTS in practice.
|
// discontinuity-carrying PES is a PUSI with a PTS in practice.
|
||||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or_else(|| {
|
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or_else(|| {
|
||||||
self.pts_marks
|
self.pts_marks
|
||||||
.last()
|
.back()
|
||||||
.map(|&(_, p)| p)
|
.map(|&(_, p)| p)
|
||||||
.filter(|&p| p >= 0)
|
.filter(|&p| p >= 0)
|
||||||
.unwrap_or(if self.pending_pts >= 0 {
|
.unwrap_or(if self.pending_pts >= 0 {
|
||||||
@@ -230,7 +230,10 @@ impl CodecParser for DtsParser {
|
|||||||
// (see `front_pts`), so an AU whose core arrived in an earlier PES keeps
|
// (see `front_pts`), so an AU whose core arrived in an earlier PES keeps
|
||||||
// that core's timestamp even when its extensions / the following core
|
// that core's timestamp even when its extensions / the following core
|
||||||
// arrive (with a later PTS) in this same parse() call.
|
// arrive (with a later PTS) in this same parse() call.
|
||||||
self.pts_marks.push((self.buf.len(), pts_ns));
|
// (pts_marks is bounded implicitly: an empty PES returns above without
|
||||||
|
// pushing a mark, and a non-empty run grows `buf`, which is cleared —
|
||||||
|
// along with pts_marks — once it exceeds MAX_AU_BYTES.)
|
||||||
|
self.pts_marks.push_back((self.buf.len(), pts_ns));
|
||||||
self.buf.extend_from_slice(&pes.data);
|
self.buf.extend_from_slice(&pes.data);
|
||||||
|
|
||||||
let mut frames = Vec::new();
|
let mut frames = Vec::new();
|
||||||
@@ -915,6 +918,37 @@ mod tests {
|
|||||||
assert_eq!(dts_core_sample_rate(&core), 48_000);
|
assert_eq!(dts_core_sample_rate(&core), 48_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dts_core_sfreq_table_matches_the_dca_spec() {
|
||||||
|
// Lock the SFREQ → sample-rate table to ffmpeg's authoritative
|
||||||
|
// `avpriv_dca_sample_rates` (ETSI TS 102 114 Table 6-4). The high-rate
|
||||||
|
// triad in particular — 48 k / 96 k / 192 k at indices 13/14/15 — must not
|
||||||
|
// be shifted; a wrong entry would compute an N× frame duration and
|
||||||
|
// reintroduce PTS drift on a 96/192 kHz DTS stream.
|
||||||
|
let mut core = make_dts_core(512);
|
||||||
|
let set_sfreq = |c: &mut [u8], idx: u8| c[8] = (c[8] & !0x3C) | ((idx & 0x0F) << 2);
|
||||||
|
for (idx, want) in [
|
||||||
|
(1u8, 8_000u32),
|
||||||
|
(2, 16_000),
|
||||||
|
(3, 32_000),
|
||||||
|
(6, 11_025),
|
||||||
|
(7, 22_050),
|
||||||
|
(8, 44_100),
|
||||||
|
(11, 12_000),
|
||||||
|
(12, 24_000),
|
||||||
|
(13, 48_000),
|
||||||
|
(14, 96_000),
|
||||||
|
(15, 192_000),
|
||||||
|
] {
|
||||||
|
set_sfreq(&mut core, idx);
|
||||||
|
assert_eq!(
|
||||||
|
dts_core_sample_rate(&core),
|
||||||
|
want,
|
||||||
|
"SFREQ {idx} must be {want} Hz"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_pes_rebases_to_its_own_pts_no_drift() {
|
fn new_pes_rebases_to_its_own_pts_no_drift() {
|
||||||
// Regression for the drift bug: a global running clock overshot a
|
// Regression for the drift bug: a global running clock overshot a
|
||||||
@@ -922,6 +956,14 @@ mod tests {
|
|||||||
// PES arrives whose PTS is BEHIND where accumulated frame durations
|
// PES arrives whose PTS is BEHIND where accumulated frame durations
|
||||||
// would put a running clock, the AU must re-base to that PES's OWN
|
// would put a running clock, the AU must re-base to that PES's OWN
|
||||||
// timestamp — tracking the container, not drifting ahead of it.
|
// timestamp — tracking the container, not drifting ahead of it.
|
||||||
|
//
|
||||||
|
// The re-base can make one emitted PTS sit just below the previous AU's
|
||||||
|
// (a fresh PES whose PTS lands under the within-PES cursor). That is
|
||||||
|
// CORRECT here and is NOT a muxer defect: the parser reports the true
|
||||||
|
// container timestamps, and the mkv muxer applies the strictly-monotonic
|
||||||
|
// per-track nudge to AUDIO at emit time (`mkv::block_ts` / `monotonic_ts`,
|
||||||
|
// tested in `mkv.rs`), so the written block DTS is always monotonic. The
|
||||||
|
// alternative — clamping in the parser — is what reintroduced the drift.
|
||||||
let mut parser = DtsParser::new();
|
let mut parser = DtsParser::new();
|
||||||
// PES A: core1 + core2 (2 frames), pts 90000.
|
// PES A: core1 + core2 (2 frames), pts 90000.
|
||||||
let mut pes_a = make_dts_core(512);
|
let mut pes_a = make_dts_core(512);
|
||||||
|
|||||||
+214
-6
@@ -52,6 +52,17 @@ pub struct H264Parser {
|
|||||||
// the stale avcC copy after a mid-title redefinition.
|
// the stale avcC copy after a mid-title redefinition.
|
||||||
cur_sps: Option<Vec<u8>>,
|
cur_sps: Option<Vec<u8>>,
|
||||||
cur_pps: Option<Vec<u8>>,
|
cur_pps: Option<Vec<u8>>,
|
||||||
|
/// Display-order PTS reconstruction, enabled only on the program-stream
|
||||||
|
/// (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 {
|
impl Default for H264Parser {
|
||||||
@@ -68,6 +79,35 @@ impl H264Parser {
|
|||||||
pps: None,
|
pps: None,
|
||||||
cur_sps: None,
|
cur_sps: None,
|
||||||
cur_pps: None,
|
cur_pps: None,
|
||||||
|
reorder: None,
|
||||||
|
mvc_passthrough: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable display-order PTS reconstruction for a program-stream source.
|
||||||
|
/// No-op (leaves timestamps as parsed) for a transport-stream source.
|
||||||
|
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
|
||||||
|
if enabled {
|
||||||
|
self.reorder = Some(super::reorder::SparsePtsReorder::new());
|
||||||
|
}
|
||||||
|
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> {
|
||||||
|
match self.reorder.as_mut() {
|
||||||
|
Some(r) => r.push(explicit, frame),
|
||||||
|
None => vec![frame],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +192,8 @@ impl CodecParser for H264Parser {
|
|||||||
// decode order and the player reorders by timecode. Use PTS, not DTS —
|
// decode order and the player reorders by timecode. Use PTS, not DTS —
|
||||||
// DTS presents B-frames in decode order (visible judder) and breaks
|
// DTS presents B-frames in decode order (visible judder) and breaks
|
||||||
// PTS-based seeking. Fall back to DTS only if PTS is absent.
|
// PTS-based seeking. Fall back to DTS only if PTS is absent.
|
||||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
|
||||||
|
let pts_ns = explicit_pts.unwrap_or(0);
|
||||||
|
|
||||||
// Single pass: detect IDR keyframes, seed/strip param sets, and convert
|
// Single pass: detect IDR keyframes, seed/strip param sets, and convert
|
||||||
// Annex B (start-code prefixed) NALUs to length-prefixed NALUs (MKV with
|
// Annex B (start-code prefixed) NALUs to length-prefixed NALUs (MKV with
|
||||||
@@ -168,17 +209,23 @@ impl CodecParser for H264Parser {
|
|||||||
// frame in the mux hot path (mirrors the HEVC parser).
|
// frame in the mux hot path (mirrors the HEVC parser).
|
||||||
let mut frame_data = Vec::with_capacity(pes.data.len() + 64);
|
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) {
|
for nal in NalIterator::new(&pes.data) {
|
||||||
let nal_type = nal[0] & 0x1F;
|
let nal_type = nal[0] & 0x1F;
|
||||||
|
|
||||||
match nal_type {
|
match nal_type {
|
||||||
// Param sets: seed avcC, strip if unchanged vs the active set,
|
// Param sets: seed avcC, strip if unchanged vs the active set,
|
||||||
// emit in-band on any change (incl. reverting to the avcC copy).
|
// 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 |=
|
emitted_sps |=
|
||||||
handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data)
|
handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data)
|
||||||
}
|
}
|
||||||
NAL_PPS => {
|
NAL_PPS if !mvc => {
|
||||||
emitted_pps |=
|
emitted_pps |=
|
||||||
handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data)
|
handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data)
|
||||||
}
|
}
|
||||||
@@ -226,7 +273,7 @@ impl CodecParser for H264Parser {
|
|||||||
// ahead of the slices (even when unchanged vs codecPrivate) so a decoder
|
// 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
|
// 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.
|
// can't revert it. Skipped per-type only when this AU already carried it.
|
||||||
if keyframe {
|
if keyframe && !mvc {
|
||||||
let mut prefix = Vec::new();
|
let mut prefix = Vec::new();
|
||||||
reassert_active(&mut prefix, &self.cur_sps, emitted_sps);
|
reassert_active(&mut prefix, &self.cur_sps, emitted_sps);
|
||||||
reassert_active(&mut prefix, &self.cur_pps, emitted_pps);
|
reassert_active(&mut prefix, &self.cur_pps, emitted_pps);
|
||||||
@@ -236,7 +283,7 @@ impl CodecParser for H264Parser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
vec![Frame {
|
let frame = Frame {
|
||||||
// Coding-type only: H.264 field order is not decoded here, so
|
// Coding-type only: H.264 field order is not decoded here, so
|
||||||
// `field_order()` stays `None` — honestly absent, never guessed.
|
// `field_order()` stays `None` — honestly absent, never guessed.
|
||||||
coding: coding_type.map(PictureInfo::coding_type_only),
|
coding: coding_type.map(PictureInfo::coding_type_only),
|
||||||
@@ -248,7 +295,15 @@ impl CodecParser for H264Parser {
|
|||||||
discontinuity: pes.discontinuity,
|
discontinuity: pes.discontinuity,
|
||||||
data: frame_data,
|
data: frame_data,
|
||||||
duration_ns: None,
|
duration_ns: None,
|
||||||
}]
|
};
|
||||||
|
self.finish(explicit_pts, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> Vec<Frame> {
|
||||||
|
match self.reorder.as_mut() {
|
||||||
|
Some(r) => r.flush(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
@@ -619,6 +674,94 @@ mod tests {
|
|||||||
v
|
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]
|
#[test]
|
||||||
fn h264_populates_measured_coding_type_and_source() {
|
fn h264_populates_measured_coding_type_and_source() {
|
||||||
use super::super::coding::CodingType;
|
use super::super::coding::CodingType;
|
||||||
@@ -667,6 +810,71 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// End-to-end sparse-PTS reconstruction through the REAL parser + reorder:
|
||||||
|
/// a program-stream source (`with_ps_reorder(true)`) that stamps a PTS only
|
||||||
|
/// on each GOP's I-frame must yield distinct, display-ordered PTS for every
|
||||||
|
/// frame — the property the mkv muxer needs so a decoder derives monotonic
|
||||||
|
/// DTS. Without the reorder the non-anchor frames all collapse to one PTS.
|
||||||
|
#[test]
|
||||||
|
fn h264_ps_reorder_reconstructs_distinct_display_pts() {
|
||||||
|
use super::super::coding::CodingType;
|
||||||
|
// slice bodies: 0x88 → I (IDR), 0x98 → P, 0x9C → B (non-IDR).
|
||||||
|
// Decode order of a classic single-B GOP: I P B P B.
|
||||||
|
let gop = |anchor_pts: Option<i64>| {
|
||||||
|
vec![
|
||||||
|
(NAL_SLICE_IDR, 0x88u8, anchor_pts),
|
||||||
|
(NAL_SLICE_NON_IDR, 0x98, None),
|
||||||
|
(NAL_SLICE_NON_IDR, 0x9C, None),
|
||||||
|
(NAL_SLICE_NON_IDR, 0x98, None),
|
||||||
|
(NAL_SLICE_NON_IDR, 0x9C, None),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
let feed = |reorder: bool| -> Vec<super::super::Frame> {
|
||||||
|
let mut p = H264Parser::new().with_ps_reorder(reorder);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
// Two GOPs; the second I carries an anchor 5 frames later (90 kHz:
|
||||||
|
// 5 * 3750 = 18750 ticks) so the reorder can calibrate a duration.
|
||||||
|
for (nal, body, pts) in gop(Some(0)).into_iter().chain(gop(Some(18750))) {
|
||||||
|
out.extend(p.parse(&make_pes(h264_nal(nal, &[body]), pts)));
|
||||||
|
}
|
||||||
|
out.extend(p.flush());
|
||||||
|
out
|
||||||
|
};
|
||||||
|
|
||||||
|
// With reorder ON: all 10 frames emitted, every PTS distinct.
|
||||||
|
let recon = feed(true);
|
||||||
|
assert_eq!(recon.len(), 10, "no frame dropped");
|
||||||
|
let mut pts: Vec<i64> = recon.iter().map(|f| f.pts_ns).collect();
|
||||||
|
let n = pts.len();
|
||||||
|
pts.sort_unstable();
|
||||||
|
pts.dedup();
|
||||||
|
assert_eq!(
|
||||||
|
pts.len(),
|
||||||
|
n,
|
||||||
|
"reconstructed PTS are all distinct (no DTS collision)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The GOP's first-displayed frame is the I; the B in decode position 2
|
||||||
|
// must display BEFORE the P in decode position 1 (classic reorder).
|
||||||
|
let g1 = &recon[0..5];
|
||||||
|
assert_eq!(g1[0].coding.unwrap().coding_type(), CodingType::I);
|
||||||
|
assert!(
|
||||||
|
g1[2].pts_ns < g1[1].pts_ns,
|
||||||
|
"B (decode idx 2) displays before its forward-anchor P (decode idx 1)"
|
||||||
|
);
|
||||||
|
assert_eq!(g1[0].pts_ns, 0, "GOP anchor locks the I to its true PTS");
|
||||||
|
|
||||||
|
// With reorder OFF (transport-stream behaviour): the non-anchor frames
|
||||||
|
// collapse to a single colliding PTS — the bug this fix removes.
|
||||||
|
let raw = feed(false);
|
||||||
|
let collisions = raw.iter().filter(|f| f.pts_ns == 0).count();
|
||||||
|
assert!(
|
||||||
|
collisions >= 8,
|
||||||
|
"without reorder the sparse-PTS frames collide on 0 (got {collisions})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression (Fight Club bug, H.264 variant): PPS id 0 = body A (→ avcC),
|
/// Regression (Fight Club bug, H.264 variant): PPS id 0 = body A (→ avcC),
|
||||||
/// redefined to B, then switched BACK to A. A streaming decoder is on B; the
|
/// redefined to B, then switched BACK to A. A streaming decoder is on B; the
|
||||||
/// revert to A == avcC must still be emitted in-band or the A-segment
|
/// revert to A == avcC must still be emitted in-band or the A-segment
|
||||||
|
|||||||
+35
-3
@@ -166,6 +166,10 @@ pub struct HevcParser {
|
|||||||
// colour-volume metadata is ever fabricated.
|
// colour-volume metadata is ever fabricated.
|
||||||
sei_mastering: Option<MasteringDisplay>,
|
sei_mastering: Option<MasteringDisplay>,
|
||||||
sei_content_light: Option<ContentLightLevel>,
|
sei_content_light: Option<ContentLightLevel>,
|
||||||
|
/// Display-order PTS reconstruction, enabled only on the program-stream
|
||||||
|
/// path where the source stamps a PTS once per GOP. `None` on the BD/UHD
|
||||||
|
/// transport path (the common HEVC case), which carries a per-frame PTS.
|
||||||
|
reorder: Option<super::reorder::SparsePtsReorder>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mastering Display Colour Volume payload (Rec. ITU-T H.265 D.2.28),
|
/// Mastering Display Colour Volume payload (Rec. ITU-T H.265 D.2.28),
|
||||||
@@ -234,6 +238,25 @@ impl HevcParser {
|
|||||||
pts_wrap_offset: 0,
|
pts_wrap_offset: 0,
|
||||||
sei_mastering: None,
|
sei_mastering: None,
|
||||||
sei_content_light: None,
|
sei_content_light: None,
|
||||||
|
reorder: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable display-order PTS reconstruction for a program-stream source.
|
||||||
|
/// No-op (leaves timestamps as parsed) for a transport-stream source.
|
||||||
|
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
|
||||||
|
if enabled {
|
||||||
|
self.reorder = Some(super::reorder::SparsePtsReorder::new());
|
||||||
|
}
|
||||||
|
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> {
|
||||||
|
match self.reorder.as_mut() {
|
||||||
|
Some(r) => r.push(explicit, frame),
|
||||||
|
None => vec![frame],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,7 +466,8 @@ impl CodecParser for HevcParser {
|
|||||||
// block timecode monotonic in storage order, which presents B-frames in
|
// block timecode monotonic in storage order, which presents B-frames in
|
||||||
// decode order (visible judder / wrong frames) and breaks PTS-based
|
// decode order (visible judder / wrong frames) and breaks PTS-based
|
||||||
// seeking. Fall back to DTS only if PTS is somehow absent.
|
// seeking. Fall back to DTS only if PTS is somehow absent.
|
||||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
|
||||||
|
let pts_ns = explicit_pts.unwrap_or(0);
|
||||||
|
|
||||||
// Auto-detect a non-seamless clip boundary from the bitstream. freemkv
|
// Auto-detect a non-seamless clip boundary from the bitstream. freemkv
|
||||||
// reads a BD title's clips as ONE concatenated sector stream and the
|
// reads a BD title's clips as ONE concatenated sector stream and the
|
||||||
@@ -651,7 +675,7 @@ impl CodecParser for HevcParser {
|
|||||||
// from the first coded picture before writing the track header). `None`
|
// from the first coded picture before writing the track header). `None`
|
||||||
// until both SEI present → SDR / no-SEI tracks carry nothing.
|
// until both SEI present → SDR / no-SEI tracks carry nothing.
|
||||||
let hdr10 = self.hdr10();
|
let hdr10 = self.hdr10();
|
||||||
vec![Frame {
|
let frame = Frame {
|
||||||
// Coding-type only: HEVC field order (pic_struct, from a pic_timing
|
// Coding-type only: HEVC field order (pic_struct, from a pic_timing
|
||||||
// SEI) is not decoded here, so field_order() stays None — honestly
|
// SEI) is not decoded here, so field_order() stays None — honestly
|
||||||
// absent, never guessed. HDR10 metadata is attached when measured.
|
// absent, never guessed. HDR10 metadata is attached when measured.
|
||||||
@@ -666,7 +690,15 @@ impl CodecParser for HevcParser {
|
|||||||
discontinuity: pes.discontinuity,
|
discontinuity: pes.discontinuity,
|
||||||
data: frame_data,
|
data: frame_data,
|
||||||
duration_ns: None,
|
duration_ns: None,
|
||||||
}]
|
};
|
||||||
|
self.finish(explicit_pts, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> Vec<Frame> {
|
||||||
|
match self.reorder.as_mut() {
|
||||||
|
Some(r) => r.flush(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
|
|||||||
+25
-3
@@ -25,6 +25,8 @@ pub mod lpcm;
|
|||||||
pub mod mpeg2;
|
pub mod mpeg2;
|
||||||
/// HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
/// HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
||||||
pub mod pgs;
|
pub mod pgs;
|
||||||
|
/// Display-order PTS reconstruction for sparse-PTS program-stream video.
|
||||||
|
pub(crate) mod reorder;
|
||||||
/// Shared MPEG/Annex-B start-code scanning helpers.
|
/// Shared MPEG/Annex-B start-code scanning helpers.
|
||||||
pub(crate) mod startcode;
|
pub(crate) mod startcode;
|
||||||
/// Dolby TrueHD / Atmos elementary-stream parser.
|
/// Dolby TrueHD / Atmos elementary-stream parser.
|
||||||
@@ -166,10 +168,14 @@ pub fn parser_for_codec(
|
|||||||
is_dvd_ps: bool,
|
is_dvd_ps: bool,
|
||||||
) -> Box<dyn CodecParser> {
|
) -> Box<dyn CodecParser> {
|
||||||
match codec {
|
match codec {
|
||||||
Codec::H264 => Box::new(h264::H264Parser::new()),
|
// `is_dvd_ps` marks a program-stream source (DVD VOB / HD-DVD EVO), whose
|
||||||
Codec::Hevc => Box::new(hevc::HevcParser::new()),
|
// video is timestamped only at GOP granularity. On that path the H.264 /
|
||||||
|
// HEVC / VC-1 parsers reconstruct a display-order PTS per frame; on the
|
||||||
|
// BD/UHD transport path (per-frame PTS) they leave timestamps untouched.
|
||||||
|
Codec::H264 => Box::new(h264::H264Parser::new().with_ps_reorder(is_dvd_ps)),
|
||||||
|
Codec::Hevc => Box::new(hevc::HevcParser::new().with_ps_reorder(is_dvd_ps)),
|
||||||
Codec::Mpeg2 => Box::new(mpeg2::Mpeg2Parser::new()),
|
Codec::Mpeg2 => Box::new(mpeg2::Mpeg2Parser::new()),
|
||||||
Codec::Vc1 => Box::new(vc1::Vc1Parser::new()),
|
Codec::Vc1 => Box::new(vc1::Vc1Parser::new().with_ps_reorder(is_dvd_ps)),
|
||||||
Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()),
|
Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()),
|
||||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
||||||
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
||||||
@@ -201,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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+132
-267
@@ -26,13 +26,10 @@
|
|||||||
//! - Extension (seq/pic):00 00 01 B5
|
//! - Extension (seq/pic):00 00 01 B5
|
||||||
//! - GOP header: 00 00 01 B8
|
//! - GOP header: 00 00 01 B8
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
use super::coding::{CodingType, Mpeg2Coding, PictureInfo};
|
use super::coding::{CodingType, Mpeg2Coding, PictureInfo};
|
||||||
use super::startcode::find_start_code;
|
use super::startcode::find_start_code;
|
||||||
use super::{CodecParser, Frame, pts_to_ns};
|
use super::{CodecParser, Frame, pts_to_ns};
|
||||||
use crate::mux::ts::PesPacket;
|
use crate::mux::ts::PesPacket;
|
||||||
use crate::pes::SourcePos;
|
|
||||||
|
|
||||||
/// Sequence header start code suffix.
|
/// Sequence header start code suffix.
|
||||||
const SEQ_HEADER_CODE: u8 = 0xB3;
|
const SEQ_HEADER_CODE: u8 = 0xB3;
|
||||||
@@ -49,10 +46,10 @@ const PICTURE_CODE: u8 = 0x00;
|
|||||||
/// Picture coding type: I-frame.
|
/// Picture coding type: I-frame.
|
||||||
const PICTURE_TYPE_I: u8 = 1;
|
const PICTURE_TYPE_I: u8 = 1;
|
||||||
|
|
||||||
/// Hard cap on the access-unit reassembly buffer. A real MPEG-2 frame is well
|
/// The access-unit reassembly cap now lives in [`crate::mux::au_assembly`] (the
|
||||||
/// under 1 MiB (DVD I-frames ~100 KB); past this cap a corrupt stream that
|
/// `AuAssembler` owns cross-PES buffering); this mirror exists only so the
|
||||||
/// never produces a second access-unit boundary is force-flushed as a single
|
/// force-flush test below can size an over-cap fixture against the same bound.
|
||||||
/// frame rather than driving unbounded allocation.
|
#[cfg(test)]
|
||||||
const MAX_AU_BUFFER: usize = 8 * 1024 * 1024;
|
const MAX_AU_BUFFER: usize = 8 * 1024 * 1024;
|
||||||
|
|
||||||
/// Cap on frames held awaiting the first PES PTS anchor. A DVD stamps a PTS in
|
/// Cap on frames held awaiting the first PES PTS anchor. A DVD stamps a PTS in
|
||||||
@@ -94,20 +91,11 @@ pub struct Mpeg2Parser {
|
|||||||
/// Raw bytes of the last seen sequence header (+ sequence extension if
|
/// Raw bytes of the last seen sequence header (+ sequence extension if
|
||||||
/// present), captured for MKV codecPrivate.
|
/// present), captured for MKV codecPrivate.
|
||||||
seq_header: Option<Vec<u8>>,
|
seq_header: Option<Vec<u8>>,
|
||||||
/// Unemitted elementary-stream bytes: the in-progress access unit plus any
|
/// Reassembles PES fragments into complete access units (one coded picture
|
||||||
/// lookahead needed to detect the next AU boundary.
|
/// with its leading sequence/GOP headers) and carries each AU's start
|
||||||
buf: Vec<u8>,
|
/// timing / source / discontinuity forward — the shared machinery the
|
||||||
/// Absolute ES byte offset of `buf[0]`. Used to associate PES PTS marks
|
/// H.264/HEVC/VC-1 parsers also use, in its MPEG-2 mode.
|
||||||
/// (recorded by absolute offset) with the access units they belong to.
|
au_asm: crate::mux::au_assembly::AuAssembler,
|
||||||
base_offset: u64,
|
|
||||||
/// `(absolute ES offset of a PES's first byte, PTS in ns)` for every PES
|
|
||||||
/// that carried a timestamp, in ascending offset order.
|
|
||||||
pts_marks: VecDeque<(u64, i64)>,
|
|
||||||
/// `(absolute ES offset of a PES's first byte, SourcePos)` for every PES
|
|
||||||
/// that carried byte-exact provenance, parallel to `pts_marks` and drained
|
|
||||||
/// by the SAME mark-drain invariant. Attaches the source position to each
|
|
||||||
/// access unit so the index carries it — never reconstructed.
|
|
||||||
source_marks: VecDeque<(u64, SourcePos)>,
|
|
||||||
/// Full-frame presentation interval (ns) at the sequence-header display rate
|
/// Full-frame presentation interval (ns) at the sequence-header display rate
|
||||||
/// (`1/frame_rate`). The field period is half this. Per-frame durations are
|
/// (`1/frame_rate`). The field period is half this. Per-frame durations are
|
||||||
/// `nb_fields × field_period`, so 2:3-telecined frames alternate 2- and
|
/// `nb_fields × field_period`, so 2:3-telecined frames alternate 2- and
|
||||||
@@ -122,6 +110,10 @@ pub struct Mpeg2Parser {
|
|||||||
/// without ever reordering emitted blocks (B-frames keep decode order; only
|
/// without ever reordering emitted blocks (B-frames keep decode order; only
|
||||||
/// their PTS is lower).
|
/// their PTS is lower).
|
||||||
gop_buf: Vec<BufferedPicture>,
|
gop_buf: Vec<BufferedPicture>,
|
||||||
|
/// Running total of `data` bytes buffered in `gop_buf` — the byte-cap counter,
|
||||||
|
/// incremented on each push and reset when the GOP flushes. Avoids re-summing
|
||||||
|
/// the whole buffer per picture (which would be O(pictures²)).
|
||||||
|
gop_bytes: usize,
|
||||||
/// Total field-display periods of all frames already emitted, in display
|
/// Total field-display periods of all frames already emitted, in display
|
||||||
/// order — the running base for each new frame's display time.
|
/// order — the running base for each new frame's display time.
|
||||||
emitted_fields: u64,
|
emitted_fields: u64,
|
||||||
@@ -129,15 +121,6 @@ pub struct Mpeg2Parser {
|
|||||||
/// each GOP's first PES PTS so video stays in sync with the PES-timestamped
|
/// each GOP's first PES PTS so video stays in sync with the PES-timestamped
|
||||||
/// audio. None until the first PES timestamp is seen.
|
/// audio. None until the first PES timestamp is seen.
|
||||||
origin_pts_ns: Option<i64>,
|
origin_pts_ns: Option<i64>,
|
||||||
/// B1: absolute ES offsets at which a concealed/lost-gap PES began, parallel
|
|
||||||
/// to `pts_marks`/`source_marks` and drained by the SAME mark-drain invariant.
|
|
||||||
/// MPEG-2 emits whole GOPs asynchronously, so a per-PES flag can't ride
|
|
||||||
/// through to the right frame (the PES that carries the gap completes the
|
|
||||||
/// PREVIOUS picture); associating by OFFSET instead stamps `discontinuity` on
|
|
||||||
/// the access unit whose own bytes begin after the gap — the first post-gap
|
|
||||||
/// picture — surviving GOP buffering + temporal reorder. The consumer's
|
|
||||||
/// ResyncGate then arms at that exact picture, mid-GOP if need be.
|
|
||||||
disc_marks: VecDeque<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One coded picture buffered awaiting its GOP's completion (see `gop_buf`).
|
/// One coded picture buffered awaiting its GOP's completion (see `gop_buf`).
|
||||||
@@ -165,16 +148,13 @@ impl Mpeg2Parser {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
seq_header: None,
|
seq_header: None,
|
||||||
buf: Vec::with_capacity(128 * 1024),
|
au_asm: crate::mux::au_assembly::AuAssembler::mpeg2(),
|
||||||
base_offset: 0,
|
|
||||||
pts_marks: VecDeque::new(),
|
|
||||||
source_marks: VecDeque::new(),
|
|
||||||
frame_duration_ns: 0,
|
frame_duration_ns: 0,
|
||||||
progressive_sequence: false,
|
progressive_sequence: false,
|
||||||
gop_buf: Vec::new(),
|
gop_buf: Vec::new(),
|
||||||
|
gop_bytes: 0,
|
||||||
emitted_fields: 0,
|
emitted_fields: 0,
|
||||||
origin_pts_ns: None,
|
origin_pts_ns: None,
|
||||||
disc_marks: VecDeque::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,195 +179,98 @@ impl Mpeg2Parser {
|
|||||||
parse_aspect_ratio(hdr)
|
parse_aspect_ratio(hdr)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain every complete access unit from `buf`, returning one Frame each.
|
/// Process one reassembled access unit (from [`AuAssembler`]): decode its
|
||||||
/// When `force` is true (EOF flush, or buffer-cap backstop) the trailing
|
/// per-picture coding info, capture a new sequence header, and buffer the
|
||||||
/// in-progress access unit is emitted even without a following boundary.
|
/// picture into the current GOP for display-order timestamping. The AU's
|
||||||
fn drain_complete_aus(&mut self, force: bool) -> Vec<Frame> {
|
/// timing / source / discontinuity were already attributed by the assembler.
|
||||||
let mut out = Vec::new();
|
fn process_au(&mut self, au: crate::mux::au_assembly::AssembledAu, out: &mut Vec<Frame>) {
|
||||||
loop {
|
let data = au.data;
|
||||||
// An access unit must contain a coded picture; without one there is
|
// An access unit must contain a coded picture; a fragment that assembled
|
||||||
// nothing to emit yet (leading sequence/GOP headers wait for it).
|
// without one (only headers, or truncated at EOF) yields nothing.
|
||||||
let Some(pic) = find_code(&self.buf, 0, PICTURE_CODE) else {
|
let Some(pic) = find_code(&data, 0, PICTURE_CODE) else {
|
||||||
// No coded picture in an over-cap buffer means we are
|
return;
|
||||||
// accumulating unparseable data (a stream with no picture
|
};
|
||||||
// start codes). Drop all but a 3-byte tail — enough to catch a
|
let end = data.len();
|
||||||
// start-code prefix straddling the boundary — and advance the
|
// Capture a sequence header for codecPrivate; a new one replaces the
|
||||||
// absolute offset so the PES-mark invariant holds. Mirrors the
|
// stored value and re-locks the frame duration.
|
||||||
// post-picture buffer backstop in the AU-boundary search below.
|
if let Some(h) = extract_seq_header(&data) {
|
||||||
if self.buf.len() > MAX_AU_BUFFER {
|
self.progressive_sequence = parse_progressive_sequence(&h);
|
||||||
let drop = self.buf.len() - 3;
|
self.seq_header = Some(h);
|
||||||
self.base_offset += drop as u64;
|
if let Some((num, den)) = self.frame_rate() {
|
||||||
self.buf.drain(..drop);
|
if num > 0 {
|
||||||
let cutoff = self.base_offset;
|
self.frame_duration_ns = 1_000_000_000i64 * den as i64 / num as i64;
|
||||||
while let Some(&(off, _)) = self.pts_marks.front() {
|
|
||||||
if off < cutoff {
|
|
||||||
self.pts_marks.pop_front();
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while let Some(&(off, _)) = self.source_marks.front() {
|
|
||||||
if off < cutoff {
|
|
||||||
self.source_marks.pop_front();
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while let Some(&off) = self.disc_marks.front() {
|
|
||||||
if off < cutoff {
|
|
||||||
self.disc_marks.pop_front();
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
// The current AU ends where the next one begins: the first
|
|
||||||
// picture / sequence / GOP start code after this picture.
|
|
||||||
let end = match find_au_start(&self.buf, pic + 4) {
|
|
||||||
Some(b) => b,
|
|
||||||
None if force => self.buf.len(),
|
|
||||||
None if self.buf.len() > MAX_AU_BUFFER => self.buf.len(),
|
|
||||||
None => break, // AU not yet complete — await the next boundary
|
|
||||||
};
|
|
||||||
if end == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1 — read everything from `buf` before any mutation of self
|
|
||||||
// (the slice borrow must end before we touch self fields).
|
|
||||||
let hdr = extract_seq_header(&self.buf[..end]);
|
|
||||||
// A GOP header (0xB8) or a fresh sequence header (0xB3) starts a new
|
|
||||||
// GOP, resetting temporal_reference to 0.
|
|
||||||
let gop_boundary = find_code(&self.buf[..end], 0, GOP_CODE).is_some()
|
|
||||||
|| find_code(&self.buf[..end], 0, SEQ_HEADER_CODE).is_some();
|
|
||||||
// picture_coding_type: the full 3-bit value (bits 5-3 of buf[pic+5]).
|
|
||||||
// 0 when the picture header is truncated (no coding type available).
|
|
||||||
let raw_coding_type = if pic + 5 < end {
|
|
||||||
(self.buf[pic + 5] >> 3) & 0x07
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
// temporal_reference: the 10 bits immediately after the picture
|
|
||||||
// start code = display order within the GOP.
|
|
||||||
let tr = if pic + 5 < end {
|
|
||||||
(((self.buf[pic + 4] as u64) << 2) | ((self.buf[pic + 5] as u64) >> 6)) & 0x3FF
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
let end_abs = self.base_offset + end as u64;
|
|
||||||
let data = self.buf[..end].to_vec();
|
|
||||||
|
|
||||||
// Phase 2 — mutate self.
|
|
||||||
if let Some(h) = hdr {
|
|
||||||
self.progressive_sequence = parse_progressive_sequence(&h);
|
|
||||||
self.seq_header = Some(h);
|
|
||||||
if let Some((num, den)) = self.frame_rate() {
|
|
||||||
if num > 0 {
|
|
||||||
self.frame_duration_ns = 1_000_000_000i64 * den as i64 / num as i64;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Decode the picture coding extension ONCE here and fold every
|
|
||||||
// per-picture datum (coding type + tff/rff/progressive_frame/
|
|
||||||
// frame_picture, plus the sequence's progressive flag) into one
|
|
||||||
// codec-agnostic `PictureInfo`. `nb_fields()`, `keyframe()`, and
|
|
||||||
// `field_order()` all derive from it; nothing downstream re-parses
|
|
||||||
// the elementary stream.
|
|
||||||
let (tff, rff, progressive_frame, frame_picture) = picture_coding_flags(&data);
|
|
||||||
let info = PictureInfo::mpeg2(
|
|
||||||
coding_type_from_raw(raw_coding_type),
|
|
||||||
Mpeg2Coding {
|
|
||||||
top_field_first: tff,
|
|
||||||
repeat_first_field: rff,
|
|
||||||
progressive_frame,
|
|
||||||
progressive_sequence: self.progressive_sequence,
|
|
||||||
frame_picture,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let keyframe = info.keyframe();
|
|
||||||
|
|
||||||
// An explicit PES PTS for this access unit, if any. By the mark-drain
|
|
||||||
// invariant the front mark's offset is >= this AU's start, so a front
|
|
||||||
// mark inside [start, end) is this AU's own timestamp.
|
|
||||||
let explicit = self
|
|
||||||
.pts_marks
|
|
||||||
.front()
|
|
||||||
.filter(|&&(off, _)| off < end_abs)
|
|
||||||
.map(|&(_, p)| p);
|
|
||||||
|
|
||||||
// Byte-exact source provenance for this AU, by the same mark-drain
|
|
||||||
// invariant as the PTS: the front source mark inside [start, end)
|
|
||||||
// belongs to this access unit.
|
|
||||||
let src = self
|
|
||||||
.source_marks
|
|
||||||
.front()
|
|
||||||
.filter(|&&(off, _)| off < end_abs)
|
|
||||||
.map(|&(_, s)| s);
|
|
||||||
|
|
||||||
// A GOP boundary means the buffered run is a COMPLETE GOP (all its
|
|
||||||
// pictures display before the next GOP's), so flush it before
|
|
||||||
// starting the new one. `temporal_reference` resets to 0 at the
|
|
||||||
// boundary, keeping each GOP's display order self-contained.
|
|
||||||
if gop_boundary && !self.gop_buf.is_empty() {
|
|
||||||
self.flush_gop(&mut out);
|
|
||||||
}
|
|
||||||
// A concealed-gap mark inside this AU's range [start, end_abs) means
|
|
||||||
// this picture's own bytes begin after the gap — the first post-gap
|
|
||||||
// AU. Same front-mark invariant as PTS/source. Carries through GOP
|
|
||||||
// buffering/reorder to the ResyncGate (which arms at this picture).
|
|
||||||
let discontinuity = self.disc_marks.front().is_some_and(|&off| off < end_abs);
|
|
||||||
self.gop_buf.push(BufferedPicture {
|
|
||||||
tr,
|
|
||||||
info,
|
|
||||||
explicit_pts: explicit,
|
|
||||||
frame: Frame {
|
|
||||||
pts_ns: 0,
|
|
||||||
keyframe,
|
|
||||||
discontinuity,
|
|
||||||
data,
|
|
||||||
duration_ns: None,
|
|
||||||
coding: Some(info),
|
|
||||||
source: src,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// Safety cap: a stream with no GOP/sequence boundaries would buffer
|
|
||||||
// unbounded. Force-flush a pathologically long run as its own GOP.
|
|
||||||
if self.gop_buf.len() >= MAX_PENDING_FRAMES {
|
|
||||||
self.flush_gop(&mut out);
|
|
||||||
}
|
|
||||||
self.buf.drain(..end);
|
|
||||||
self.base_offset = end_abs;
|
|
||||||
// Drop PTS marks fully consumed by the emitted AU; keep the mark at
|
|
||||||
// the boundary (it belongs to the next AU).
|
|
||||||
while let Some(&(off, _)) = self.pts_marks.front() {
|
|
||||||
if off < end_abs {
|
|
||||||
self.pts_marks.pop_front();
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while let Some(&(off, _)) = self.source_marks.front() {
|
|
||||||
if off < end_abs {
|
|
||||||
self.source_marks.pop_front();
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
while let Some(&off) = self.disc_marks.front() {
|
|
||||||
if off < end_abs {
|
|
||||||
self.disc_marks.pop_front();
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// EOF: emit the final (possibly incomplete) GOP so nothing is dropped.
|
// A GOP header (0xB8) or a fresh sequence header (0xB3) starts a new GOP,
|
||||||
if force {
|
// resetting temporal_reference to 0.
|
||||||
self.flush_gop(&mut out);
|
let gop_boundary = find_code(&data, 0, GOP_CODE).is_some()
|
||||||
|
|| find_code(&data, 0, SEQ_HEADER_CODE).is_some();
|
||||||
|
// picture_coding_type: the full 3-bit value (bits 5-3 of data[pic+5]).
|
||||||
|
// 0 when the picture header is truncated (no coding type available).
|
||||||
|
let raw_coding_type = if pic + 5 < end {
|
||||||
|
(data[pic + 5] >> 3) & 0x07
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
// temporal_reference: the 10 bits immediately after the picture start
|
||||||
|
// code = display order within the GOP.
|
||||||
|
let tr = if pic + 5 < end {
|
||||||
|
(((data[pic + 4] as u64) << 2) | ((data[pic + 5] as u64) >> 6)) & 0x3FF
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
// Decode the picture coding extension ONCE here and fold every
|
||||||
|
// per-picture datum (coding type + tff/rff/progressive_frame/
|
||||||
|
// frame_picture, plus the sequence's progressive flag) into one
|
||||||
|
// codec-agnostic `PictureInfo`. `nb_fields()`, `keyframe()`, and
|
||||||
|
// `field_order()` all derive from it; nothing downstream re-parses the
|
||||||
|
// elementary stream.
|
||||||
|
let (tff, rff, progressive_frame, frame_picture) = picture_coding_flags(&data);
|
||||||
|
let info = PictureInfo::mpeg2(
|
||||||
|
coding_type_from_raw(raw_coding_type),
|
||||||
|
Mpeg2Coding {
|
||||||
|
top_field_first: tff,
|
||||||
|
repeat_first_field: rff,
|
||||||
|
progressive_frame,
|
||||||
|
progressive_sequence: self.progressive_sequence,
|
||||||
|
frame_picture,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let keyframe = info.keyframe();
|
||||||
|
|
||||||
|
// A GOP boundary means the buffered run is a COMPLETE GOP (all its
|
||||||
|
// pictures display before the next GOP's), so flush it before starting
|
||||||
|
// the new one. `temporal_reference` resets to 0 at the boundary, keeping
|
||||||
|
// each GOP's display order self-contained.
|
||||||
|
if gop_boundary && !self.gop_buf.is_empty() {
|
||||||
|
self.flush_gop(out);
|
||||||
|
}
|
||||||
|
self.gop_bytes += data.len();
|
||||||
|
self.gop_buf.push(BufferedPicture {
|
||||||
|
tr,
|
||||||
|
info,
|
||||||
|
explicit_pts: au.pts,
|
||||||
|
frame: Frame {
|
||||||
|
pts_ns: 0,
|
||||||
|
keyframe,
|
||||||
|
// The assembler attributes the concealed-gap flag to the AU whose
|
||||||
|
// own bytes begin after the gap — the first post-gap picture — so
|
||||||
|
// it rides through GOP buffering/reorder to the ResyncGate.
|
||||||
|
discontinuity: au.discontinuity,
|
||||||
|
data,
|
||||||
|
duration_ns: None,
|
||||||
|
coding: Some(info),
|
||||||
|
source: au.source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Safety cap: a stream with no GOP/sequence boundaries would buffer
|
||||||
|
// unbounded. Force-flush a pathologically long run as its own GOP —
|
||||||
|
// bounded by BOTH the frame count and the total buffered bytes, so a
|
||||||
|
// crafted stream of few-but-huge pictures cannot over-allocate either.
|
||||||
|
if self.gop_buf.len() >= MAX_PENDING_FRAMES || self.gop_bytes >= MAX_PENDING_BYTES {
|
||||||
|
self.flush_gop(out);
|
||||||
}
|
}
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit the buffered GOP. Each frame's PTS is the display-order prefix-sum of
|
/// Emit the buffered GOP. Each frame's PTS is the display-order prefix-sum of
|
||||||
@@ -403,6 +286,8 @@ impl Mpeg2Parser {
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The GOP is fully drained below; reset the running byte counter.
|
||||||
|
self.gop_bytes = 0;
|
||||||
let field_period = self.frame_duration_ns / 2;
|
let field_period = self.frame_duration_ns / 2;
|
||||||
if field_period <= 0 {
|
if field_period <= 0 {
|
||||||
// No sequence header / frame rate yet (malformed lead-in): emit in
|
// No sequence header / frame rate yet (malformed lead-in): emit in
|
||||||
@@ -448,31 +333,31 @@ impl CodecParser for Mpeg2Parser {
|
|||||||
if pes.data.is_empty() {
|
if pes.data.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
// Record this PES's timestamp against the absolute offset of its first
|
// Feed the fragment to the assembler, which reframes the elementary
|
||||||
// ES byte, BEFORE appending. MKV block timecodes are presentation
|
// stream on picture boundaries and hands back each complete access unit
|
||||||
// timestamps; prefer PTS (DTS shows B-frames in decode order — judder
|
// with its start timing. MKV block timecodes are presentation timestamps;
|
||||||
// and broken seeking), falling back to DTS only when PTS is absent.
|
// prefer PTS (DTS shows B-frames in decode order — judder and broken
|
||||||
let off = self.base_offset + self.buf.len() as u64;
|
// seeking), falling back to DTS only when PTS is absent.
|
||||||
if let Some(ts) = pes.pts.or(pes.dts) {
|
let pts = pes.pts.or(pes.dts).map(pts_to_ns);
|
||||||
self.pts_marks.push_back((off, pts_to_ns(ts)));
|
let aus = self
|
||||||
|
.au_asm
|
||||||
|
.push(&pes.data, pts, None, pes.source, pes.discontinuity);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for au in aus {
|
||||||
|
self.process_au(au, &mut out);
|
||||||
}
|
}
|
||||||
if let Some(src) = pes.source {
|
out
|
||||||
self.source_marks.push_back((off, src));
|
|
||||||
}
|
|
||||||
// A concealed/lost gap on this PES marks the access unit its bytes begin —
|
|
||||||
// associated by offset (like PTS/source) so it lands on the first post-gap
|
|
||||||
// picture, not the previous one that completes when this PES arrives.
|
|
||||||
if pes.discontinuity {
|
|
||||||
self.disc_marks.push_back(off);
|
|
||||||
}
|
|
||||||
self.buf.extend_from_slice(&pes.data);
|
|
||||||
self.drain_complete_aus(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flush(&mut self) -> Vec<Frame> {
|
fn flush(&mut self) -> Vec<Frame> {
|
||||||
// drain_complete_aus(true) force-completes the trailing access unit and
|
// Force-complete the trailing access unit, then flush the final GOP so
|
||||||
// flushes the final GOP, so nothing is left buffered at EOF.
|
// nothing is left buffered at EOF.
|
||||||
self.drain_complete_aus(true)
|
let mut out = Vec::new();
|
||||||
|
for au in self.au_asm.flush() {
|
||||||
|
self.process_au(au, &mut out);
|
||||||
|
}
|
||||||
|
self.flush_gop(&mut out);
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
@@ -518,25 +403,6 @@ fn find_code(data: &[u8], from: usize, want: u8) -> Option<usize> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the next access-unit boundary at or after `from`: the position of a
|
|
||||||
/// picture (0x00), sequence header (0xB3), or GOP (0xB8) start code. Extension
|
|
||||||
/// (0xB5), slice (0x01..=0xAF), user-data (0xB2) and sequence-end (0xB7) codes
|
|
||||||
/// belong to the current access unit and are NOT boundaries.
|
|
||||||
fn find_au_start(data: &[u8], from: usize) -> Option<usize> {
|
|
||||||
let mut pos = from;
|
|
||||||
while let Some(sc) = find_start_code(data, pos) {
|
|
||||||
if sc + 3 >= data.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let code = data[sc + 3];
|
|
||||||
if code == PICTURE_CODE || code == SEQ_HEADER_CODE || code == GOP_CODE {
|
|
||||||
return Some(sc);
|
|
||||||
}
|
|
||||||
pos = sc + 4;
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse horizontal and vertical resolution from sequence header bytes.
|
/// Parse horizontal and vertical resolution from sequence header bytes.
|
||||||
/// The sequence header must start with 00 00 01 B3.
|
/// The sequence header must start with 00 00 01 B3.
|
||||||
fn parse_resolution(hdr: &[u8]) -> Option<(u16, u16)> {
|
fn parse_resolution(hdr: &[u8]) -> Option<(u16, u16)> {
|
||||||
@@ -1546,13 +1412,12 @@ mod tests {
|
|||||||
let mut data = make_picture_header(PICTURE_TYPE_I);
|
let mut data = make_picture_header(PICTURE_TYPE_I);
|
||||||
// > MAX_AU_BUFFER of slice bytes with no following picture/seq/GOP.
|
// > MAX_AU_BUFFER of slice bytes with no following picture/seq/GOP.
|
||||||
data.extend(std::iter::repeat_n(0xAA, MAX_AU_BUFFER + 1024));
|
data.extend(std::iter::repeat_n(0xAA, MAX_AU_BUFFER + 1024));
|
||||||
let frames = parser.parse(&make_pes(data, Some(0)));
|
// The AU assembler force-completes the ~8 MiB AU (no boundary), and the
|
||||||
assert!(
|
// GOP byte cap (MAX_PENDING_BYTES) then force-flushes that oversized GOP
|
||||||
frames.is_empty(),
|
// during parse rather than buffering it unbounded.
|
||||||
"over-cap AU is force-COMPLETED (bounded) but buffered in its GOP"
|
let mut frames = parser.parse(&make_pes(data, Some(0)));
|
||||||
);
|
frames.extend(parser.flush());
|
||||||
let frames = parser.flush();
|
assert_eq!(frames.len(), 1, "over-cap AU force-flushed, not dropped");
|
||||||
assert_eq!(frames.len(), 1, "force-flushed at EOF, not dropped");
|
|
||||||
assert!(frames[0].keyframe);
|
assert!(frames[0].keyframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
//! Display-order PTS reconstruction for sparse-PTS program-stream video.
|
||||||
|
//!
|
||||||
|
//! MPEG program streams (DVD VOB, HD-DVD EVO) timestamp video at GOP
|
||||||
|
//! granularity: only one access unit per GOP carries a PES PTS, and the rest
|
||||||
|
//! arrive with none. The H.264 / HEVC / VC-1 parsers collapse a missing PTS to
|
||||||
|
//! `0` (`pes.pts.or(dts).unwrap_or(0)`), so on such a source every non-anchor
|
||||||
|
//! frame lands on the same block timestamp. A decoder then cannot order them and
|
||||||
|
//! reports "non monotonically increasing dts". (The MPEG-2 parser already avoids
|
||||||
|
//! this by reconstructing per-picture PTS from `temporal_reference`; these three
|
||||||
|
//! codecs carry no such field.)
|
||||||
|
//!
|
||||||
|
//! [`SparsePtsReorder`] reconstructs a display-order PTS for every frame from two
|
||||||
|
//! signals the parsers already provide — the coded picture type (I/P/B) and the
|
||||||
|
//! sparse anchor PTS — plus a per-frame duration self-calibrated from the spacing
|
||||||
|
//! between consecutive GOP anchors (no external frame-rate needed). It mirrors
|
||||||
|
//! the MPEG-2 parser's GOP-buffered origin-locking, but derives display order via
|
||||||
|
//! the classic single-anchor-delay rule instead of `temporal_reference`:
|
||||||
|
//!
|
||||||
|
//! - In DECODE order an anchor (I/P) is stored before the B-frames that
|
||||||
|
//! reference it forward, so decode `I P B P B` displays as `I B P B P`.
|
||||||
|
//! - The rule that produces that mapping: an anchor is displayed only after the
|
||||||
|
//! previously-held anchor; a B-frame displays immediately. This is exact for
|
||||||
|
//! the classic (non-hierarchical) GOP structures HD-DVD H.264/VC-1 use.
|
||||||
|
//!
|
||||||
|
//! This reconstruction is applied ONLY on the program-stream path
|
||||||
|
//! (`ContentFormat::MpegPs`). BD/UHD transport streams carry a per-frame PTS and
|
||||||
|
//! are never routed through it, so the primary decode path is untouched.
|
||||||
|
|
||||||
|
use super::Frame;
|
||||||
|
use super::coding::CodingType;
|
||||||
|
|
||||||
|
/// Fallback per-frame duration (ns) when the anchor spacing cannot calibrate one
|
||||||
|
/// (a stream with a single GOP, or no anchor PTS at all): 24000/1001 fps film,
|
||||||
|
/// the dominant HD-DVD cadence. Only affects intra-GOP spacing — each GOP's
|
||||||
|
/// origin is re-locked to its own anchor PTS, so a wrong fallback cannot drift
|
||||||
|
/// the timeline across GOPs.
|
||||||
|
const FALLBACK_FRAME_DUR_NS: i64 = 1_001_000_000 / 24;
|
||||||
|
|
||||||
|
/// Force-complete the current GOP once it reaches this many buffered pictures
|
||||||
|
/// even without a keyframe. A GOP is normally a few dozen frames; a stream that
|
||||||
|
/// never signals a keyframe (open-GOP recovery-point coding, or crafted/corrupt
|
||||||
|
/// disc bytes) would otherwise buffer every access unit — the whole title — in
|
||||||
|
/// RAM. Mirrors the MPEG-2 parser's `MAX_PENDING_FRAMES` backstop so no
|
||||||
|
/// reassembly buffer grows unbounded on disc-controlled input.
|
||||||
|
const MAX_GOP_FRAMES: usize = 600;
|
||||||
|
|
||||||
|
/// Byte cap on the buffered GOP, complementing [`MAX_GOP_FRAMES`]. A GOP holds a
|
||||||
|
/// couple hundred MB at most in practice; this force-completes a run of
|
||||||
|
/// few-but-huge access units so a crafted/corrupt stream cannot over-allocate
|
||||||
|
/// (the AU assembler caps each frame at 8 MiB, so 600 frames alone could reach
|
||||||
|
/// ~5 GiB without this).
|
||||||
|
const MAX_GOP_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// One buffered coded picture awaiting its GOP's completion.
|
||||||
|
struct Pending {
|
||||||
|
/// Explicit PES PTS (ns) for this AU, or `None` when the source omitted it.
|
||||||
|
explicit: Option<i64>,
|
||||||
|
/// Coded picture type; `P` (anchor) when the parser could not determine it,
|
||||||
|
/// so an unknown frame is never mis-placed as a bi-predicted B.
|
||||||
|
ctype: CodingType,
|
||||||
|
frame: Frame,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A completed GOP, buffered until the NEXT GOP's anchor is known so a per-frame
|
||||||
|
/// duration can be calibrated from the two anchors before its frames are emitted.
|
||||||
|
struct Gop {
|
||||||
|
pend: Vec<Pending>,
|
||||||
|
/// Display index (0-based) of each `pend` entry, in `pend` (decode) order.
|
||||||
|
dispidx: Vec<i64>,
|
||||||
|
/// Display-frame count (== `pend.len()`).
|
||||||
|
count: i64,
|
||||||
|
/// The anchor: `(explicit_pts, dispidx)` of the first buffered frame that
|
||||||
|
/// carried an explicit PTS, used to lock the display origin. `None` when the
|
||||||
|
/// GOP carried no PTS at all (origin then continues from the running base).
|
||||||
|
anchor: Option<(i64, i64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconstructs display-order PTS for a sparse-PTS video elementary stream.
|
||||||
|
pub(crate) struct SparsePtsReorder {
|
||||||
|
/// Frames of the GOP currently accumulating, in decode order.
|
||||||
|
cur: Vec<Pending>,
|
||||||
|
/// Total `data` bytes buffered in `cur` — the byte-cap counter, reset each
|
||||||
|
/// time `cur` is drained into a completed GOP.
|
||||||
|
cur_bytes: usize,
|
||||||
|
/// The previously-completed GOP, held one step so its duration can be
|
||||||
|
/// calibrated from the next GOP's anchor before it is emitted.
|
||||||
|
held: Option<Gop>,
|
||||||
|
/// Self-calibrated per-frame display duration (ns); 0 until two anchors seen.
|
||||||
|
dur_ns: i64,
|
||||||
|
/// Display time (ns) at which the next emitted GOP should begin, when its own
|
||||||
|
/// anchor is absent. Advanced by each emitted GOP.
|
||||||
|
next_start_ns: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SparsePtsReorder {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
cur: Vec::new(),
|
||||||
|
cur_bytes: 0,
|
||||||
|
held: None,
|
||||||
|
dur_ns: 0,
|
||||||
|
next_start_ns: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one parsed frame with its explicit PES PTS (or `None`). Returns any
|
||||||
|
/// frames whose display PTS is now finalized (emitted in decode order).
|
||||||
|
pub(crate) fn push(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
|
||||||
|
let ctype = frame
|
||||||
|
.coding
|
||||||
|
.map(|c| c.coding_type())
|
||||||
|
.unwrap_or(CodingType::P);
|
||||||
|
// A keyframe opens a new GOP: the picture already accumulated in `cur` is
|
||||||
|
// a complete GOP. Complete it (this frame belongs to the NEW GOP). Also
|
||||||
|
// force-complete a pathologically long run that never signalled a
|
||||||
|
// keyframe — bounded by BOTH frame count and total buffered bytes, so a
|
||||||
|
// crafted/corrupt stream of few-but-huge access units cannot buffer
|
||||||
|
// without bound.
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let over_cap = self.cur.len() >= MAX_GOP_FRAMES || self.cur_bytes >= MAX_GOP_BYTES;
|
||||||
|
if (frame.keyframe || over_cap) && !self.cur.is_empty() {
|
||||||
|
out = self.complete_current_gop();
|
||||||
|
}
|
||||||
|
self.cur_bytes += frame.data.len();
|
||||||
|
self.cur.push(Pending {
|
||||||
|
explicit,
|
||||||
|
ctype,
|
||||||
|
frame,
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush all buffered frames at end of stream.
|
||||||
|
pub(crate) fn flush(&mut self) -> Vec<Frame> {
|
||||||
|
let mut out = self.complete_current_gop();
|
||||||
|
if let Some(gop) = self.held.take() {
|
||||||
|
out.extend(self.emit_gop(gop));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move `cur` into a completed [`Gop`]; if a GOP was already held, calibrate
|
||||||
|
/// the duration from the two anchors and emit the held one.
|
||||||
|
fn complete_current_gop(&mut self) -> Vec<Frame> {
|
||||||
|
if self.cur.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let pend = std::mem::take(&mut self.cur);
|
||||||
|
self.cur_bytes = 0;
|
||||||
|
let dispidx = display_indices(pend.iter().map(|p| p.ctype));
|
||||||
|
let count = pend.len() as i64;
|
||||||
|
let anchor = pend
|
||||||
|
.iter()
|
||||||
|
.zip(&dispidx)
|
||||||
|
.find_map(|(p, &d)| p.explicit.map(|pts| (pts, d)));
|
||||||
|
let gop = Gop {
|
||||||
|
pend,
|
||||||
|
dispidx,
|
||||||
|
count,
|
||||||
|
anchor,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
match self.held.take() {
|
||||||
|
Some(held) => {
|
||||||
|
// Calibrate a per-frame duration from the two anchors' spacing,
|
||||||
|
// spread across the held GOP's display-frame count. Approximate
|
||||||
|
// (assumes both anchors sit at a similar relative display slot),
|
||||||
|
// but each GOP re-locks its own origin, so the estimate only sets
|
||||||
|
// intra-GOP spacing.
|
||||||
|
if self.dur_ns == 0 {
|
||||||
|
if let (Some((p_held, _)), Some((p_next, _))) = (held.anchor, gop.anchor) {
|
||||||
|
let span = p_next - p_held;
|
||||||
|
if span > 0 && held.count > 0 {
|
||||||
|
self.dur_ns = (span / held.count).max(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = self.emit_gop(held);
|
||||||
|
self.held = Some(gop);
|
||||||
|
}
|
||||||
|
None => self.held = Some(gop),
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign each frame in `gop` its display PTS and return them in decode order.
|
||||||
|
fn emit_gop(&mut self, gop: Gop) -> Vec<Frame> {
|
||||||
|
let dur = if self.dur_ns > 0 {
|
||||||
|
self.dur_ns
|
||||||
|
} else {
|
||||||
|
FALLBACK_FRAME_DUR_NS
|
||||||
|
};
|
||||||
|
// Lock the display origin: prefer the GOP's own anchor PTS (back out its
|
||||||
|
// display offset); otherwise continue from the running base.
|
||||||
|
let origin = match gop.anchor {
|
||||||
|
Some((pts, didx)) => pts - didx * dur,
|
||||||
|
None => self.next_start_ns,
|
||||||
|
};
|
||||||
|
let Gop {
|
||||||
|
pend,
|
||||||
|
dispidx,
|
||||||
|
count,
|
||||||
|
..
|
||||||
|
} = gop;
|
||||||
|
let mut out = Vec::with_capacity(pend.len());
|
||||||
|
for (mut p, didx) in pend.into_iter().zip(dispidx) {
|
||||||
|
p.frame.pts_ns = origin + didx * dur;
|
||||||
|
// Carry the calibrated per-frame duration so the muxer emits a
|
||||||
|
// BlockDuration and the back-patched Segment Duration covers the
|
||||||
|
// final frame (the source gives no duration on this path).
|
||||||
|
p.frame.duration_ns = Some(dur as u64);
|
||||||
|
out.push(p.frame);
|
||||||
|
}
|
||||||
|
// Next GOP with no anchor continues after this one's last display slot.
|
||||||
|
self.next_start_ns = origin + count * dur;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display index (0-based, decode order in → decode order out) for a GOP's coded
|
||||||
|
/// picture types via the classic single-anchor-delay reorder: an anchor (I/P) is
|
||||||
|
/// displayed only after the previously-held anchor; a B displays immediately.
|
||||||
|
/// Decode `I P B P B` → display indices `[0, 2, 1, 4, 3]` (display `I B P B P`).
|
||||||
|
fn display_indices(types: impl Iterator<Item = CodingType>) -> Vec<i64> {
|
||||||
|
let types: Vec<CodingType> = types.collect();
|
||||||
|
let mut disp = vec![0i64; types.len()];
|
||||||
|
let mut held: Option<usize> = None;
|
||||||
|
let mut cursor = 0i64;
|
||||||
|
for (i, &c) in types.iter().enumerate() {
|
||||||
|
match c {
|
||||||
|
CodingType::I | CodingType::P => {
|
||||||
|
if let Some(h) = held {
|
||||||
|
disp[h] = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
held = Some(i);
|
||||||
|
}
|
||||||
|
CodingType::B => {
|
||||||
|
disp[i] = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(h) = held {
|
||||||
|
disp[h] = cursor;
|
||||||
|
}
|
||||||
|
disp
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::mux::codec::coding::PictureInfo;
|
||||||
|
|
||||||
|
fn frame(ctype: CodingType, keyframe: bool) -> Frame {
|
||||||
|
Frame {
|
||||||
|
keyframe,
|
||||||
|
coding: Some(PictureInfo::coding_type_only(ctype)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_indices_map_classic_gop() {
|
||||||
|
use CodingType::*;
|
||||||
|
// decode I P B P B -> display I B P B P
|
||||||
|
let d = display_indices([I, P, B, P, B].into_iter());
|
||||||
|
assert_eq!(d, vec![0, 2, 1, 4, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_indices_all_anchors_are_identity() {
|
||||||
|
use CodingType::*;
|
||||||
|
let d = display_indices([I, P, P, P].into_iter());
|
||||||
|
assert_eq!(d, vec![0, 1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconstructs_monotonic_display_pts_from_one_anchor_per_gop() {
|
||||||
|
use CodingType::*;
|
||||||
|
// Two GOPs of 5 frames, decode order I P B P B, anchor PTS only on the
|
||||||
|
// GOP's I (0 ns, then ~5-frames-later). Frame duration should calibrate
|
||||||
|
// to the spacing/5 and every frame get a distinct increasing display PTS.
|
||||||
|
let dur = 41_708_333i64;
|
||||||
|
let mut r = SparsePtsReorder::new();
|
||||||
|
let mut got: Vec<i64> = Vec::new();
|
||||||
|
// GOP 1: anchor on the I at t=0.
|
||||||
|
for (k, (ct, pts)) in [(I, Some(0i64)), (P, None), (B, None), (P, None), (B, None)]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let out = r.push(pts, frame(ct, k == 0));
|
||||||
|
got.extend(out.iter().map(|f| f.pts_ns));
|
||||||
|
}
|
||||||
|
// GOP 2: anchor on the I at t = 5*dur (its true display time).
|
||||||
|
for (k, (ct, pts)) in [
|
||||||
|
(I, Some(5 * dur)),
|
||||||
|
(P, None),
|
||||||
|
(B, None),
|
||||||
|
(P, None),
|
||||||
|
(B, None),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let out = r.push(pts, frame(ct, k == 0));
|
||||||
|
got.extend(out.iter().map(|f| f.pts_ns));
|
||||||
|
}
|
||||||
|
got.extend(r.flush().iter().map(|f| f.pts_ns));
|
||||||
|
|
||||||
|
// Ten frames out, none dropped.
|
||||||
|
assert_eq!(got.len(), 10, "all frames emitted");
|
||||||
|
// The calibrated duration is (5*dur)/5 = dur.
|
||||||
|
// GOP 1 decode order I P B P B -> display indices 0 2 1 4 3 -> PTS:
|
||||||
|
assert_eq!(
|
||||||
|
&got[0..5],
|
||||||
|
&[0, 2 * dur, dur, 4 * dur, 3 * dur],
|
||||||
|
"GOP1 display PTS in decode order"
|
||||||
|
);
|
||||||
|
// GOP 2 re-locks origin to 5*dur.
|
||||||
|
assert_eq!(
|
||||||
|
&got[5..10],
|
||||||
|
&[5 * dur, 7 * dur, 6 * dur, 9 * dur, 8 * dur],
|
||||||
|
"GOP2 display PTS continue monotonically per display order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_flushes_a_gop_that_exceeds_the_byte_cap() {
|
||||||
|
use CodingType::*;
|
||||||
|
// Few-but-huge access units with no keyframe must not accumulate past the
|
||||||
|
// byte cap: a handful of ~MAX_GOP_BYTES/4-sized frames force-completes the
|
||||||
|
// GOP well before the frame-count cap, bounding memory.
|
||||||
|
let big = MAX_GOP_BYTES / 4 + 1;
|
||||||
|
let mut r = SparsePtsReorder::new();
|
||||||
|
let mut emitted = 0usize;
|
||||||
|
// Enough huge frames to trigger several byte-cap completions (a GOP is
|
||||||
|
// held one step for duration calibration, so the first emit lands after
|
||||||
|
// the second cap fires) — well under the 600-frame count cap.
|
||||||
|
for i in 0..16 {
|
||||||
|
let mut f = frame(P, false);
|
||||||
|
f.data = vec![0u8; big];
|
||||||
|
emitted += r.push((i == 0).then_some(0), f).len();
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
emitted >= 1,
|
||||||
|
"byte cap force-flushed (emitted {emitted}) before the frame-count cap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_flushes_a_gop_that_never_signals_a_keyframe() {
|
||||||
|
use CodingType::*;
|
||||||
|
// A stream that never flags a keyframe (open-GOP recovery points, or a
|
||||||
|
// crafted/corrupt disc) must not buffer the whole title: the cap
|
||||||
|
// force-completes GOPs so frames are emitted well before flush().
|
||||||
|
let mut r = SparsePtsReorder::new();
|
||||||
|
let mut emitted = 0usize;
|
||||||
|
for i in 0..(MAX_GOP_FRAMES * 3) {
|
||||||
|
let pts = (i == 0).then_some(0);
|
||||||
|
emitted += r.push(pts, frame(P, false)).len();
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
emitted >= MAX_GOP_FRAMES,
|
||||||
|
"cap force-flushed GOPs before EOF (emitted {emitted})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_pts_collisions_within_a_gop() {
|
||||||
|
use CodingType::*;
|
||||||
|
// Every frame distinct in DISPLAY order — the property the mkv muxer
|
||||||
|
// needs so a decoder can derive monotonic DTS.
|
||||||
|
let mut r = SparsePtsReorder::new();
|
||||||
|
let mut all: Vec<i64> = Vec::new();
|
||||||
|
for gop in 0..3 {
|
||||||
|
for (k, ct) in [I, P, B, P, B].into_iter().enumerate() {
|
||||||
|
let pts = (k == 0).then_some(gop as i64 * 5 * 41_708_333);
|
||||||
|
all.extend(r.push(pts, frame(ct, k == 0)).iter().map(|f| f.pts_ns));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all.extend(r.flush().iter().map(|f| f.pts_ns));
|
||||||
|
let mut sorted = all.clone();
|
||||||
|
sorted.sort_unstable();
|
||||||
|
sorted.dedup();
|
||||||
|
assert_eq!(sorted.len(), all.len(), "no two frames share a display PTS");
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-3
@@ -103,6 +103,10 @@ pub struct Vc1Parser {
|
|||||||
cur_entry_point: Option<Vec<u8>>,
|
cur_entry_point: Option<Vec<u8>>,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
/// Display-order PTS reconstruction, enabled only on the program-stream
|
||||||
|
/// (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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Vc1Parser {
|
impl Default for Vc1Parser {
|
||||||
@@ -120,6 +124,25 @@ impl Vc1Parser {
|
|||||||
cur_entry_point: None,
|
cur_entry_point: None,
|
||||||
width: 1920,
|
width: 1920,
|
||||||
height: 1080,
|
height: 1080,
|
||||||
|
reorder: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable display-order PTS reconstruction for a program-stream source.
|
||||||
|
/// No-op (leaves timestamps as parsed) for a transport-stream source.
|
||||||
|
pub(crate) fn with_ps_reorder(mut self, enabled: bool) -> Self {
|
||||||
|
if enabled {
|
||||||
|
self.reorder = Some(super::reorder::SparsePtsReorder::new());
|
||||||
|
}
|
||||||
|
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> {
|
||||||
|
match self.reorder.as_mut() {
|
||||||
|
Some(r) => r.push(explicit, frame),
|
||||||
|
None => vec![frame],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +192,8 @@ impl CodecParser for Vc1Parser {
|
|||||||
// decode order and the player reorders by timecode. Use PTS, not DTS —
|
// decode order and the player reorders by timecode. Use PTS, not DTS —
|
||||||
// DTS presents B-frames in decode order (visible judder) and breaks
|
// DTS presents B-frames in decode order (visible judder) and breaks
|
||||||
// PTS-based seeking. Fall back to DTS only if PTS is absent.
|
// PTS-based seeking. Fall back to DTS only if PTS is absent.
|
||||||
let ts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
let explicit_pts = pes.pts.or(pes.dts).map(pts_to_ns);
|
||||||
|
let ts_ns = explicit_pts.unwrap_or(0);
|
||||||
let mut has_seq_header = false;
|
let mut has_seq_header = false;
|
||||||
let mut has_entry_point = false;
|
let mut has_entry_point = false;
|
||||||
let mut frame_start: Option<usize> = None;
|
let mut frame_start: Option<usize> = None;
|
||||||
@@ -321,7 +345,7 @@ impl CodecParser for Vc1Parser {
|
|||||||
vc1_frame_coding_type(data.get(fs + 4..)?, self.cur_seq_header.as_deref())
|
vc1_frame_coding_type(data.get(fs + 4..)?, self.cur_seq_header.as_deref())
|
||||||
});
|
});
|
||||||
|
|
||||||
vec![Frame {
|
let frame = Frame {
|
||||||
// Coding-type only: VC-1 field order is not decoded here, so
|
// Coding-type only: VC-1 field order is not decoded here, so
|
||||||
// field_order() stays None — honestly absent, never guessed.
|
// field_order() stays None — honestly absent, never guessed.
|
||||||
coding: coding_type.map(PictureInfo::coding_type_only),
|
coding: coding_type.map(PictureInfo::coding_type_only),
|
||||||
@@ -333,7 +357,15 @@ impl CodecParser for Vc1Parser {
|
|||||||
discontinuity: pes.discontinuity,
|
discontinuity: pes.discontinuity,
|
||||||
data: frame_data,
|
data: frame_data,
|
||||||
duration_ns: None,
|
duration_ns: None,
|
||||||
}]
|
};
|
||||||
|
self.finish(explicit_pts, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> Vec<Frame> {
|
||||||
|
match self.reorder.as_mut() {
|
||||||
|
Some(r) => r.flush(),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
|
|||||||
+17
-5
@@ -241,11 +241,23 @@ impl EsWriter for AnnexBWriter {
|
|||||||
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
|
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
|
||||||
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
|
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
|
||||||
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
|
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
|
||||||
match codec {
|
let converted = match codec {
|
||||||
Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(),
|
Codec::Hevc => hvcc_to_annex_b(record),
|
||||||
Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(),
|
Codec::H264 => avcc_to_annex_b(record),
|
||||||
_ => Vec::new(),
|
_ => 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.
|
/// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
|
||||||
|
|||||||
+20
-4
@@ -7,7 +7,7 @@
|
|||||||
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
|
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
|
||||||
//! already runs on a producer thread; the *consumer* (main) thread
|
//! already runs on a producer thread; the *consumer* (main) thread
|
||||||
//! still serialises `ts_demuxer.feed` (M2TS parsing) with the codec
|
//! 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
|
//! codec parse at ~44 % of consumer wall time — i.e. feed is heavy
|
||||||
//! enough that pipelining it with parse pays for itself.
|
//! enough that pipelining it with parse pays for itself.
|
||||||
//!
|
//!
|
||||||
@@ -204,6 +204,14 @@ impl DemuxThread {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = recycle_tx.send(buf);
|
let _ = recycle_tx.send(buf);
|
||||||
|
// No demuxer (a BdTs title with zero streams): still send
|
||||||
|
// an empty batch so an early consumer disconnect is
|
||||||
|
// detected here too, exactly like the ts/ps branches above.
|
||||||
|
// Without it this worker reads the whole disc even after
|
||||||
|
// the consumer has dropped.
|
||||||
|
if tx.send(DemuxBatch::Ts(Vec::new())).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Flush tail packets at EOF.
|
// Flush tail packets at EOF.
|
||||||
@@ -479,7 +487,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn no_demuxer_configured_still_recycles_and_eofs() {
|
fn no_demuxer_configured_still_recycles_and_eofs() {
|
||||||
// With neither ts nor ps set, the worker must still recycle buffers
|
// With neither ts nor ps set, the worker must still recycle buffers
|
||||||
// and terminate with Eof — never emit a spurious Ts/Ps batch.
|
// and terminate with Eof — also forward an empty batch per buffer for disconnect detection.
|
||||||
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(4);
|
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(4);
|
||||||
let (rc_tx, rc_rx) = bounded::<Vec<u8>>(4);
|
let (rc_tx, rc_rx) = bounded::<Vec<u8>>(4);
|
||||||
let (_dt, rx) = DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), None, None, None).unwrap();
|
let (_dt, rx) = DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), None, None, None).unwrap();
|
||||||
@@ -492,8 +500,16 @@ mod tests {
|
|||||||
drop(pf_tx);
|
drop(pf_tx);
|
||||||
|
|
||||||
let batches = collect_batches(&rx, Duration::from_secs(5));
|
let batches = collect_batches(&rx, Duration::from_secs(5));
|
||||||
assert_eq!(batches.len(), 1, "only the Eof sentinel");
|
// The no-demuxer branch now forwards an empty Ts batch per buffer for
|
||||||
assert!(matches!(batches[0], DemuxBatch::Eof));
|
// early consumer-disconnect detection (same rationale as the ts/ps
|
||||||
|
// branches), then the Eof sentinel.
|
||||||
|
assert_eq!(
|
||||||
|
batches.len(),
|
||||||
|
2,
|
||||||
|
"empty Ts disconnect-probe batch, then Eof"
|
||||||
|
);
|
||||||
|
assert!(matches!(batches[0], DemuxBatch::Ts(ref v) if v.is_empty()));
|
||||||
|
assert!(matches!(batches[1], DemuxBatch::Eof));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+21
-27
@@ -108,11 +108,6 @@ pub struct DiscStream {
|
|||||||
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
|
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
|
||||||
/// (raw / unencrypted disc) makes the decorator a pass-through.
|
/// (raw / unencrypted disc) makes the decorator a pass-through.
|
||||||
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
|
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,
|
title: DiscTitle,
|
||||||
/// Mirror of the keys handed in at construction. The decorator
|
/// Mirror of the keys handed in at construction. The decorator
|
||||||
/// owns the cryptographic state; this field is kept for
|
/// 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
|
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
|
||||||
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
|
// 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
|
// MUX path (read > decrypt > mux): decrypt every unit in place and pass the
|
||||||
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
|
// bytes to the muxer; a unit that decrypts to broken TS is the muxer's
|
||||||
// decode/mux stream (live-drive single-pass / direct), never the
|
// concern, never conceal / re-fetch / count as loss (fail loud only on a
|
||||||
// ciphertext-preserving sweep, so concealment is always correct here.
|
// genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive
|
||||||
let mut reader =
|
// single-pass / direct), never the ciphertext-preserving sweep.
|
||||||
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
|
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
|
||||||
|
|
||||||
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
|
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
|
||||||
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
|
// 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
|
// the decorator is a pass-through). Reset the unit base the probe read
|
||||||
// advanced so the first fill_extents read starts cleanly.
|
// advanced so the first fill_extents read starts cleanly.
|
||||||
reader.set_unit_base(0);
|
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
|
// B1 resync gates: one per stream, video flagged so the gate only
|
||||||
// drop-to-keyframes video (audio/subtitle always admit). Computed before
|
// drop-to-keyframes video (audio/subtitle always admit). Computed before
|
||||||
@@ -311,7 +303,6 @@ impl DiscStream {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
reader,
|
reader,
|
||||||
decrypt_loss,
|
|
||||||
title,
|
title,
|
||||||
decrypt_keys,
|
decrypt_keys,
|
||||||
unit_align,
|
unit_align,
|
||||||
@@ -1003,14 +994,12 @@ impl crate::pes::Stream for DiscStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn lost_bytes(&self) -> u64 {
|
fn lost_bytes(&self) -> u64 {
|
||||||
// Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time
|
// Read-error zero-fill loss (counted in fill_extents) — real missing
|
||||||
// loss — bytes of scrambled AACS units the decorator could not decrypt
|
// content the abort gate must see. There is no decrypt-loss term: the
|
||||||
// and passed through still encrypted (the TS assembler silently drops
|
// decrypt path passes bad-encoded/undecryptable units through (a broken-TS
|
||||||
// them). Both are real missing content the abort gate must see; without
|
// unit is the muxer's concern, and a missing key is indistinguishable from
|
||||||
// the decrypt term a partial key failure reports lost_bytes=0 and a rip
|
// bad authoring here), so only physical read loss is reported.
|
||||||
// missing segments passes even under abort_on_lost_secs=0.
|
|
||||||
self.lost_bytes
|
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 {
|
let keys = crate::decrypt::DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0u8; 16])],
|
unit_keys: vec![(0, [0u8; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
|
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
|
||||||
stream.skip_errors = true;
|
stream.skip_errors = true;
|
||||||
@@ -1520,11 +1510,15 @@ mod tests {
|
|||||||
"read at lba {lba} is not unit-aligned (offset {} % {ALIGN} != 0)",
|
"read at lba {lba} is not unit-aligned (offset {} % {ALIGN} != 0)",
|
||||||
lba - ext_start
|
lba - ext_start
|
||||||
);
|
);
|
||||||
// Non-tail reads must be a whole number of units; the only
|
// Non-tail reads must be a whole number of units; the only permitted
|
||||||
// permitted short read is the final partial unit (here COUNT is a
|
// short read is a final partial unit (below one unit). Assert it
|
||||||
// multiple of ALIGN, so every read should be unit-multiple unless
|
// rather than documenting it — a mid-stream non-unit-multiple read
|
||||||
// it shrank below one unit — which is itself a single unit).
|
// would straddle AACS unit boundaries and decrypt under the wrong
|
||||||
let _ = count;
|
// alignment.
|
||||||
|
assert!(
|
||||||
|
count as u32 % ALIGN == 0 || (count as u32) < ALIGN,
|
||||||
|
"read count {count} is neither a whole number of units nor a sub-unit tail"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// At least one error was skipped (the bad unit) and a SectorSkipped
|
// At least one error was skipped (the bad unit) and a SectorSkipped
|
||||||
|
|||||||
@@ -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).
|
/// Write a complete EBML float element (8-byte double).
|
||||||
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
|
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
|
||||||
write_id(w, id)?;
|
write_id(w, id)?;
|
||||||
@@ -481,6 +506,25 @@ pub const LUMINANCE_MIN: u32 = 0x55DA;
|
|||||||
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
|
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
|
||||||
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
|
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
|
||||||
pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED;
|
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
|
// Audio
|
||||||
pub const AUDIO: u32 = 0xE1;
|
pub const AUDIO: u32 = 0xE1;
|
||||||
@@ -960,6 +1004,31 @@ mod tests {
|
|||||||
assert_eq!(buf, [0x42, 0x86, 0x81, 0x00]);
|
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,
|
// write_float — EBML floats here are always 8-byte IEEE-754 doubles,
|
||||||
// big-endian (Matroska SamplingFrequency/Duration). size byte = 0x88.
|
// big-endian (Matroska SamplingFrequency/Duration). size byte = 0x88.
|
||||||
|
|||||||
+640
-43
@@ -68,6 +68,82 @@ const COLOUR_RANGE_LIMITED: u8 = 1;
|
|||||||
/// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec).
|
/// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec).
|
||||||
const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343;
|
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,
|
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
|
||||||
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
|
/// 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
|
/// (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 +
|
/// stream is parsed. When `Some`, the serializer emits MasteringMetadata +
|
||||||
/// MaxCLL/MaxFALL inside Colour; when `None` they are omitted entirely.
|
/// MaxCLL/MaxFALL inside Colour; when `None` they are omitted entirely.
|
||||||
pub hdr10: Option<crate::mux::codec::Hdr10Metadata>,
|
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
|
/// 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
|
// coded picture's PictureInfo before the header is written (the same
|
||||||
// deferred path FieldOrder uses). `None` here → omitted unless seen.
|
// deferred path FieldOrder uses). `None` here → omitted unless seen.
|
||||||
hdr10: None,
|
hdr10: None,
|
||||||
|
mvc_params: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +535,7 @@ impl MkvTrack {
|
|||||||
bit_depth: 0,
|
bit_depth: 0,
|
||||||
dv_config: None,
|
dv_config: None,
|
||||||
hdr10: None,
|
hdr10: None,
|
||||||
|
mvc_params: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +573,7 @@ impl MkvTrack {
|
|||||||
bit_depth: 0,
|
bit_depth: 0,
|
||||||
dv_config: None,
|
dv_config: None,
|
||||||
hdr10: 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
|
/// the primary video. `None` when the title has no video track (no track
|
||||||
/// drives epochs).
|
/// drives epochs).
|
||||||
primary_video_track: Option<usize>,
|
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).
|
/// Cross-clip timeline-continuity corrector (clip-boundary PTS rebasing).
|
||||||
continuity: TimelineContinuity,
|
continuity: TimelineContinuity,
|
||||||
cues: Vec<CuePoint>,
|
cues: Vec<CuePoint>,
|
||||||
@@ -557,6 +652,19 @@ pub struct MkvMuxer<W: Write + Seek> {
|
|||||||
track_uids: Vec<u64>,
|
track_uids: Vec<u64>,
|
||||||
/// Segment duration in seconds (from `Info`), for the BPS denominator.
|
/// Segment duration in seconds (from `Info`), for the BPS denominator.
|
||||||
duration_secs: f64,
|
duration_secs: f64,
|
||||||
|
/// Byte offset of the DURATION element's 8-byte payload when it was written
|
||||||
|
/// as a patch-later placeholder — the source supplied no duration (e.g.
|
||||||
|
/// HD-DVD, whose `.MAP` timemaps are not parsed). `None` when a real duration
|
||||||
|
/// was written up-front. Back-patched at `finish()` from the muxed timeline.
|
||||||
|
duration_patch_pos: Option<u64>,
|
||||||
|
/// 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
|
/// 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
|
/// 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
|
/// corrected from the AC-3 bitstream `acmod` of the first frame on the
|
||||||
@@ -677,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,
|
/// 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,
|
/// 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;
|
/// a handful of tracks), so 1 byte covers `< 0x80`, 2 bytes covers `< 0x4000`,
|
||||||
/// no heap allocation, called once per block on the mux hot path.
|
/// 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`
|
/// Each width uses a marker bit that must NOT collide with the payload's top
|
||||||
/// guards the 0x4000 bound: at or above it, `(track_num >> 8)` is >= 0x40 and
|
/// byte: the 1-byte marker is 0x80 (7 payload bits), the 2-byte marker 0x40
|
||||||
/// OR-ing the 0x40 length marker would clobber it, corrupting the track
|
/// (14 payload bits), the 3-byte marker 0x20 (21 payload bits). Handling all
|
||||||
/// number. Not reachable today (track numbers are `i+1` over a few streams),
|
/// three in RELEASE (not just `debug_assert`) means an out-of-2-byte-range
|
||||||
/// so this documents the bound rather than handling 3-byte VINTs.
|
/// track number can never silently clobber the marker bit and corrupt the
|
||||||
fn track_vint(track_num: usize) -> ([u8; 2], usize) {
|
/// 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 {
|
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 {
|
} else {
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
track_num < 0x4000,
|
track_num < 0x20_0000,
|
||||||
"track number {track_num} exceeds the 14-bit 2-byte EBML VINT range"
|
"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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,11 +886,21 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
ebml::TIMESTAMP_SCALE,
|
ebml::TIMESTAMP_SCALE,
|
||||||
TIMESTAMP_SCALE_NS as u64,
|
TIMESTAMP_SCALE_NS as u64,
|
||||||
)?;
|
)?;
|
||||||
if duration_secs > 0.0 {
|
let duration_patch_pos = if duration_secs > 0.0 {
|
||||||
// Duration is expressed in TimestampScale ticks (not ms).
|
// Duration is expressed in TimestampScale ticks (not ms).
|
||||||
let duration_ticks = duration_secs * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64;
|
let duration_ticks = duration_secs * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64;
|
||||||
ebml::write_float(&mut writer, ebml::DURATION, duration_ticks)?;
|
ebml::write_float(&mut writer, ebml::DURATION, duration_ticks)?;
|
||||||
}
|
None
|
||||||
|
} else {
|
||||||
|
// The source declared no duration (e.g. HD-DVD — its `.MAP` timemaps
|
||||||
|
// are not parsed). Reserve a DURATION placeholder now and back-patch
|
||||||
|
// it at finish() from the muxed timeline, so the file still declares a
|
||||||
|
// runtime instead of showing "unknown". The 8-byte float payload sits
|
||||||
|
// 3 bytes in (2-byte ID `0x4489` + 1-byte size `0x88`).
|
||||||
|
let pos = writer.stream_position()?;
|
||||||
|
ebml::write_float(&mut writer, ebml::DURATION, 0.0)?;
|
||||||
|
Some(pos + 3)
|
||||||
|
};
|
||||||
// Stamp the freemkv version so any muxed file is traceable to the build
|
// Stamp the freemkv version so any muxed file is traceable to the build
|
||||||
// that produced it (MediaInfo "Writing application"/"library").
|
// that produced it (MediaInfo "Writing application"/"library").
|
||||||
ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?;
|
ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?;
|
||||||
@@ -787,9 +917,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
|
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
|
||||||
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
||||||
std::collections::HashMap::new();
|
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() {
|
for (i, track) in tracks.iter().enumerate() {
|
||||||
let track_uid = (i + 1) as u64 | 0x100_0000;
|
let track_uid = (i + 1) as u64 | 0x100_0000;
|
||||||
track_uids.push(track_uid);
|
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)?;
|
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_NUMBER, (i + 1) as u64)?;
|
||||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
|
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
|
||||||
@@ -809,7 +953,20 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref cp) = track.codec_private {
|
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
|
// Pre-0.13 a deferred codecPrivate path existed for video tracks
|
||||||
// (placeholder reserve + later seek-back fill via
|
// (placeholder reserve + later seek-back fill via
|
||||||
@@ -905,6 +1062,40 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
ebml::end_master(&mut writer, vid_pos)?;
|
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
|
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
||||||
// TrackEntry (sibling of Video). Carries the dvcC so players /
|
// TrackEntry (sibling of Video). Carries the dvcC so players /
|
||||||
// mediainfo recognise the track as Dolby Vision.
|
// mediainfo recognise the track as Dolby Vision.
|
||||||
@@ -995,6 +1186,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
primary_video_track: tracks
|
primary_video_track: tracks
|
||||||
.iter()
|
.iter()
|
||||||
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
||||||
|
track_has_mvc_mapping,
|
||||||
continuity: TimelineContinuity::new(),
|
continuity: TimelineContinuity::new(),
|
||||||
cues: Vec::new(),
|
cues: Vec::new(),
|
||||||
frame_count: 0,
|
frame_count: 0,
|
||||||
@@ -1007,6 +1199,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
track_bytes: vec![0u64; tracks.len()],
|
track_bytes: vec![0u64; tracks.len()],
|
||||||
track_uids,
|
track_uids,
|
||||||
duration_secs,
|
duration_secs,
|
||||||
|
duration_patch_pos,
|
||||||
|
max_block_ticks: 0,
|
||||||
|
last_video_keyframe_ticks: None,
|
||||||
ac3_channel_fixups,
|
ac3_channel_fixups,
|
||||||
opening_capture: None,
|
opening_capture: None,
|
||||||
})
|
})
|
||||||
@@ -1038,6 +1233,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
/// (`None`) leaves the written value untouched: an interlaced track keeps
|
/// (`None`) leaves the written value untouched: an interlaced track keeps
|
||||||
/// its guess rather than being cleared via a multi-element change. The byte
|
/// 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.
|
/// 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(
|
pub fn write_frame(
|
||||||
&mut self,
|
&mut self,
|
||||||
track_idx: usize,
|
track_idx: usize,
|
||||||
@@ -1045,6 +1247,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
keyframe: bool,
|
keyframe: bool,
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
duration_ns: Option<u64>,
|
duration_ns: Option<u64>,
|
||||||
|
block_additional: Option<&[u8]>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
// --log-level 3: capture the first ~100 coded frames per track to the
|
// --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
|
// side file BEFORE any timeline mangling, with the codec parser's own
|
||||||
@@ -1196,17 +1399,82 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
// Committed to writing this frame — record its (monotonic) timestamp so
|
// Committed to writing this frame — record its (monotonic) timestamp so
|
||||||
// the next block on this track is forced strictly later.
|
// the next block on this track is forced strictly later.
|
||||||
self.last_pts_ticks.insert(track_idx, pts_ticks);
|
self.last_pts_ticks.insert(track_idx, pts_ticks);
|
||||||
|
// Track the highest block timestamp so a missing source duration can be
|
||||||
|
// back-patched from the real muxed runtime at finish().
|
||||||
|
// Track the block END (start + its own duration when known), not just
|
||||||
|
// the start, so a back-patched Segment Duration covers the final frame's
|
||||||
|
// full presentation instead of understating the runtime by one frame.
|
||||||
|
let block_end_ticks =
|
||||||
|
pts_ticks + duration_ns.map_or(0, |d| (d as i64 / TIMESTAMP_SCALE_NS).max(1));
|
||||||
|
self.max_block_ticks = self.max_block_ticks.max(block_end_ticks);
|
||||||
|
|
||||||
let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
|
let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
|
||||||
match duration_ns {
|
let duration_ticks =
|
||||||
Some(dur_ns) => {
|
duration_ns.map(|dur_ns| (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64);
|
||||||
// BlockDuration is in TimestampScale ticks, floored at 1.
|
// A BlockAdditional with BlockAddID=2 is only conforming when the track
|
||||||
let duration_ticks = (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64;
|
// declared the matching mvcC BlockAdditionMapping. If it did not (the
|
||||||
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, duration_ticks)?;
|
// 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 => {
|
Some(_) => None,
|
||||||
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
|
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;
|
self.frame_count += 1;
|
||||||
|
|
||||||
@@ -1268,6 +1536,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
if self.frame_count == 0 {
|
if self.frame_count == 0 {
|
||||||
return Err(crate::error::Error::MkvInvalid.into());
|
return Err(crate::error::Error::MkvInvalid.into());
|
||||||
}
|
}
|
||||||
|
// The source declared no duration up-front (DURATION was reserved as a
|
||||||
|
// placeholder). Derive the real runtime from the muxed timeline so the
|
||||||
|
// Segment declares it — and so the BPS tags below can be computed.
|
||||||
|
if self.duration_patch_pos.is_some() && self.max_block_ticks > 0 {
|
||||||
|
self.duration_secs =
|
||||||
|
self.max_block_ticks as f64 * TIMESTAMP_SCALE_NS as f64 / 1_000_000_000.0;
|
||||||
|
}
|
||||||
// Close final cluster
|
// Close final cluster
|
||||||
self.end_cluster()?;
|
self.end_cluster()?;
|
||||||
|
|
||||||
@@ -1322,6 +1597,30 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
|
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
|
||||||
self.writer.write_all(&offset.to_be_bytes())?;
|
self.writer.write_all(&offset.to_be_bytes())?;
|
||||||
}
|
}
|
||||||
|
// Back-patch the DURATION placeholder (source supplied no duration) with
|
||||||
|
// the real runtime from the muxed timeline. The CUES-void and seek-to-end
|
||||||
|
// below re-seek absolutely, so no position restore is needed here.
|
||||||
|
if let Some(pos) = self.duration_patch_pos {
|
||||||
|
if self.max_block_ticks > 0 {
|
||||||
|
self.writer.seek(std::io::SeekFrom::Start(pos))?;
|
||||||
|
self.writer
|
||||||
|
.write_all(&(self.max_block_ticks as f64).to_be_bytes())?;
|
||||||
|
} else {
|
||||||
|
// The timeline never advanced past tick 0 (a degenerate
|
||||||
|
// single-frame recovery at t=0 with no per-frame duration): we
|
||||||
|
// can't derive a runtime, so DON'T leave a literal DURATION=0.0
|
||||||
|
// (players read that as a zero-length/corrupt file). Void the
|
||||||
|
// whole 11-byte DURATION element (ID 2 + size 1 + 8-byte payload)
|
||||||
|
// so the Segment simply omits it, as an unknown-duration source
|
||||||
|
// did before the back-patch. `pos` is the payload start (+3 from
|
||||||
|
// the element start), so back up 3.
|
||||||
|
self.writer.seek(std::io::SeekFrom::Start(pos - 3))?;
|
||||||
|
ebml::write_id(&mut self.writer, ebml::VOID)?;
|
||||||
|
ebml::write_size(&mut self.writer, 9)?; // 11 - 1 (Void id) - 1 (size)
|
||||||
|
self.writer.write_all(&[0u8; 9])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Neutralise the unused CUES Seek entry. The entry is a fixed 21-byte
|
// Neutralise the unused CUES Seek entry. The entry is a fixed 21-byte
|
||||||
// Seek master: SEEK(2 ID + 1 size) + SEEK_ID(2+1) + 4-byte target id +
|
// Seek master: SEEK(2 ID + 1 size) + SEEK_ID(2+1) + 4-byte target id +
|
||||||
// SEEK_POSITION(2+1) + 8-byte value = 21 bytes. A Void (0xEC, 1-byte ID)
|
// SEEK_POSITION(2+1) + 8-byte value = 21 bytes. A Void (0xEC, 1-byte ID)
|
||||||
@@ -1460,6 +1759,52 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
ebml::end_master(&mut self.writer, bg_pos)?;
|
ebml::end_master(&mut self.writer, bg_pos)?;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1643,6 +1988,7 @@ mod tests {
|
|||||||
bit_depth: 0,
|
bit_depth: 0,
|
||||||
dv_config: None,
|
dv_config: None,
|
||||||
hdr10: None,
|
hdr10: None,
|
||||||
|
mvc_params: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1672,6 +2018,7 @@ mod tests {
|
|||||||
bit_depth: 0,
|
bit_depth: 0,
|
||||||
dv_config: None,
|
dv_config: None,
|
||||||
hdr10: None,
|
hdr10: None,
|
||||||
|
mvc_params: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1755,7 +2102,7 @@ mod tests {
|
|||||||
let tracks = [make_video_track()];
|
let tracks = [make_video_track()];
|
||||||
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||||
muxer
|
muxer
|
||||||
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None)
|
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let data = muxer.writer.into_inner();
|
let data = muxer.writer.into_inner();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1775,7 +2122,7 @@ mod tests {
|
|||||||
let tracks = [make_video_track()];
|
let tracks = [make_video_track()];
|
||||||
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
|
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
|
||||||
muxer
|
muxer
|
||||||
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
|
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
muxer.finish().unwrap();
|
muxer.finish().unwrap();
|
||||||
|
|
||||||
@@ -1801,7 +2148,7 @@ mod tests {
|
|||||||
let tracks = [make_video_track()];
|
let tracks = [make_video_track()];
|
||||||
let mut muxer = MkvMuxer::new(writer, &tracks, Some("NoCue"), 60.0, &[]).unwrap();
|
let mut muxer = MkvMuxer::new(writer, &tracks, Some("NoCue"), 60.0, &[]).unwrap();
|
||||||
muxer
|
muxer
|
||||||
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
|
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Force the zero-cue branch: drop every cue before finalizing.
|
// Force the zero-cue branch: drop every cue before finalizing.
|
||||||
let cues_entry_pos = muxer.cues_seek_entry_pos.expect("CUES seek entry recorded");
|
let cues_entry_pos = muxer.cues_seek_entry_pos.expect("CUES seek entry recorded");
|
||||||
@@ -2052,16 +2399,16 @@ mod tests {
|
|||||||
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
|
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
|
||||||
// Write frames to both tracks
|
// Write frames to both tracks
|
||||||
muxer
|
muxer
|
||||||
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None)
|
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
muxer
|
muxer
|
||||||
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None)
|
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
muxer
|
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();
|
.unwrap();
|
||||||
muxer
|
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();
|
.unwrap();
|
||||||
// Should not panic
|
// Should not panic
|
||||||
let data = muxer.writer.into_inner();
|
let data = muxer.writer.into_inner();
|
||||||
@@ -2076,11 +2423,11 @@ mod tests {
|
|||||||
|
|
||||||
// Record position before first frame
|
// Record position before first frame
|
||||||
let pos_before_kf = muxer.writer.position();
|
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();
|
let pos_after_kf = muxer.writer.position();
|
||||||
|
|
||||||
muxer
|
muxer
|
||||||
.write_frame(0, 1_000_000, false, &[0xBB], None)
|
.write_frame(0, 1_000_000, false, &[0xBB], None, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let pos_after_nkf = muxer.writer.position();
|
let pos_after_nkf = muxer.writer.position();
|
||||||
|
|
||||||
@@ -2326,7 +2673,7 @@ mod tests {
|
|||||||
let writer = SharedWriter(shared.clone());
|
let writer = SharedWriter(shared.clone());
|
||||||
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, chapters).unwrap();
|
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, chapters).unwrap();
|
||||||
for (t, pts, kf, data) in frames {
|
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;
|
let frame_count = muxer.frame_count;
|
||||||
muxer.finish().unwrap();
|
muxer.finish().unwrap();
|
||||||
@@ -2688,7 +3035,7 @@ mod tests {
|
|||||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
||||||
for f in &frames {
|
for f in &frames {
|
||||||
muxer
|
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();
|
.unwrap();
|
||||||
}
|
}
|
||||||
muxer.finish().unwrap();
|
muxer.finish().unwrap();
|
||||||
@@ -2852,6 +3199,49 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scan for the first DURATION element (`0x4489`, 8-byte float payload) and
|
||||||
|
/// return its value in TimestampScale ticks.
|
||||||
|
fn find_duration_ticks(data: &[u8]) -> Option<f64> {
|
||||||
|
data.windows(3)
|
||||||
|
.position(|w| w == [0x44, 0x89, 0x88])
|
||||||
|
.and_then(|i| data.get(i + 3..i + 11))
|
||||||
|
.map(|b| f64::from_be_bytes(b.try_into().unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duration_placeholder_voided_when_timeline_never_advances() {
|
||||||
|
// Degenerate recovery: a source with no declared duration muxes exactly
|
||||||
|
// one keyframe at tick 0 with no per-frame duration, so max_block_ticks
|
||||||
|
// stays 0. The reserved DURATION placeholder must be VOIDED (element
|
||||||
|
// omitted) rather than left as a bogus 0.0 that players read as a
|
||||||
|
// zero-length file.
|
||||||
|
let tracks = [make_video_track()];
|
||||||
|
let one_frame = vec![(0usize, 0i64, true, vec![0xAAu8; 16])];
|
||||||
|
let (data, _) = mux_to_bytes(&tracks, &[], &one_frame);
|
||||||
|
assert_eq!(
|
||||||
|
find_duration_ticks(&data),
|
||||||
|
None,
|
||||||
|
"no DURATION element (placeholder voided), not a 0.0 duration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duration_backpatched_from_timeline_when_source_gives_none() {
|
||||||
|
// `mux_to_bytes` muxes with `duration_secs = 0.0` (as an HD-DVD title
|
||||||
|
// does), so DURATION is reserved as a placeholder and must be
|
||||||
|
// back-patched from the muxed timeline at finish() — not left 0/absent.
|
||||||
|
let tracks = [make_video_track()];
|
||||||
|
let frames = frames_for(5.0, 1.0); // ~5 s of 24 fps video
|
||||||
|
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||||
|
let dur = find_duration_ticks(&data).expect("DURATION element present");
|
||||||
|
let expect = 5.0 * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64; // ~50000 ticks
|
||||||
|
assert!(dur > 0.0, "duration back-patched from timeline, not 0");
|
||||||
|
assert!(
|
||||||
|
(dur - expect).abs() < 60.0,
|
||||||
|
"duration ~ real runtime: got {dur} ticks, expected ~{expect}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn seekhead_points_to_real_elements() {
|
fn seekhead_points_to_real_elements() {
|
||||||
let tracks = [make_video_track(), make_audio_track()];
|
let tracks = [make_video_track(), make_audio_track()];
|
||||||
@@ -3034,12 +3424,14 @@ mod tests {
|
|||||||
let writer = SharedWriter(shared.clone());
|
let writer = SharedWriter(shared.clone());
|
||||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
||||||
// Audio frames (track 1) and non-keyframe video — no track-0 keyframe.
|
// Audio frames (track 1) and non-keyframe video — no track-0 keyframe.
|
||||||
muxer.write_frame(1, 0, true, &[0xAA; 8], None).unwrap();
|
|
||||||
muxer
|
muxer
|
||||||
.write_frame(0, 10_000_000, false, &[0xBB; 8], None)
|
.write_frame(1, 0, true, &[0xAA; 8], None, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
muxer
|
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();
|
.unwrap();
|
||||||
let err = muxer.finish().unwrap_err();
|
let err = muxer.finish().unwrap_err();
|
||||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||||
@@ -3114,7 +3506,7 @@ mod tests {
|
|||||||
let writer = SharedWriter(shared.clone());
|
let writer = SharedWriter(shared.clone());
|
||||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
||||||
for (t, pts, kf, data) in &frames_in_order {
|
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();
|
muxer.finish().unwrap();
|
||||||
let data = shared.lock().unwrap().clone().into_inner();
|
let data = shared.lock().unwrap().clone().into_inner();
|
||||||
@@ -3127,7 +3519,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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.
|
// 1-byte form for track numbers < 0x80, high bit set.
|
||||||
let (b, n) = track_vint(1);
|
let (b, n) = track_vint(1);
|
||||||
assert_eq!(&b[..n], &[0x81]);
|
assert_eq!(&b[..n], &[0x81]);
|
||||||
@@ -3138,6 +3530,12 @@ mod tests {
|
|||||||
assert_eq!(&b[..n], &[0x40, 0x80]);
|
assert_eq!(&b[..n], &[0x40, 0x80]);
|
||||||
let (b, n) = track_vint(0x3FFF);
|
let (b, n) = track_vint(0x3FFF);
|
||||||
assert_eq!(&b[..n], &[0x7F, 0xFF]);
|
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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -3184,7 +3582,7 @@ mod tests {
|
|||||||
let writer = SharedWriter(shared.clone());
|
let writer = SharedWriter(shared.clone());
|
||||||
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, &[]).unwrap();
|
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, &[]).unwrap();
|
||||||
for (t, pts, kf, data, dur) in frames {
|
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();
|
muxer.finish().unwrap();
|
||||||
shared.lock().unwrap().clone().into_inner()
|
shared.lock().unwrap().clone().into_inner()
|
||||||
@@ -4033,10 +4431,10 @@ mod tests {
|
|||||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 10.0, &[]).unwrap();
|
let mut muxer = MkvMuxer::new(writer, &tracks, None, 10.0, &[]).unwrap();
|
||||||
// Video keyframe 1000 bytes; audio frame 500 bytes.
|
// Video keyframe 1000 bytes; audio frame 500 bytes.
|
||||||
muxer
|
muxer
|
||||||
.write_frame(0, 0, true, &vec![0xABu8; 1000], None)
|
.write_frame(0, 0, true, &vec![0xABu8; 1000], None, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
muxer
|
muxer
|
||||||
.write_frame(1, 0, false, &vec![0xCDu8; 500], None)
|
.write_frame(1, 0, false, &vec![0xCDu8; 500], None, None)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
muxer.finish().unwrap();
|
muxer.finish().unwrap();
|
||||||
let data = shared.lock().unwrap().clone().into_inner();
|
let data = shared.lock().unwrap().clone().into_inner();
|
||||||
@@ -4086,8 +4484,10 @@ mod tests {
|
|||||||
// lfeon(0) = 0b0100_0000 = 0x40. acmod_channels only needs >= 8 bytes.
|
// lfeon(0) = 0b0100_0000 = 0x40. acmod_channels only needs >= 8 bytes.
|
||||||
let ac3 = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8 << 3, 0x40, 0x00];
|
let ac3 = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8 << 3, 0x40, 0x00];
|
||||||
// Open a cluster with a video keyframe first (cluster invariant).
|
// Open a cluster with a video keyframe first (cluster invariant).
|
||||||
muxer.write_frame(0, 0, true, &[0x01, 0x02], None).unwrap();
|
muxer
|
||||||
muxer.write_frame(1, 0, false, &ac3, None).unwrap();
|
.write_frame(0, 0, true, &[0x01, 0x02], None, None)
|
||||||
|
.unwrap();
|
||||||
|
muxer.write_frame(1, 0, false, &ac3, None, None).unwrap();
|
||||||
muxer.finish().unwrap();
|
muxer.finish().unwrap();
|
||||||
let data = shared.lock().unwrap().clone().into_inner();
|
let data = shared.lock().unwrap().clone().into_inner();
|
||||||
|
|
||||||
@@ -4132,6 +4532,203 @@ mod tests {
|
|||||||
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
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) -------------
|
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
|
||||||
//
|
//
|
||||||
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
|
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
|
||||||
|
|||||||
+554
-53
@@ -120,14 +120,206 @@ struct PendingMux {
|
|||||||
video_track: Option<usize>,
|
video_track: Option<usize>,
|
||||||
/// `--log-level 3` opening-capture side-file path (if any).
|
/// `--log-level 3` opening-capture side-file path (if any).
|
||||||
opening_capture_path: Option<std::path::PathBuf>,
|
opening_capture_path: Option<std::path::PathBuf>,
|
||||||
/// Frames received before activation, replayed in order once built.
|
/// Frames received before activation, replayed in order once built. Each
|
||||||
buffered: Vec<crate::pes::PesFrame>,
|
/// 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.
|
/// Matroska container stream.
|
||||||
pub struct MkvStream {
|
pub struct MkvStream {
|
||||||
disc_title: DiscTitle,
|
disc_title: DiscTitle,
|
||||||
mode: Mode,
|
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 {
|
impl MkvStream {
|
||||||
@@ -146,10 +338,38 @@ impl MkvStream {
|
|||||||
title: &DiscTitle,
|
title: &DiscTitle,
|
||||||
output_path: Option<&std::path::Path>,
|
output_path: Option<&std::path::Path>,
|
||||||
) -> io::Result<Self> {
|
) -> 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 tracks = Vec::new();
|
||||||
let mut has_default_video = false;
|
let mut has_default_video = false;
|
||||||
let mut has_default_audio = 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() {
|
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 {
|
let mut track = match s {
|
||||||
crate::disc::Stream::Video(v) => MkvTrack::video(v),
|
crate::disc::Stream::Video(v) => MkvTrack::video(v),
|
||||||
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
|
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()) {
|
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
|
||||||
track.codec_private = Some(cp.clone());
|
track.codec_private = Some(cp.clone());
|
||||||
}
|
}
|
||||||
|
stream_to_track.push(Some(tracks.len()));
|
||||||
tracks.push(track);
|
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
|
// Defer muxer construction (and the TrackEntry dump) until the first
|
||||||
// coded picture arrives, so the primary video track's FieldOrder is set
|
// coded picture arrives, so the primary video track's FieldOrder is set
|
||||||
// from the parser's MEASURED value before the header is written — never
|
// from the parser's MEASURED value before the header is written — never
|
||||||
@@ -177,6 +419,7 @@ impl MkvStream {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
disc_title: title.clone(),
|
disc_title: title.clone(),
|
||||||
|
mvc,
|
||||||
mode: Mode::Write(WriteMode::Pending(Box::new(PendingMux {
|
mode: Mode::Write(WriteMode::Pending(Box::new(PendingMux {
|
||||||
writer,
|
writer,
|
||||||
tracks,
|
tracks,
|
||||||
@@ -209,6 +452,24 @@ impl MkvStream {
|
|||||||
if let Some(vt) = pending.video_track {
|
if let Some(vt) = pending.video_track {
|
||||||
apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen);
|
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).
|
// --log-level 3: dump the FINAL TrackEntry metadata (field order set).
|
||||||
for (i, track) in pending.tracks.iter().enumerate() {
|
for (i, track) in pending.tracks.iter().enumerate() {
|
||||||
crate::diag::dump_mkv_track((i + 1) as u64, track);
|
crate::diag::dump_mkv_track((i + 1) as u64, track);
|
||||||
@@ -223,18 +484,72 @@ impl MkvStream {
|
|||||||
if let Some(path) = &pending.opening_capture_path {
|
if let Some(path) = &pending.opening_capture_path {
|
||||||
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
|
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
|
||||||
}
|
}
|
||||||
for f in pending.buffered.drain(..) {
|
for (f, additional) in pending.buffered.drain(..) {
|
||||||
muxer.write_frame(f.track, f.pts, f.keyframe, &f.data, f.duration_ns)?;
|
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)));
|
self.mode = Mode::Write(WriteMode::Active(Box::new(muxer)));
|
||||||
Ok(())
|
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.
|
/// Open an MKV file for reading → PES frames.
|
||||||
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
|
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)?;
|
let (disc_title, codec_privates, ts_scale_ns) = parse_mkv_header(&mut reader)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
disc_title,
|
disc_title,
|
||||||
|
mvc: None,
|
||||||
mode: Mode::Read(ReadState {
|
mode: Mode::Read(ReadState {
|
||||||
reader: Box::new(reader),
|
reader: Box::new(reader),
|
||||||
cluster_ts_ticks: 0,
|
cluster_ts_ticks: 0,
|
||||||
@@ -425,59 +740,40 @@ impl crate::pes::Stream for MkvStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||||
// Fast paths.
|
if matches!(self.mode, Mode::Read(_)) {
|
||||||
match &mut self.mode {
|
return Err(crate::error::Error::StreamReadOnly.into());
|
||||||
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(_)) => {}
|
|
||||||
}
|
}
|
||||||
// Pending: the first video frame (or the safety cap) triggers muxer
|
// Non-3D fast path: emit the frame directly, no clone, no buffering.
|
||||||
// construction; that frame's coding sets the field order. Other frames
|
if self.mvc.is_none() {
|
||||||
// buffer until then.
|
return self.emit(frame, None);
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
// 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<()> {
|
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
|
// A title that produced no frames (or only buffered ones) is still
|
||||||
// finalized into a valid MKV: activate now with no measured coding.
|
// finalized into a valid MKV: activate now with no measured coding.
|
||||||
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
|
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
|
||||||
@@ -720,7 +1016,11 @@ fn parse_track(
|
|||||||
arem = arem.saturating_sub(ahlen as u64 + as_);
|
arem = arem.saturating_sub(ahlen as u64 + as_);
|
||||||
match aid {
|
match aid {
|
||||||
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
|
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_)?;
|
skip_bytes(r, as_)?;
|
||||||
}
|
}
|
||||||
@@ -916,6 +1216,207 @@ mod tests {
|
|||||||
use crate::pes::Stream as _;
|
use crate::pes::Stream as _;
|
||||||
use std::io::Cursor;
|
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]
|
#[test]
|
||||||
fn apply_coding_to_track_sets_measured_field_order_never_guesses() {
|
fn apply_coding_to_track_sets_measured_field_order_never_guesses() {
|
||||||
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
|
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub mod resolve;
|
|||||||
// accessors and an alternate `DemuxThread` spawn path. They are kept as
|
// accessors and an alternate `DemuxThread` spawn path. They are kept as
|
||||||
// part of the parser/demux surface and covered by unit tests; allow the
|
// part of the parser/demux surface and covered by unit tests; allow the
|
||||||
// dead-code lint rather than delete still-relevant scaffolding.
|
// dead-code lint rather than delete still-relevant scaffolding.
|
||||||
|
pub(crate) mod au_assembly;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(crate) mod codec;
|
pub(crate) mod codec;
|
||||||
pub(crate) mod demux_sink;
|
pub(crate) mod demux_sink;
|
||||||
@@ -116,6 +117,7 @@ pub use network::NetworkStream;
|
|||||||
pub use null::NullStream;
|
pub use null::NullStream;
|
||||||
pub use pipelined_stream::PipelinedPesStream;
|
pub use pipelined_stream::PipelinedPesStream;
|
||||||
pub use resolve::build_iso_pipeline;
|
pub use resolve::build_iso_pipeline;
|
||||||
|
pub use resolve::resolve_mux_key_map;
|
||||||
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
||||||
pub use stdio::StdioStream;
|
pub use stdio::StdioStream;
|
||||||
|
|
||||||
|
|||||||
+193
-51
@@ -56,14 +56,6 @@ pub struct PipelinedPesStream {
|
|||||||
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
|
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
|
||||||
/// per-poll reads it replaces were needless hot-path overhead.
|
/// per-poll reads it replaces were needless hot-path overhead.
|
||||||
skip_parse: bool,
|
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
|
/// 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
|
/// are expected on every disc; instead of a per-packet WARN they're tallied
|
||||||
/// and summarised once at EOF.
|
/// and summarised once at EOF.
|
||||||
@@ -75,6 +67,24 @@ pub struct PipelinedPesStream {
|
|||||||
/// Per-track "is inter-coded video" flag (only video has cross-frame
|
/// Per-track "is inter-coded video" flag (only video has cross-frame
|
||||||
/// references the gate must protect). Indexed by stream index.
|
/// references the gate must protect). Indexed by stream index.
|
||||||
is_video: Vec<bool>,
|
is_video: Vec<bool>,
|
||||||
|
/// Per-track access-unit assembler. On the PS path a program-stream video AU
|
||||||
|
/// is split across many fixed-size PES fragments; this reassembles them to the
|
||||||
|
/// codec's AU boundary so the parser sees AU-complete PES — the same shape the
|
||||||
|
/// TS demuxer already delivers via PUSI. Self-framing codecs (MPEG-2, audio)
|
||||||
|
/// use passthrough, so every track runs through it uniformly. Indexed by
|
||||||
|
/// stream index. (TS titles are AU-complete already, so this is a passthrough
|
||||||
|
/// there too — `consume_ts` does not use it.)
|
||||||
|
au_asm: Vec<super::au_assembly::AuAssembler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Codec` of a stream, for configuring its [`AuAssembler`].
|
||||||
|
fn stream_codec(s: &crate::disc::Stream) -> crate::disc::Codec {
|
||||||
|
use crate::disc::Stream;
|
||||||
|
match s {
|
||||||
|
Stream::Video(v) => v.codec,
|
||||||
|
Stream::Audio(a) => a.codec,
|
||||||
|
Stream::Subtitle(sub) => sub.codec,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PipelinedPesStream {
|
impl PipelinedPesStream {
|
||||||
@@ -102,6 +112,11 @@ impl PipelinedPesStream {
|
|||||||
let resync = (0..title.streams.len())
|
let resync = (0..title.streams.len())
|
||||||
.map(|_| super::resync::ResyncGate::new())
|
.map(|_| super::resync::ResyncGate::new())
|
||||||
.collect();
|
.collect();
|
||||||
|
let au_asm = title
|
||||||
|
.streams
|
||||||
|
.iter()
|
||||||
|
.map(|s| super::au_assembly::AuAssembler::for_codec(stream_codec(s)))
|
||||||
|
.collect();
|
||||||
Self {
|
Self {
|
||||||
title,
|
title,
|
||||||
parsers,
|
parsers,
|
||||||
@@ -111,27 +126,13 @@ impl PipelinedPesStream {
|
|||||||
pending_frames: std::collections::VecDeque::new(),
|
pending_frames: std::collections::VecDeque::new(),
|
||||||
eof: false,
|
eof: false,
|
||||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||||
decrypt_loss: None,
|
|
||||||
dropped_nav_packets: 0,
|
dropped_nav_packets: 0,
|
||||||
resync,
|
resync,
|
||||||
is_video,
|
is_video,
|
||||||
|
au_asm,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
/// Pull one batch of `PesPacket`s from the demux thread, run
|
||||||
/// codec parse on each, enqueue resulting `PesFrame`s on
|
/// codec parse on each, enqueue resulting `PesFrame`s on
|
||||||
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
|
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
|
||||||
@@ -254,23 +255,50 @@ impl PipelinedPesStream {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let pes = PesPacket {
|
// Carry the PS demuxer's byte-exact source stamp through to the codec
|
||||||
// Carry the PS demuxer's byte-exact source stamp through to the
|
// parser, exactly as the TS path does — provenance must survive the
|
||||||
// codec parser, exactly as the TS path does — provenance must
|
// PsPacket → PesPacket seam so the frame's `source` reaches the
|
||||||
// survive the PsPacket → PesPacket seam so the frame's `source`
|
// mux/index (FVI `src`), never reconstructed.
|
||||||
// reaches the mux/index (FVI `src`), never reconstructed.
|
let (pts_i64, dts_i64, src) = (
|
||||||
source: ps.source,
|
ps.pts.map(|p| p as i64),
|
||||||
pid,
|
ps.dts.map(|d| d as i64),
|
||||||
pts: ps.pts.map(|p| p as i64),
|
ps.source,
|
||||||
dts: ps.dts.map(|d| d as i64),
|
);
|
||||||
data: ps.data,
|
// Reassemble the PS fragments into AU-complete PES for this track
|
||||||
// PS (DVD/CSS) path: no AACS conceal → no continuity-gap flag.
|
// (passthrough for self-framing codecs — MPEG-2/audio), so the parser
|
||||||
discontinuity: false,
|
// sees exactly the AU-complete shape a transport stream delivers. The
|
||||||
|
// AU-start PTS/source survive the reassembly. A track with no assembler
|
||||||
|
// (only reachable via a hand-built `pid_to_track` outrunning the stream
|
||||||
|
// list) passes the fragment straight through. (PS path: no AACS conceal
|
||||||
|
// → no continuity-gap flag.)
|
||||||
|
let pkts: Vec<PesPacket> = match self.au_asm.get_mut(track) {
|
||||||
|
Some(asm) => asm
|
||||||
|
.push_owned(ps.data, pts_i64, dts_i64, src, false)
|
||||||
|
.into_iter()
|
||||||
|
.map(|au| PesPacket {
|
||||||
|
source: au.source,
|
||||||
|
pid,
|
||||||
|
pts: au.pts,
|
||||||
|
dts: au.dts,
|
||||||
|
data: au.data,
|
||||||
|
discontinuity: au.discontinuity,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
None => vec![PesPacket {
|
||||||
|
source: src,
|
||||||
|
pid,
|
||||||
|
pts: pts_i64,
|
||||||
|
dts: dts_i64,
|
||||||
|
data: ps.data,
|
||||||
|
discontinuity: false,
|
||||||
|
}],
|
||||||
};
|
};
|
||||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
for pes in &pkts {
|
||||||
for frame in parser.parse(&pes) {
|
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||||
self.pending_frames
|
for frame in parser.parse(pes) {
|
||||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
self.pending_frames
|
||||||
|
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,11 +343,30 @@ impl Stream for PipelinedPesStream {
|
|||||||
let pending = &mut self.pending_frames;
|
let pending = &mut self.pending_frames;
|
||||||
let resync = &mut self.resync;
|
let resync = &mut self.resync;
|
||||||
let is_video = &self.is_video;
|
let is_video = &self.is_video;
|
||||||
|
let au_asm = &mut self.au_asm;
|
||||||
for (pid, parser) in self.parsers.iter_mut() {
|
for (pid, parser) in self.parsers.iter_mut() {
|
||||||
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
|
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
for frame in parser.flush() {
|
// First: the trailing access unit(s) the PS assembler
|
||||||
|
// buffered past the final fragment (the last AU has no
|
||||||
|
// following boundary). Parse them, THEN drain the parser's
|
||||||
|
// own internal buffer (MPEG-2 final GOP, DTS-HD tail).
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
let tail = au_asm.get_mut(track).map(|a| a.flush()).unwrap_or_default();
|
||||||
|
for au in tail {
|
||||||
|
let pes = PesPacket {
|
||||||
|
source: au.source,
|
||||||
|
pid: *pid,
|
||||||
|
pts: au.pts,
|
||||||
|
dts: au.dts,
|
||||||
|
data: au.data,
|
||||||
|
discontinuity: au.discontinuity,
|
||||||
|
};
|
||||||
|
frames.extend(parser.parse(&pes));
|
||||||
|
}
|
||||||
|
frames.extend(parser.flush());
|
||||||
|
for frame in frames {
|
||||||
let emit = match resync.get_mut(track) {
|
let emit = match resync.get_mut(track) {
|
||||||
Some(gate) => gate.admit(
|
Some(gate) => gate.admit(
|
||||||
is_video.get(track).copied().unwrap_or(false),
|
is_video.get(track).copied().unwrap_or(false),
|
||||||
@@ -394,17 +441,9 @@ impl Stream for PipelinedPesStream {
|
|||||||
.and_then(|(_, parser)| parser.codec_private())
|
.and_then(|(_, parser)| parser.codec_private())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lost_bytes(&self) -> u64 {
|
// `lost_bytes` uses the trait default (0): the file-backed highway has no
|
||||||
// The file-backed highway has no read-error zero-fill term (resolve
|
// read-error zero-fill term (resolve/mapfile tracks physical read loss
|
||||||
// tracks read loss separately), but the producer's decrypt step can
|
// separately) and the decrypt path no longer reports a decrypt-loss term.
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -801,6 +840,109 @@ mod tests {
|
|||||||
assert!(stream.read().unwrap().is_none(), "unmappable PS dropped");
|
assert!(stream.read().unwrap().is_none(), "unmappable PS dropped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a single-video-stream title on `codec`, a [`CountingParser`] (1 frame
|
||||||
|
/// per PES it is handed), and feed three 0xE0 program-stream fragments that
|
||||||
|
/// together form TWO H.264 access units (AUD-delimited); only AU-start
|
||||||
|
/// fragments carry a PTS. Returns every emitted frame.
|
||||||
|
fn run_ps_fragments(codec: Codec) -> Vec<crate::pes::PesFrame> {
|
||||||
|
let mut title = DiscTitle::empty();
|
||||||
|
title.streams.push(crate::disc::Stream::Video(VideoStream {
|
||||||
|
pid: crate::mux::ps::DVD_VIDEO_PID,
|
||||||
|
codec,
|
||||||
|
resolution: Resolution::R1080p,
|
||||||
|
frame_rate: FrameRate::F23_976,
|
||||||
|
hdr: HdrFormat::Sdr,
|
||||||
|
color_space: ColorSpace::Bt709,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
measured_cicp: None,
|
||||||
|
}));
|
||||||
|
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
|
||||||
|
crate::mux::ps::DVD_VIDEO_PID,
|
||||||
|
Box::new(CountingParser {
|
||||||
|
per_pes: 1,
|
||||||
|
flush_n: 0,
|
||||||
|
cp: None,
|
||||||
|
}),
|
||||||
|
)];
|
||||||
|
let pid_to_track = vec![(crate::mux::ps::DVD_VIDEO_PID, 0usize)];
|
||||||
|
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
|
||||||
|
|
||||||
|
let frag = |pts, data: &[u8]| PsPacket {
|
||||||
|
source: None,
|
||||||
|
stream_id: 0xE0,
|
||||||
|
sub_stream_id: None,
|
||||||
|
pts,
|
||||||
|
dts: None,
|
||||||
|
data: data.to_vec(),
|
||||||
|
};
|
||||||
|
tx.send(DemuxBatch::Ps(vec![
|
||||||
|
frag(Some(9_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA]), // AU1: AUD + slice head
|
||||||
|
frag(None, &[0xBB, 0xCC]), // AU1: slice tail (no PTS)
|
||||||
|
frag(Some(18_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD]), // AU2 opener (AUD closes AU1)
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(DemuxBatch::Eof).unwrap();
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(f) = stream.read().unwrap() {
|
||||||
|
out.push(f);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PS-path integration: an H.264 access unit split across several fixed-size
|
||||||
|
/// PES fragments (only the first with a PTS) must be REJOINED so the parser
|
||||||
|
/// sees one AU-complete PES with the AU-START pts — not one bogus per-fragment
|
||||||
|
/// frame each with pts 0 (the HD-DVD truncation/corruption bug). The
|
||||||
|
/// `CountingParser` makes it observable: 3 fragments forming 2 AUs → 2 frames.
|
||||||
|
#[test]
|
||||||
|
fn ps_h264_au_split_across_fragments_reassembles_to_one_frame() {
|
||||||
|
let frames = run_ps_fragments(Codec::H264);
|
||||||
|
assert_eq!(
|
||||||
|
frames.len(),
|
||||||
|
2,
|
||||||
|
"3 fragments → 2 access units, not 3 frames"
|
||||||
|
);
|
||||||
|
assert_eq!(frames[0].track, 0);
|
||||||
|
assert_eq!(
|
||||||
|
frames[0].data,
|
||||||
|
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA, 0xBB, 0xCC],
|
||||||
|
"AU1 = fragment1 + fragment2 rejoined"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
frames[0].pts, 9_000,
|
||||||
|
"AU carries its START pts, not the mid-fragment None→0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
frames[1].data,
|
||||||
|
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD],
|
||||||
|
"AU2 flushed at EOF (no following boundary)"
|
||||||
|
);
|
||||||
|
assert_eq!(frames[1].pts, 18_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contrast: a self-framing codec (MPEG-2 reassembles in its own parser) uses
|
||||||
|
/// a Passthrough assembler — the SAME three fragments pass straight through as
|
||||||
|
/// three frames, byte-identical to the pre-assembler behaviour. This proves the
|
||||||
|
/// reassembly is gated by codec and does not disturb the DVD/MPEG-2 path.
|
||||||
|
#[test]
|
||||||
|
fn ps_self_framing_codec_is_not_reassembled() {
|
||||||
|
let frames = run_ps_fragments(Codec::Mpeg2);
|
||||||
|
assert_eq!(
|
||||||
|
frames.len(),
|
||||||
|
3,
|
||||||
|
"MPEG-2 passthrough: one frame per fragment"
|
||||||
|
);
|
||||||
|
assert_eq!(frames[0].pts, 9_000);
|
||||||
|
assert_eq!(
|
||||||
|
frames[1].pts, 0,
|
||||||
|
"mid-fragment has no PTS under passthrough"
|
||||||
|
);
|
||||||
|
assert_eq!(frames[2].pts, 18_000);
|
||||||
|
}
|
||||||
|
|
||||||
/// A batch with no trackable packets must NOT terminate the stream early:
|
/// A batch with no trackable packets must NOT terminate the stream early:
|
||||||
/// pump_one_batch loops to the next batch. Here an empty-but-untracked
|
/// pump_one_batch loops to the next batch. Here an empty-but-untracked
|
||||||
/// batch is followed by a real frame batch — the consumer must skip the
|
/// batch is followed by a real frame batch — the consumer must skip the
|
||||||
|
|||||||
+249
-5
@@ -27,6 +27,12 @@ const PRIVATE_STREAM_1: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_1;
|
|||||||
/// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable
|
/// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable
|
||||||
/// elementary stream; expected to be dropped on every disc.
|
/// elementary stream; expected to be dropped on every disc.
|
||||||
const PRIVATE_STREAM_2: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_2;
|
const PRIVATE_STREAM_2: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_2;
|
||||||
|
/// Extended stream id (0xFD) — the H.222.0 escape whereby the real stream id is
|
||||||
|
/// the `stream_id_extension` carried in the PES extension. HD-DVD `.evo` puts its
|
||||||
|
/// VC-1 video (and HD audio) here (Shaun of the Dead: VC-1 on `0xFD` ext `0x55`);
|
||||||
|
/// a transport stream never uses it. The elementary-stream bytes follow the PES
|
||||||
|
/// header exactly like any other PES — only the routing key differs.
|
||||||
|
const EXTENDED_STREAM_ID: u8 = 0xFD;
|
||||||
|
|
||||||
/// Hard cap on the demuxer's reassembly buffer. A length-0 (unbounded) video
|
/// Hard cap on the demuxer's reassembly buffer. A length-0 (unbounded) video
|
||||||
/// PES is delimited by the next PS-layer boundary; if a corrupt stream declares
|
/// PES is delimited by the next PS-layer boundary; if a corrupt stream declares
|
||||||
@@ -73,9 +79,16 @@ pub const DVD_VIDEO_PID: u16 = 0xE0;
|
|||||||
/// source of truth shared with `Disc::scan_dvd_titles`
|
/// source of truth shared with `Disc::scan_dvd_titles`
|
||||||
/// (`src/disc/dvd.rs`), which sets each `AudioStream.pid` from the same
|
/// (`src/disc/dvd.rs`), which sets each `AudioStream.pid` from the same
|
||||||
/// function so demuxer output routes through the title's `pid_to_track`.
|
/// function so demuxer output routes through the title's `pid_to_track`.
|
||||||
|
///
|
||||||
|
/// HD-DVD (`.evo` Enhanced VOB) carries Dolby Digital Plus (E-AC-3) on
|
||||||
|
/// `private_stream_1` sub-stream ids `0xC0..=0xC7` — a range DVD never uses
|
||||||
|
/// (DVD audio is `0x80..=0x8F` / `0xA0..=0xA7`), so admitting it here is purely
|
||||||
|
/// additive and cannot change any DVD mapping. The PID is `0xBD00 | sub` just
|
||||||
|
/// like the DVD audio ranges, so a mixed HD-DVD title (four DD+ tracks
|
||||||
|
/// `0xC0..0xC3`) routes each track to its own distinct PID.
|
||||||
pub fn dvd_audio_pid(sub_stream_id: u8) -> Option<u16> {
|
pub fn dvd_audio_pid(sub_stream_id: u8) -> Option<u16> {
|
||||||
match sub_stream_id {
|
match sub_stream_id {
|
||||||
0x80..=0x8F | 0xA0..=0xA7 => Some(0xBD00 | sub_stream_id as u16),
|
0x80..=0x8F | 0xA0..=0xA7 | 0xC0..=0xC7 => Some(0xBD00 | sub_stream_id as u16),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +103,17 @@ pub fn dvd_subtitle_pid(sub_stream_id: u8) -> Option<u16> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Canonical PID for an HD-DVD extended-stream-id (`0xFD`) stream, keyed by its
|
||||||
|
/// `stream_id_extension`: `0xFD00 | ext`. Disjoint from the DVD video (`0xE0`) and
|
||||||
|
/// `private_stream_1` (`0xBD00..`) PID spaces, so several elementary streams
|
||||||
|
/// multiplexed on `0xFD` (VC-1 video, MLP/TrueHD audio) never collide. The
|
||||||
|
/// scanner's head probe and `PsPacket::dvd_pid` derive the same PID from the same
|
||||||
|
/// `stream_id_extension`, so demux output routes through the title's
|
||||||
|
/// `pid_to_track`.
|
||||||
|
pub fn hddvd_extended_pid(stream_id_extension: u8) -> u16 {
|
||||||
|
0xFD00 | stream_id_extension as u16
|
||||||
|
}
|
||||||
|
|
||||||
impl PsPacket {
|
impl PsPacket {
|
||||||
/// Map this packet to the canonical DVD PID assigned by
|
/// Map this packet to the canonical DVD PID assigned by
|
||||||
/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can
|
/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can
|
||||||
@@ -112,6 +136,12 @@ impl PsPacket {
|
|||||||
let sub = self.sub_stream_id?;
|
let sub = self.sub_stream_id?;
|
||||||
dvd_audio_pid(sub).or_else(|| dvd_subtitle_pid(sub))
|
dvd_audio_pid(sub).or_else(|| dvd_subtitle_pid(sub))
|
||||||
}
|
}
|
||||||
|
// HD-DVD extended-stream-id (0xFD): route by the stream_id_extension
|
||||||
|
// (carried in `sub_stream_id`) to a distinct `0xFD00 | ext` PID, so a
|
||||||
|
// disc that puts several elementary streams on 0xFD keeps them apart.
|
||||||
|
// The codec (VC-1 etc.) is decided by the scanner's head probe, not
|
||||||
|
// here — this only assigns a stable routing key.
|
||||||
|
EXTENDED_STREAM_ID => self.sub_stream_id.map(hddvd_extended_pid),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,9 +377,74 @@ fn find_ps_boundary(data: &[u8], from: usize) -> Option<usize> {
|
|||||||
/// Check whether a start code byte is a valid PES stream ID that carries payload.
|
/// Check whether a start code byte is a valid PES stream ID that carries payload.
|
||||||
fn is_pes_stream_id(id: u8) -> bool {
|
fn is_pes_stream_id(id: u8) -> bool {
|
||||||
// Video: 0xE0-0xEF, MPEG audio: 0xC0-0xDF, private stream 1: 0xBD,
|
// Video: 0xE0-0xEF, MPEG audio: 0xC0-0xDF, private stream 1: 0xBD,
|
||||||
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc.
|
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc. — plus the HD-DVD
|
||||||
// We parse anything in the payload-bearing PES range.
|
// extended-stream-id (0xFD), which carries VC-1 video / HD audio.
|
||||||
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id)
|
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id) || id == EXTENDED_STREAM_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For an extended-stream-id (`0xFD`) PES, walk the optional PES-header fields to
|
||||||
|
/// the PES extension and read the 7-bit `stream_id_extension` — the real stream
|
||||||
|
/// id. `data` starts at the PES start code; the optional fields live in
|
||||||
|
/// `data[9..header_end]` (all bounds-checked against `header_end`). Returns `None`
|
||||||
|
/// if the extension is absent or malformed.
|
||||||
|
fn parse_stream_id_extension(data: &[u8], flags2: u8, header_end: usize) -> Option<u8> {
|
||||||
|
let get = |p: usize| -> Option<u8> {
|
||||||
|
if p < header_end {
|
||||||
|
data.get(p).copied()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut pos = 9usize;
|
||||||
|
let pts_dts = (flags2 >> 6) & 0x03;
|
||||||
|
if pts_dts & 0x02 != 0 {
|
||||||
|
pos += 5; // PTS
|
||||||
|
}
|
||||||
|
if pts_dts == 0x03 {
|
||||||
|
pos += 5; // DTS
|
||||||
|
}
|
||||||
|
if flags2 & 0x20 != 0 {
|
||||||
|
pos += 6; // ESCR
|
||||||
|
}
|
||||||
|
if flags2 & 0x10 != 0 {
|
||||||
|
pos += 3; // ES_rate
|
||||||
|
}
|
||||||
|
if flags2 & 0x08 != 0 {
|
||||||
|
pos += 1; // DSM_trick_mode
|
||||||
|
}
|
||||||
|
if flags2 & 0x04 != 0 {
|
||||||
|
pos += 1; // additional_copy_info
|
||||||
|
}
|
||||||
|
if flags2 & 0x02 != 0 {
|
||||||
|
pos += 2; // PES_CRC
|
||||||
|
}
|
||||||
|
if flags2 & 0x01 == 0 {
|
||||||
|
return None; // no PES_extension
|
||||||
|
}
|
||||||
|
let ext_flags = get(pos)?;
|
||||||
|
pos += 1;
|
||||||
|
if ext_flags & 0x80 != 0 {
|
||||||
|
pos += 16; // PES_private_data
|
||||||
|
}
|
||||||
|
if ext_flags & 0x40 != 0 {
|
||||||
|
// pack_header_field: 1-byte length + that many bytes.
|
||||||
|
pos += 1 + get(pos)? as usize;
|
||||||
|
}
|
||||||
|
if ext_flags & 0x20 != 0 {
|
||||||
|
pos += 2; // program_packet_sequence_counter
|
||||||
|
}
|
||||||
|
if ext_flags & 0x10 != 0 {
|
||||||
|
pos += 2; // P-STD_buffer
|
||||||
|
}
|
||||||
|
if ext_flags & 0x01 == 0 {
|
||||||
|
return None; // no PES_extension_flag_2
|
||||||
|
}
|
||||||
|
// PES_extension_field_length (7 bits, marker in the top bit), then the
|
||||||
|
// stream_id_extension byte: present when its top bit (the extension flag) is 0.
|
||||||
|
let _field_len = get(pos)? & 0x7F;
|
||||||
|
pos += 1;
|
||||||
|
let b = get(pos)?;
|
||||||
|
(b & 0x80 == 0).then_some(b & 0x7F)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a single PES packet from a byte slice that starts at the start code.
|
/// Parse a single PES packet from a byte slice that starts at the start code.
|
||||||
@@ -416,10 +511,30 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> {
|
|||||||
|
|
||||||
// For private stream 1, the first payload byte is the sub-stream ID,
|
// For private stream 1, the first payload byte is the sub-stream ID,
|
||||||
// followed by a sub-header whose length depends on the sub-stream type.
|
// followed by a sub-header whose length depends on the sub-stream type.
|
||||||
let (sub_stream_id, es_data) = if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() {
|
let (sub_stream_id, es_data) = if stream_id == EXTENDED_STREAM_ID {
|
||||||
|
// HD-DVD extended-stream-id: the real stream id lives in the
|
||||||
|
// stream_id_extension inside the PES extension. There is no leading
|
||||||
|
// sub-header byte on the payload (unlike private_stream_1), so the ES
|
||||||
|
// is the payload verbatim.
|
||||||
|
(
|
||||||
|
parse_stream_id_extension(data, data[7], header_end),
|
||||||
|
payload.to_vec(),
|
||||||
|
)
|
||||||
|
} else if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() {
|
||||||
let sub_id = payload[0];
|
let sub_id = payload[0];
|
||||||
let skip = match sub_id {
|
let skip = match sub_id {
|
||||||
0x80..=0x8F => 4, // AC3/DTS: sub_id + frame_count + access_unit_ptr(2)
|
0x80..=0x8F => 4, // AC3/DTS: sub_id + frame_count + access_unit_ptr(2)
|
||||||
|
// HD-DVD Dolby Digital Plus (E-AC-3): the sub-header is the same
|
||||||
|
// 4-byte shape as DVD AC-3 — sub_id + number_of_frames(1) +
|
||||||
|
// first_access_unit_pointer(2). Verified empirically on ANCHORMAN
|
||||||
|
// EVO: across every 0xC0..=0xC7 packet the 0x0B77 E-AC-3 syncword
|
||||||
|
// sits `first_access_unit_pointer` bytes past this 4-byte header
|
||||||
|
// (the leading bytes are the tail of the previous frame). Stripping
|
||||||
|
// exactly these 4 bytes on EVERY packet yields a clean, continuous
|
||||||
|
// E-AC-3 elementary stream that the ac3 parser reassembles across
|
||||||
|
// PES boundaries; a shorter skip would splice the sub-header bytes
|
||||||
|
// into a straddling frame and corrupt it.
|
||||||
|
0xC0..=0xC7 => 4,
|
||||||
0xA0..=0xA7 => 7, // LPCM: sub_id + frames + ptr(2) + emphasis + quant_freq + channels
|
0xA0..=0xA7 => 7, // LPCM: sub_id + frames + ptr(2) + emphasis + quant_freq + channels
|
||||||
_ => 1,
|
_ => 1,
|
||||||
};
|
};
|
||||||
@@ -836,6 +951,90 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_extended_stream_id_extracts_stream_id_extension() {
|
||||||
|
// SHAUN's VC-1 video PES: stream_id 0xFD, flags2=0x01 (PES_extension
|
||||||
|
// only), header_data_length=3, optional bytes 0x0F/... — build the
|
||||||
|
// minimal well-formed variant: ext_flags=0x01 (PES_extension_flag_2),
|
||||||
|
// field_len=0x81, stream_id_extension=0x55. Payload is the ES.
|
||||||
|
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
||||||
|
let opt = [0x01u8, 0x81, 0x55];
|
||||||
|
let es = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||||
|
let len = (3 + opt.len() + es.len()) as u16;
|
||||||
|
pkt.extend_from_slice(&len.to_be_bytes());
|
||||||
|
pkt.extend_from_slice(&[0x80, 0x01, opt.len() as u8]);
|
||||||
|
pkt.extend_from_slice(&opt);
|
||||||
|
pkt.extend_from_slice(&es);
|
||||||
|
|
||||||
|
let parsed = parse_pes_packet(&pkt).expect("parses");
|
||||||
|
assert_eq!(parsed.stream_id, EXTENDED_STREAM_ID);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.sub_stream_id,
|
||||||
|
Some(0x55),
|
||||||
|
"stream_id_extension extracted from PES extension"
|
||||||
|
);
|
||||||
|
// ES is the payload verbatim — no leading sub-header byte stripped.
|
||||||
|
assert_eq!(parsed.data, es);
|
||||||
|
// Routes to the extended-stream-id PID space.
|
||||||
|
assert_eq!(parsed.dvd_pid(), Some(hddvd_extended_pid(0x55)));
|
||||||
|
assert_eq!(parsed.dvd_pid(), Some(0xFD55));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_extended_stream_id_skips_pts_and_dts_before_the_extension() {
|
||||||
|
// The common real case: an AU-opening 0xFD VC-1 video PES carries a PTS
|
||||||
|
// (and often DTS) in the optional-header region, which the parser must
|
||||||
|
// SKIP (PTS +5, DTS +5) to reach the PES_extension → stream_id_extension.
|
||||||
|
// Both branches were previously untested (flags2 there was 0x01, skipping
|
||||||
|
// everything), so an off-by-one in the skip would silently misroute video.
|
||||||
|
let build = |flags2: u8, skip: usize| {
|
||||||
|
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
||||||
|
// optional region: `skip` bytes (PTS/DTS placeholders) then
|
||||||
|
// ext_flags=0x01, field_len=0x81, stream_id_extension=0x55.
|
||||||
|
let mut opt = vec![0xFFu8; skip];
|
||||||
|
opt.extend_from_slice(&[0x01, 0x81, 0x55]);
|
||||||
|
let es = [0xDEu8, 0xAD];
|
||||||
|
let len = (3 + opt.len() + es.len()) as u16;
|
||||||
|
pkt.extend_from_slice(&len.to_be_bytes());
|
||||||
|
// flags1=0x80, flags2, header_data_length = optional region length.
|
||||||
|
pkt.extend_from_slice(&[0x80, flags2, opt.len() as u8]);
|
||||||
|
pkt.extend_from_slice(&opt);
|
||||||
|
pkt.extend_from_slice(&es);
|
||||||
|
pkt
|
||||||
|
};
|
||||||
|
// PTS present (pts_dts bits = 10 → flags2 0x80) + PES_extension (0x01).
|
||||||
|
let pts_only = parse_pes_packet(&build(0x81, 5)).expect("parses");
|
||||||
|
assert_eq!(
|
||||||
|
pts_only.sub_stream_id,
|
||||||
|
Some(0x55),
|
||||||
|
"extension found after skipping a 5-byte PTS"
|
||||||
|
);
|
||||||
|
// PTS+DTS present (pts_dts bits = 11 → flags2 0xC0) + PES_extension.
|
||||||
|
let pts_dts = parse_pes_packet(&build(0xC1, 10)).expect("parses");
|
||||||
|
assert_eq!(
|
||||||
|
pts_dts.sub_stream_id,
|
||||||
|
Some(0x55),
|
||||||
|
"extension found after skipping a 10-byte PTS+DTS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_extended_stream_id_without_extension_yields_no_sub_id() {
|
||||||
|
// A 0xFD PES that declares no PES_extension (flags2=0x00) can't carry a
|
||||||
|
// stream_id_extension → sub_stream_id None, and dvd_pid falls through.
|
||||||
|
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
||||||
|
let es = [0x11u8, 0x22];
|
||||||
|
let len = (3 + es.len()) as u16;
|
||||||
|
pkt.extend_from_slice(&len.to_be_bytes());
|
||||||
|
pkt.extend_from_slice(&[0x80, 0x00, 0x00]);
|
||||||
|
pkt.extend_from_slice(&es);
|
||||||
|
|
||||||
|
let parsed = parse_pes_packet(&pkt).expect("parses");
|
||||||
|
assert_eq!(parsed.sub_stream_id, None);
|
||||||
|
assert_eq!(parsed.dvd_pid(), None);
|
||||||
|
assert_eq!(parsed.data, es);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dvd_pid_matches_scanner_assignment() {
|
fn dvd_pid_matches_scanner_assignment() {
|
||||||
// Video → 0xE0 (matches dvd.rs VideoStream pid).
|
// Video → 0xE0 (matches dvd.rs VideoStream pid).
|
||||||
@@ -1175,6 +1374,51 @@ mod tests {
|
|||||||
assert_eq!(dvd_audio_pid(0xA8), None);
|
assert_eq!(dvd_audio_pid(0xA8), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hddvd_ddplus_substream_maps_to_bd_pid() {
|
||||||
|
// HD-DVD Dolby Digital Plus sub-ids 0xC0..=0xC7 map to 0xBD00|sub,
|
||||||
|
// distinct per track and disjoint from the DVD audio space. A DVD never
|
||||||
|
// emits these, so the range is purely additive.
|
||||||
|
assert_eq!(dvd_audio_pid(0xC0), Some(0xBDC0));
|
||||||
|
assert_eq!(dvd_audio_pid(0xC3), Some(0xBDC3));
|
||||||
|
assert_eq!(dvd_audio_pid(0xC7), Some(0xBDC7));
|
||||||
|
// Just outside the range.
|
||||||
|
assert_eq!(dvd_audio_pid(0xBF), None);
|
||||||
|
assert_eq!(dvd_audio_pid(0xC8), None);
|
||||||
|
// Four DD+ tracks (ANCHORMAN) get four distinct PIDs.
|
||||||
|
let pids: Vec<u16> = (0xC0u8..=0xC3).map(|s| dvd_audio_pid(s).unwrap()).collect();
|
||||||
|
assert_eq!(pids, vec![0xBDC0, 0xBDC1, 0xBDC2, 0xBDC3]);
|
||||||
|
// And route through dvd_pid on a private_stream_1 packet.
|
||||||
|
assert_eq!(mk(0xBD, Some(0xC0)).dvd_pid(), Some(0xBDC0));
|
||||||
|
assert_eq!(mk(0xBD, Some(0xC3)).dvd_pid(), Some(0xBDC3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hddvd_ddplus_pes_strips_4byte_subheader_to_syncword() {
|
||||||
|
// A private_stream_1 PES carrying DD+ (sub-id 0xC0) has a 4-byte
|
||||||
|
// sub-header (sub_id + num_frames(1) + access_unit_ptr(2)); the demuxer
|
||||||
|
// must strip exactly those 4 bytes so es_data begins at the E-AC-3
|
||||||
|
// payload — here the 0x0B77 syncword sits right after the sub-header.
|
||||||
|
let mut demuxer = PsDemuxer::new();
|
||||||
|
let mut data = vec![
|
||||||
|
0x00, 0x00, 0x01, 0xBD, // private stream 1
|
||||||
|
0x00, 0x0B, // PES_packet_length = 11 (flags2 + hdl1 + 8 payload)
|
||||||
|
0x80, 0x00, 0x00, // no PTS, header_data_len = 0
|
||||||
|
0xC0, // sub-stream id: DD+ track 0
|
||||||
|
0x01, 0x00, 0x00, // num_frames(1) + access_unit_ptr(2)
|
||||||
|
0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + payload
|
||||||
|
];
|
||||||
|
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
||||||
|
let p = demuxer.feed(&data);
|
||||||
|
assert_eq!(p.len(), 1);
|
||||||
|
assert_eq!(p[0].sub_stream_id, Some(0xC0));
|
||||||
|
assert_eq!(
|
||||||
|
p[0].data,
|
||||||
|
vec![0x0B, 0x77, 0xDE, 0xAD],
|
||||||
|
"4-byte DD+ sub-header stripped; es_data starts at the syncword"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dvd_subtitle_pid_range_boundaries() {
|
fn dvd_subtitle_pid_range_boundaries() {
|
||||||
// VobSub subtitle sub-ids 0x20..=0x3F map to the identity PID.
|
// VobSub subtitle sub-ids 0x20..=0x3F map to the identity PID.
|
||||||
|
|||||||
+399
-33
@@ -358,6 +358,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
|||||||
// case must NOT raise a false E7023.
|
// case must NOT raise a false E7023.
|
||||||
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
||||||
.map_err(|e| -> io::Error { e.into() })?;
|
.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)
|
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
||||||
// by probing the first DECRYPTED access units of the chosen title.
|
// by probing the first DECRYPTED access units of the chosen title.
|
||||||
// A fresh reader avoids disturbing the mux reader below. Skipped in
|
// A fresh reader avoids disturbing the mux reader below. Skipped in
|
||||||
@@ -385,11 +390,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
|||||||
let title = disc.titles[idx].clone();
|
let title = disc.titles[idx].clone();
|
||||||
let format = disc.content_format;
|
let format = disc.content_format;
|
||||||
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
|
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
|
||||||
// sequential read from fast storage, no bad sectors. Measured
|
// sequential read from fast storage, no bad sectors. Empirically
|
||||||
// optimum on the rip1 testbed; bumping to 16384 sectors (32 MiB)
|
// optimal; bumping to 16384 sectors (32 MiB) regressed (more cache
|
||||||
// regressed (more cache pressure, longer per-batch latency starves
|
// pressure, longer per-batch latency starves the consumer between
|
||||||
// the consumer between iterations). Physical drives keep smaller
|
// iterations). Physical drives keep smaller batches for adaptive
|
||||||
// batches for adaptive error handling.
|
// error handling.
|
||||||
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
||||||
|
|
||||||
// Pass `DecryptKeys::None` to the decrypt decorator when
|
// Pass `DecryptKeys::None` to the decrypt decorator when
|
||||||
@@ -569,7 +574,17 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
|||||||
pids.push(pid);
|
pids.push(pid);
|
||||||
pid_to_track.push((pid, idx));
|
pid_to_track.push((pid, idx));
|
||||||
let is_dvd_ps = matches!(format, ContentFormat::MpegPs);
|
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 {
|
let (ts, ps) = match format {
|
||||||
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),
|
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),
|
||||||
@@ -584,6 +599,350 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
|||||||
(parsers, pid_to_track, ts, ps)
|
(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
|
/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for
|
||||||
/// a `FileSectorSource`-backed reader. Returns the resulting
|
/// a `FileSectorSource`-backed reader. Returns the resulting
|
||||||
/// `PipelinedPesStream`.
|
/// `PipelinedPesStream`.
|
||||||
@@ -602,17 +961,16 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
|||||||
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
||||||
/// pipeline stops at the next boundary. `None` disables cancellation.
|
/// pipeline stops at the next boundary. `None` disables cancellation.
|
||||||
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
||||||
/// - `fetch`: optional fresh-key-on-failure callback (see
|
/// - `fetch`: optional key source used UP FRONT by [`resolve_mux_key_map`] to
|
||||||
/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the
|
/// secure any CPS-unit key the pool is missing. Not a per-unit mux-time
|
||||||
/// decrypt decorator hands that ciphertext to `fetch` and adds any key it
|
/// callback: the map decides the key for every LBA before the read loop starts.
|
||||||
/// returns. `None` keeps the prior behaviour (the unit is counted as loss).
|
|
||||||
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
|
// 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.
|
// point; grouping them into a struct would only move the same fields around.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||||
reader: S,
|
mut reader: S,
|
||||||
title: DiscTitle,
|
title: DiscTitle,
|
||||||
keys: crate::decrypt::DecryptKeys,
|
mut keys: crate::decrypt::DecryptKeys,
|
||||||
batch_sectors: u16,
|
batch_sectors: u16,
|
||||||
format: ContentFormat,
|
format: ContentFormat,
|
||||||
halt: Option<crate::halt::Halt>,
|
halt: Option<crate::halt::Halt>,
|
||||||
@@ -629,25 +987,30 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
|||||||
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
|
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
|
||||||
_ => 1,
|
_ => 1,
|
||||||
};
|
};
|
||||||
// MUX path: tolerate decrypt loss. An undecryptable content unit is concealed
|
// MUX path: read > decrypt > mux. Resolve the proactive AACS key map UP FRONT
|
||||||
// (NULL TS fill) + tallied + logged, never an abort — decrypt-verify is a RIP
|
// — one key per CPS unit / segment, secured from the configured source and
|
||||||
// gate, not a mux gate (P3). The rip's own read paths keep their fail-loud
|
// recorded against the LBA ranges it covers. The mux then decrypts each unit
|
||||||
// decorator; only this mux pipeline opts in.
|
// 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 =
|
let mut decrypting =
|
||||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys)
|
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||||
.tolerate_decrypt_loss();
|
if let Some(map) = key_map {
|
||||||
// Install the fresh-key-on-failure callback (if any) so a unit no held key
|
decrypting = decrypting.with_key_map(map);
|
||||||
// decrypts is re-tried via the application's key source before being counted
|
|
||||||
// as loss.
|
|
||||||
if let Some(cb) = fetch {
|
|
||||||
decrypting = decrypting.with_key_fetch(cb);
|
|
||||||
}
|
}
|
||||||
// Grab the decrypt-loss counter before the decorator is moved into the
|
// Loss-counter handle. The mux does NOT tally decrypt-quality misses: a
|
||||||
// producer thread. It tracks bytes of scrambled AACS units no key could
|
// broken-TS unit is the muxer's concern, and a missing key is an up-front
|
||||||
// decrypt — silent loss the demux drops; the consuming stream surfaces it
|
// resolve failure — indistinguishable from bad authoring at this seam, so
|
||||||
// through `lost_bytes()` so the mux abort gate sees a partial decrypt
|
// counting it would false-abort a bad-encoded-but-decryptable disc. A genuine
|
||||||
// failure rather than a clean rip.
|
// can't-decrypt surfaces as `Err`; `lost_bytes()` reflects physical read loss
|
||||||
let decrypt_loss = decrypting.decrypt_loss();
|
// only (there is no decrypt-loss term to fold in).
|
||||||
|
|
||||||
// Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
|
// Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
|
||||||
// the reader, probe the feature head through the (plaintext) decrypting
|
// the reader, probe the feature head through the (plaintext) decrypting
|
||||||
@@ -673,10 +1036,13 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
|||||||
let (demux_thread, demux_rx) =
|
let (demux_thread, demux_rx) =
|
||||||
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
|
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
|
||||||
.map_err(|e| -> io::Error { e.into() })?;
|
.map_err(|e| -> io::Error { e.into() })?;
|
||||||
Ok(
|
Ok(PipelinedPesStream::new(
|
||||||
PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track)
|
demux_thread,
|
||||||
.with_decrypt_loss(decrypt_loss),
|
demux_rx,
|
||||||
)
|
title,
|
||||||
|
parsers,
|
||||||
|
pid_to_track,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a
|
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a
|
||||||
|
|||||||
+29
-21
@@ -12,9 +12,11 @@ use crate::consts::TS_PACKET_BYTES;
|
|||||||
|
|
||||||
/// TS sync byte.
|
/// TS sync byte.
|
||||||
const SYNC_BYTE: u8 = 0x47;
|
const SYNC_BYTE: u8 = 0x47;
|
||||||
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream; the P3
|
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream. The demuxer
|
||||||
/// concealment fill emits null packets on this PID, tagged with an
|
/// still recognises a `0x1FFF` packet with an adaptation-field
|
||||||
/// adaptation-field discontinuity_indicator to signal a concealed gap.
|
/// 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;
|
const NULL_PID: u16 = 0x1FFF;
|
||||||
|
|
||||||
/// A reassembled PES packet with timestamp info.
|
/// A reassembled PES packet with timestamp info.
|
||||||
@@ -34,8 +36,10 @@ pub struct PesPacket {
|
|||||||
pub source: Option<crate::pes::SourcePos>,
|
pub source: Option<crate::pes::SourcePos>,
|
||||||
/// True when one or more packets for this stream were lost before this PES —
|
/// 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 continuity break (CC gap or adaptation-field discontinuity_indicator) on
|
||||||
/// a tracked PID, or the CC-independent concealment marker the mux emits when
|
/// a tracked PID, or a CC-independent NULL-TS concealment marker (P3/B1). NOTE:
|
||||||
/// it replaces an undecryptable unit with NULL-TS packets (P3/A2). This PES is
|
/// 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
|
/// 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
|
/// 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
|
/// boundary flags the PES STARTING after it (never the one just flushed). So
|
||||||
@@ -167,6 +171,10 @@ impl PesAssembler {
|
|||||||
self.buffer.clear();
|
self.buffer.clear();
|
||||||
self.active = false;
|
self.active = false;
|
||||||
self.header_remaining = 0;
|
self.header_remaining = 0;
|
||||||
|
// A dropped partial PES is a gap in the elementary stream — flag
|
||||||
|
// it so the NEXT completed PES carries a discontinuity, matching
|
||||||
|
// every other partial-drop path in this file (lines 395/479/504).
|
||||||
|
self.pending_discontinuity = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.buffer.extend_from_slice(data);
|
self.buffer.extend_from_slice(data);
|
||||||
@@ -196,7 +204,7 @@ impl PesAssembler {
|
|||||||
/// BD Transport Stream demuxer.
|
/// BD Transport Stream demuxer.
|
||||||
pub struct TsDemuxer {
|
pub struct TsDemuxer {
|
||||||
assemblers: Vec<PesAssembler>,
|
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
|
remainder: Vec<u8>, // leftover bytes from previous feed() call
|
||||||
/// Absolute source byte offset of the NEXT byte to be fed — the running
|
/// 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.
|
/// base that turns an in-buffer packet offset into a source position.
|
||||||
@@ -220,20 +228,17 @@ impl TsDemuxer {
|
|||||||
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
|
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
|
||||||
/// valid (wholly-unused) table.
|
/// valid (wholly-unused) table.
|
||||||
pub fn new(pids: &[u16]) -> Self {
|
pub fn new(pids: &[u16]) -> Self {
|
||||||
// The PID→assembler index is stored as i16 (-1 = untracked), so a
|
// The PID→assembler index is stored as i32 (-1 = untracked). PIDs are
|
||||||
// 32768th+ tracked PID would truncate to a negative value and be
|
// u16 (≤ 65535) and the assembler index `i` is bounded by the number of
|
||||||
// silently treated as untracked. Callers pass a handful of PIDs
|
// distinct PIDs (≤ 65536), both far below i32::MAX, so `i as i32` can
|
||||||
// (BD-TS has at most ~8192), so this is a programmer-error guard.
|
// never truncate to a negative value and be mis-read as untracked —
|
||||||
debug_assert!(
|
// unlike an i16 table, this is safe in RELEASE, not just under debug.
|
||||||
pids.len() <= i16::MAX as usize,
|
|
||||||
"TsDemuxer: too many PIDs for an i16 index table"
|
|
||||||
);
|
|
||||||
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
|
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
|
||||||
let table_size = (max_pid + 1).max(8192);
|
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());
|
let mut assemblers = Vec::with_capacity(pids.len());
|
||||||
for (i, &pid) in pids.iter().enumerate() {
|
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));
|
assemblers.push(PesAssembler::new(pid));
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
@@ -364,10 +369,13 @@ impl TsDemuxer {
|
|||||||
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
||||||
let adaptation = (ts[3] >> 4) & 0x03;
|
let adaptation = (ts[3] >> 4) & 0x03;
|
||||||
|
|
||||||
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable
|
// P3/B1 CONCEALMENT MARKER: a NULL-TS packet (PID 0x1FFF) carrying an
|
||||||
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an
|
// adaptation-field discontinuity_indicator. NOTE: the in-tree writer that
|
||||||
// adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`).
|
// laid these down on an undecryptable unit was removed with the pure-decrypt
|
||||||
// This is the authoritative loss signal — unlike a tracked PID's 4-bit
|
// 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
|
// 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
|
// 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
|
// (no prior CC to diff against). The decrypt layer cannot know which
|
||||||
@@ -1053,7 +1061,7 @@ mod tests {
|
|||||||
|
|
||||||
/// One 192-byte BD source packet that is a B1 concealment marker: a PID-0x1FFF
|
/// 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
|
/// 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> {
|
fn null_marker_packet() -> Vec<u8> {
|
||||||
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
|
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
|
||||||
pkt[4] = SYNC_BYTE; // 0x47
|
pkt[4] = SYNC_BYTE; // 0x47
|
||||||
|
|||||||
+310
-913
File diff suppressed because it is too large
Load Diff
+63
-12
@@ -16,6 +16,7 @@
|
|||||||
pub mod decrypting;
|
pub mod decrypting;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod prefetched;
|
pub mod prefetched;
|
||||||
|
pub mod recovery;
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
||||||
@@ -128,6 +129,10 @@ impl SectorSource for Box<dyn SectorSource> {
|
|||||||
fn set_speed(&mut self, kbs: u16) {
|
fn set_speed(&mut self, kbs: u16) {
|
||||||
(**self).set_speed(kbs)
|
(**self).set_speed(kbs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_unit_base(&mut self, lba: u32) {
|
||||||
|
(**self).set_unit_base(lba)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SectorSource for &mut (dyn SectorSource + '_) {
|
impl SectorSource for &mut (dyn SectorSource + '_) {
|
||||||
@@ -159,6 +164,10 @@ impl SectorSource for &mut (dyn SectorSource + '_) {
|
|||||||
fn set_speed(&mut self, kbs: u16) {
|
fn set_speed(&mut self, kbs: u16) {
|
||||||
(**self).set_speed(kbs)
|
(**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.
|
/// Write 2048-byte sectors to a disc image or composed sink.
|
||||||
@@ -180,7 +189,7 @@ pub trait SectorSink: Send {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub use crate::io::file_sector_source::FileSectorSource;
|
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 file::FileSectorSink;
|
||||||
pub use prefetched::PrefetchedSectorSource;
|
pub use prefetched::PrefetchedSectorSource;
|
||||||
|
|
||||||
@@ -197,23 +206,33 @@ mod tests {
|
|||||||
capacity: u32,
|
capacity: u32,
|
||||||
reads: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
reads: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
||||||
speeds: Arc<Mutex<Vec<u16>>>,
|
speeds: Arc<Mutex<Vec<u16>>>,
|
||||||
|
unit_bases: Arc<Mutex<Vec<u32>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `Spy` under test plus the handles recording its reads and speed sets.
|
/// A `Spy` under test plus the handles recording its reads, speed sets,
|
||||||
type SpyHarness = (Spy, Arc<Mutex<Vec<(u32, u16, bool)>>>, Arc<Mutex<Vec<u16>>>);
|
/// and unit-base sets.
|
||||||
|
type SpyHarness = (
|
||||||
|
Spy,
|
||||||
|
Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
||||||
|
Arc<Mutex<Vec<u16>>>,
|
||||||
|
Arc<Mutex<Vec<u32>>>,
|
||||||
|
);
|
||||||
|
|
||||||
impl Spy {
|
impl Spy {
|
||||||
fn new(capacity: u32) -> SpyHarness {
|
fn new(capacity: u32) -> SpyHarness {
|
||||||
let reads = Arc::new(Mutex::new(Vec::new()));
|
let reads = Arc::new(Mutex::new(Vec::new()));
|
||||||
let speeds = Arc::new(Mutex::new(Vec::new()));
|
let speeds = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let unit_bases = Arc::new(Mutex::new(Vec::new()));
|
||||||
(
|
(
|
||||||
Self {
|
Self {
|
||||||
capacity,
|
capacity,
|
||||||
reads: reads.clone(),
|
reads: reads.clone(),
|
||||||
speeds: speeds.clone(),
|
speeds: speeds.clone(),
|
||||||
|
unit_bases: unit_bases.clone(),
|
||||||
},
|
},
|
||||||
reads,
|
reads,
|
||||||
speeds,
|
speeds,
|
||||||
|
unit_bases,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,6 +256,16 @@ mod tests {
|
|||||||
fn set_speed(&mut self, kbs: u16) {
|
fn set_speed(&mut self, kbs: u16) {
|
||||||
self.speeds.lock().unwrap().push(kbs);
|
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
|
/// The default `capacity_sectors` is 0 (unknown). Grounding: trait
|
||||||
@@ -285,7 +314,7 @@ mod tests {
|
|||||||
/// Box<dyn SectorSource>` forwarding bodies.
|
/// Box<dyn SectorSource>` forwarding bodies.
|
||||||
#[test]
|
#[test]
|
||||||
fn boxed_dyn_forwards_all_methods() {
|
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);
|
let mut boxed: Box<dyn SectorSource> = Box::new(spy);
|
||||||
|
|
||||||
assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward");
|
assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward");
|
||||||
@@ -307,24 +336,46 @@ mod tests {
|
|||||||
vec![5400],
|
vec![5400],
|
||||||
"set_speed must forward"
|
"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 + '_)`.
|
/// Grounding: `impl SectorSource for &mut (dyn SectorSource + '_)`.
|
||||||
#[test]
|
#[test]
|
||||||
fn mut_ref_dyn_forwards_all_methods() {
|
fn mut_ref_dyn_forwards_all_methods() {
|
||||||
let (mut spy, reads, speeds) = Spy::new(123);
|
let (mut spy, reads, speeds, unit_bases) = Spy::new(123);
|
||||||
let r: &mut dyn SectorSource = &mut spy;
|
|
||||||
|
|
||||||
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 mut buf = vec![0u8; 2 * 2048];
|
||||||
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
|
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
|
||||||
assert_eq!(n, 2 * 2048);
|
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!(*reads.lock().unwrap(), vec![(7, 2, false)]);
|
||||||
assert_eq!(*speeds.lock().unwrap(), vec![8800]);
|
assert_eq!(*speeds.lock().unwrap(), vec![8800]);
|
||||||
|
assert_eq!(
|
||||||
|
*unit_bases.lock().unwrap(),
|
||||||
|
vec![128],
|
||||||
|
"set_unit_base must forward through &mut dyn"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,11 +252,14 @@ impl PrefetchedSectorSource {
|
|||||||
};
|
};
|
||||||
if bytes <= buf.capacity() {
|
if bytes <= buf.capacity() {
|
||||||
// Re-expose `bytes` without zero-filling pages that
|
// Re-expose `bytes` without zero-filling pages that
|
||||||
// `read_sectors` is about to overwrite. The enclosing
|
// `read_sectors` is about to overwrite. Sound because the
|
||||||
// capacity guard makes the `set_len` provably sound even
|
// enclosing `bytes <= capacity` guard bounds the length,
|
||||||
// if a recycled buffer ever comes back smaller than the
|
// and every byte below `capacity` is physically
|
||||||
// `vec![0u8; batch_bytes]` it was born with.
|
// initialised: buffers are born `vec![0u8; batch_bytes]`
|
||||||
debug_assert!(bytes <= buf.capacity(), "set_len exceeds capacity");
|
// 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) };
|
unsafe { buf.set_len(bytes) };
|
||||||
} else {
|
} else {
|
||||||
buf.resize(bytes, 0);
|
buf.resize(bytes, 0);
|
||||||
@@ -358,7 +361,6 @@ impl PrefetchedSectorSource {
|
|||||||
// and suppresses `self`'s own `Drop` (which would otherwise
|
// and suppresses `self`'s own `Drop` (which would otherwise
|
||||||
// double-`join`), leaving NO extra live endpoint behind. This
|
// double-`join`), leaving NO extra live endpoint behind. This
|
||||||
// is the panic-free equivalent of the `Option::take` approach.
|
// is the panic-free equivalent of the `Option::take` approach.
|
||||||
let total = self.total_sectors;
|
|
||||||
let me = std::mem::ManuallyDrop::new(self);
|
let me = std::mem::ManuallyDrop::new(self);
|
||||||
// SAFETY: `me` is `ManuallyDrop`, so none of these fields will
|
// SAFETY: `me` is `ManuallyDrop`, so none of these fields will
|
||||||
// be dropped by `me`. Each `ptr::read` performs exactly one
|
// be dropped by `me`. Each `ptr::read` performs exactly one
|
||||||
@@ -367,7 +369,7 @@ impl PrefetchedSectorSource {
|
|||||||
let producer = unsafe { std::ptr::read(&me.producer) };
|
let producer = unsafe { std::ptr::read(&me.producer) };
|
||||||
let rx = unsafe { std::ptr::read(&me.rx) };
|
let rx = unsafe { std::ptr::read(&me.rx) };
|
||||||
let recycle = unsafe { std::ptr::read(&me.recycle_tx) };
|
let recycle = unsafe { std::ptr::read(&me.recycle_tx) };
|
||||||
(rx, recycle, PrefetchShell { producer, total })
|
(rx, recycle, PrefetchShell { producer })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,8 +378,6 @@ impl PrefetchedSectorSource {
|
|||||||
/// producer, even though the channels have been peeled off.
|
/// producer, even though the channels have been peeled off.
|
||||||
pub struct PrefetchShell {
|
pub struct PrefetchShell {
|
||||||
producer: Option<JoinHandle<()>>,
|
producer: Option<JoinHandle<()>>,
|
||||||
#[allow(dead_code)]
|
|
||||||
total: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for PrefetchShell {
|
impl Drop for PrefetchShell {
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
//! The recovery seam: what a read does when a content unit will not decrypt.
|
||||||
|
//!
|
||||||
|
//! Per-format miss policy does NOT belong in the generic decrypt decorator
|
||||||
|
//! (L2). The input stream (L3, e.g. [`crate::mux::disc::DiscStream`]) knows what
|
||||||
|
//! it is reading and installs a [`Recover`] at construction; the decorator
|
||||||
|
//! executes it at the one seam and honours the returned outcome. This keeps
|
||||||
|
//! "a DVD re-cracks, a BD/UHD fetches a fresh key" out of the decryptor, where
|
||||||
|
//! it would otherwise smear across `if`-branches.
|
||||||
|
//!
|
||||||
|
//! The recovery type ([`Recover`]) names **no encryption scheme**. It is a
|
||||||
|
//! generic `FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome` that
|
||||||
|
//! operates on the generic [`DecryptKeys`] the whole decrypt path already uses,
|
||||||
|
//! so a scheme is never baked into the type — only into the factory that builds
|
||||||
|
//! a recovery:
|
||||||
|
//! * [`none`] — no recovery; a miss is loss (raw sweep / clear).
|
||||||
|
//! * [`key_fetch`] — AACS key-fetch: hand the failing ciphertext to the
|
||||||
|
//! application's key source and add any returned keys to the pool. An AACS
|
||||||
|
//! 2.1 forensic-segment unit that no key opens is just an undecryptable unit
|
||||||
|
//! like any other — a loss is a loss, with no FMTS-specific branch here.
|
||||||
|
//!
|
||||||
|
//! CSS is deliberately NOT on this seam — and the reason is precise: this seam is
|
||||||
|
//! for recovery that needs something `decrypt_sectors` does not have (an EXTERNAL
|
||||||
|
//! key source for AACS, a segment map for FMTS). CSS's title key changes per VOB
|
||||||
|
//! region and is re-cracked constantly, but always FROM THE DATA ITSELF — no
|
||||||
|
//! external input — so CSS SELF-recovers inside `decrypt_sectors` (see
|
||||||
|
//! [`crate::css::descramble_region`]). The generic type here would accept a CSS
|
||||||
|
//! recovery, but CSS has no reason to use it.
|
||||||
|
|
||||||
|
use crate::decrypt::DecryptKeys;
|
||||||
|
use crate::sector::KeyFetch;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// The result of running a recovery on a read's still-scrambled units: how many
|
||||||
|
/// bytes remain loss after recovery ran. A loss is a loss — an undecryptable
|
||||||
|
/// unit is concealed and counted the same whatever the scheme (an AACS 2.1
|
||||||
|
/// forensic-segment unit with no variant key is just another undecryptable
|
||||||
|
/// unit).
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct MissOutcome {
|
||||||
|
/// Bytes that remain loss after recovery.
|
||||||
|
pub dropped: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MissOutcome {
|
||||||
|
/// All `n` bytes are loss.
|
||||||
|
fn loss(n: usize) -> Self {
|
||||||
|
Self { dropped: n }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cap on how many times one recovery will call its fetch closure over its
|
||||||
|
/// lifetime — bounds key-server traffic to ~O(distinct CPS units) even if
|
||||||
|
/// scrambled units keep arriving. A disc has only a handful of unit keys.
|
||||||
|
const MAX_FETCH_CALLS: usize = 16;
|
||||||
|
|
||||||
|
/// Cap on how many still-scrambled sample units are handed to the fetch closure
|
||||||
|
/// per call — a few samples suffice for a key service to identify and validate
|
||||||
|
/// the key, and it bounds the request size.
|
||||||
|
const MAX_FETCH_SAMPLES: usize = 8;
|
||||||
|
|
||||||
|
/// Stable per-run fingerprint of a failing unit's ciphertext, for the dedup set.
|
||||||
|
/// `DefaultHasher` is fixed-seed, so equal samples map to equal fingerprints
|
||||||
|
/// within a process — all the dedup needs.
|
||||||
|
fn sample_fp(sample: &[u8]) -> u64 {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
sample.hash(&mut h);
|
||||||
|
h.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-decrypt `buf` after the key pool grew, content-gated identically to the
|
||||||
|
/// first read so a non-content unit is never re-attempted. Mirrors the
|
||||||
|
/// decorator's `decrypt_buf` dispatch.
|
||||||
|
fn redecrypt(
|
||||||
|
buf: &mut [u8],
|
||||||
|
keys: &mut DecryptKeys,
|
||||||
|
unit_key_idx: usize,
|
||||||
|
lba: u32,
|
||||||
|
content: Option<&[(u32, u32)]>,
|
||||||
|
prev_dropped: usize,
|
||||||
|
) -> usize {
|
||||||
|
match content {
|
||||||
|
Some(ranges) => {
|
||||||
|
crate::decrypt::decrypt_sectors_in_content(buf, keys, unit_key_idx, lba, ranges)
|
||||||
|
}
|
||||||
|
None => crate::decrypt::decrypt_sectors(buf, keys, unit_key_idx),
|
||||||
|
}
|
||||||
|
.unwrap_or(prev_dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a read hands a recovery on a miss: the disc's decrypt parameters and how
|
||||||
|
/// many bytes the held keys could not decrypt. Scheme-neutral — a recovery reads
|
||||||
|
/// only the generic [`DecryptKeys`] and these fields.
|
||||||
|
pub struct RecoverCtx {
|
||||||
|
/// Which AACS unit-key index the read decrypts with (ignored by non-AACS).
|
||||||
|
pub unit_key_idx: usize,
|
||||||
|
/// Base LBA of the read.
|
||||||
|
pub lba: u32,
|
||||||
|
/// The encrypted-content extent map, when the read is content-gated.
|
||||||
|
pub content: Option<Arc<[(u32, u32)]>>,
|
||||||
|
/// Bytes the held keys could not decrypt before recovery ran.
|
||||||
|
pub prev_dropped: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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,
|
||||||
|
target: &mut [u8],
|
||||||
|
ciphertext: &[u8],
|
||||||
|
keys: &mut DecryptKeys,
|
||||||
|
ctx: &RecoverCtx,
|
||||||
|
) -> usize {
|
||||||
|
let prev_dropped = ctx.prev_dropped;
|
||||||
|
if *calls >= MAX_FETCH_CALLS {
|
||||||
|
return prev_dropped;
|
||||||
|
}
|
||||||
|
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
// 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 (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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if samples.is_empty() {
|
||||||
|
return prev_dropped;
|
||||||
|
}
|
||||||
|
// Skip the call when EVERY failing unit here is one a prior fetch already
|
||||||
|
// came back empty for — re-asking identical ciphertext only burns a request.
|
||||||
|
// A unit not asked about yet (e.g. a second CPS unit) still gets its chance.
|
||||||
|
let fps: Vec<u64> = samples.iter().map(|s| sample_fp(s)).collect();
|
||||||
|
if fps.iter().all(|fp| dry.contains(fp)) {
|
||||||
|
return prev_dropped;
|
||||||
|
}
|
||||||
|
*calls += 1;
|
||||||
|
let fresh = (fetch)(&samples);
|
||||||
|
// Add only keys we don't already hold (dedup by value).
|
||||||
|
let mut added = 0usize;
|
||||||
|
if let DecryptKeys::Aacs { unit_keys, .. } = keys {
|
||||||
|
for k in fresh {
|
||||||
|
if !unit_keys.iter().any(|(_, have)| *have == k) {
|
||||||
|
let idx = unit_keys.len() as u32;
|
||||||
|
unit_keys.push((idx, k));
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if added == 0 {
|
||||||
|
// Nothing new for THESE units — remember them so we don't re-ask the same
|
||||||
|
// ciphertext, but leave the door open for other units.
|
||||||
|
dry.extend(fps);
|
||||||
|
return prev_dropped;
|
||||||
|
}
|
||||||
|
// 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(
|
||||||
|
target,
|
||||||
|
keys,
|
||||||
|
ctx.unit_key_idx,
|
||||||
|
ctx.lba,
|
||||||
|
ctx.content.as_deref(),
|
||||||
|
prev_dropped,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(|_target, _ciphertext, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AACS key-fetch recovery (BD / UHD): on a miss, ask the application's key
|
||||||
|
/// source for a key that opens the failing ciphertext and add it to the pool.
|
||||||
|
pub fn key_fetch(fetch: KeyFetch) -> Recover {
|
||||||
|
let mut dry: HashSet<u64> = HashSet::new();
|
||||||
|
let mut calls: usize = 0;
|
||||||
|
Box::new(move |target, ciphertext, keys, ctx| {
|
||||||
|
MissOutcome::loss(aacs_fetch_step(
|
||||||
|
&mut dry, &mut calls, &fetch, target, ciphertext, keys, ctx,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
/// A 6144-byte aligned unit that reads as still-scrambled: CPI bits set on
|
||||||
|
/// byte 0 (so `aacs_unit_encrypted` flags it) and every 192-byte TS-sync
|
||||||
|
/// probe position forced off 0x47. `tag` varies the whole body so distinct
|
||||||
|
/// tags produce distinct fingerprints (mirrors decrypt.rs `scrambled_region`).
|
||||||
|
fn scrambled_unit(tag: u8) -> Vec<u8> {
|
||||||
|
let len = ALIGNED_UNIT_LEN;
|
||||||
|
let mut v: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31) ^ tag).collect();
|
||||||
|
let mut off = 4;
|
||||||
|
while off < len {
|
||||||
|
v[off] = 0xA5; // never a 0x47 sync
|
||||||
|
off += 192;
|
||||||
|
}
|
||||||
|
v[0] |= 0xC0; // CPI: reads as encrypted content
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recovery context reading at clip-relative `lba` with `prev` bytes the
|
||||||
|
/// held keys could not decrypt.
|
||||||
|
fn ctx(lba: u32, prev: usize) -> RecoverCtx {
|
||||||
|
RecoverCtx {
|
||||||
|
unit_key_idx: 0,
|
||||||
|
lba,
|
||||||
|
content: None,
|
||||||
|
prev_dropped: prev,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn none_recovers_nothing() {
|
||||||
|
let mut r = none();
|
||||||
|
let mut buf = scrambled_unit(0x33);
|
||||||
|
let mut keys = DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![],
|
||||||
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
|
};
|
||||||
|
let cipher = buf.clone();
|
||||||
|
let out = r(&mut buf, &cipher, &mut keys, &ctx(0, 6144));
|
||||||
|
assert_eq!(out.dropped, 6144);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_fetch_adds_returned_keys_to_the_pool() {
|
||||||
|
// The fetch returns one key; it must be appended to the (empty) pool. We
|
||||||
|
// assert the pool grew (the decrypt itself is exercised end-to-end by the
|
||||||
|
// decorator's integration tests); here we pin the seam's key-plumbing.
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let c2 = Arc::clone(&calls);
|
||||||
|
let fetch: KeyFetch = Arc::new(move |samples: &[Vec<u8>]| {
|
||||||
|
c2.fetch_add(1, Ordering::SeqCst);
|
||||||
|
assert!(!samples.is_empty(), "failing ciphertext is forwarded");
|
||||||
|
vec![[0xAB; 16]]
|
||||||
|
});
|
||||||
|
let mut r = key_fetch(fetch);
|
||||||
|
let mut buf = scrambled_unit(0x33);
|
||||||
|
let mut keys = DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![],
|
||||||
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
|
};
|
||||||
|
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!()
|
||||||
|
};
|
||||||
|
assert_eq!(unit_keys.len(), 1, "returned key added to the pool");
|
||||||
|
assert_eq!(unit_keys[0].1, [0xAB; 16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_fetch_does_not_re_ask_dry_ciphertext() {
|
||||||
|
// A fetch that returns nothing marks the ciphertext dry; a second miss on
|
||||||
|
// the SAME ciphertext must not call the fetch again.
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let c2 = Arc::clone(&calls);
|
||||||
|
let fetch: KeyFetch = Arc::new(move |_: &[Vec<u8>]| {
|
||||||
|
c2.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Vec::new() // never helps
|
||||||
|
});
|
||||||
|
let mut r = key_fetch(fetch);
|
||||||
|
let mut keys = DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![],
|
||||||
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
|
};
|
||||||
|
let mut buf = scrambled_unit(0x44);
|
||||||
|
let cipher = buf.clone();
|
||||||
|
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||||
|
let mut buf2 = scrambled_unit(0x44); // identical ciphertext
|
||||||
|
let cipher2 = buf2.clone();
|
||||||
|
r(&mut buf2, &cipher2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||||
|
assert_eq!(
|
||||||
|
calls.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"identical dry ciphertext is not re-asked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_fetch_call_budget_bounds_fetches() {
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let c2 = Arc::clone(&calls);
|
||||||
|
let fetch: KeyFetch = Arc::new(move |_: &[Vec<u8>]| {
|
||||||
|
c2.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Vec::new()
|
||||||
|
});
|
||||||
|
let mut r = key_fetch(fetch);
|
||||||
|
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);
|
||||||
|
let cipher = buf.clone();
|
||||||
|
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
calls.load(Ordering::SeqCst),
|
||||||
|
MAX_FETCH_CALLS,
|
||||||
|
"fetch is capped at MAX_FETCH_CALLS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+330
-52
@@ -325,53 +325,6 @@ impl UdfFs {
|
|||||||
Ok(merged)
|
Ok(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All sector ranges that contain data (metadata + all files including STREAM).
|
|
||||||
/// For full disc-to-ISO dumps — reads only allocated sectors, skips gaps.
|
|
||||||
pub fn all_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> {
|
|
||||||
let mut ranges = Vec::new();
|
|
||||||
|
|
||||||
// UDF structure sectors
|
|
||||||
let meta_end = self.metadata_start.saturating_add(self.metadata_sectors);
|
|
||||||
ranges.push((0, meta_end));
|
|
||||||
|
|
||||||
// Walk entire tree including STREAM directories
|
|
||||||
self.collect_all_file_ranges(reader, &self.root, &mut ranges)?;
|
|
||||||
|
|
||||||
// Merge overlapping/adjacent ranges and sort
|
|
||||||
ranges.sort_by_key(|r| r.0);
|
|
||||||
let merged = merge_ranges(&ranges);
|
|
||||||
Ok(merged)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_all_file_ranges(
|
|
||||||
&self,
|
|
||||||
reader: &mut dyn SectorSource,
|
|
||||||
entry: &DirEntry,
|
|
||||||
ranges: &mut Vec<(u32, u32)>,
|
|
||||||
) -> Result<()> {
|
|
||||||
for child in &entry.entries {
|
|
||||||
if child.is_dir {
|
|
||||||
self.collect_all_file_ranges(reader, child, ranges)?;
|
|
||||||
} else {
|
|
||||||
// Include the ICB sector
|
|
||||||
ranges.push((self.meta_to_abs(child.meta_lba)?, 1));
|
|
||||||
|
|
||||||
// Include ALL file data extents (large m2ts files have many)
|
|
||||||
if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) {
|
|
||||||
for (data_lba, data_len) in extents {
|
|
||||||
let abs_start = match self.partition_start.checked_add(data_lba) {
|
|
||||||
Some(v) => v,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let sector_count = (data_len as u64).div_ceil(2048) as u32;
|
|
||||||
ranges.push((abs_start, sector_count));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_file_ranges(
|
fn collect_file_ranges(
|
||||||
&self,
|
&self,
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
@@ -640,10 +593,27 @@ impl UdfFs {
|
|||||||
match next_block {
|
match next_block {
|
||||||
Some(cont_lba) => {
|
Some(cont_lba) => {
|
||||||
read_sector(reader, self.meta_to_abs(cont_lba)?, &mut block)?;
|
read_sector(reader, self.meta_to_abs(cont_lba)?, &mut block)?;
|
||||||
// A continuation block is a list of Short ADs from byte 0,
|
// A continuation block does NOT begin with allocation
|
||||||
// spanning the whole sector.
|
// descriptors — it begins with a 24-byte Allocation Extent
|
||||||
ad_start = 0;
|
// Descriptor (ECMA-167 4/14.5, tag id 258): the 16-byte
|
||||||
ad_bytes = block.len();
|
// 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,
|
None => break,
|
||||||
}
|
}
|
||||||
@@ -1582,8 +1552,15 @@ mod tests {
|
|||||||
|
|
||||||
/// A continuation block: a bare list of short ADs from byte 0.
|
/// A continuation block: a bare list of short ADs from byte 0.
|
||||||
fn build_cont_block(ads: &[(u32, u32, u32)]) -> [u8; 2048] {
|
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 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 {
|
for &(etype, dlen, dlba) in ads {
|
||||||
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
|
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
|
||||||
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
|
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
|
||||||
@@ -1637,6 +1614,35 @@ mod tests {
|
|||||||
assert_eq!(extents, vec![(10, 4096), (20, 2048)]);
|
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]
|
#[test]
|
||||||
fn icb_extents_long_ad_returns_all_extents_not_just_first() {
|
fn icb_extents_long_ad_returns_all_extents_not_just_first() {
|
||||||
// Regression: BD-ROM large .m2ts files use Long ADs (16-byte
|
// Regression: BD-ROM large .m2ts files use Long ADs (16-byte
|
||||||
@@ -1784,6 +1790,57 @@ mod tests {
|
|||||||
assert!(inf[2048..].iter().all(|&b| b == 0xBB));
|
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]
|
#[test]
|
||||||
fn merge_ranges_saturates_near_u32_max() {
|
fn merge_ranges_saturates_near_u32_max() {
|
||||||
// Adjacent ranges near u32::MAX must not panic (debug) or wrap.
|
// Adjacent ranges near u32::MAX must not panic (debug) or wrap.
|
||||||
@@ -2391,3 +2448,224 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shared UDF image fixtures for tests across the `disc::*` format scanners.
|
||||||
|
///
|
||||||
|
/// Builds an in-memory disc image that [`read_filesystem`] can navigate — a
|
||||||
|
/// [`MemDisc`] SectorSource plus a [`DirSpec`] tree laid out via [`lay_dir`] and
|
||||||
|
/// [`build_udf_skeleton`]. Format-agnostic: BD (`bluray.rs`), HD-DVD
|
||||||
|
/// (`hddvd.rs`), and the format detector (`disc/mod.rs`) all build their own
|
||||||
|
/// trees (`BDMV/`, `HVDVD_TS/`, `AACS/…`) on top of these primitives, so each
|
||||||
|
/// format's tests live in that format's file, not piled into one.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod fixture {
|
||||||
|
use crate::sector::SectorSource;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// PART_START == META_START: file LBAs (partition-relative) and ICB/dir LBAs
|
||||||
|
/// (metadata-relative) share one address space (abs = PART_START + lba), so
|
||||||
|
/// `read_filesystem` takes the single-partition path.
|
||||||
|
pub(crate) const PART_START: u32 = 2000;
|
||||||
|
|
||||||
|
/// In-memory `SectorSource` (absolute-LBA → 2048-byte sector map); unmapped
|
||||||
|
/// sectors read as zeroes.
|
||||||
|
pub(crate) struct MemDisc {
|
||||||
|
sectors: HashMap<u32, [u8; 2048]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemDisc {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sectors: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
||||||
|
self.sectors.insert(lba, data);
|
||||||
|
}
|
||||||
|
/// Write arbitrary-length bytes at `lba`, split across 2048-byte sectors.
|
||||||
|
pub(crate) fn put_bytes(&mut self, lba: u32, bytes: &[u8]) {
|
||||||
|
for (i, chunk) in bytes.chunks(2048).enumerate() {
|
||||||
|
let mut s = [0u8; 2048];
|
||||||
|
s[..chunk.len()].copy_from_slice(chunk);
|
||||||
|
self.put(lba + i as u32, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SectorSource for MemDisc {
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
lba: u32,
|
||||||
|
count: u16,
|
||||||
|
buf: &mut [u8],
|
||||||
|
_recovery: bool,
|
||||||
|
) -> crate::error::Result<usize> {
|
||||||
|
let need = count as usize * 2048;
|
||||||
|
for i in 0..count as u32 {
|
||||||
|
let off = i as usize * 2048;
|
||||||
|
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
|
||||||
|
buf[off..off + 2048].copy_from_slice(&s);
|
||||||
|
}
|
||||||
|
Ok(need)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file's placement: ICB metadata LBA, data-extent LBA, byte length,
|
||||||
|
/// Long-AD (16-byte, real BD-ROM layout) vs Short-AD, optional contents.
|
||||||
|
pub(crate) struct FileSpec {
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) icb_lba: u32,
|
||||||
|
pub(crate) data_lba: u32,
|
||||||
|
pub(crate) size: u32,
|
||||||
|
pub(crate) long_ad: bool,
|
||||||
|
pub(crate) contents: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory node: ICB LBA, FID-list LBA, child files and subdirectories.
|
||||||
|
pub(crate) struct DirSpec {
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) icb_lba: u32,
|
||||||
|
pub(crate) dir_data_lba: u32,
|
||||||
|
pub(crate) files: Vec<FileSpec>,
|
||||||
|
pub(crate) subdirs: Vec<DirSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an Extended File Entry ICB (tag 266) with one allocation descriptor.
|
||||||
|
pub(crate) fn build_file_icb(size: u32, data_lba: u32, long_ad: bool) -> [u8; 2048] {
|
||||||
|
let mut s = [0u8; 2048];
|
||||||
|
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
|
||||||
|
if long_ad {
|
||||||
|
s[34..36].copy_from_slice(&1u16.to_le_bytes()); // ICB flags → Long AD
|
||||||
|
}
|
||||||
|
s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length
|
||||||
|
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
|
||||||
|
let ad_size: u32 = if long_ad { 16 } else { 8 };
|
||||||
|
s[212..216].copy_from_slice(&ad_size.to_le_bytes()); // l_ad
|
||||||
|
s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes());
|
||||||
|
s[220..224].copy_from_slice(&data_lba.to_le_bytes());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_dir_icb(dir_data_lba: u32, dir_data_len: u32) -> [u8; 2048] {
|
||||||
|
build_file_icb(dir_data_len, dir_data_lba, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one File Identifier Descriptor (tag 257) to `buf`.
|
||||||
|
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
||||||
|
let start = buf.len();
|
||||||
|
let name_field: Vec<u8> = if is_parent {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
let mut v = vec![0x08u8];
|
||||||
|
v.extend_from_slice(name.as_bytes());
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let l_fi = name_field.len();
|
||||||
|
let mut fid = vec![0u8; 38];
|
||||||
|
fid[0..2].copy_from_slice(&257u16.to_le_bytes()); // FID tag
|
||||||
|
let mut file_chars = 0u8;
|
||||||
|
if is_dir {
|
||||||
|
file_chars |= 0x02;
|
||||||
|
}
|
||||||
|
if is_parent {
|
||||||
|
file_chars |= 0x08;
|
||||||
|
}
|
||||||
|
fid[18] = file_chars;
|
||||||
|
fid[19] = l_fi as u8;
|
||||||
|
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes()); // ICB long_ad LBA @24
|
||||||
|
fid[36..38].copy_from_slice(&0u16.to_le_bytes()); // l_iu @36
|
||||||
|
buf.extend_from_slice(&fid);
|
||||||
|
buf.extend_from_slice(&name_field);
|
||||||
|
let used = buf.len() - start;
|
||||||
|
let pad = (used + 3) & !3;
|
||||||
|
buf.resize(start + pad, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively lay a [`DirSpec`] into the [`MemDisc`].
|
||||||
|
pub(crate) fn lay_dir(disc: &mut MemDisc, dir: &DirSpec) {
|
||||||
|
let mut fids = Vec::new();
|
||||||
|
push_fid(&mut fids, "", dir.icb_lba, true, true);
|
||||||
|
for f in &dir.files {
|
||||||
|
push_fid(&mut fids, &f.name, f.icb_lba, false, false);
|
||||||
|
disc.put(
|
||||||
|
PART_START + f.icb_lba,
|
||||||
|
build_file_icb(f.size, f.data_lba, f.long_ad),
|
||||||
|
);
|
||||||
|
if !f.contents.is_empty() {
|
||||||
|
disc.put_bytes(PART_START + f.data_lba, &f.contents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sub in &dir.subdirs {
|
||||||
|
push_fid(&mut fids, &sub.name, sub.icb_lba, true, false);
|
||||||
|
}
|
||||||
|
disc.put(
|
||||||
|
PART_START + dir.icb_lba,
|
||||||
|
build_dir_icb(dir.dir_data_lba, fids.len() as u32),
|
||||||
|
);
|
||||||
|
disc.put_bytes(PART_START + dir.dir_data_lba, &fids);
|
||||||
|
for sub in &dir.subdirs {
|
||||||
|
lay_dir(disc, sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the static UDF anchor/VDS/FSD so `read_filesystem` reaches
|
||||||
|
/// `root_icb_lba` (single partition map → metadata_start == PART_START).
|
||||||
|
pub(crate) fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) {
|
||||||
|
let mut avdp = [0u8; 2048];
|
||||||
|
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
|
||||||
|
disc.put(256, avdp);
|
||||||
|
|
||||||
|
let mut pd = [0u8; 2048];
|
||||||
|
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
|
||||||
|
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
|
||||||
|
disc.put(32, pd);
|
||||||
|
|
||||||
|
let mut lvd = [0u8; 2048];
|
||||||
|
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
|
||||||
|
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
|
||||||
|
disc.put(33, lvd);
|
||||||
|
|
||||||
|
let mut td = [0u8; 2048];
|
||||||
|
td[0..2].copy_from_slice(&8u16.to_le_bytes());
|
||||||
|
disc.put(34, td);
|
||||||
|
|
||||||
|
let mut fsd = [0u8; 2048];
|
||||||
|
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
|
||||||
|
fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes());
|
||||||
|
disc.put(PART_START, fsd);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn file(
|
||||||
|
name: &str,
|
||||||
|
icb_lba: u32,
|
||||||
|
data_lba: u32,
|
||||||
|
size: u32,
|
||||||
|
long_ad: bool,
|
||||||
|
) -> FileSpec {
|
||||||
|
FileSpec {
|
||||||
|
name: name.to_string(),
|
||||||
|
icb_lba,
|
||||||
|
data_lba,
|
||||||
|
size,
|
||||||
|
long_ad,
|
||||||
|
contents: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn file_with(
|
||||||
|
name: &str,
|
||||||
|
icb_lba: u32,
|
||||||
|
data_lba: u32,
|
||||||
|
contents: Vec<u8>,
|
||||||
|
long_ad: bool,
|
||||||
|
) -> FileSpec {
|
||||||
|
FileSpec {
|
||||||
|
name: name.to_string(),
|
||||||
|
icb_lba,
|
||||||
|
data_lba,
|
||||||
|
size: contents.len() as u32,
|
||||||
|
long_ad,
|
||||||
|
contents,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+55
-11
@@ -9,6 +9,7 @@ use freemkv_unlock as fu;
|
|||||||
fn to_fu_drive_id(drive_id: &crate::identity::DriveId) -> fu::DriveId {
|
fn to_fu_drive_id(drive_id: &crate::identity::DriveId) -> fu::DriveId {
|
||||||
fu::DriveId {
|
fu::DriveId {
|
||||||
vendor_id: drive_id.vendor_id.clone(),
|
vendor_id: drive_id.vendor_id.clone(),
|
||||||
|
product_id: drive_id.product_id.clone(),
|
||||||
product_revision: drive_id.product_revision.clone(),
|
product_revision: drive_id.product_revision.clone(),
|
||||||
vendor_specific: drive_id.vendor_specific.clone(),
|
vendor_specific: drive_id.vendor_specific.clone(),
|
||||||
firmware_date: drive_id.firmware_date.clone(),
|
firmware_date: drive_id.firmware_date.clone(),
|
||||||
@@ -101,27 +102,70 @@ pub(crate) fn map_host_certs(certs: &[crate::aacs::types::HostCert]) -> Vec<fu::
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// News up the unlockers, build the context for `kind`, and run the FIRST
|
/// Result of a capability dispatch: `(matched_name, result)`. `matched_name` is
|
||||||
/// matching one — returning its `Result` so the caller can both consume what it
|
/// the unlocker that handled it (or `""` if none did) — lets the caller record
|
||||||
/// learned (vid / bus_key / drive_unlocked) AND render the specific failure
|
/// WHICH unlocker ran (e.g. `LibreDrive` vs `Renesas`), distinct from the ld-only
|
||||||
/// (the AACS cert path maps the `UnlockError` to its outcome trace). `Err(
|
/// identity lookup [`unlocker_name`]. Iterating stops at the first unlocker whose
|
||||||
/// NotApplicable)` when nothing matched. `host_certs` are collected by the
|
/// capability method returns anything other than `NotApplicable` — i.e. an actual
|
||||||
/// caller — lazily, only for AACS; pass `&[]` for the drive-prep / CSS kinds.
|
/// unlock (`Ok`) OR a real failure such as a dead bus (`Err(Transport)`), which
|
||||||
pub(crate) fn run_unlockers(
|
/// the caller must surface rather than skip.
|
||||||
|
type Dispatch = (
|
||||||
|
&'static str,
|
||||||
|
std::result::Result<fu::Unlocked, fu::UnlockError>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Try each unlocker's `capability` (`unlock_features` or `unlock_bus`) in
|
||||||
|
/// registration order, stopping at the first that doesn't decline. Shared by
|
||||||
|
/// [`run_features`] and [`run_bus`].
|
||||||
|
fn dispatch(
|
||||||
scsi: &mut dyn crate::scsi::ScsiTransport,
|
scsi: &mut dyn crate::scsi::ScsiTransport,
|
||||||
drive_id: &crate::identity::DriveId,
|
drive_id: &crate::identity::DriveId,
|
||||||
kind: fu::DiscKind,
|
kind: fu::DiscKind,
|
||||||
host_certs: &[fu::HostCert],
|
host_certs: &[fu::HostCert],
|
||||||
) -> std::result::Result<fu::Unlocked, fu::UnlockError> {
|
capability: impl Fn(
|
||||||
|
&dyn fu::Unlocker,
|
||||||
|
&mut dyn fu::scsi::ScsiTransport,
|
||||||
|
&fu::UnlockCtx,
|
||||||
|
) -> std::result::Result<fu::Unlocked, fu::UnlockError>,
|
||||||
|
) -> Dispatch {
|
||||||
let id = to_fu_drive_id(drive_id);
|
let id = to_fu_drive_id(drive_id);
|
||||||
let ctx = fu::UnlockCtx::new(&id, kind, host_certs);
|
let ctx = fu::UnlockCtx::new(&id, kind, host_certs);
|
||||||
let mut adapter = ScsiAdapter(scsi);
|
let mut adapter = ScsiAdapter(scsi);
|
||||||
for u in fu::all_unlockers() {
|
for u in fu::all_unlockers() {
|
||||||
if u.matches(&ctx) {
|
match capability(u.as_ref(), &mut adapter, &ctx) {
|
||||||
return u.unlock(&mut adapter, &ctx);
|
// This unlocker doesn't provide the capability for this drive/disc —
|
||||||
|
// try the next one.
|
||||||
|
Err(fu::UnlockError::NotApplicable) => continue,
|
||||||
|
// An actual unlock, or a real failure (e.g. Transport) — stop here.
|
||||||
|
other => return (u.name(), other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(fu::UnlockError::NotApplicable)
|
("", Err(fu::UnlockError::NotApplicable))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive-prep: unlock DRIVE FEATURES (riplock/speed, OEM VID). `host_certs` are
|
||||||
|
/// not needed for features — pass `&[]`; `kind` is `Unknown` at drive-prep.
|
||||||
|
pub(crate) fn run_features(
|
||||||
|
scsi: &mut dyn crate::scsi::ScsiTransport,
|
||||||
|
drive_id: &crate::identity::DriveId,
|
||||||
|
) -> Dispatch {
|
||||||
|
dispatch(scsi, drive_id, fu::DiscKind::Unknown, &[], |u, s, c| {
|
||||||
|
u.unlock_features(s, c)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content: remove BUS ENCRYPTION for the mounted disc. Called only when the bus
|
||||||
|
/// isn't already clear (the `oem_vid`/`bus_encryption_removed` gate). `host_certs`
|
||||||
|
/// are the caller-collected certs for the AACS route; `kind` selects Aacs vs Css.
|
||||||
|
pub(crate) fn run_bus(
|
||||||
|
scsi: &mut dyn crate::scsi::ScsiTransport,
|
||||||
|
drive_id: &crate::identity::DriveId,
|
||||||
|
kind: fu::DiscKind,
|
||||||
|
host_certs: &[fu::HostCert],
|
||||||
|
) -> Dispatch {
|
||||||
|
dispatch(scsi, drive_id, kind, host_certs, |u, s, c| {
|
||||||
|
u.unlock_bus(s, c)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The names of every REGISTERED unlocker, in dispatch order. Registry-driven —
|
/// The names of every REGISTERED unlocker, in dispatch order. Registry-driven —
|
||||||
|
|||||||
+67
-61
@@ -131,16 +131,15 @@ fn aacs_decrypt_unit_roundtrip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify it looks encrypted (body TS syncs scrambled)
|
// 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
|
// Now decrypt
|
||||||
let result = aacs::content::decrypt_unit(&mut plain, &unit_key);
|
aacs::content::decrypt_unit(&mut plain, &unit_key);
|
||||||
assert!(
|
assert!(
|
||||||
result,
|
aacs::content::is_clean(&plain, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"decrypt_unit should return true on valid encrypted unit"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!aacs::content::ts_sync_destroyed(&plain),
|
|
||||||
"decrypted unit should read as clear (TS syncs restored)"
|
"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");
|
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]
|
#[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
|
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
||||||
// scrambled. (Flag bits play no role.)
|
// scrambled. (Flag bits play no role.)
|
||||||
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
@@ -270,7 +269,7 @@ fn aacs_ts_sync_destroyed_detection() {
|
|||||||
off += 192;
|
off += 192;
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&clear),
|
aacs::content::is_clean(&clear, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"clear unit (syncs intact) must not be scrambled"
|
"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[0] = 0xC0; // copy-control bits
|
||||||
flagged[7] = 0xC0; // TSC bits
|
flagged[7] = 0xC0; // TSC bits
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&flagged),
|
aacs::content::is_clean(&flagged, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"flag bits must not be read as encryption"
|
"flag bits must not be read as encryption"
|
||||||
);
|
);
|
||||||
|
|
||||||
// A scrambled body (syncs destroyed) → scrambled.
|
// A scrambled body (syncs destroyed) → scrambled.
|
||||||
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
assert!(
|
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"
|
"unit with no intact TS syncs must read as scrambled"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Too short
|
// Too short
|
||||||
let short = vec![0xFFu8; 100];
|
let short = vec![0xFFu8; 100];
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&short),
|
aacs::content::is_clean(&short, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"short buffer should not be detected"
|
"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]
|
#[test]
|
||||||
fn aacs_decrypt_unit_unencrypted_passthrough() {
|
fn aacs_clear_unit_reports_not_encrypted() {
|
||||||
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN];
|
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;
|
let mut off = 4;
|
||||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||||
unit[off] = 0x47;
|
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.
|
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
|
||||||
unit[0] &= 0x3F;
|
unit[0] &= 0x3F;
|
||||||
let original = unit.clone();
|
|
||||||
let key = [0xAA; 16];
|
|
||||||
|
|
||||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
assert!(aacs::content::is_clean(
|
||||||
let result = aacs::content::decrypt_unit(&mut unit, &key);
|
&unit,
|
||||||
assert!(result, "clear unit should return true");
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
assert_eq!(unit, original, "clear unit should be unchanged");
|
));
|
||||||
|
assert!(
|
||||||
|
!aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
|
"CPI-clear unit reports not-encrypted; the caller never decrypts it"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AACS cross-validation with independent AES implementation ──────────────
|
// ── AACS cross-validation with independent AES implementation ──────────────
|
||||||
@@ -413,11 +417,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// -- Decrypt with the library --
|
// -- Decrypt with the library --
|
||||||
let ok = aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
||||||
assert!(
|
|
||||||
ok,
|
|
||||||
"decrypt_unit returned false (TS sync verification failed)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -458,44 +458,14 @@ fn aacs_cross_validation_alternate_key() {
|
|||||||
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
|
&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.
|
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||||
assert_eq!(&plaintext[..], &expected[..]);
|
assert_eq!(&plaintext[..], &expected[..]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied
|
// (`decrypt_bus` is a crate-internal layer — its cross-validation lives in-crate
|
||||||
/// per-sector to bytes 16..2048 (bus encryption layer).
|
// in `aacs::content`'s unit tests, not here.)
|
||||||
#[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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CSS roundtrip test vectors ─────────────────────────────────────────────
|
// ── 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 \
|
This is expected: synthetic sectors lack the TAB1 output encoding \
|
||||||
present in real CSS-encrypted DVD sectors."
|
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.",
|
The LFSR0 recovery phase may not converge for this combination.",
|
||||||
title_key, seed
|
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
@@ -27,21 +27,39 @@ fn decrypt_sectors_with_aacs_keys_works() {
|
|||||||
|
|
||||||
let unit_key: [u8; 16] = [0xAAu8; 16];
|
let unit_key: [u8; 16] = [0xAAu8; 16];
|
||||||
|
|
||||||
// Encrypt the unit using AACS algorithm
|
// Apply the key to the pattern to produce ciphertext-shaped bytes for the
|
||||||
aacs::content::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
|
// 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 aacs_keys = DecryptKeys::Aacs {
|
||||||
let mut keys = DecryptKeys::Aacs {
|
|
||||||
unit_keys: vec![(0u32, unit_key)],
|
unit_keys: vec![(0u32, unit_key)],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: libfreemkv::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
|
let mut none_keys = DecryptKeys::None;
|
||||||
|
|
||||||
// decrypt_sectors should handle this without error
|
// The regression this guards is passing `DecryptKeys::None` where AACS keys
|
||||||
let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &mut keys, 0);
|
// 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!(
|
assert_ne!(
|
||||||
result.is_ok(),
|
with_aacs, unit,
|
||||||
"decrypt_sectors with AACS keys should not error"
|
"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;
|
off += 192;
|
||||||
}
|
}
|
||||||
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
// 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.
|
// Flag bits on a synced unit do not make it look encrypted.
|
||||||
unit[0] = 0xC0;
|
unit[0] = 0xC0;
|
||||||
unit[7] = 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.
|
// Scrambled body (syncs gone) → encrypted.
|
||||||
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
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.
|
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
||||||
@@ -111,6 +138,7 @@ fn decrypt_keys_is_encrypted_variants() {
|
|||||||
let aacs = DecryptKeys::Aacs {
|
let aacs = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![],
|
unit_keys: vec![],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: libfreemkv::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
assert!(aacs.is_encrypted());
|
assert!(aacs.is_encrypted());
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ fn patch_block_sectors_zero_does_not_busy_spin() {
|
|||||||
progress: None,
|
progress: None,
|
||||||
halt: Some(halt.clone()),
|
halt: Some(halt.clone()),
|
||||||
key_fetch: None,
|
key_fetch: None,
|
||||||
fast_capture: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let outcome = disc.patch(&mut reader, &iso_path, &opts);
|
let outcome = disc.patch(&mut reader, &iso_path, &opts);
|
||||||
|
|||||||
+187
-29
@@ -232,8 +232,6 @@ struct Golden {
|
|||||||
bytes_unreadable: u64,
|
bytes_unreadable: u64,
|
||||||
/// `bytes_pending` (NonTrimmed) at end.
|
/// `bytes_pending` (NonTrimmed) at end.
|
||||||
bytes_pending: u64,
|
bytes_pending: u64,
|
||||||
/// Did the pass exit via wedge-detection?
|
|
||||||
wedged_exit: bool,
|
|
||||||
/// Sanity bound on trace length — patch makes a finite number of
|
/// Sanity bound on trace length — patch makes a finite number of
|
||||||
/// reads bounded by `MAX_SKIPS_PER_RANGE * range_sectors` plus
|
/// reads bounded by `MAX_SKIPS_PER_RANGE * range_sectors` plus
|
||||||
/// retries. Asserted as an UPPER bound only (so any reduction in
|
/// 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_good: capacity_sectors as u64 * 2048,
|
||||||
bytes_unreadable: 0,
|
bytes_unreadable: 0,
|
||||||
bytes_pending: 0,
|
bytes_pending: 0,
|
||||||
wedged_exit: false,
|
|
||||||
max_reads: 8, // adaptive batch=32 reads finishes 16 sectors in 1 read; allow up to 8.
|
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");
|
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),
|
// Sense-family error paths in `Disc::patch`: NOT_READY-then-recover,
|
||||||
// ILLEGAL_REQUEST (wedge), and ABORTED_COMMAND profiles. Each would
|
// HARDWARE_ERROR, ILLEGAL_REQUEST, and ABORTED_COMMAND.
|
||||||
// trigger long real-time sleeps inside `handle_read_failure`:
|
|
||||||
//
|
//
|
||||||
// - NOT_READY (sense_key=0x02, asc=0x02/0x03/0x04): 15 s pause per
|
// These drive `disc.patch(...)` DIRECTLY rather than through `run_profile`
|
||||||
// occurrence (`patch_not_ready_pause`), and retries the same LBA
|
// (which drives `Disc::copy`, whose SWEEP path really sleeps on NOT_READY /
|
||||||
// in-place. Even one NOT_READY costs the test 15 s wall-time.
|
// 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
|
// The load-bearing invariant asserted across every PERSISTENT failure sense is
|
||||||
// (`WEDGE_FAMILY_COOLDOWN_SECS`), bounded by
|
// the recovery contract: a patch pass NEVER promotes a sector to Unreadable
|
||||||
// `WEDGE_ABORT_THRESHOLD=16` before wedged-exit. Worst case ~8
|
// (the orchestrator does that only after the final pass) and NEVER silently
|
||||||
// minutes per profile.
|
// drops bytes — a still-bad sector stays NonTrimmed (pending), so
|
||||||
//
|
// good + pending always conserves the total. Exact good/pending splits are
|
||||||
// The sleeps are not injectable. Adding them would require either a
|
// left loose so wedge-skip tuning can't spuriously fail these.
|
||||||
// `now()` / `sleep()` trait injection (out of scope for the unification
|
|
||||||
// task) or a "test mode" compile-time flag (architectural smell). The
|
/// Run a single-always-bad-sector (LBA 130, inside a NonTrimmed [128,192)
|
||||||
// behavioural contracts for those paths are captured in
|
/// range) patch pass with the given failure step and return the final map
|
||||||
// `read_error.rs`'s in-module tests instead — they exercise the
|
/// stats. 256-sector synthetic disc; everything outside the range is Finished.
|
||||||
// classifier without invoking the patch loop's sleep side-effects.
|
fn single_dead_sector_patch_stats(step: ScriptStep) -> libfreemkv::disc::mapfile::MapStats {
|
||||||
//
|
let capacity_sectors: u32 = 256;
|
||||||
// If the unification ever proceeds, the next step is to add a clock
|
let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);
|
||||||
// injection point in `handle_read_failure` and extend this fixture
|
reader.always(130, step);
|
||||||
// with the wedge/NOT_READY profiles too.
|
|
||||||
|
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 ────────
|
// ──────── 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
|
// 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 —
|
// 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
|
// 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
|
// 32-block untouched. (The old `fast_capture` knob was removed: the handler
|
||||||
// breadth-first "fast on all ranges, then escalate" ORDERING it once provided is
|
// chain supersedes it. The breadth-first "fast on all ranges, then escalate"
|
||||||
// a scheduling concern for the handler scheduler, tracked separately.)
|
// 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
|
// The load-bearing invariant is unchanged: NO data is dropped. A still-bad
|
||||||
// sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable.
|
// 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,
|
progress: None,
|
||||||
halt: None,
|
halt: None,
|
||||||
key_fetch: None,
|
key_fetch: None,
|
||||||
fast_capture: true,
|
|
||||||
};
|
};
|
||||||
disc.patch(&mut reader, &iso_path, &opts)
|
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 map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
||||||
let stats = Mapfile::load(&map_path).unwrap().stats();
|
let stats = Mapfile::load(&map_path).unwrap().stats();
|
||||||
|
|||||||
Reference in New Issue
Block a user