Compare commits
189
Commits
6b0bcbb43f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
767d205fd4 | ||
|
|
bbb5a953f1 | ||
|
|
3f238ab3dc | ||
|
|
ec62618bde | ||
|
|
15111d544b | ||
|
|
19b646e012 | ||
|
|
17694af625 | ||
|
|
828f5c0192 | ||
|
|
075223a4bd | ||
|
|
94ac4a442a | ||
|
|
f468237279 | ||
|
|
227545fabc | ||
|
|
05fd7fcbfa | ||
|
|
967d0ac77e | ||
|
|
eb0aa3556e | ||
|
|
7ba43d9c02 | ||
|
|
bf9a69ec80 | ||
|
|
6d6e60fdf8 | ||
|
|
661ab138c6 | ||
|
|
c00384d4df | ||
|
|
e8c151792d | ||
|
|
20b36229a5 | ||
|
|
71aad385c7 | ||
|
|
ec5b10f83a | ||
|
|
5181f6ce19 | ||
|
|
a711ee1d00 | ||
|
|
f2b7cc9bdd | ||
|
|
6f53767e8b | ||
|
|
7ed798e386 | ||
|
|
b9568242df | ||
|
|
8ac18fa631 | ||
|
|
197489fb7c | ||
|
|
dc7dfc6041 | ||
|
|
0acb326079 | ||
|
|
e632874665 | ||
|
|
88e58bfc95 | ||
|
|
279ba0dd7c | ||
|
|
1eb6910bdb | ||
|
|
e380e3b7c8 | ||
|
|
ff349fa61b | ||
|
|
52fd0f733a | ||
|
|
5b03fd8ebc | ||
|
|
c635190b0d | ||
|
|
34c5293704 | ||
|
|
bb59166e48 | ||
|
|
ea047b57d6 | ||
|
|
909fe48628 | ||
|
|
da19280950 | ||
|
|
2274423a6f | ||
|
|
c1f1593003 | ||
|
|
6718c9cdb2 | ||
|
|
9fdd5edb65 | ||
|
|
8a5a26f2a5 | ||
|
|
97ce0b7fab | ||
|
|
e194ef1585 | ||
|
|
4d1b922232 | ||
|
|
3841ae2250 | ||
|
|
2ccb5c9d01 | ||
|
|
b2bd5f8b3e | ||
|
|
6f055394c7 | ||
|
|
98f3dc513f | ||
|
|
5ecfe7c69a | ||
|
|
f255361683 | ||
|
|
c6e6bb9f4b | ||
|
|
f7edd4e6a9 | ||
|
|
a947439171 | ||
|
|
8e6114cd2a | ||
|
|
8f55cb78d2 | ||
|
|
65e14fe3b7 | ||
|
|
1e3610fd75 | ||
|
|
0c9d375548 | ||
|
|
8aff7fe708 | ||
|
|
489545c865 | ||
|
|
281d8baed6 | ||
|
|
6a4ac97a33 | ||
|
|
a8563e9fa3 | ||
|
|
63f6909ff0 | ||
|
|
9f33306a0a | ||
|
|
43cdc1351d | ||
|
|
5728a7c577 | ||
|
|
f85d91a17a | ||
|
|
a688e2c642 | ||
|
|
05fb632d7c | ||
|
|
3cb0a8f41c | ||
|
|
9dbfb70f7e | ||
|
|
9af3f7da7a | ||
|
|
2638c3075e | ||
|
|
3661942bdb | ||
|
|
43c1f9bda0 | ||
|
|
37832ac2dd | ||
|
|
2263d2cc4e | ||
|
|
3546648faa | ||
|
|
98000869b2 | ||
|
|
e308c5b825 | ||
|
|
5e1f880f6e | ||
|
|
ffe8ee8684 | ||
|
|
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 | ||
|
|
122a03b23d | ||
|
|
dcc553a716 | ||
|
|
3b06a4c844 | ||
|
|
f9d112e481 | ||
|
|
f4fe651cb9 | ||
|
|
5ff04649ba | ||
|
|
84aaceceb7 | ||
|
|
cdee9739fd | ||
|
|
f55f11d043 | ||
|
|
f55d8f7acd | ||
|
|
31b0ba323a | ||
|
|
188baced39 | ||
|
|
bce11a2de0 | ||
|
|
7a79577343 | ||
|
|
f07251c2d4 | ||
|
|
eb23ab4586 | ||
|
|
61b6070f03 | ||
|
|
125a8e5bf0 | ||
|
|
741c1ea11d | ||
|
|
e713b26b87 | ||
|
|
fcbd667add | ||
|
|
88c03152e2 | ||
|
|
c8e7ad5e56 | ||
|
|
f122f08628 | ||
|
|
b2c3540989 | ||
|
|
3a4307def6 | ||
|
|
917026d566 |
@@ -39,29 +39,11 @@ jobs:
|
||||
# would always fail (no lockfile to lock against on a fresh runner).
|
||||
- run: cargo test
|
||||
|
||||
# crates.io publish is an INDEPENDENT job: it serves EXTERNAL consumers only.
|
||||
# The freemkv binaries no longer depend on it (they git-tag-pin libfreemkv via
|
||||
# a committed [patch.crates-io]), so this publish runs in parallel with their
|
||||
# release builds rather than gating them. It `needs: [verify, test]` so a
|
||||
# failing test suite still blocks publication to crates.io — external
|
||||
# consumers who `cargo add libfreemkv` must never receive a release whose
|
||||
# tests were failing. (The two upstream jobs run in parallel, so this gate
|
||||
# does not serialize publish behind test beyond their own completion.)
|
||||
publish:
|
||||
needs: [verify, test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# --no-verify: CI already compiled this exact commit (in the `test` job
|
||||
# and on every push via ci.yml). cargo publish's default re-verify does a
|
||||
# full cold release build of the packaged tarball, which here is pure
|
||||
# redundant work (~a cold lib build). Skip it.
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish --no-verify
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
# NOTE: there is no crates.io publish job. libfreemkv is git-tag-only
|
||||
# (`package.publish = false` — it git-deps the firmware crate freemkv-unlock,
|
||||
# which never ships to crates.io). Every consumer git-tag-pins libfreemkv via
|
||||
# a committed [patch.crates-io]; the git tag itself IS the release artifact.
|
||||
# A `cargo publish` here fails hard on `publish = false`, so it was removed.
|
||||
|
||||
release:
|
||||
# Only needs `verify`; the GitHub Release can be cut as soon as the version
|
||||
|
||||
+564
-4
@@ -1,5 +1,541 @@
|
||||
# Changelog
|
||||
|
||||
## [1.6.0] — UNRELEASED
|
||||
|
||||
### Added
|
||||
|
||||
- **High-level orchestration API — a single mux driver and a disc session.**
|
||||
`mux_stream` drives the whole read → decrypt → demux → write pipeline for any
|
||||
source (`MuxInput::Url` / `Iso` / `Session` / `Live`), so consumers stop
|
||||
hand-rolling the frame pump. `DiscSession` hoists drive open + SCSI bring-up +
|
||||
scan + key resolution behind one type; `scan_iso` does the same for a
|
||||
file-backed ISO; `resolve_keys` / `resolve_keys_for` resolve base AACS keys.
|
||||
These let the CLI and autorip shrink to thin front-ends (and back the new
|
||||
`freemkv-engine` crate).
|
||||
- **Per-title stream selection (`StreamSelection`).** A pure primitive that
|
||||
prunes a `DiscTitle`'s audio/subtitle streams to a chosen set of PIDs (video
|
||||
is always kept) before the mux builds its demux state — so track headers,
|
||||
`codec_privates`, and frame routing all follow the pruned list, with no
|
||||
demux-internal filter. Carried on `MuxOptions.selection` /
|
||||
`InputOptions.selection` (both default to keep-everything, a no-op).
|
||||
Languages are the caller's concern; the library speaks PIDs.
|
||||
- `DiscTitle::audio_streams()` / `subtitle_streams()` / `video_streams()` —
|
||||
typed iterators over each stream class.
|
||||
- `MuxOptions` gains a per-call write-pipeline `send_deadline` and derives
|
||||
`Default`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The recovery strategy moved to the new `freemkv-engine` crate.** Sweep,
|
||||
patch, the retry-decision state machine, mapfile bookkeeping, and damage
|
||||
classification are freemkv's specific recovery *philosophy*, not disc-access
|
||||
primitives — they now live in `freemkv-engine`, which composes libfreemkv's
|
||||
public API. The library keeps the raw single-shot read, SCSI-sense-fact
|
||||
translation (`SenseFamily`, now in `scsi`), decrypt, and the mux highway.
|
||||
- Small deliberate `pub` promotions to support the engine as an external
|
||||
consumer: `Disc::resolve_content_key_map` / `encrypted_content_ranges`,
|
||||
`io::WritebackFile`, `drive::extract_scsi_context`, `disc::locate_ranges`.
|
||||
- New typed error classifiers re-exported at the crate root — `is_halt`,
|
||||
`is_skippable_title_stub`, `is_disc_level_no_key` — and a new error variant
|
||||
`SelectionPidUnknown` (E6014).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mux correctness pass** (the `v1.4.0..HEAD` 10-phase audit): DTS core-header
|
||||
false-drops that dropped good DTS frames; the TrueHD channel-correction probe
|
||||
now runs correctly on AACS discs (7.1/Atmos no longer understated as 5.1);
|
||||
an FMTS phase-probe read fault is distinguished from a wrong key; multi-CPS
|
||||
and orphan-clip keying; the AACS key map is now a *positive* map (a sector
|
||||
with no key passes through rather than failing), with fail-loud on genuinely
|
||||
unresolvable keys; a user Stop mid-read is reported as `completed = false`
|
||||
(a stop is not a failure), not a spurious error.
|
||||
|
||||
## [1.5.2] — 2026-07-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- TrueHD 7.1/Atmos channel correction now works on AACS-encrypted (Blu-ray/UHD)
|
||||
discs. The channel-correction probe was built without an AACS key map, so on
|
||||
every AACS disc its first read failed and the correction was silently skipped —
|
||||
a 7.1/Atmos TrueHD track was muxed with its MPLS-declared channel count (often
|
||||
understated 5.1). The probe now resolves and installs the same key map the mux
|
||||
read uses.
|
||||
- AACS 2.1 (FMTS) discs: a non-forensic title (menu/extras playlist, or any clip
|
||||
that carries no forensic segments) no longer hard-fails the rip. `resolve_fmts_key_map`
|
||||
now filters segments to those addressable within the title and falls back to the
|
||||
base Unit-Key map when none apply — previously the first non-forensic title
|
||||
aborted the whole-disc decrypt and blocked muxing any non-main title. A
|
||||
forensic phase probe whose sampled units are all source-zero padding (an
|
||||
even/odd tie) no longer aborts the rip either.
|
||||
- Multi-CPS AACS `dir://` extraction now decrypts each clip with its own CPS-unit
|
||||
key instead of keying the whole disc with unit key 0 (which silently wrote
|
||||
secondary-CPS files as garbage). A missing key fails loud at resolve. Single-CPS
|
||||
extraction is unchanged (one key opens every unit, orphan clips included).
|
||||
- A trailing partial aligned unit that is inside a mapped range AND flagged
|
||||
encrypted now fails loud (a CBC fragment split across a boundary cannot be
|
||||
decrypted) instead of being emitted as ciphertext-as-clear.
|
||||
- CSS DVDs no longer mux to garbage. Every DVD read path — the file-backed mux
|
||||
highway (`build_iso_pipeline`) and the live-drive single-pass `DiscStream` —
|
||||
now resolves the per-VTS title key at read time through one shared step
|
||||
(`resolve_dvd_title_key`), cracked keylessly in playback order from the title's
|
||||
own extents. An uncrackable title hard-fails (E7023) instead of passing
|
||||
scrambled sectors through as plaintext; `--raw` skips the crack entirely; a
|
||||
user Stop mid-crack surfaces as `Halted`.
|
||||
|
||||
### Changed
|
||||
|
||||
- DVD scan no longer cracks a title key up front (the key is per-VTS, so a single
|
||||
disc key was meaningless). Scan does only the CSS bus-auth read-unlock — hoisted
|
||||
before the UDF prefetch so scrambled small/menu VOBs no longer cost a rejected
|
||||
read each. Cuts a CSS-DVD scan from ~25s to ~6s.
|
||||
- Unlocker report: the DVD entry is renamed `CSS` → `DVD`.
|
||||
|
||||
## [1.5.1] — 2026-07-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TrueHD audio is no longer silently dropped (and no longer sends decoders out
|
||||
of memory).** The previous release added an MLP major-sync checksum gate to drop
|
||||
genuinely-undecodable audio frames, but the checksum was computed with mismatched
|
||||
byte order — the 16-bit CRC result was folded in one endianness and compared in
|
||||
the other — so it never validated a real major sync. The parser then judged every
|
||||
major sync corrupt and dropped every audio frame from the first one onward,
|
||||
flushing the whole TrueHD track at the end of the file: de-interleaved from the
|
||||
video, which sent players and integrity checkers into an unbounded memory spiral
|
||||
("decoder ran out of memory"). The checksum now matches the reference
|
||||
implementation byte-exact (cross-verified against real 7.1/Atmos and 5.1 discs),
|
||||
so major syncs validate and only genuinely-corrupt frames are dropped; as a
|
||||
safety net, a major sync the parser still can't validate is kept rather than
|
||||
allowed to drop an entire track. TrueHD titles produced after the checksum gate
|
||||
landed need a re-rip.
|
||||
- **HD DVD AACS key files are now found on every disc, not just the common
|
||||
layout.** The AACS directory and title-key filename on HD DVD are chosen by
|
||||
the authoring house, and freemkv previously assumed one fixed spelling
|
||||
(`/ANY!/VTKF000.AACS`). Discs that name their AACS directory differently (e.g.
|
||||
`AAC!` instead of `ANY!`) or ship numbered title-key files (`VTKF090.AACS` /
|
||||
`VTKF100.AACS` rather than `VTKF000.AACS`) are now handled: the AACS directory
|
||||
is located by its contents and every title-key file in it is picked up. Blu-ray
|
||||
and UHD are unaffected.
|
||||
- **HD DVD multi-title decryption reads the right keys.** The HD DVD title-key
|
||||
file (`VTKF*.AACS`) stores its keys in 36-byte records — per the AACS HD DVD
|
||||
specification, and confirmed byte-exact on real discs. freemkv had been reading
|
||||
them at a 32-byte stride, which lands the first key correctly but drifts off
|
||||
every key after it, so only single-title discs decrypted. Discs with more than
|
||||
one protected title now recover every title's key instead of only the first.
|
||||
(Choosing the correct title-key file when a disc carries several playlists
|
||||
still needs verification against an encrypted HD DVD.)
|
||||
- **A dirty disc can no longer "rip clean" but decode with errors.** freemkv now
|
||||
asks the drive to *report* marginal reads instead of silently returning
|
||||
best-effort data as success — on smudged/scratched media a drive can hand back
|
||||
subtly-wrong bytes with a clean status, which used to slip through the rip and
|
||||
surface only as playback/decode errors. A read the drive had to fight for is
|
||||
now distrusted and re-read in the patch pass: a clean re-read wins, and a spot
|
||||
that's genuinely unreadable becomes an honest gap rather than silently-wrong
|
||||
data. Best-effort per drive, and it changes nothing on a clean disc.
|
||||
|
||||
## [1.5.0] — 2026-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- **MP4 as a source (`mp4://`)** — read a progressive `.mp4` back in and send it to
|
||||
any sink (`mp4:// mkv://`, `mp4:// audio://`, `mp4:// json://`, …). The round-trip
|
||||
is frame-exact.
|
||||
- **Native MP4 output (`mp4://`)** — a disc goes straight to a play-everywhere
|
||||
`.mp4` in one decrypt pass, no ffmpeg. Carries HEVC / H.264 video (with HDR10) and
|
||||
AC-3, E-AC-3, and DTS / DTS-HD audio, and is faststart by default so it plays over
|
||||
HTTP without downloading the end first. It's a **compatibility export, not
|
||||
archival**: MP4 can't hold TrueHD, LPCM, or bitmap (PGS / VobSub) subtitles, so
|
||||
those are **excluded with a loud, itemized report — never a silent drop**
|
||||
(`mkv://` stays the keep-everything path).
|
||||
- **Five extraction sinks — dissect a title, don't just rip it.** New destinations
|
||||
that pull one part of a title out on its own:
|
||||
- **`video://dir/`** — each video track to its own native elementary-stream file.
|
||||
- **`audio://dir/`** — each audio track to its own file in its native container
|
||||
(`.thd`, `.dts`, `.ac3`, `.eac3`, `.aac`, `.flac`; LPCM as `.pcm`).
|
||||
- **`sub://dir/`** — each subtitle track to its own file (PGS `.sup`, VobSub
|
||||
`.idx` + `.sub`, text `.srt`).
|
||||
- **`chapters://file`** — a title's chapter markers as a sidecar (`.xml` / `.txt`
|
||||
/ `.ogm` / `.vtt`).
|
||||
- **`json://file`** — a title's complete structure as JSON.
|
||||
|
||||
`chapters://` and `json://` read nothing of the elementary streams, so they
|
||||
return in seconds.
|
||||
- **Damaged audio frames are dropped instead of shipped as glitches.** When a
|
||||
source disc has a corrupt audio frame, freemkv drops that frame rather than muxing
|
||||
it as a decoder-choking glitch — keeping A/V in sync (a drop is a silence gap,
|
||||
never a shift) and logging every drop. Works across DTS, AC-3 / E-AC-3, FLAC,
|
||||
MP2 / MP3, AAC, and TrueHD, each using the format's own integrity check; a track
|
||||
that is mostly undecodable is dropped whole. This catches structurally-broken
|
||||
frames — corruption of the audio *data* inside an otherwise-valid frame is source
|
||||
damage that can't be told from good data without decoding.
|
||||
- **Forced subtitles detected from the stream.** A PGS subtitle track is flagged
|
||||
forced when its content is entirely forced/narrative subtitles, read from the
|
||||
stream itself rather than the disc's metadata — so it works on discs that carry
|
||||
none. `info -v` reports the same, so `info` and a rip agree.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`json://` emits the complete title model** — every field the scan resolved:
|
||||
video resolution / frame rate / HDR / colour, audio channel layout / sample rate /
|
||||
language / purpose, the subtitle forced flag, plus the clip list and chapter names.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TrueHD: a couple of transient errors no longer discard a whole track.** A
|
||||
corrupt access unit is dropped forward to the next clean sync point, but a short
|
||||
burst of damage no longer trips the whole-track drop, and a corrupt sync point can
|
||||
no longer shift the audio that follows.
|
||||
- **Free-format MP2 / MP3** is a legal, decodable mode and is no longer dropped.
|
||||
|
||||
## [1.4.5] — 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **FMTS (AACS 2.1) forensic discs now mux to a clean, single-variant stream.** A
|
||||
forensic segment interleaves the local device group's variant with a foreign
|
||||
group's at the aligned-unit level. The mux decrypted only our half but left the
|
||||
foreign half in the buffer as ciphertext, on the assumption that the demuxer
|
||||
"drops untouched ciphertext cleanly." It does not — a foreign unit's bytes hit a
|
||||
tracked PID at the 192-byte stride, mis-parse, and trip the demux's concealed-gap
|
||||
keyframe resync, which discards good frames of ours around every segment (visible
|
||||
playback glitches). `AacsKeyMap::read_plan` now turns the map into the title's
|
||||
read plan: every default / CPS unit, plus inside a forensic segment **only our
|
||||
phase's units**. The foreign half is never read, decrypted, or handed to the
|
||||
demux. On a retail 4K UHD title this took concealed-gap resyncs from **349 → 0**
|
||||
and recovered ~2 GB of previously-dropped frames. Wired into **both** mux paths —
|
||||
the file-backed highway (`build_iso_pipeline`) and the inline live-drive
|
||||
`DiscStream` (`with_key_map`) — so single- and multi-pass FMTS rips are both clean.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Key-bearing types redact their `Debug` output.** Every type that carries key
|
||||
material (device keys, processing keys, unit keys, media keys, VUKs, resolved
|
||||
chains, CSS/AACS state, …) now prints a `<redacted>` marker instead of the bytes,
|
||||
so no key can reach a log or panic message. Each is covered by a test asserting no
|
||||
key byte appears.
|
||||
- **Hex parsing is centralized and case-insensitive.** A single set of canonical
|
||||
`0x`/`0X`-tolerant hex→integer parsers replaces scattered ad-hoc parsing (this is
|
||||
what silently dropped keydb device keys written with an uppercase `0X` prefix).
|
||||
- **Internal-only public surface narrowed to `pub(crate)`, and duplicate
|
||||
`foo_with_X` methods collapsed to one** — no behavioral change, smaller API.
|
||||
|
||||
## [1.4.4] — 2026-07-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Online key requests are no longer silently dropped on discs that yield few
|
||||
sample units.** The online key source refuses any request carrying fewer than
|
||||
`MIN_SAMPLE_UNITS` (8) encrypted-content samples — too few can match an
|
||||
incidental unit rather than the one asked about (a false positive, most acute on
|
||||
AACS 2.1 forensic-variant content). autorip gathered only 4, so every online
|
||||
lookup was skipped before it ever reached the key service and surfaced to the
|
||||
user as "key service down." autorip's sample count is now tied to
|
||||
`MIN_SAMPLE_UNITS` with a **compile-time floor**, so it can never regress below
|
||||
the minimum again.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The online request is assembled from a proven-sufficient sample set.** New
|
||||
`DecodeSampleSet` (`libfreemkv::keysource`) wraps the content-unit samples and
|
||||
can only be constructed with at least `MIN_SAMPLE_UNITS` of them — so an online
|
||||
key request cannot be built from too few samples. The minimum is validated once,
|
||||
at construction, rather than by a runtime check a caller could forget.
|
||||
|
||||
## [1.4.3] — 2026-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
- **`MIN_SAMPLE_UNITS` moved to the base crate.** The minimum sample count an
|
||||
online key request must carry now has a single definition in
|
||||
`libfreemkv::keysource`; `freemkv-keysources` re-exports it, so the online source
|
||||
and libfreemkv's own forensic query size their requests from one shared value.
|
||||
- **The online unit-key reply is parsed as a list.** A response carries either a
|
||||
single Unit Key (ordinary disc) or the full ordered set (an AACS 2.1
|
||||
forensic-variant disc); the client accepts both and maps array position to
|
||||
forensic index.
|
||||
|
||||
### Added
|
||||
|
||||
- **Forensic-variant online query samples the anchor segment.** On an AACS 2.1
|
||||
forensic-variant disc the online key query draws its sample from the first
|
||||
forensic segment (index 1) — one canonical, deterministic sample — instead of an
|
||||
arbitrary segment.
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- **AACS 2.1 Media Key Variant support.** The Media Key Variant scheme is now
|
||||
detected and parsed from the real MKB record types found on variant discs —
|
||||
`0x2d` (Encrypted Media Key Variant Data), `0x2f` (Variant Key Data table,
|
||||
65,535 × 16), and `0x0c` (variant cvalues, one per subset-difference slot) —
|
||||
replacing the earlier placeholder `0x82`/`0x83` types, which were a guess and
|
||||
appear on no real MKB. The V2.0→V2.1 upgrade detection and fixtures are updated
|
||||
accordingly, so a genuine AACS 2.1 variant disc now resolves.
|
||||
- **`resolve_candidate`** — one composed, pure-derivation boil-down for a
|
||||
candidate key at any ladder rung (DK/PK/MK/VUK → terminal unit keys), parsing
|
||||
`Unit_Key_RO.inf` at the disc's declared AACS version and returning every CPS
|
||||
unit key. Consumers stop re-composing the ladder; every client hardens a single
|
||||
implementation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`mk_from_dk` does the real Subset-Difference walk again.** It previously ran
|
||||
the Media-Key-Variant path, which needed an integrator KCD absent in-tree and
|
||||
errored for every real disc — effectively dead for both consumers. It now
|
||||
performs the genuine device-key SD walk; the Volume ID enters at the VUK step
|
||||
(where it belongs), not the MK step. This revives the DK→MK fallback across the
|
||||
toolchain (`freemkv-keysources` adopts the corrected two-argument call).
|
||||
- **autorip: a down online key service is no longer reported as a missing key.**
|
||||
When the online key source resolves no key for an encrypted disc, autorip now
|
||||
runs one bounded reachability probe (SSRF-pinned, ~8 s, no redirects) and
|
||||
distinguishes a transient outage (transport error / 502·503·504 → down; 429 →
|
||||
rate-limited) from a genuine no-key (any real HTTP answer → up). A transient
|
||||
verdict triggers a bounded key-resolution retry (3 attempts, 8/16/32 s backoff)
|
||||
and, if the service stays down, parks the disc in a distinct retryable state
|
||||
("Key service unavailable — temporary outage, not a missing key; will retry.")
|
||||
instead of the permanent "no keys found". Never hammers the drive or service.
|
||||
|
||||
### Performance
|
||||
|
||||
- **Processing-Key resolution is ~15× faster on UHD.** A Processing Key is the
|
||||
key at its subset-difference node (one AES-G from the Media Key), so it is now
|
||||
tried directly against the MKB cvalue tables (direct PK × cvalue iteration)
|
||||
instead of BFS-walking the SD tree at unknown depth — which was both wrong for
|
||||
terminal PKs and slow on a large UHD MKB (~181k cvalues). PK derivation on UHD
|
||||
drops from ~37 s to ~2.4 s; the SD tree walk now lives solely in the device-key
|
||||
path.
|
||||
|
||||
### autorip
|
||||
|
||||
- **Clear stuck move errors from the System tab.** Each move-queue error now has
|
||||
a ✕ to dismiss it, plus Clear all and Refresh — so a resolved or stale error
|
||||
can be cleared without restarting the container (the mover re-records any that
|
||||
are still genuinely failing on its next tick).
|
||||
|
||||
## [1.2.1] — 2026-07-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **DVD DTS audio no longer muxes with non-monotonic timestamps.** A DVD
|
||||
Program Stream packs several DTS core frames into one PES packet; the parser
|
||||
stamped every access unit with that single PES timestamp and no per-frame
|
||||
duration, so consecutive frames collided on one PTS and a strict decode/remux
|
||||
(ffmpeg) rejected the track — `non monotonically increasing dts to muxer`.
|
||||
The DTS parser now derives each core frame's duration from its header
|
||||
(`(NBLKS+1)*32` samples ÷ the `SFREQ` sample rate) and re-bases to each PES's
|
||||
own container timestamp, advancing by a frame duration only *within* a single
|
||||
PES — so the track stays monotonic and does not drift past its real length on
|
||||
a feature-long title. The UHD DTS-HD MA path (one access unit per PES) is
|
||||
unaffected: each unit keeps its own PES timestamp, preserving the 1.2.0 per-PES
|
||||
attribution. Completes the DVD DTS fix begun in 1.2.0 (which corrected the
|
||||
silent-track routing, exposing this timing bug). Note: genuinely corrupt
|
||||
source DTS frames — valid framing, bad audio blocks — are passed through
|
||||
faithfully; freemkv never fabricates or drops audio it can't prove is bad.
|
||||
|
||||
## [1.2.0] — 2026-07-01
|
||||
|
||||
### Breaking
|
||||
@@ -32,6 +568,22 @@ consumers are the in-tree toolchain crates.
|
||||
|
||||
### Added
|
||||
|
||||
- **Pass-N marginal-sector recovery specialists.** The patch pass gained a
|
||||
roster of parameterized recovery techniques — read speed (max/min), cache
|
||||
bypass (FUA), and traversal (linear fwd/rev, bisect, cache-prime, oscillate,
|
||||
per-sector speed-sweep) — each targeting a distinct physical failure mode of
|
||||
marginal media. A per-rip **decayed (EWMA) scorecard** grades every technique
|
||||
by its recent recovery rate and re-orders them best-first, so the engine
|
||||
hardcodes no conclusion: a technique that fits *this* disc floats to the front
|
||||
and one that doesn't self-deprioritises (but is never dropped). Every read is
|
||||
wedge-safe and deadline-bounded; the existing fast/deep recovery behavior is
|
||||
unchanged (the specialists are additive, tried only on the hardened residue).
|
||||
- **Opt-in flat-pool recovery scheduler (`FREEMKV_PATCH_FLAT`).** Collapses the
|
||||
breadth-first recovery tiers into one flat pool so every technique gets a shot
|
||||
at each bad range immediately, scorecard-ordered — a data-driven bandit for a
|
||||
hardened residual (e.g. a late resume) where the tiered ladder would spend a
|
||||
long time on cheap techniques before reaching the specialists. Unset keeps the
|
||||
proven tier ladder as the default.
|
||||
- **`PassProgress` is the complete, mapfile-free progress contract.** Every
|
||||
emission now carries the fully-rendered "where is the damage" drilldown
|
||||
(`located`): the bad ranges annotated with chapter + movie-time offset, the
|
||||
@@ -115,6 +667,14 @@ consumers are the in-tree toolchain crates.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **DVD DTS/LPCM audio tracks no longer mux silent.** On DVD-Video the
|
||||
`private_stream_1` sub-stream id's low nibble is the audio-stream *number*
|
||||
(shared across codecs), not a per-codec ordinal. A DTS or LPCM track that
|
||||
wasn't the disc's first audio stream got a sub-id one too low, so the demux
|
||||
routing key (`0xBD00 | sub_id`) never matched and every packet was dropped —
|
||||
the track appeared in the container but played silent (AC-3 at position 0
|
||||
worked by coincidence). Audio sub-stream ids are now assigned by positional
|
||||
stream number, so a DTS 5.0 track after an AC-3 5.1 track routes correctly.
|
||||
- **ISO mux no longer drops real video at content-fragment tails.** A title's
|
||||
encrypted content can end mid-AACS-unit, with the disc zero-padding the rest
|
||||
of the 6144-byte aligned unit to the next fragment. The decrypt-verify
|
||||
@@ -176,7 +736,7 @@ consumers are the in-tree toolchain crates.
|
||||
|
||||
- **Post-read decrypt-verify gate.** Every AACS unit read off the disc is now
|
||||
buffered, re-aligned to its clip-file 6144-byte unit grid, and verified
|
||||
(CPI flag → decrypt → strict all-32 TS-sync, matching libaacs `_verify_ts`)
|
||||
(CPI flag → decrypt → strict all-32 TS-sync)
|
||||
before it is signed off as good. A unit that no held or freshly-fetched key
|
||||
decrypts is treated exactly like a bad read — re-read by
|
||||
the patch pass, terminal loss only if truly unrecoverable — closing the
|
||||
@@ -197,7 +757,7 @@ consumers are the in-tree toolchain crates.
|
||||
|
||||
- **AACS decrypt acceptance is now standards-strict.** A key is accepted only
|
||||
when the decrypted unit has the TS sync byte on *all* 32 source packets
|
||||
(libaacs `_verify_ts`), replacing a majority-vote heuristic where a wrong key
|
||||
(all-32 TS-sync verify), replacing a majority-vote heuristic where a wrong key
|
||||
could coincidentally restore enough syncs to pass and silently corrupt a unit.
|
||||
- keydb download/save moved out of the library into freemkv-keysources;
|
||||
libfreemkv no longer has any keydb I/O (it already held no keys).
|
||||
@@ -205,7 +765,7 @@ consumers are the in-tree toolchain crates.
|
||||
### Fixed
|
||||
|
||||
- **AACS content-certificate bus-encryption flag read from the wrong bit.** The
|
||||
flag is bit 7 of byte 1 (libaacs `p[1] >> 7`) but was read as bit 0, so a
|
||||
flag is bit 7 of byte 1 (`p[1] >> 7`) but was read as bit 0, so a
|
||||
bus-encrypted disc parsed as *not* bus-encrypted — defeating the fail-loud
|
||||
guard that refuses to decrypt bus-wrapped data to garbage when no bus key was
|
||||
obtained. Also corrected the cc_id offset (byte 14) and the AACS2 type marker
|
||||
@@ -505,7 +1065,7 @@ hardening.
|
||||
|
||||
- **Keyless DVD/CSS title-key recovery.** A CSS-protected DVD decrypts with no
|
||||
key database — the title key is recovered directly from the scrambled disc
|
||||
data via the Stevenson known-plaintext attack (ported from libdvdcss) and
|
||||
data via the Stevenson known-plaintext attack and
|
||||
validated by descrambling a sector and confirming the known plaintext
|
||||
reappears, so a wrong key fails cleanly instead of producing silent garbage
|
||||
(`src/css/stevenson.rs`). `Disc::scan_image` recovers the same title key from
|
||||
|
||||
+1
-1
@@ -24,4 +24,4 @@ cargo test
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree your code will be licensed under AGPL-3.0.
|
||||
By contributing, you agree your code will be licensed under MIT.
|
||||
|
||||
+8
-2
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.2.0"
|
||||
version = "1.6.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
license = "MIT"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
||||
@@ -36,6 +36,12 @@ rand = "0.8"
|
||||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.22.1"
|
||||
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
||||
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
||||
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
|
||||
# attribute order, self-closing tags).
|
||||
roxmltree = "0.20"
|
||||
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2026 FreeMKV Contributors
|
||||
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, version 3 of the License.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
|
||||
# libfreemkv
|
||||
|
||||
@@ -190,4 +190,4 @@ Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/fre
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only
|
||||
MIT
|
||||
|
||||
@@ -7,7 +7,7 @@ AACS decryption requires an external `keydb.cfg` (default
|
||||
material is compiled in; DVD CSS player keys are the only compiled-in keys.
|
||||
|
||||
**Repository:** <https://github.com/freemkv/libfreemkv>
|
||||
**License:** AGPL-3.0-only
|
||||
**License:** MIT
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,10 +19,11 @@ reach into the others.
|
||||
The library exposes flat verbs; the caller drives the multipass loop. Autorip
|
||||
runs `Disc::sweep` once, then loops `Disc::patch` until either the mapfile is
|
||||
clean or the configured retry budget is exhausted, then hands the ISO off to
|
||||
the mux pipeline. The `freemkv` CLI does the same shape with a
|
||||
terminal-output progress sink. Layer 3 runs inside any consumer of
|
||||
`DiscStream` (direct PES pipeline, ISO playback, etc.) without caller
|
||||
involvement.
|
||||
the mux pipeline. The `freemkv` CLI does the same shape, but as of 1.6.0 the
|
||||
loop itself (including the multi-title rip loop) lives one layer up, in the
|
||||
shared `freemkv-engine` crate, with a terminal-output progress sink plugged
|
||||
into it as the `Sink`. Layer 3 runs inside any consumer of `DiscStream`
|
||||
(direct PES pipeline, ISO playback, etc.) without caller involvement.
|
||||
|
||||
Three primitives compose the disc-side flow:
|
||||
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
//! AACS derivation "boil-down" — one public home for the key chain.
|
||||
//!
|
||||
//! Thin newtypes at the API boundary and three wrapper functions over the
|
||||
//! existing crypto. Nothing here re-implements a primitive: every function
|
||||
//! delegates to the already-audited code in [`super::keys`] and
|
||||
//! [`super::variants`], so the boil-down cannot drift from production math.
|
||||
//!
|
||||
//! The newtypes wrap bare `[u8; 16]` ONLY at this boundary — the crypto
|
||||
//! internals continue to operate on raw arrays. They exist so a caller threads
|
||||
//! the chain `DK → MK → VUK → UK` without confusing one 16-byte secret for
|
||||
//! another, not to refactor the resolver.
|
||||
//!
|
||||
//! Chain (matches `aacs::keys::resolve_keys_classical` path 1 and
|
||||
//! `aacs::keys::resolve_keys_v21` path 1 byte-for-byte):
|
||||
//!
|
||||
//! ```text
|
||||
//! mk_from_dk(device_keys, mkb, vid) → MediaKey (Km)
|
||||
//! mk_from_pk(processing_keys, mkb) → MediaKey (Km)
|
||||
//! vuk_from_mk(MediaKey, Vid) → Vuk (= AES-G(Km, VID))
|
||||
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each)
|
||||
//! ```
|
||||
//!
|
||||
//! `mk_from_dk` and `mk_from_pk` are two entry points to the SAME Media Key:
|
||||
//! the device-key path walks the MKB's Media-Key-Variant chain, the
|
||||
//! processing-key path walks the MKB's Subset-Difference cvalue tables. Neither
|
||||
//! needs a VID (the VID enters at `vuk_from_mk`).
|
||||
|
||||
use super::keys::{decrypt_unit_key, derive_media_key_from_pk, derive_vuk};
|
||||
use super::types::DeviceKey;
|
||||
use super::variants::{KEY_CORRECTION_DATA_PLACEHOLDER, derive_media_key_variant, walk_mkb};
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vid(pub [u8; 16]);
|
||||
|
||||
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MediaKey(pub [u8; 16]);
|
||||
|
||||
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
||||
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
///
|
||||
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
|
||||
/// handed to [`uk_from_vuk`] (i.e. its order in `Unit_Key_RO.inf`'s key-storage
|
||||
/// area). The CPS-unit *number* association (the `u32` in
|
||||
/// `ResolvedKeys::unit_keys`) is a higher-level concern owned by
|
||||
/// [`super::keys::parse_unit_key_ro`], which pairs each positional key with its
|
||||
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
}
|
||||
|
||||
/// Derive the Volume Unique Key from a Media Key and Volume ID.
|
||||
///
|
||||
/// Wraps [`derive_vuk`] verbatim: `VUK = AES-128-ECB-DECRYPT(MK, VID) XOR VID`.
|
||||
/// This is byte-identical to the inline `derive_vuk(&mk, ctx.volume_id)` call in
|
||||
/// every classical resolver path AND to the `Kvu = AES-G(Km, VID)` step inside
|
||||
/// [`derive_media_key_variant`] (AES-G and `derive_vuk` are the same math), so
|
||||
/// `vuk_from_mk(mk_from_dk(..)?, vid)` reproduces the V21 variant VUK exactly.
|
||||
pub fn vuk_from_mk(mk: MediaKey, vid: Vid) -> Vuk {
|
||||
Vuk(derive_vuk(&mk.0, &vid.0))
|
||||
}
|
||||
|
||||
/// Decrypt the disc's encrypted title keys with a VUK.
|
||||
///
|
||||
/// Wraps [`decrypt_unit_key`] (AES-128-ECB-DECRYPT) per entry, mirroring the
|
||||
/// `derive_uks` closure in `resolve_keys_classical` / `resolve_keys_v21`. The
|
||||
/// returned `UnitKey::idx` is the slice position; pair with CPS-unit numbers via
|
||||
/// [`super::keys::parse_unit_key_ro`] when the numbering matters.
|
||||
pub fn uk_from_vuk(vuk: Vuk, enc_title_keys: &[[u8; 16]]) -> Vec<UnitKey> {
|
||||
enc_title_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, enc)| UnitKey {
|
||||
idx: i as u32,
|
||||
key: decrypt_unit_key(&vuk.0, enc),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derive the Media Key (Km) from device keys via the Media Key Variant chain.
|
||||
///
|
||||
/// Wraps [`walk_mkb`] + [`derive_media_key_variant`] with exactly the arguments
|
||||
/// `resolve_keys_v21` path 1 passes: the placeholder Key Correction Data and the
|
||||
/// disc Volume ID. Returns the FIRST tuple element `Km` (the Media Key) — the
|
||||
/// resolver treats `Km` as the media key and derives the VUK from it as
|
||||
/// `Kvu = AES-G(Km, VID)`, which equals [`vuk_from_mk`]`(MediaKey(km), vid)`. The
|
||||
/// variant fn's second element is that already-derived `Kvu`; returning `Km`
|
||||
/// keeps this primitive at the "media key" level so the chain composes.
|
||||
///
|
||||
/// Because the integrator KCD is unavailable in-tree (the placeholder is
|
||||
/// rejected by the variant chain), this returns `Err` for every real disc today
|
||||
/// — byte-for-byte identical to `resolve_keys_v21` path 1, which the resolver
|
||||
/// also leaves unreachable in production. All variant-chain failures collapse to
|
||||
/// [`Error::AacsMkUnavailable`] (E7018): no numeric distinction is load-bearing
|
||||
/// at this boundary, and the variant error carries no English to preserve.
|
||||
pub fn mk_from_dk(
|
||||
device_keys: &[DeviceKey],
|
||||
mkb: &[u8],
|
||||
vid: Vid,
|
||||
) -> Result<MediaKey, crate::error::Error> {
|
||||
let records = walk_mkb(mkb);
|
||||
match derive_media_key_variant(
|
||||
&records,
|
||||
device_keys,
|
||||
&KEY_CORRECTION_DATA_PLACEHOLDER,
|
||||
&vid.0,
|
||||
) {
|
||||
Ok((km, _kvu)) => Ok(MediaKey(km)),
|
||||
Err(_) => Err(crate::error::Error::AacsMkUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the Media Key (Km) from one or more Processing Keys and the disc MKB.
|
||||
///
|
||||
/// Wraps [`derive_media_key_from_pk`] — the Subset-Difference PK→MK walk: each
|
||||
/// processing key is validated (and tree-walked) against the MKB's cvalue tables
|
||||
/// (records `0x04`/`0x05`) until one yields the Media Key whose verify record
|
||||
/// (`0x81`/`0x86`) matches. Unlike [`mk_from_dk`] this path is reachable for
|
||||
/// real discs — a leaked/precomputed AACS Processing Key in the keydb resolves
|
||||
/// the Media Key directly. No VID is involved at this step; the VID enters at
|
||||
/// [`vuk_from_mk`].
|
||||
///
|
||||
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no processing key resolves
|
||||
/// the MKB — the same terminal error as [`mk_from_dk`]; no numeric distinction
|
||||
/// is load-bearing at this boundary.
|
||||
///
|
||||
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
|
||||
pub fn mk_from_pk(
|
||||
processing_keys: &[[u8; 16]],
|
||||
mkb: &[u8],
|
||||
) -> Result<MediaKey, crate::error::Error> {
|
||||
match derive_media_key_from_pk(mkb, processing_keys) {
|
||||
Some(km) => Ok(MediaKey(km)),
|
||||
None => Err(crate::error::Error::AacsMkUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::decrypt::aes_ecb_encrypt;
|
||||
use crate::aacs::keys::{decrypt_unit_key, derive_vuk};
|
||||
|
||||
/// `vuk_from_mk` must equal the inline `derive_vuk` path bit-for-bit, for
|
||||
/// several known (MK, VID) vectors.
|
||||
#[test]
|
||||
fn vuk_from_mk_matches_inline_derive_vuk() {
|
||||
let cases: [([u8; 16], [u8; 16]); 3] = [
|
||||
([0x5A; 16], [0xA5; 16]),
|
||||
([0x11; 16], [0x22; 16]),
|
||||
(
|
||||
[
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
|
||||
0x0D, 0x0E, 0x0F,
|
||||
],
|
||||
[
|
||||
0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87, 0x78, 0x69, 0x5A, 0x4B, 0x3C,
|
||||
0x2D, 0x1E, 0x0F,
|
||||
],
|
||||
),
|
||||
];
|
||||
for (mk, vid) in cases {
|
||||
let inline = derive_vuk(&mk, &vid);
|
||||
let boiled = vuk_from_mk(MediaKey(mk), Vid(vid));
|
||||
assert_eq!(boiled.0, inline, "vuk_from_mk must equal derive_vuk");
|
||||
}
|
||||
}
|
||||
|
||||
/// `uk_from_vuk` must equal the inline `decrypt_unit_key` path bit-for-bit
|
||||
/// and carry positional indices 0..n. Built by encrypting known plaintext
|
||||
/// title keys under the VUK (the same primitive the resolver inverts).
|
||||
#[test]
|
||||
fn uk_from_vuk_matches_inline_decrypt_unit_key() {
|
||||
let vuk = [0x5Au8; 16];
|
||||
let plain_keys = [[0x11u8; 16], [0x22u8; 16], [0xCDu8; 16]];
|
||||
let enc: Vec<[u8; 16]> = plain_keys
|
||||
.iter()
|
||||
.map(|k| aes_ecb_encrypt(&vuk, k))
|
||||
.collect();
|
||||
|
||||
let boiled = uk_from_vuk(Vuk(vuk), &enc);
|
||||
assert_eq!(boiled.len(), enc.len());
|
||||
for (i, uk) in boiled.iter().enumerate() {
|
||||
assert_eq!(uk.idx, i as u32, "idx must be the positional index");
|
||||
// Matches the inline derive_uks closure: decrypt_unit_key(vuk, enc).
|
||||
assert_eq!(uk.key, decrypt_unit_key(&vuk, &enc[i]));
|
||||
// And recovers the original plaintext title key.
|
||||
assert_eq!(
|
||||
uk.key, plain_keys[i],
|
||||
"VUK roundtrip recovers the title key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `uk_from_vuk` on an empty slice yields no keys (no panic, no phantom idx).
|
||||
#[test]
|
||||
fn uk_from_vuk_empty_is_empty() {
|
||||
assert!(uk_from_vuk(Vuk([0u8; 16]), &[]).is_empty());
|
||||
}
|
||||
|
||||
/// `mk_from_dk` returns `Err(AacsMkUnavailable)` for the placeholder-KCD
|
||||
/// path that production also leaves unreachable — never a wrong key, never a
|
||||
/// panic — on both an empty MKB and a non-variant MKB.
|
||||
#[test]
|
||||
fn mk_from_dk_errors_without_integrator_kcd() {
|
||||
let dk = DeviceKey {
|
||||
key: [0x11; 16],
|
||||
node: 1,
|
||||
uv: 1,
|
||||
u_mask_shift: 0,
|
||||
};
|
||||
// Empty MKB → not a variant MKB → Err.
|
||||
let e = mk_from_dk(std::slice::from_ref(&dk), &[], Vid([0x09; 16]));
|
||||
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
|
||||
// A variant-looking MKB (0x82 record) still cannot complete without the
|
||||
// integrator KCD, so it also errors — never silently yields a key.
|
||||
let mut mkb: Vec<u8> = Vec::new();
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // variant data record
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
let e2 = mk_from_dk(&[dk], &mkb, Vid([0x09; 16]));
|
||||
assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
}
|
||||
|
||||
/// Build a 4-byte MKB record header (type + 3-byte big-endian total length,
|
||||
/// header included) and append `body`. Mirrors the MKB record framing the
|
||||
/// parser expects; no crypto.
|
||||
fn mkb_record(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||
let total = 4 + body.len();
|
||||
let mut rec = Vec::with_capacity(total);
|
||||
rec.push(rec_type);
|
||||
rec.push(((total >> 16) & 0xFF) as u8);
|
||||
rec.push(((total >> 8) & 0xFF) as u8);
|
||||
rec.push((total & 0xFF) as u8);
|
||||
rec.extend_from_slice(body);
|
||||
rec
|
||||
}
|
||||
|
||||
/// `mk_from_pk` resolves a planted Processing Key against a synthetic MKB and
|
||||
/// drives the FULL boil chain PK → MK → VUK → UK. The MKB is built with the
|
||||
/// same (pk, cv, mk_dv, uv) construction the production SD walk validates, so
|
||||
/// this proves a PK entry yields real Unit Keys — not just an `Ok`.
|
||||
#[test]
|
||||
fn mk_from_pk_drives_full_chain_to_uks() {
|
||||
let pk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
// cv = AES-E(pk, mk_raw), where mk_raw is mk with the last-4-bytes-uv XOR
|
||||
// pre-undone, so the validate step XORs uv back in and recovers mk.
|
||||
let mut mk_raw = mk;
|
||||
for a in 0..4 {
|
||||
mk_raw[12 + a] ^= uv[a];
|
||||
}
|
||||
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||
|
||||
// mk_dv = AES-E(mk, magic||pad): AES-D(mk, mk_dv) starts with the AACS
|
||||
// verify sentinel.
|
||||
let mut vd = [0x11u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = aes_ecb_encrypt(&mk, &vd);
|
||||
|
||||
// Synthetic MKB: type/version (0x10), verify record (0x86 = mk_dv),
|
||||
// one-entry SD index (0x04 = [u_mask_shift=0][uv]), one-entry cvalue
|
||||
// table (0x05 = cv).
|
||||
let mut sd = vec![0u8];
|
||||
sd.extend_from_slice(&uv);
|
||||
let mut mkb = Vec::new();
|
||||
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
|
||||
mkb.extend_from_slice(&mkb_record(0x04, &sd));
|
||||
mkb.extend_from_slice(&mkb_record(0x05, &cv));
|
||||
|
||||
// PK → MK.
|
||||
let got_mk = mk_from_pk(std::slice::from_ref(&pk), &mkb).expect("planted PK resolves MK");
|
||||
assert_eq!(got_mk, MediaKey(mk), "mk_from_pk recovers the planted MK");
|
||||
|
||||
// MK → VUK → UK over an encrypted title key.
|
||||
let vid = Vid([0x42u8; 16]);
|
||||
let plain_uk = [0x7Eu8; 16];
|
||||
let vuk = vuk_from_mk(got_mk, vid);
|
||||
let enc = aes_ecb_encrypt(&vuk.0, &plain_uk);
|
||||
let uks = uk_from_vuk(vuk, std::slice::from_ref(&enc));
|
||||
assert_eq!(uks.len(), 1);
|
||||
assert_eq!(uks[0].key, plain_uk, "PK chain recovers the title key");
|
||||
|
||||
// A corrupt PK resolves nothing.
|
||||
let mut bad = pk;
|
||||
bad[0] ^= 0xFF;
|
||||
assert!(matches!(
|
||||
mk_from_pk(std::slice::from_ref(&bad), &mkb),
|
||||
Err(crate::error::Error::AacsMkUnavailable)
|
||||
));
|
||||
}
|
||||
}
|
||||
+1366
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
//! AACS common cryptographic primitives — [C] Chapter 2 / §3.2.2.
|
||||
//!
|
||||
//! Source: `[C]` = AACS Introduction and Common Cryptographic Elements Book,
|
||||
//! Rev 0.953. The shared low-level building blocks — AES-128 ECB E/D, AES-G,
|
||||
//! the AES-G3 Triple Generator, AES-CBC decrypt — and their fixed constants
|
||||
//! (`iv0`, `s0`). Used by every AACS generation; relocated here so the
|
||||
//! primitives live in one place instead of being scattered across the
|
||||
//! content / keys / variant modules.
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
/// Fixed IV used by AACS for all AES-CBC operations. [C] §2.1.2 (default CBC IV, `iv0`).
|
||||
pub(crate) const AACS_IV: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
];
|
||||
|
||||
/// AES-128-ECB encrypt a single 16-byte block. [C] §2.1.1 (`AES-128E`).
|
||||
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
cipher.encrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block. [C] §2.1.1 (`AES-128D`).
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
cipher.decrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-CBC decrypt in-place with the fixed AACS IV. [C] §2.1.2 (`AES-128CBCD`).
|
||||
///
|
||||
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
|
||||
/// block is silently ignored; all callers pass aligned regions (6128 and
|
||||
/// 2032 bytes), and the assert documents/enforces that contract.
|
||||
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len() % 16 == 0,
|
||||
"aes_cbc_decrypt requires a block-aligned slice"
|
||||
);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let num_blocks = data.len() / 16;
|
||||
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
|
||||
for i in (0..num_blocks).rev() {
|
||||
let offset = i * 16;
|
||||
let prev = if i == 0 {
|
||||
AACS_IV
|
||||
} else {
|
||||
let mut p = [0u8; 16];
|
||||
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
|
||||
p
|
||||
};
|
||||
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
||||
cipher.decrypt_block(&mut block);
|
||||
for j in 0..16 {
|
||||
data[offset + j] = block[j] ^ prev[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2. [C] §2.1.3 (note: uses AES-128**D**).
|
||||
///
|
||||
/// The Media Key Variant chain uses AES-G to derive both the variant
|
||||
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
|
||||
/// (`Kvu = AES-G(Km, VID)`). See [`super::derive::derive_vuk`] for the
|
||||
/// classical VUK form — the math is identical, this exposes it as a
|
||||
/// neutral primitive for the variant chain.
|
||||
pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
||||
let mut out = aes_ecb_decrypt(x1, x2);
|
||||
for i in 0..16 {
|
||||
out[i] ^= x2[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// AACS-G3 seed constant (`s0`). [C] §3.2.2.
|
||||
pub(crate) const AESG3_SEED: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||
];
|
||||
|
||||
/// AACS-G3: derive a subkey from a parent key. [C] §3.2.2 (Triple AES Generator:
|
||||
/// 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.
|
||||
///
|
||||
/// Shared with [`super::variant`] (its variant chain runs the same SD
|
||||
/// tree); a single definition keeps the two walks byte-identical.
|
||||
pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
let mut seed = AESG3_SEED;
|
||||
seed[15] = seed[15].wrapping_add(inc);
|
||||
let mut out = aes_ecb_decrypt(key, &seed);
|
||||
for i in 0..16 {
|
||||
out[i] ^= seed[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
-1501
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,725 @@
|
||||
//! Media-key derivation: DK/PK → Media Key via the subset-difference tree.
|
||||
//! [C] §3.2.2–§3.2.5.
|
||||
|
||||
use super::crypto::*;
|
||||
use super::inf::*;
|
||||
use super::mkb::*;
|
||||
use super::types::*;
|
||||
|
||||
/// Derive Media Key from MKB data using processing keys.
|
||||
///
|
||||
/// A Processing Key is **terminal**: it is the key at its Subset-Difference
|
||||
/// node, one `AES-G` from the Media Key. So this is the fast path — each PK is
|
||||
/// tried *directly* against the MKB cvalue tables (no tree descent) — the
|
||||
/// direct PK × cvalue iteration. On a large AACS 2.x UHD MKB
|
||||
/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node
|
||||
/// label and walking the tree.
|
||||
///
|
||||
/// If you hold a **device-node label** at unknown tree depth (not a terminal
|
||||
/// PK), derive its Media Key through the device-key path
|
||||
/// ([`derive_media_key_from_dk`]) — that path owns the Subset-Difference tree
|
||||
/// walk; the PK path never descends.
|
||||
///
|
||||
/// MKB format:
|
||||
/// Record type 0x10 = Type and Version Record (has MKB version)
|
||||
/// Record type 0x81 = Verify Media Key Record, AACS 1.0 (has mk_dv)
|
||||
/// Record type 0x86 = Verify Media Key Record, AACS 2.0/2.1 (has mk_dv)
|
||||
/// Record type 0x04 = Subset-Difference Index (has UVS entries)
|
||||
/// Record type 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04)
|
||||
/// Record type 0x07 = Explicit Subset-Difference Record (NOT cvalues)
|
||||
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
|
||||
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||
let cvalues = mkb_find_cvalues(mkb)?;
|
||||
try_pk_against_tables(processing_keys, &uvs, &cvalues, &mk_dv)
|
||||
}
|
||||
|
||||
/// Core terminal-PK table scan over explicit record bodies. Each processing
|
||||
/// key is tried **directly** against every `(uv, cvalue)` pair — no tree
|
||||
/// descent. Reached in production via [`derive_media_key_from_pk`]; factored
|
||||
/// out so reproduction harnesses can drive it with explicit tables.
|
||||
pub(crate) fn try_pk_against_tables(
|
||||
processing_keys: &[[u8; 16]],
|
||||
uvs: &[u8],
|
||||
cvalues: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
) -> Option<[u8; 16]> {
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
|
||||
for pk in processing_keys {
|
||||
for i in 0..num_uvs {
|
||||
if (i + 1) * 16 > cvalues.len() {
|
||||
continue;
|
||||
}
|
||||
let record_start = i * 5;
|
||||
if record_start + 5 > uvs.len() {
|
||||
continue;
|
||||
}
|
||||
let uv = &uvs[record_start + 1..record_start + 5];
|
||||
let cv = &cvalues[i * 16..(i + 1) * 16];
|
||||
if let Some(mk) = validate_processing_key(pk, cv, uv, mk_dv) {
|
||||
return Some(mk);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate a processing key against a cvalue/UV pair.
|
||||
/// Returns the Media Key if valid.
|
||||
///
|
||||
/// Steps (media key: [C] §3.2.4; verify relation: [C] §3.2.5.1.4):
|
||||
/// 1. `mk = AES-128D(pk, cvalue)` [C] §3.2.4
|
||||
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only) [C] §3.2.4
|
||||
/// 3. `dec_vd = AES-128D(mk, mk_dv)` [C] §3.2.5.1.4
|
||||
/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid. [C] §3.2.5.1.4
|
||||
pub(crate) fn validate_processing_key(
|
||||
pk: &[u8; 16],
|
||||
cvalue: &[u8],
|
||||
uv: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
) -> Option<[u8; 16]> {
|
||||
if cvalue.len() < 16 || uv.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Step 1: mk = AES-128D(pk, cvalue)
|
||||
let mut cv = [0u8; 16];
|
||||
cv.copy_from_slice(&cvalue[..16]);
|
||||
let mut mk = aes_ecb_decrypt(pk, &cv);
|
||||
|
||||
// Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]).
|
||||
for a in 0..4 {
|
||||
mk[12 + a] ^= uv[a];
|
||||
}
|
||||
|
||||
// Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic.
|
||||
let dec_vd = aes_ecb_decrypt(&mk, mk_dv);
|
||||
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
if dec_vd[..8] == VERIFY_MAGIC {
|
||||
return Some(mk);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Compute v_mask from a UV value. [C] §3.2.3. Shared with [`super::variant`].
|
||||
pub(super) fn calc_v_mask(uv: u32) -> u32 {
|
||||
let mut v_mask: u32 = 0xFFFF_FFFF;
|
||||
while (uv & !v_mask) == 0 && v_mask != 0 {
|
||||
v_mask <<= 1;
|
||||
}
|
||||
v_mask
|
||||
}
|
||||
|
||||
/// 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::variant`].
|
||||
pub(super) fn calc_pk_from_dk(
|
||||
dk: &[u8; 16],
|
||||
uv: u32,
|
||||
v_mask: u32,
|
||||
dev_key_v_mask: u32,
|
||||
) -> [u8; 16] {
|
||||
// Descend from the device node to the record node, following the record's
|
||||
// `uv` bits. At each level only the child we descend INTO is needed (the
|
||||
// sibling is computed but never used), and the Processing Key is the
|
||||
// `aesg3(.,1)` of the FINAL node — so we derive ONE child per level and the
|
||||
// PK once at the end, instead of left/pk/right at every level. Identical
|
||||
// result, ~3x fewer block ops. (left child = `aesg3(node,0)`, right = `,2`.)
|
||||
let mut node = *dk;
|
||||
let mut current_v_mask = dev_key_v_mask;
|
||||
|
||||
// The subset-difference tree is at most 32 levels deep (u32 mask), so the
|
||||
// walk must converge in <= 32 steps. The arithmetic `>> 1` sign-extends
|
||||
// current_v_mask, so a v_mask coarser than dev_key_v_mask (reachable from
|
||||
// a crafted/corrupt MKB) would otherwise saturate at 0xFFFF_FFFF and spin
|
||||
// forever — bound the loop to keep a bad disc from hanging the rip thread.
|
||||
let mut steps = 0u32;
|
||||
while current_v_mask != v_mask {
|
||||
if steps >= 32 {
|
||||
break;
|
||||
}
|
||||
steps += 1;
|
||||
// Find the highest unset bit in current_v_mask
|
||||
let mut bit_pos: i32 = -1;
|
||||
for i in (0..32).rev() {
|
||||
if (current_v_mask & (1u32 << i)) == 0 {
|
||||
bit_pos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let inc = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
|
||||
0 // left child
|
||||
} else {
|
||||
2 // right child
|
||||
};
|
||||
node = aesg3(&node, inc);
|
||||
|
||||
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
|
||||
}
|
||||
|
||||
aesg3(&node, 1)
|
||||
}
|
||||
|
||||
/// Derive Media Key from MKB using device keys (subset-difference tree).
|
||||
///
|
||||
/// Thin wrapper over [`derive_media_key_and_pk_from_dk`] that drops the
|
||||
/// intermediate Processing Key. Callers that need the PK lineage (e.g.
|
||||
/// the key service banking DK·PK·MK) should call the `_and_pk_` form.
|
||||
pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> {
|
||||
derive_media_key_and_pk_from_dk(mkb, device_keys).map(|(mk, _pk)| mk)
|
||||
}
|
||||
|
||||
/// Derive both the Media Key and the intermediate Processing Key from an
|
||||
/// MKB using device keys (subset-difference tree).
|
||||
///
|
||||
/// Identical walk to [`derive_media_key_from_dk`]; this form additionally
|
||||
/// returns the Processing Key `Kp` derived at the matching subset-difference
|
||||
/// node — the value `calc_pk_from_dk` produces immediately before it
|
||||
/// validates into the Media Key. Returns `Some((mk, pk))` for the first DK
|
||||
/// that walks a uv slot whose Processing Key validates against the MKB.
|
||||
pub fn derive_media_key_and_pk_from_dk(
|
||||
mkb: &[u8],
|
||||
device_keys: &[DeviceKey],
|
||||
) -> Option<([u8; 16], [u8; 16])> {
|
||||
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||
let cvalues = mkb_find_cvalues(mkb)?;
|
||||
|
||||
// Count UV entries
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
|
||||
for dk in device_keys {
|
||||
let device_number = dk.node as u32;
|
||||
|
||||
// Find applying subset-difference for this device
|
||||
for uvs_idx in 0..num_uvs {
|
||||
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
||||
let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value
|
||||
|
||||
// `num_uvs` was computed via `take_while(.. c[0] & 0xC0 == 0)`, so
|
||||
// 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) have those bits clear but would
|
||||
// panic in debug / wrap to a wrong mask in release. The MKB byte is
|
||||
// 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 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// u-mask = shift count of low-order 0 bits ([C] §3.2.5.1.5); v-mask [C] §3.2.3.
|
||||
let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift;
|
||||
let v_mask = calc_v_mask(uv);
|
||||
|
||||
// Subset-difference applies iff (d&mu)==(uv&mu) && (d&mv)!=(uv&mv). [C] §3.2.4.
|
||||
if ((device_number & u_mask) == (uv & u_mask))
|
||||
&& ((device_number & v_mask) != (uv & v_mask))
|
||||
{
|
||||
// Found matching subset-difference — find the right device key.
|
||||
// dk.u_mask_shift is a u8 from keydb with no range check;
|
||||
// guard the shift the same way as the MKB byte above.
|
||||
if dk.u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
let dev_key_v_mask = calc_v_mask(dk.uv);
|
||||
let dev_key_u_mask: u32 = 0xFFFF_FFFF << dk.u_mask_shift;
|
||||
|
||||
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
|
||||
// Derive processing key via tree traversal
|
||||
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
|
||||
|
||||
// Validate and derive media key
|
||||
if uvs_idx < cvalues.len() / 16 {
|
||||
let cv = &cvalues[uvs_idx * 16..(uvs_idx + 1) * 16];
|
||||
if let Some(mk) =
|
||||
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv)
|
||||
{
|
||||
return Some((mk, pk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Recover the subset-difference position (`node`, `uv`, `u_mask_shift`) of an
|
||||
/// UNPOSITIONED device key by scanning a disc MKB. A device key alone (just the
|
||||
/// 16 bytes) cannot be walked — the walk needs its tree node. This finds that
|
||||
/// node empirically: for each MKB subset-difference record, it tries the device
|
||||
/// at the record's node AND at every ancestor v-position (the device may sit one
|
||||
/// or more levels ABOVE the record, descending via AES-G to reach it), deriving
|
||||
/// the candidate Processing Key DIRECTLY (one [`calc_pk_from_dk`] per candidate,
|
||||
/// no full re-walk) and checking it validates against that record's cvalue.
|
||||
///
|
||||
/// On the first verifying candidate it pins `(uv, u_mask_shift)` — invariant for
|
||||
/// the key across all discs — and resolves a gate-passing `node` (a one-time
|
||||
/// ≤32-try search at the single hit). Returns a [`DeviceKey`] ready to bank and
|
||||
/// reuse on every future disc via [`derive_media_key_from_dk`]. `None` if the
|
||||
/// key does not apply to this MKB.
|
||||
///
|
||||
/// Cost is `O(slots × tree_depth)` — linear in the MKB's subset-difference
|
||||
/// index, not the quartic cost of re-deriving per candidate.
|
||||
pub fn recover_dk_position(mkb: &[u8], key: &[u8; 16]) -> Option<DeviceKey> {
|
||||
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||
let cvalues = mkb_find_cvalues(mkb)?;
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
let n_cv = cvalues.len() / 16;
|
||||
|
||||
// Hoisted ONCE for the whole scan: the Processing Key the device produces if
|
||||
// it sits EXACTLY at a record (zero descent) is `AES-G3(key, 1)` — it does
|
||||
// not depend on the record, so the zero-descent probe of every slot reuses
|
||||
// this single value instead of re-deriving it per slot.
|
||||
let pk_zero_descent = aesg3(key, 1);
|
||||
|
||||
// The slots are independent, so the scan parallelises — a UHD MKB has ~181k
|
||||
// slots (~26s single-threaded). `find_map_any` returns the first matching
|
||||
// node found by any thread and cancels the rest; a valid MKB has exactly one
|
||||
// matching subset-difference, so which thread finds it is immaterial.
|
||||
use rayon::prelude::*;
|
||||
let found = (0..num_uvs.min(n_cv)).into_par_iter().find_map_any(|i| {
|
||||
let u_mask_shift = uvs[5 * i];
|
||||
if u_mask_shift >= 32 {
|
||||
return None;
|
||||
}
|
||||
let p_uv = &uvs[1 + 5 * i..];
|
||||
let uv_r = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv_r == 0 {
|
||||
return None;
|
||||
}
|
||||
let v_mask = calc_v_mask(uv_r);
|
||||
let cv = &cvalues[i * 16..(i + 1) * 16];
|
||||
let uv_bytes = &uvs[1 + i * 5..];
|
||||
|
||||
// Zero descent (device sits at this slot's node): cheapest, most common.
|
||||
if validate_processing_key(&pk_zero_descent, cv, uv_bytes, &mk_dv).is_some() {
|
||||
return Some((uv_r, u_mask_shift));
|
||||
}
|
||||
// Descent: device is an ANCESTOR of the slot. Walk the depth bit up from
|
||||
// the slot's lowest set bit; each level descends to the slot's node.
|
||||
let p = uv_r.trailing_zeros();
|
||||
for k in (p + 1)..32 {
|
||||
let uv_d = if k + 1 >= 32 {
|
||||
1u32 << k
|
||||
} else {
|
||||
(uv_r & (0xFFFF_FFFFu32 << (k + 1))) | (1u32 << k)
|
||||
};
|
||||
let pk = calc_pk_from_dk(key, uv_r, v_mask, calc_v_mask(uv_d));
|
||||
if validate_processing_key(&pk, cv, uv_bytes, &mk_dv).is_some() {
|
||||
return Some((uv_d, u_mask_shift));
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
found.and_then(|(uv, mask)| resolve_dk_node(mkb, key, uv, mask))
|
||||
}
|
||||
|
||||
/// Resolve a positioned [`DeviceKey`] for an orphan `key` known to sit at
|
||||
/// `(uv, u_mask_shift)`: find a `device_number` (node) that passes the walk's
|
||||
/// subset-difference gate on `mkb`. The derived key is independent of the exact
|
||||
/// node (it only gates), so any gating node yields the same Media Key — a
|
||||
/// one-time ≤32-try search, run only once at the recovered position.
|
||||
pub(crate) fn resolve_dk_node(
|
||||
mkb: &[u8],
|
||||
key: &[u8; 16],
|
||||
uv: u32,
|
||||
u_mask_shift: u8,
|
||||
) -> Option<DeviceKey> {
|
||||
for b in 0..u_mask_shift {
|
||||
let dk = DeviceKey {
|
||||
key: *key,
|
||||
node: ((uv ^ (1u32 << b)) & 0xFFFF) as u16,
|
||||
uv,
|
||||
u_mask_shift,
|
||||
};
|
||||
if derive_media_key_from_dk(mkb, std::slice::from_ref(&dk)).is_some() {
|
||||
return Some(dk);
|
||||
}
|
||||
}
|
||||
// Degenerate MKB (no gating bit): fall back to the node itself.
|
||||
Some(DeviceKey {
|
||||
key: *key,
|
||||
node: (uv & 0xFFFF) as u16,
|
||||
uv,
|
||||
u_mask_shift,
|
||||
})
|
||||
}
|
||||
|
||||
/// Public, side-effect-free accessors over the MKB record helpers, exposed so
|
||||
/// independent reproduction harnesses (e.g. `examples/prove_hkd_aacs.rs`) can
|
||||
/// exercise the exact same parser + verify primitives the production walk uses.
|
||||
/// These are thin wrappers — no new logic.
|
||||
#[doc(hidden)]
|
||||
pub mod probe {
|
||||
use super::super::crypto::aes_ecb_decrypt;
|
||||
|
||||
/// `mk_dv` from the MKB's Verify-Media-Key record (type 0x81 / 0x86).
|
||||
pub fn mkb_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
super::mkb_find_mk_dv(mkb)
|
||||
}
|
||||
|
||||
/// Body of the MKB's Subset-Difference Index record (type 0x04).
|
||||
pub fn mkb_subdiff(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
super::mkb_find_subdiff_records(mkb)
|
||||
}
|
||||
|
||||
/// Body of the MKB's Media-Key-Data (cvalues) record. Selects record
|
||||
/// `0x05` (the large cvalue table, 1:1 with the `0x04` Subset-Difference
|
||||
/// index on AACS 2.x UHD MKBs), falling back to `0x07` only when `0x05`
|
||||
/// is absent.
|
||||
pub fn mkb_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
super::mkb_find_cvalues(mkb)
|
||||
}
|
||||
|
||||
/// Body (header stripped) of the first MKB record of `rec_type`. Lets a
|
||||
/// harness pin an exact record type for cross-checking the production
|
||||
/// cvalue selection (e.g. compare record `0x05` vs `0x07` sizes).
|
||||
pub fn mkb_record_body(mkb: &[u8], rec_type: u8) -> Option<Vec<u8>> {
|
||||
super::find_record_body(mkb, rec_type)
|
||||
}
|
||||
|
||||
/// AES-128-ECB single-block decrypt (the AACS verify primitive).
|
||||
pub fn aes_dec(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
|
||||
aes_ecb_decrypt(key, block)
|
||||
}
|
||||
|
||||
/// Does `km` satisfy the MKB's Verify-Media-Key relation?
|
||||
/// `AES-D(km, mk_dv)[0..8] == 01 23 45 67 89 AB CD EF`.
|
||||
pub fn km_verifies(mkb: &[u8], km: &[u8; 16]) -> bool {
|
||||
match super::mkb_find_mk_dv(mkb) {
|
||||
Some(mk_dv) => {
|
||||
aes_ecb_decrypt(km, &mk_dv)[..8] == [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Volume key: Media Key + Volume ID → VUK → unit keys ──────────────────────
|
||||
|
||||
/// Derive VUK from Media Key and Volume ID. [PR] §3.3 / [BD] §3.3
|
||||
/// (`Kvu = AES-G(Km, IDv)`; AES-G uses AES-128D):
|
||||
/// VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
|
||||
pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] {
|
||||
let mut vuk = aes_ecb_decrypt(media_key, volume_id);
|
||||
for i in 0..16 {
|
||||
vuk[i] ^= volume_id[i];
|
||||
}
|
||||
vuk
|
||||
}
|
||||
|
||||
/// Decrypt an encrypted unit key using the VUK (AES-128-ECB). [PR] §3.5
|
||||
/// (Title Key unwrap `Kt = AES-128D(Ku, Kte)`); the BD "CPS Unit Key" synonym is [BD] §3.9.3.
|
||||
pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] {
|
||||
aes_ecb_decrypt(vuk, encrypted_uk)
|
||||
}
|
||||
|
||||
/// Decrypt every encrypted unit key in a parsed `Unit_Key_RO.inf` with a VUK,
|
||||
/// paired with its declared CPS-unit number. THE single VUK→unit-keys step:
|
||||
/// both classical/v21 resolvers and [`resolve_candidate`] call this, so the
|
||||
/// map cannot drift between the player and harvest paths.
|
||||
pub(crate) fn derive_unit_keys(uk_file: &UnitKeyFile, vuk: &[u8; 16]) -> Vec<(u32, [u8; 16])> {
|
||||
uk_file
|
||||
.encrypted_keys
|
||||
.iter()
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A candidate key at any rung of the AACS ladder, handed to [`resolve_candidate`].
|
||||
///
|
||||
/// Each variant carries the [`super::types`] newtype for that rung (a `Dk` is a
|
||||
/// POSITIONED [`DeviceKey`] — recover an unpositioned one with
|
||||
/// [`recover_dk_position`] first).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KeyCandidate {
|
||||
Uk(UnitKey),
|
||||
Vuk(Vuk),
|
||||
Mk(MediaKey),
|
||||
Pk(ProcessingKey),
|
||||
Dk(DeviceKey),
|
||||
}
|
||||
|
||||
/// The AACS key chain derived from a candidate, from [`resolve_candidate`].
|
||||
///
|
||||
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
||||
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
|
||||
/// its declared CPS-unit number); the caller runs
|
||||
/// `decrypt_unit` + `is_clean_ts` to find which one actually opens the
|
||||
/// disc. Rungs above the candidate are `None`.
|
||||
#[derive(Clone)]
|
||||
pub struct ResolvedChain {
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
pub vuk: Option<Vuk>,
|
||||
pub mk: Option<MediaKey>,
|
||||
pub pk: Option<ProcessingKey>,
|
||||
/// The positioned device key (for a `Dk` candidate).
|
||||
pub dk: Option<DeviceKey>,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `unit_keys` holds raw title-key bytes, never printed. The
|
||||
// other rungs are `types` newtypes that self-redact. Guarded by
|
||||
// `resolved_chain_debug_is_redacted`.
|
||||
impl std::fmt::Debug for ResolvedChain {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ResolvedChain")
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.field("vuk", &self.vuk)
|
||||
.field("mk", &self.mk)
|
||||
.field("pk", &self.pk)
|
||||
.field("dk", &self.dk)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the full AACS key chain from a candidate key of ANY ladder rung.
|
||||
///
|
||||
/// Runs the deterministic derivation DOWNWARD to the disc's terminal unit keys:
|
||||
/// `DK → MK → VUK → UKs`, `PK → MK → VUK → UKs`, `MK → VUK → UKs`,
|
||||
/// `VUK → UKs`, or `UK → itself`. Composes the raw derivation primitives
|
||||
/// ([`derive_media_key_from_pk`], [`derive_media_key_and_pk_from_dk`],
|
||||
/// [`derive_vuk`], [`derive_unit_keys`]) and parses `Unit_Key_RO.inf` at the
|
||||
/// version the disc's MKB declares, so a multi-CPS disc yields all its unit
|
||||
/// keys from the one candidate.
|
||||
///
|
||||
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
|
||||
/// `unit_keys` against a real encrypted unit with
|
||||
/// `decrypt_unit` + `is_clean_ts` to prove the candidate opens the disc.
|
||||
///
|
||||
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
|
||||
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
||||
/// one, or an unparseable/empty `Unit_Key_RO.inf`.
|
||||
pub fn resolve_candidate(
|
||||
candidate: &KeyCandidate,
|
||||
mkb: &[u8],
|
||||
unit_key_ro: &[u8],
|
||||
vid: Option<Vid>,
|
||||
) -> Option<ResolvedChain> {
|
||||
// Boil a VUK → all unit keys, each paired with its declared CPS-unit number.
|
||||
// Derive the stride version from the disc's own MKB, then defer to the shared
|
||||
// `derive_unit_keys` (the one place both resolvers and this path decrypt).
|
||||
let boil = |vuk: Vuk| -> Option<Vec<(u32, [u8; 16])>> {
|
||||
let version = mkb_type(mkb)
|
||||
.map(|t| t.generation())
|
||||
.unwrap_or(AacsVersion::V10);
|
||||
// BD/UHD Unit_Key_RO.inf or HD DVD VTKF000.AACS — dispatched by magic.
|
||||
let ukf = parse_title_keys(unit_key_ro, version)?;
|
||||
if ukf.encrypted_keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(derive_unit_keys(&ukf, &vuk.0))
|
||||
};
|
||||
|
||||
match candidate {
|
||||
KeyCandidate::Uk(uk) => Some(ResolvedChain {
|
||||
unit_keys: vec![(uk.idx, uk.key)],
|
||||
vuk: None,
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
}),
|
||||
KeyCandidate::Vuk(v) => Some(ResolvedChain {
|
||||
unit_keys: boil(*v)?,
|
||||
vuk: Some(*v),
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
}),
|
||||
KeyCandidate::Mk(mk) => {
|
||||
let vuk = Vuk(derive_vuk(&mk.0, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(*mk),
|
||||
pk: None,
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Pk(pk) => {
|
||||
let km = derive_media_key_from_pk(mkb, std::slice::from_ref(&pk.0))?;
|
||||
let vuk = Vuk(derive_vuk(&km, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(MediaKey(km)),
|
||||
pk: Some(*pk),
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Dk(dk) => {
|
||||
let (km, pk) = derive_media_key_and_pk_from_dk(mkb, std::slice::from_ref(dk))?;
|
||||
let vuk = Vuk(derive_vuk(&km, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(MediaKey(km)),
|
||||
pk: Some(ProcessingKey(pk)),
|
||||
dk: Some(dk.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resolve_candidate_tests {
|
||||
use super::*;
|
||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||
|
||||
/// `ResolvedChain.unit_keys` holds raw title-key bytes (the other rungs are
|
||||
/// self-redacting `types` newtypes). `Debug` must not leak the title keys.
|
||||
#[test]
|
||||
fn resolved_chain_debug_is_redacted() {
|
||||
let c = ResolvedChain {
|
||||
unit_keys: vec![(1, [0xD5; 16])],
|
||||
vuk: None,
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
};
|
||||
let dbg = format!("{c:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"ResolvedChain leaked unit keys: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("unit_keys_len"),
|
||||
"ResolvedChain missing redaction: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Minimal AACS-1.0 (48-byte stride) `Unit_Key_RO.inf` with `n` encrypted
|
||||
/// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n.
|
||||
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
|
||||
let uk_pos = 32usize;
|
||||
let stride = 48usize;
|
||||
let n = encs.len();
|
||||
let total = uk_pos + 48 + n.saturating_sub(1) * stride + 16;
|
||||
let mut inf = vec![0u8; total.max(20)];
|
||||
inf[..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
|
||||
for (i, k) in encs.iter().enumerate() {
|
||||
let o = uk_pos + 48 + i * stride;
|
||||
inf[o..o + 16].copy_from_slice(k);
|
||||
}
|
||||
inf
|
||||
}
|
||||
|
||||
/// A VUK candidate boils to ALL the disc's unit keys, each paired with its
|
||||
/// declared CPS-unit number, and each key equals the VUK-decrypt of its slot.
|
||||
#[test]
|
||||
fn resolve_candidate_vuk_returns_all_cps_units() {
|
||||
let vuk = Vuk([0x33u8; 16]);
|
||||
let encs = [[0x11u8; 16], [0x22u8; 16], [0x44u8; 16]];
|
||||
let inf = synth_inf(&encs);
|
||||
let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None).expect("vuk derives");
|
||||
let cps: Vec<u32> = r.unit_keys.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(
|
||||
cps,
|
||||
vec![1, 2, 3],
|
||||
"every CPS unit surfaced, numbered from the inf"
|
||||
);
|
||||
for ((_, key), enc) in r.unit_keys.iter().zip(encs.iter()) {
|
||||
assert_eq!(
|
||||
*key,
|
||||
decrypt_unit_key(&vuk.0, enc),
|
||||
"key = VUK-decrypt of its slot"
|
||||
);
|
||||
}
|
||||
assert_eq!(r.vuk, Some(vuk));
|
||||
assert!(r.mk.is_none() && r.pk.is_none() && r.dk.is_none());
|
||||
}
|
||||
|
||||
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_is_itself() {
|
||||
let uk = UnitKey::new(2, [0x9u8; 16]);
|
||||
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal");
|
||||
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||
assert!(r.vuk.is_none() && r.mk.is_none());
|
||||
}
|
||||
|
||||
/// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops.
|
||||
#[test]
|
||||
fn resolve_candidate_mk_requires_vid() {
|
||||
let r = resolve_candidate(&KeyCandidate::Mk(MediaKey([1u8; 16])), &[], &[], None);
|
||||
assert!(r.is_none(), "MK path returns None without a VID");
|
||||
}
|
||||
|
||||
/// A planted Processing Key resolves against a synthetic MKB and drives the
|
||||
/// FULL chain PK → MK → VUK → UK — proving a PK candidate yields real keys.
|
||||
#[test]
|
||||
fn resolve_candidate_pk_drives_full_chain() {
|
||||
let pk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
let mut mk_raw = mk;
|
||||
for a in 0..4 {
|
||||
mk_raw[12 + a] ^= uv[a];
|
||||
}
|
||||
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||
|
||||
let mut vd = [0x11u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = aes_ecb_encrypt(&mk, &vd);
|
||||
|
||||
// 4-byte record header (type + BE24 total length) + body.
|
||||
let rec = |t: u8, body: &[u8]| -> Vec<u8> {
|
||||
let total = 4 + body.len();
|
||||
let mut r = vec![
|
||||
t,
|
||||
((total >> 16) & 0xFF) as u8,
|
||||
((total >> 8) & 0xFF) as u8,
|
||||
(total & 0xFF) as u8,
|
||||
];
|
||||
r.extend_from_slice(body);
|
||||
r
|
||||
};
|
||||
let mut sd = vec![0u8];
|
||||
sd.extend_from_slice(&uv);
|
||||
let mut mkb = Vec::new();
|
||||
mkb.extend_from_slice(&rec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||
mkb.extend_from_slice(&rec(0x86, &mk_dv));
|
||||
mkb.extend_from_slice(&rec(0x04, &sd));
|
||||
mkb.extend_from_slice(&rec(0x05, &cv));
|
||||
|
||||
let vid = Vid([0x42u8; 16]);
|
||||
let plain_uk = [0x7Eu8; 16];
|
||||
let vuk = derive_vuk(&mk, &vid.0);
|
||||
let enc = aes_ecb_encrypt(&vuk, &plain_uk);
|
||||
let inf = synth_inf(std::slice::from_ref(&enc));
|
||||
|
||||
let r = resolve_candidate(&KeyCandidate::Pk(ProcessingKey(pk)), &mkb, &inf, Some(vid))
|
||||
.expect("planted PK resolves the full chain");
|
||||
assert_eq!(r.mk, Some(MediaKey(mk)), "PK recovers the planted MK");
|
||||
assert_eq!(r.unit_keys.len(), 1);
|
||||
assert_eq!(
|
||||
r.unit_keys[0].1, plain_uk,
|
||||
"PK chain recovers the title key"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
pub fn collect_host_certs(
|
||||
opts: &crate::disc::ScanOptions,
|
||||
mkb: Option<u32>,
|
||||
) -> Vec<crate::aacs::HostCert> {
|
||||
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
|
||||
) -> Vec<crate::aacs::types::HostCert> {
|
||||
let mut host_certs: Vec<crate::aacs::types::HostCert> = Vec::new();
|
||||
if let Some(c) = &opts.credentials {
|
||||
host_certs.extend(c.host_certs.iter().cloned());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! 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) => {
|
||||
// `seg.index` is an untrusted u16 from IndividualSegment.tbl; a real
|
||||
// forensic index is 1..=32. Compare in u16 space so a corrupt/crafted
|
||||
// index above 255 can't truncate into a valid u8 and alias our index.
|
||||
// The disposition carries a u8 for diagnostics (saturated — an
|
||||
// out-of-range index is never ours anyway).
|
||||
let seg_index = seg.index;
|
||||
let diag = seg_index.min(u8::MAX as u16) as u8;
|
||||
match disc_index {
|
||||
Some(v) if u16::from(v) == seg_index => UnitDisposition::Index(v),
|
||||
Some(_) => UnitDisposition::DropForeignIndex(diag),
|
||||
None => UnitDisposition::ForensicNoKey(diag),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 out_of_range_index_does_not_truncate_into_ours() {
|
||||
// A crafted/corrupt segment index of 288 (0x0120) truncates to 32 in a
|
||||
// u8. With our disc index resolved as 32, the old `seg.index as u8`
|
||||
// compare would alias it to OUR index and decrypt with the wrong key.
|
||||
// The u16 compare must instead classify it as foreign.
|
||||
let segs = tbl(&[(288, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(32)),
|
||||
UnitDisposition::DropForeignIndex(255)
|
||||
);
|
||||
}
|
||||
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
}
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
//! AACS on-disc key-input files: `Unit_Key_RO.inf` parsing, the disc-hash
|
||||
//! keydb lookup key, the Content Certificate, and the in-drive MKB read.
|
||||
//! These turn raw disc files into the structures the key paths consume.
|
||||
|
||||
use super::mkb::*;
|
||||
|
||||
/// Parsed Unit_Key_RO.inf file.
|
||||
#[derive(Debug)]
|
||||
pub struct UnitKeyFile {
|
||||
/// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key
|
||||
pub disc_hash: [u8; 20],
|
||||
/// Application type (1 = BD-ROM)
|
||||
pub app_type: u8,
|
||||
/// Number of BDMV directories
|
||||
pub num_bdmv_dir: u8,
|
||||
/// Whether SKB MKB is used
|
||||
pub use_skb_mkb: bool,
|
||||
/// AACS generation this file's stride matches
|
||||
pub version: AacsVersion,
|
||||
/// Encrypted unit keys (CPS unit number, encrypted key)
|
||||
pub encrypted_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
|
||||
pub title_cps_unit: Vec<u16>,
|
||||
}
|
||||
|
||||
/// Compute disc hash (SHA1 of Unit_Key_RO.inf content).
|
||||
pub fn disc_hash(data: &[u8]) -> [u8; 20] {
|
||||
use sha1::{Digest, Sha1};
|
||||
let hash = Sha1::digest(data);
|
||||
let mut out = [0u8; 20];
|
||||
out.copy_from_slice(&hash);
|
||||
out
|
||||
}
|
||||
|
||||
/// Format disc hash as hex string with 0x prefix (for KEYDB lookup).
|
||||
pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||
let mut s = String::with_capacity(42);
|
||||
s.push_str("0x");
|
||||
for b in hash {
|
||||
s.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse Unit_Key_RO.inf from raw bytes.
|
||||
///
|
||||
/// Format (from AACS spec):
|
||||
/// [0..4] BE32: offset to key storage area (uk_pos)
|
||||
/// [16] app_type (1 = BD-ROM)
|
||||
/// [17] num_bdmv_dir
|
||||
/// [18] bit 7: use_skb_mkb
|
||||
/// [20..22] BE16: first_play CPS unit
|
||||
/// [22..24] BE16: top_menu CPS unit
|
||||
/// [24..26] BE16: num_titles
|
||||
/// [26..] title entries: 2 bytes padding + 2 bytes CPS unit, × num_titles
|
||||
///
|
||||
/// Key storage at uk_pos:
|
||||
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
|
||||
/// [uk_pos+48..] encrypted keys, 16 bytes each
|
||||
/// AACS 1.0: 48-byte stride
|
||||
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
|
||||
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hash = disc_hash(data);
|
||||
|
||||
// Header
|
||||
let app_type = data[16];
|
||||
let num_bdmv_dir = data[17];
|
||||
let use_skb_mkb = (data[18] >> 7) & 1 == 1;
|
||||
|
||||
// Key storage offset
|
||||
let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if uk_pos + 2 > data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Number of unit keys
|
||||
let num_uk = u16::from_be_bytes([data[uk_pos], data[uk_pos + 1]]) as usize;
|
||||
if num_uk == 0 {
|
||||
return Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
version,
|
||||
encrypted_keys: Vec::new(),
|
||||
title_cps_unit: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Stride between keys
|
||||
let stride = version.unit_key_stride();
|
||||
|
||||
// Validate size
|
||||
let keys_start = uk_pos + 48; // first key at uk_pos + 48
|
||||
if keys_start + 16 > data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract encrypted keys
|
||||
let mut encrypted_keys = Vec::with_capacity(num_uk);
|
||||
let mut pos = keys_start;
|
||||
for i in 0..num_uk {
|
||||
if pos + 16 > data.len() {
|
||||
break;
|
||||
}
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(&data[pos..pos + 16]);
|
||||
encrypted_keys.push(((i + 1) as u32, key));
|
||||
pos += stride;
|
||||
}
|
||||
|
||||
// The loop above `break`s if the buffer runs out mid-key. A short list
|
||||
// means the .inf is malformed/truncated — reject it rather than silently
|
||||
// accepting fewer keys than the header declared, which would later map
|
||||
// title CPS units to nonexistent keys.
|
||||
if encrypted_keys.len() != num_uk {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Title → CPS unit mapping (AACS Unit_Key_RO format): each on-disc CPS
|
||||
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
|
||||
// index to a 0-based key index. We mirror that so the stored value is a safe,
|
||||
// ready-to-use key index rather than a raw 1-based number.
|
||||
let to_key_idx = |cps: u16| -> u16 {
|
||||
if cps >= 1 && cps as usize <= num_uk {
|
||||
cps - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let mut title_cps_unit = Vec::new();
|
||||
if data.len() >= 26 {
|
||||
let first_play = u16::from_be_bytes([data[20], data[21]]);
|
||||
let top_menu = u16::from_be_bytes([data[22], data[23]]);
|
||||
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
|
||||
|
||||
title_cps_unit.push(to_key_idx(first_play));
|
||||
title_cps_unit.push(to_key_idx(top_menu));
|
||||
|
||||
for i in 0..num_titles {
|
||||
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
|
||||
if off + 2 <= data.len() {
|
||||
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
|
||||
title_cps_unit.push(to_key_idx(cps));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
version,
|
||||
encrypted_keys,
|
||||
title_cps_unit,
|
||||
})
|
||||
}
|
||||
|
||||
/// HD DVD Video Title Key File (`VTKF%%%.AACS`) magic — "DVD_HD_V_TKF".
|
||||
pub const VTKF_MAGIC: &[u8; 12] = b"DVD_HD_V_TKF";
|
||||
/// Fixed header length before the first Title Key Entry (AACS HD DVD Book,
|
||||
/// Table 3-8).
|
||||
const VTKF_HEADER_LEN: usize = 0x80;
|
||||
/// Title Key Entry stride (Table 3-8): 1-byte `BIFO` + 3 reserved + 16-byte
|
||||
/// encrypted title key + 16-byte binding MAC = 36 bytes.
|
||||
const VTKF_ENTRY_LEN: usize = 0x24;
|
||||
/// Byte offset of the encrypted title key within an entry (after `BIFO` + 3
|
||||
/// reserved).
|
||||
const VTKF_KEY_OFF: usize = 4;
|
||||
/// Number of Title Key Entry slots in a VTKF (Table 3-8): a fixed 64.
|
||||
const VTKF_MAX_ENTRIES: usize = 64;
|
||||
/// `BIFO` bit 7 (`AV_FLG`): set = this slot carries an available title key.
|
||||
const VTKF_AV_FLG: u8 = 0x80;
|
||||
|
||||
/// Parse an HD DVD `VTKF%%%.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 — AACS "HD DVD and DVD Pre-recorded Book" Table 3-8, a fixed
|
||||
/// 2480-byte file, verified byte-exact against real discs (Freedom `VTKF090`,
|
||||
/// Dukes of Hazzard `VTKF000`):
|
||||
/// ```text
|
||||
/// [0x00..0x0C] magic "DVD_HD_V_TKF"
|
||||
/// [0x0C..0x10] BE32 HD_VTKF_SIZE (2480)
|
||||
/// [0x10..0x1C] associated playlist name ("VPLST%%%.XPL")
|
||||
/// [0x1C..0x80] reserved
|
||||
/// [0x80..] 64 entries × 36 bytes:
|
||||
/// BIFO (1) | reserved (3) | ENCRYPTED title key (16) | binding MAC (16)
|
||||
/// BIFO bit 7 (AV_FLG) set = this slot holds a title key
|
||||
/// (pre-recorded discs fill the binding MAC with 0xFF)
|
||||
/// [0x9A0..2480] 16-byte TKF MAC (CMAC keyed by Kvu — NOT a key)
|
||||
/// ```
|
||||
/// The slot index (1-based) is the CPS unit number, so an absent slot is
|
||||
/// SKIPPED (not a terminator) — collapsing gaps would renumber later keys and
|
||||
/// hand the wrong title key to CPS unit N+1. The title→CPS mapping is
|
||||
/// playlist-driven (`VPLST%%%.XPL`) and owned by the HD DVD enumerator, so
|
||||
/// `title_cps_unit` is left empty here.
|
||||
///
|
||||
/// The prior parser used a 32-byte stride (a 12-byte pad instead of the 16-byte
|
||||
/// binding MAC). That reads entry #1 correctly but drifts +4 bytes per entry
|
||||
/// after it, so it only decrypted single-CPS-unit discs; every multi-key VTKF
|
||||
/// (Freedom, Harry Potter) yielded garbage keys for CPS unit ≥2.
|
||||
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();
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
let pos = VTKF_HEADER_LEN + n * VTKF_ENTRY_LEN;
|
||||
if pos + VTKF_ENTRY_LEN > data.len() {
|
||||
break;
|
||||
}
|
||||
// AV_FLG clear = empty slot: skip it, but keep the slot index as the CPS
|
||||
// number (do NOT break — a gap must not renumber the keys that follow).
|
||||
if data[pos] & VTKF_AV_FLG == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(&data[pos + VTKF_KEY_OFF..pos + VTKF_KEY_OFF + 16]);
|
||||
encrypted_keys.push((n as u32 + 1, key));
|
||||
}
|
||||
if encrypted_keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type: 0, // HD DVD VTKF carries no BD-ROM app_type
|
||||
num_bdmv_dir: 0, // BD-only concept
|
||||
use_skb_mkb: false,
|
||||
version: AacsVersion::V10, // HD DVD is always AACS 1.0
|
||||
encrypted_keys,
|
||||
title_cps_unit: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a disc's title-key file, dispatching on the self-describing magic:
|
||||
/// an HD DVD `VTKF000.AACS` (`DVD_HD_V_TKF`) → [`parse_vtkf`]; anything else is a
|
||||
/// BD/UHD `Unit_Key_RO.inf` → [`parse_unit_key_ro`]. Both return the same
|
||||
/// [`UnitKeyFile`], so every downstream AACS derivation stays container-agnostic
|
||||
/// — the single seam where BD-vs-HD-DVD key layout is resolved (mirrors the key
|
||||
/// service, which classifies HD DVD by the very same magic).
|
||||
pub fn parse_title_keys(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||
if data.len() >= 12 && &data[..12] == VTKF_MAGIC {
|
||||
parse_vtkf(data)
|
||||
} else {
|
||||
parse_unit_key_ro(data, version)
|
||||
}
|
||||
}
|
||||
|
||||
/// MKB disc structure format code.
|
||||
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
||||
|
||||
/// MKB pack buffer size.
|
||||
const MKB_PACK_SIZE: usize = 32772;
|
||||
|
||||
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
|
||||
/// Returns the concatenated MKB data from all packs.
|
||||
pub fn read_mkb_from_drive(
|
||||
session: &mut dyn crate::scsi::ScsiTransport,
|
||||
) -> crate::error::Result<Vec<u8>> {
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
|
||||
|
||||
let cdb = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
MKB_DISC_STRUCTURE_FORMAT,
|
||||
(MKB_PACK_SIZE >> 8) as u8,
|
||||
(MKB_PACK_SIZE & 0xFF) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = vec![0u8; 32772];
|
||||
session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
|
||||
|
||||
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||
if data_len < 2 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let len = data_len - 2;
|
||||
let num_packs = buf[3] as usize;
|
||||
|
||||
let mut mkb = Vec::with_capacity(32768 * num_packs.max(1));
|
||||
if len > 0 && len <= 32768 {
|
||||
mkb.extend_from_slice(&buf[4..4 + len]);
|
||||
}
|
||||
|
||||
// Read remaining packs
|
||||
for pack in 1..num_packs {
|
||||
let mut cdb = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
MKB_DISC_STRUCTURE_FORMAT,
|
||||
(MKB_PACK_SIZE >> 8) as u8,
|
||||
(MKB_PACK_SIZE & 0xFF) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
// Pack number goes in address field
|
||||
cdb[2] = ((pack >> 24) & 0xFF) as u8;
|
||||
cdb[3] = ((pack >> 16) & 0xFF) as u8;
|
||||
cdb[4] = ((pack >> 8) & 0xFF) as u8;
|
||||
cdb[5] = (pack & 0xFF) as u8;
|
||||
|
||||
let mut buf = vec![0u8; 32772];
|
||||
if session
|
||||
.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
|
||||
.is_ok()
|
||||
{
|
||||
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||
if len > 2 && len - 2 <= 32768 {
|
||||
mkb.extend_from_slice(&buf[4..4 + len - 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mkb)
|
||||
}
|
||||
|
||||
/// AACS Content Certificate — identifies disc AACS version and features.
|
||||
#[derive(Debug)]
|
||||
pub struct ContentCert {
|
||||
/// Bus encryption enabled flag
|
||||
pub bus_encryption: bool,
|
||||
/// Content Certificate ID (6 bytes)
|
||||
pub cc_id: [u8; 6],
|
||||
/// AACS generation indicated by the certificate type byte.
|
||||
///
|
||||
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
|
||||
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
|
||||
/// V20 from V21 — Variant detection happens after the MKB walk.
|
||||
pub version: AacsVersion,
|
||||
}
|
||||
|
||||
/// Parse a Content Certificate (ContentXXX.cer) file.
|
||||
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Content Certificate layout (per the AACS content-cert format):
|
||||
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||||
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (`p + 14`)
|
||||
let version = if data[0] == 0x00 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
};
|
||||
// The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made
|
||||
// a bus-encrypted cert (byte1=0x80) read as `false`, defeating the
|
||||
// AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs.
|
||||
let bus_encryption = (data[1] >> 7) & 1 == 1;
|
||||
let mut cc_id = [0u8; 6];
|
||||
cc_id.copy_from_slice(&data[14..20]);
|
||||
|
||||
Some(ContentCert {
|
||||
bus_encryption,
|
||||
cc_id,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vtkf_tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a synthetic `VTKF%%%.AACS` matching the real on-disc layout (AACS
|
||||
/// HD DVD Book Table 3-8, verified against Freedom `VTKF090` and Dukes
|
||||
/// `VTKF000`): magic, BE32 size, playlist name, reserved to 0x80, then 64
|
||||
/// entry slots of 36 bytes (the first `keys.len()` present with `AV_FLG`
|
||||
/// set, the rest empty), a reserved gap, and the 16-byte trailing TKF MAC.
|
||||
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
|
||||
const FILE_LEN: usize = 2480;
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(VTKF_MAGIC); // 0x00
|
||||
v.extend_from_slice(&(FILE_LEN as u32).to_be_bytes()); // 0x0C HD_VTKF_SIZE
|
||||
v.extend_from_slice(b"VPLST000.XPL"); // 0x10 playlist name
|
||||
v.resize(VTKF_HEADER_LEN, 0); // reserve to first entry (0x80)
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
if let Some(k) = keys.get(n) {
|
||||
v.push(VTKF_AV_FLG); // BIFO: AV_FLG set (present)
|
||||
v.extend_from_slice(&[0, 0, 0]); // reserved
|
||||
v.extend_from_slice(k); // 16-byte encrypted title key
|
||||
v.extend_from_slice(&[0xFFu8; 16]); // binding MAC (0xFF, pre-recorded)
|
||||
} else {
|
||||
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]); // empty slot (AV_FLG clear)
|
||||
}
|
||||
}
|
||||
v.resize(FILE_LEN - 16, 0); // reserved gap before the trailer
|
||||
v.extend_from_slice(&[0xABu8; 16]); // TKF MAC (must NOT be read as a key)
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_reads_present_entries_skips_empty_ignores_mac() {
|
||||
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 empty slots and the trailing
|
||||
// 16-byte TKF MAC are NOT mistaken for keys. Critically, k2/k3 are read
|
||||
// at the 36-byte stride (offsets 0xA4, 0xC8); the old 32-byte stride
|
||||
// misread them from inside the previous entry's binding MAC.
|
||||
assert_eq!(ukf.encrypted_keys.len(), 3);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[0],
|
||||
(1, k1),
|
||||
"CPS units = 1-based slot index"
|
||||
);
|
||||
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_reads_a_full_64_entry_file() {
|
||||
// Real discs (Freedom, Dukes) carry all 64 slots present. Every key must
|
||||
// come back, none dropped and none drifted — the regression the 32-byte
|
||||
// stride failed.
|
||||
let keys: Vec<[u8; 16]> = (0..VTKF_MAX_ENTRIES).map(|n| [n as u8; 16]).collect();
|
||||
let ukf = parse_vtkf(&synth_vtkf(&keys)).expect("64-entry VTKF");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 64);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[63],
|
||||
(64, [63u8; 16]),
|
||||
"entry 64 at 0x{:x}",
|
||||
VTKF_HEADER_LEN + 63 * VTKF_ENTRY_LEN
|
||||
);
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
//! AACS Media Key Block — [C] Chapter 3.
|
||||
//!
|
||||
//! The MKB record format (framing walker, the `MkbRecord` view, record-body
|
||||
//! finders), the MKBType / AACS-generation classification, and MKB-file
|
||||
//! utilities (content length, trimming, version). Consolidated here so the one
|
||||
//! place that understands MKB bytes is `mkb`. Some duplicate record finders
|
||||
//! still live side by side pending a follow-up that collapses them.
|
||||
|
||||
// ── 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`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MkbRecord {
|
||||
/// Byte offset of the record within the MKB.
|
||||
pub offset: usize,
|
||||
/// Record type byte.
|
||||
pub rec_type: u8,
|
||||
/// Record length in bytes (includes the 4-byte header).
|
||||
pub rec_len: usize,
|
||||
/// Record body (the bytes after the 4-byte header).
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Walk an MKB into a flat list of records.
|
||||
///
|
||||
/// MKB record framing per AACS: 1 byte type, 3 bytes BE length
|
||||
/// INCLUDING the 4-byte header, followed by payload. The walker stops
|
||||
/// at the first `(type=0, len=0)` end marker or at end of buffer.
|
||||
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
|
||||
mkb_records(mkb)
|
||||
.map(|(offset, rec_type, rec_len)| MkbRecord {
|
||||
offset,
|
||||
rec_type,
|
||||
rec_len,
|
||||
body: mkb[offset + 4..offset + rec_len].to_vec(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// THE single MKB record-framing walker: yields `(offset, rec_type, rec_len)`
|
||||
/// for each record — a 4-byte header (type byte + big-endian 24-bit length)
|
||||
/// 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
|
||||
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
|
||||
/// 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
|
||||
/// six hand-rolled copies).
|
||||
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
|
||||
let mut pos = 0usize;
|
||||
std::iter::from_fn(move || {
|
||||
if pos + 4 > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = ((mkb[pos + 1] as usize) << 16)
|
||||
| ((mkb[pos + 2] as usize) << 8)
|
||||
| (mkb[pos + 3] as usize);
|
||||
if rec_type == 0 && rec_len == 0 {
|
||||
return None;
|
||||
}
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let here = pos;
|
||||
pos += rec_len;
|
||||
Some((here, rec_type, rec_len))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == rec_type && !r.body.is_empty())
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
/// AACS protection generation a disc carries.
|
||||
///
|
||||
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
|
||||
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
|
||||
/// and is upgraded to `V21` only after the MKB walk turns up the real Variant
|
||||
/// records `0x2d` / `0x2f` (Encrypted Media Key Variant Data and the Variant
|
||||
/// Key Data table).
|
||||
///
|
||||
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
|
||||
/// bytes for V20 / V21.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AacsVersion {
|
||||
/// AACS 1.0 — original BD-ROM.
|
||||
V10,
|
||||
/// AACS 2.0 — UHD-BD, classical Media Key derivation.
|
||||
V20,
|
||||
/// AACS 2.1 — UHD-BD with Media Key Variant chain on top of V20.
|
||||
V21,
|
||||
}
|
||||
|
||||
/// AACS major version as the small integer threaded through the scan / key
|
||||
/// paths (`AacsState.version`, `DiscInputs.version`, `DiscInputsCtx::new`):
|
||||
/// 1 = AACS 1.0 (BD), 2 = AACS 2.x (UHD). Centralised so the bare `1`/`2` — and
|
||||
/// the V10-vs-else stride choice it drives — lives in exactly one place.
|
||||
pub const AACS_MAJOR_BD: u8 = 1;
|
||||
|
||||
pub const AACS_MAJOR_UHD: u8 = 2;
|
||||
|
||||
impl AacsVersion {
|
||||
/// Stride (in bytes) between successive encrypted unit keys in
|
||||
/// `Unit_Key_RO.inf`.
|
||||
pub(crate) fn unit_key_stride(self) -> usize {
|
||||
match self {
|
||||
AacsVersion::V10 => 48,
|
||||
AacsVersion::V20 | AacsVersion::V21 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// This version as the major integer ([`AACS_MAJOR_BD`] / [`AACS_MAJOR_UHD`]).
|
||||
pub fn major(self) -> u8 {
|
||||
match self {
|
||||
AacsVersion::V10 => AACS_MAJOR_BD,
|
||||
AacsVersion::V20 | AacsVersion::V21 => AACS_MAJOR_UHD,
|
||||
}
|
||||
}
|
||||
|
||||
/// The version a bare major integer selects for stride purposes: only the
|
||||
/// BD major is V10; every other value takes the V20/V21 64-byte stride.
|
||||
pub fn from_major(major: u8) -> Self {
|
||||
if major == AACS_MAJOR_BD {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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): [RE] — not in the public spec (from real 2.x MKBs).
|
||||
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
|
||||
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
|
||||
let found = mkb_records(mkb).find(|&(_, rt, len)| {
|
||||
(rt == REC_VERIFY_MEDIA_KEY_V1 || rt == REC_VERIFY_MEDIA_KEY_V2) && len >= 20
|
||||
});
|
||||
match found {
|
||||
Some((o, rec_type, rec_len)) => {
|
||||
let mut dv = [0u8; 16];
|
||||
dv.copy_from_slice(&mkb[o + 4..o + 20]);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_found",
|
||||
rec_type,
|
||||
pos = o,
|
||||
rec_len,
|
||||
"mk_dv extracted from MKB"
|
||||
);
|
||||
Some(dv)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_not_found",
|
||||
"no 0x81/0x86 record with rec_len>=20 found"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Subset-Difference records (type 0x04) in MKB. [C] §3.2.5.1.5.
|
||||
pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
find_record_body(mkb, 0x04)
|
||||
}
|
||||
|
||||
/// Find the Media Key Data Record (cvalues table) in an MKB. [C] §3.2.4 / §3.2.5.1.7.
|
||||
///
|
||||
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
|
||||
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
|
||||
/// 5-byte Subset-Difference index entries in record `0x04` — the standard AACS
|
||||
/// MKB layout (`0x05` cvalues 1:1 with the `0x04` subset-difference index).
|
||||
///
|
||||
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
|
||||
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
|
||||
/// the giant `0x04` index), while record `0x07` (Explicit
|
||||
/// Subset-Difference Record) is a much smaller structure (~96 entries) and
|
||||
/// is NOT the cvalue table. An earlier version of this function preferred
|
||||
/// `0x07`, which under-tested the Subset-Difference walk on UHD discs and
|
||||
/// prevented the DK→walk path from ever finding the matching uv. The
|
||||
/// selection MUST therefore be `0x05`-first; `0x07` is only a fallback for
|
||||
/// malformed/legacy MKBs that somehow lack a `0x05` record.
|
||||
pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(body) = find_record_body(mkb, 0x05) {
|
||||
return Some(body);
|
||||
}
|
||||
find_record_body(mkb, 0x07)
|
||||
}
|
||||
|
||||
/// Walk an MKB and return the payload (header stripped) of the first
|
||||
/// record matching `rec_type`. Returns `None` if no such record exists or
|
||||
/// the record is empty.
|
||||
pub(crate) fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == rec_type_wanted && len > 4)
|
||||
.map(|(o, _, len)| mkb[o + 4..o + len].to_vec())
|
||||
}
|
||||
|
||||
/// Real content length of an MKB: the byte offset where the record stream
|
||||
/// ends. MKB files (especially `MKB_RW.inf`, but `MKB_RO.inf` too on some
|
||||
/// discs) are allocated to a fixed size — often ~128 MiB — with the records at
|
||||
/// the front and the rest zero padding. Walking records (type+len) and stopping
|
||||
/// at the first padding byte (`type == 0` / zero-length / overrun) gives the
|
||||
/// actual size so callers can trim off megabytes of zeros before sending or
|
||||
/// archiving. Returns `mkb.len()` only if the whole buffer parsed as records.
|
||||
pub fn mkb_content_len(mkb: &[u8]) -> usize {
|
||||
// End of the last framed record = where the fixed-region zero padding begins.
|
||||
// (The `00 000000` terminator / overrun stops the walk; real MKBs pad with
|
||||
// zeros, so this matches the prior "stop at the first padding byte".)
|
||||
mkb_records(mkb)
|
||||
.last()
|
||||
.map(|(o, _, len)| o + len)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Trim an MKB's trailing fixed-region padding to its real content length —
|
||||
/// but ONLY when [`mkb_content_len`] actually found one. It returns 0 for an
|
||||
/// MKB whose first record cannot be parsed; truncating to 0 in that case would
|
||||
/// hand downstream consumers (and the online key service) an EMPTY MKB that can
|
||||
/// never resolve. So a 0 (or a length that isn't strictly inside the buffer)
|
||||
/// leaves the MKB untouched. A 0.31.0 regression dropped this guard and
|
||||
/// `truncate`-d unconditionally, zeroing unrecognised MKBs.
|
||||
pub fn trim_mkb(mut mkb: Vec<u8>) -> Vec<u8> {
|
||||
let n = mkb_content_len(&mkb);
|
||||
if n > 0 && n < mkb.len() {
|
||||
mkb.truncate(n);
|
||||
}
|
||||
mkb
|
||||
}
|
||||
|
||||
/// Get MKB version from Type and Version Record (type 0x10).
|
||||
/// Layout: 4-byte record header at `pos` (type + BE24 length), then the
|
||||
/// record body starts at `pos + 4`. The body holds the BE u32 Type field at
|
||||
/// body offset 0 (`pos + 4`), then the BE u32 version at body offset 4
|
||||
/// (`pos + 8`).
|
||||
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
// 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).
|
||||
mkb_records(mkb)
|
||||
.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]]))
|
||||
}
|
||||
|
||||
/// `0x00031003` — recordable media MKB (Class I & II compute Km directly).
|
||||
pub const MKB_TYPE_3_RECORDABLE: u32 = 0x0003_1003;
|
||||
|
||||
/// `0x00041003` — AACS 1.0 pre-recorded content MKB (KCD-based). Standard BD.
|
||||
pub const MKB_TYPE_4_PRERECORDED: u32 = 0x0004_1003;
|
||||
|
||||
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
|
||||
pub const MKB_TYPE_10_CLASS_II: u32 = 0x000A_1003;
|
||||
|
||||
/// `0x48141003` — AACS 2.0 Category C (UHD content) MKB type value.
|
||||
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
|
||||
|
||||
/// `0x48151003` — AACS 2.1 Category C (UHD content) MKB type value.
|
||||
pub const MKB_21_CATEGORY_C: u32 = 0x4815_1003;
|
||||
|
||||
/// The AACS MKB Type field, decoded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MkbType {
|
||||
/// Type 3 — recordable media.
|
||||
Recordable,
|
||||
/// Type 4 — AACS 1.0 pre-recorded content (KCD). Standard Blu-ray.
|
||||
Prerecorded,
|
||||
/// Type 10 — Class II / Unified (SKB).
|
||||
ClassII,
|
||||
/// AACS 2.0 Category C — UHD content.
|
||||
CategoryC20,
|
||||
/// AACS 2.1 Category C — UHD content.
|
||||
CategoryC21,
|
||||
/// Unrecognized MKBType value (raw field preserved).
|
||||
Other(u32),
|
||||
}
|
||||
|
||||
impl MkbType {
|
||||
pub(crate) fn from_raw(raw: u32) -> Self {
|
||||
match raw {
|
||||
MKB_TYPE_3_RECORDABLE => MkbType::Recordable,
|
||||
MKB_TYPE_4_PRERECORDED => MkbType::Prerecorded,
|
||||
MKB_TYPE_10_CLASS_II => MkbType::ClassII,
|
||||
MKB_20_CATEGORY_C => MkbType::CategoryC20,
|
||||
MKB_21_CATEGORY_C => MkbType::CategoryC21,
|
||||
other => MkbType::Other(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// AACS generation this MKB belongs to (Category C → 2.0/2.1, else 1.0).
|
||||
pub fn generation(self) -> AacsVersion {
|
||||
match self {
|
||||
MkbType::CategoryC21 => AacsVersion::V21,
|
||||
MkbType::CategoryC20 => AacsVersion::V20,
|
||||
_ => AacsVersion::V10,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` for UHD (AACS 2.x Category C); `false` for Blu-ray (AACS 1.x).
|
||||
pub fn is_uhd(self) -> bool {
|
||||
matches!(self, MkbType::CategoryC20 | MkbType::CategoryC21)
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw 32-bit MKBType field from the Type-and-Version record (0x10), bytes
|
||||
/// 4-7. `None` if no 0x10 record is present. [C] §3.2.5.1.1 Table 3-2.
|
||||
pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
|
||||
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
|
||||
mkb_records(mkb)
|
||||
.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]]))
|
||||
}
|
||||
|
||||
/// Decode an MKB's Type field. `None` if no Type-and-Version record is present.
|
||||
pub fn mkb_type(mkb: &[u8]) -> Option<MkbType> {
|
||||
mkb_type_raw(mkb).map(MkbType::from_raw)
|
||||
}
|
||||
|
||||
/// `Some(true)` if this MKB is a UHD (AACS 2.x Category C) block, `Some(false)`
|
||||
/// for Blu-ray (AACS 1.x), `None` if the Type record is absent.
|
||||
pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+291
-60
@@ -13,20 +13,52 @@
|
||||
//!
|
||||
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
||||
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
||||
//!
|
||||
//! ## Spec provenance
|
||||
//!
|
||||
//! The crypto below carries `[TAG] §x.y` citations back to the published AACS
|
||||
//! specification (Final Rev 0.953), so each primitive links to the section it
|
||||
//! implements:
|
||||
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
|
||||
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
|
||||
//! - `[BD]` — AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
|
||||
//! - `[RE]` — reverse-engineered from real discs, cited only where the public
|
||||
//! spec is silent (the `0x86` verify record and the Category-C MKB type values).
|
||||
|
||||
pub mod boil;
|
||||
pub mod decrypt;
|
||||
pub mod content;
|
||||
pub mod crypto;
|
||||
pub mod derive;
|
||||
pub mod host_certs;
|
||||
pub mod keys;
|
||||
pub mod index_select;
|
||||
pub mod inf;
|
||||
pub mod mkb;
|
||||
pub mod provider;
|
||||
pub mod resolve;
|
||||
pub mod segment;
|
||||
pub mod segment_key;
|
||||
pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variants;
|
||||
pub mod variant;
|
||||
|
||||
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
|
||||
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
|
||||
/// or changing a fallback in one place can then never silently diverge the
|
||||
/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory
|
||||
/// discovery.
|
||||
///
|
||||
/// BD and UHD keep their key material under a fixed `/AACS/…` tree, so those
|
||||
/// paths are constants. HD DVD keeps the equivalents in a reserved root
|
||||
/// directory whose NAME is authoring-house-specific — observed `ANY!` (Dukes
|
||||
/// of Hazzard) and `AAC!` (Freedom / Memory-Tech), each with a `<name>!_BAK`
|
||||
/// mirror — and whose title-key file is NOT always `VTKF000.AACS` (Freedom
|
||||
/// ships `VTKF090.AACS` + `VTKF100.AACS`). So the HD DVD files are DISCOVERED
|
||||
/// from the parsed UDF tree ([`find_hddvd_aacs_dir`] + [`role_paths`]), never
|
||||
/// hardcoded.
|
||||
///
|
||||
/// Each key ROLE ([`AacsRole`]) resolves to an ordered candidate list — the
|
||||
/// BD/UHD constants first, then whatever the HD DVD directory actually holds —
|
||||
/// which every reader walks with [`read_first`], 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 none of them, so it falls through to the discovered
|
||||
/// entries. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
|
||||
/// disc_hash / MKB / VID that another reader feeds a key service.
|
||||
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
||||
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
||||
@@ -35,50 +67,135 @@ pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
||||
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||||
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||||
|
||||
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
|
||||
pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk};
|
||||
// Structured, English-free resolution trace.
|
||||
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
|
||||
/// An AACS key-input role. [`role_paths`] maps it to an ordered candidate path
|
||||
/// list (BD/UHD constants, then the discovered HD DVD files).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AacsRole {
|
||||
/// Title-key file: BD/UHD `Unit_Key_RO.inf`, HD DVD `VTKF*.AACS`
|
||||
/// (magic `DVD_HD_V_TKF`). The disc_hash is `SHA1` of this file.
|
||||
UnitKey,
|
||||
/// Media Key Block: BD/UHD `MKB_RO/RW.inf`, HD DVD `MKBROM.AACS`.
|
||||
Mkb,
|
||||
/// Content certificate: BD/UHD `Content000/001.cer`, HD DVD
|
||||
/// `CONTENT_CERT.AACS` (byte 0 gives the AACS major).
|
||||
ContentCert,
|
||||
}
|
||||
|
||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
|
||||
aacs_unit_needs_decrypt, aacs_unit_still_ciphertext, decrypt_bus, decrypt_unit,
|
||||
decrypt_unit_checked, decrypt_unit_full, decrypt_unit_try_keys, fill_null_ts_unit,
|
||||
is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, unit_is_clean_ps,
|
||||
unit_is_clean_ts, unit_key_validates,
|
||||
};
|
||||
// `probe` is a reproduction-harness helper (see keys.rs), not part of the
|
||||
// documented 1.0 surface; keep it reachable but off the rendered docs so we
|
||||
// don't commit semver stability to test primitives.
|
||||
#[doc(hidden)]
|
||||
pub use keys::probe;
|
||||
pub use keys::{
|
||||
AACS_MAJOR_BD, AACS_MAJOR_UHD, AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C,
|
||||
MKB_TYPE_3_RECORDABLE, MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext,
|
||||
ResolveFailure, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_content_len, mkb_is_uhd, mkb_type, mkb_type_raw, mkb_version, parse_content_cert,
|
||||
parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2,
|
||||
resolve_keys_v21, resolve_keys_with_reason, trim_mkb,
|
||||
};
|
||||
pub use provider::KeyProvider;
|
||||
pub use types::{DeviceKey, DiscEntry, HostCert};
|
||||
pub use variants::{
|
||||
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
||||
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
|
||||
};
|
||||
/// The HD DVD AACS directory in a parsed UDF tree, if present.
|
||||
///
|
||||
/// Identified structurally, NOT by a hardcoded name: the root child directory
|
||||
/// whose name ends in `!` (so the `<name>!_BAK` backup mirror, which also ends
|
||||
/// in a non-`!` char, is not mistaken for it) and which contains `MKBROM.AACS`.
|
||||
/// Observed real names: `ANY!` (Dukes of Hazzard), `AAC!` (Freedom). A BD/UHD
|
||||
/// disc has no such directory → `None`.
|
||||
pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> {
|
||||
udf.root.entries.iter().find(|e| {
|
||||
e.is_dir
|
||||
&& e.name.ends_with('!')
|
||||
&& e.entries
|
||||
.iter()
|
||||
.any(|c| !c.is_dir && c.name.eq_ignore_ascii_case("MKBROM.AACS"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Ordered candidate paths for an AACS key [`AacsRole`]: the fixed BD/UHD
|
||||
/// `/AACS/…` paths first, then the actual HD DVD files discovered in the disc's
|
||||
/// AACS directory (see [`find_hddvd_aacs_dir`]). A disc has only one family, so
|
||||
/// the other family's entries simply never read.
|
||||
///
|
||||
/// For [`AacsRole::UnitKey`] every `VTKF*.AACS` in the directory is appended in
|
||||
/// sorted name order — a disc may carry more than one variant (Freedom:
|
||||
/// `VTKF090` + `VTKF100`), not just `VTKF000`.
|
||||
pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String> {
|
||||
let mut v: Vec<String> = match role {
|
||||
AacsRole::UnitKey => vec![PATH_UNIT_KEY_RO, PATH_UNIT_KEY_RO_DUPLICATE],
|
||||
AacsRole::Mkb => vec![PATH_MKB_RO, PATH_MKB_RW],
|
||||
AacsRole::ContentCert => vec![PATH_CONTENT_CERT, PATH_CONTENT_CERT_ALT],
|
||||
}
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
if let Some(dir) = find_hddvd_aacs_dir(udf) {
|
||||
let d = &dir.name;
|
||||
match role {
|
||||
AacsRole::Mkb => v.push(format!("/{d}/MKBROM.AACS")),
|
||||
AacsRole::ContentCert => v.push(format!("/{d}/CONTENT_CERT.AACS")),
|
||||
AacsRole::UnitKey => {
|
||||
// Glob VTKF*.AACS — the title-key filename is not fixed at
|
||||
// VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a
|
||||
// deterministic try order.
|
||||
//
|
||||
// Each VTKF%%%.AACS is bound to ONE playlist (VPLST%%%.XPL): the
|
||||
// TKF's 12-byte PLAYLIST_NAME field (bytes 0x10..0x1C) names the
|
||||
// playlist whose Title Keys it carries, and keys from a TKF whose
|
||||
// name does not match the title's playlist must not be used. The
|
||||
// caller resolves this by trying candidates in sorted order and
|
||||
// decrypting with the one whose keys verify — correct for a
|
||||
// single-playlist disc; a name-matched selection keyed on the
|
||||
// active playlist is the precise form for multi-playlist discs.
|
||||
let mut names: Vec<&str> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir)
|
||||
.filter(|e| {
|
||||
let u = e.name.to_ascii_uppercase();
|
||||
u.starts_with("VTKF") && u.ends_with(".AACS")
|
||||
})
|
||||
.map(|e| e.name.as_str())
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
v.extend(names.into_iter().map(|n| format!("/{d}/{n}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Walk an AACS role's candidate paths (from [`role_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. Generic over the path
|
||||
/// element (`&str` or owned `String`) so it accepts the `Vec<String>` that
|
||||
/// [`role_paths`] builds from the discovered HD DVD directory.
|
||||
pub(crate) fn read_first<S, F>(candidates: &[S], mut read: F) -> crate::error::Result<Vec<u8>>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
F: FnMut(&str) -> crate::error::Result<Vec<u8>>,
|
||||
{
|
||||
for path in candidates {
|
||||
if let Ok(buf) = read(path.as_ref()) {
|
||||
return Ok(buf);
|
||||
}
|
||||
}
|
||||
Err(crate::error::Error::AacsNoKeys)
|
||||
}
|
||||
|
||||
// The module structure IS the public API — consumers import from the owning
|
||||
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
||||
// `aacs::derive::{derive_vuk, resolve_candidate}`, `aacs::resolve::resolve_keys_v2`).
|
||||
// The `derive::probe` reproduction harness stays reachable via its module path.
|
||||
//
|
||||
// A small set of flat re-exports is kept for the typed key primitives and the
|
||||
// content-decrypt entry points that downstream key-source crates import through
|
||||
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
|
||||
// lets those crates track the module refactor without a lockstep re-pin.
|
||||
pub use content::ALIGNED_UNIT_LEN;
|
||||
pub use derive::derive_vuk;
|
||||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Re-export surface guards. The module's public API is the set of
|
||||
//! `pub use` items above. A regression that drops or renames an export
|
||||
//! (the class of bug that shipped in 0.31.0 by silently changing a
|
||||
//! surface) breaks compilation of these references, so they act as a
|
||||
//! compile-time contract for the crate's AACS surface.
|
||||
//! Surface guards. The public API is the module tree itself (no facade).
|
||||
//! Touching one representative item per module keeps these as a
|
||||
//! compile-time contract that the module paths stay stable.
|
||||
|
||||
use super::*;
|
||||
use super::content::ALIGNED_UNIT_LEN;
|
||||
use super::inf::{disc_hash, disc_hash_hex};
|
||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||
use super::variant::is_variant_mkb;
|
||||
|
||||
#[test]
|
||||
fn aligned_unit_len_is_three_2048_byte_sectors() {
|
||||
@@ -100,21 +217,135 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_correction_data_placeholder_is_all_zero() {
|
||||
// The variant chain refuses to run against this all-zero placeholder
|
||||
// KCD; the public constant must therefore be exactly 16 zero bytes.
|
||||
assert_eq!(KEY_CORRECTION_DATA_PLACEHOLDER, [0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_helpers_are_callable_through_the_facade() {
|
||||
// Touch a representative function from each re-export group so a
|
||||
// dropped/renamed export fails to compile. These are smoke calls, not
|
||||
// behavioural assertions (behaviour is covered in each module).
|
||||
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
|
||||
fn public_helpers_are_callable_by_module_path() {
|
||||
// Touch a representative function from each module so a dropped/renamed
|
||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||
let _ = !crate::aacs::content::is_clean(
|
||||
&[0u8; ALIGNED_UNIT_LEN],
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
let _ = mk_from_pk(&[[0u8; 16]], &[]);
|
||||
let _ = super::derive::resolve_candidate(
|
||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HD DVD AACS directory / filename discovery ────────────────────────
|
||||
//
|
||||
// The HD DVD AACS dir name and title-key filename are authoring-specific
|
||||
// and were previously hardcoded to `/ANY!/VTKF000.AACS`. These verify the
|
||||
// discovery replacement against both real-disc shapes: Freedom (`AAC!` +
|
||||
// `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||||
|
||||
#[test]
|
||||
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||||
use crate::udf::fixture::*;
|
||||
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||||
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||||
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||||
let mut disc = MemDisc::new();
|
||||
let aacs_files = vec![
|
||||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||||
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||||
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||||
];
|
||||
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: aacs_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AAC!_BAK".to_string(),
|
||||
icb_lba: 30,
|
||||
dir_data_lba: 31,
|
||||
files: bak_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
// Discovered structurally (ends in '!', holds MKBROM.AACS) — the real
|
||||
// AACS dir, never the `_BAK` mirror.
|
||||
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||||
assert_eq!(dir.name, "AAC!");
|
||||
|
||||
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||||
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
"/AAC!/VTKF090.AACS".to_string(),
|
||||
"/AAC!/VTKF100.AACS".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/MKBROM.AACS"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/CONTENT_CERT.AACS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() {
|
||||
use crate::udf::fixture::*;
|
||||
// A `/AACS/` tree (BD/UHD) has no '!' directory → discovery finds none
|
||||
// and the candidate list is exactly the static BD/UHD paths.
|
||||
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: "AACS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file("Unit_Key_RO.inf", 100, 5000, 2048, true),
|
||||
file("MKB_RO.inf", 101, 5100, 2048, 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!(super::find_hddvd_aacs_dir(&udf).is_none());
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ use super::types::{DeviceKey, DiscEntry, HostCert};
|
||||
/// Source of AACS key material.
|
||||
///
|
||||
/// 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.
|
||||
pub trait KeyProvider: Send + Sync {
|
||||
/// Device keys (top-of-tree, walked by the resolver).
|
||||
|
||||
+119
-1124
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,454 @@
|
||||
//! 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;
|
||||
|
||||
/// 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 {
|
||||
// SPNs are untrusted (from IndividualSegment.tbl); an inverted record
|
||||
// (start_spn > end_spn) would underflow `end_byte - 1 - start_byte` below.
|
||||
if s.start_spn > s.end_spn {
|
||||
continue;
|
||||
}
|
||||
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 a positive AacsKeyMap: an LBA in no range has no key.
|
||||
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges);
|
||||
assert_eq!(map.key_idx_for(500), None, "outside any segment → no key");
|
||||
assert_eq!(
|
||||
map.key_idx_for(1012),
|
||||
Some(5),
|
||||
"inside index-5 segment → key 5"
|
||||
);
|
||||
assert_eq!(
|
||||
map.key_idx_for(1940),
|
||||
Some(7),
|
||||
"inside index-7 segment → key 7"
|
||||
);
|
||||
assert_eq!(
|
||||
map.key_idx_for(1019),
|
||||
None,
|
||||
"segment end is exclusive → no key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_skips_inverted_segment_without_underflow() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// start_spn == end_spn + 1: `end_byte - 1 - start_byte` would underflow.
|
||||
// The record must be skipped rather than panic (debug) / wrap (release).
|
||||
let segs = vec![Segment {
|
||||
index: 5,
|
||||
start_spn: 200,
|
||||
end_spn: 199,
|
||||
}];
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert!(ranges.is_empty(), "inverted segment yields no range");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
+225
-3
@@ -6,7 +6,7 @@
|
||||
//! owns only the crypto and these value types that flow through it.
|
||||
|
||||
/// A device key for MKB subset-difference tree processing.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceKey {
|
||||
pub key: [u8; 16],
|
||||
pub node: u16,
|
||||
@@ -15,7 +15,7 @@ pub struct DeviceKey {
|
||||
}
|
||||
|
||||
/// Host certificate + private key for AACS SCSI authentication.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
||||
pub private_key: [u8; 20],
|
||||
@@ -27,8 +27,75 @@ pub struct HostCert {
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vid(pub [u8; 16]);
|
||||
|
||||
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MediaKey(pub [u8; 16]);
|
||||
|
||||
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
||||
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// Processing Key (Kp, 16 bytes) — an MKB Subset-Difference key that yields the
|
||||
/// Media Key. A leaked/precomputed PK in the keydb, or the intermediate PK a
|
||||
/// device-key walk derives at its matching SD node.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProcessingKey(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
///
|
||||
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
|
||||
/// handed to the VUK→UK step (i.e. its order in `Unit_Key_RO.inf`'s key-storage
|
||||
/// area). The CPS-unit *number* association is a higher-level concern owned by
|
||||
/// [`super::inf::parse_unit_key_ro`], which pairs each positional key with its
|
||||
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
/// AACS 2.1 (FMTS) forensic **index** tag (see [`crate::aacs::segment`]).
|
||||
///
|
||||
/// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0
|
||||
/// key and for the bulk of a 2.1 title. `1..=32` = a forensic index key that
|
||||
/// decrypts the `IndividualSegment.tbl` segments tagged with that same index.
|
||||
/// This is the per-segment index (1..32), NOT the AACS 2.1 Media Key Variant
|
||||
/// (the 65536-value device selector), which is a separate MKB-layer concern.
|
||||
pub index_number: u8,
|
||||
}
|
||||
|
||||
impl UnitKey {
|
||||
/// An ordinary (non-forensic) unit key: `index_number == 0`. The value
|
||||
/// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title.
|
||||
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
index_number: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A forensic index key: `index_number` in `1..=32`, decrypting the
|
||||
/// `IndividualSegment.tbl` segments tagged with that index.
|
||||
pub const fn forensic(idx: u32, key: [u8; 16], index_number: u8) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
index_number,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this key decrypts ordinary (non-forensic) content (index 0).
|
||||
pub const fn is_default_index(&self) -> bool {
|
||||
self.index_number == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct DiscEntry {
|
||||
/// Disc hash (20 bytes, hex)
|
||||
pub disc_hash: String,
|
||||
@@ -43,3 +110,158 @@ pub struct DiscEntry {
|
||||
/// Unit keys (title keys) indexed by CPS unit number
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
// ── Redacting `Debug` impls ──────────────────────────────────────────────────
|
||||
//
|
||||
// Every type above carries AACS secret material (device keys, host PRIVATE keys,
|
||||
// media/volume/processing/unit keys). `#[derive(Debug)]` would print those bytes
|
||||
// verbatim, so a stray `debug!("{:?}", …)` or a panic message would leak the
|
||||
// keys. These hand-written impls print only NON-secret shape (presence, lengths,
|
||||
// tree coordinates, indices) — never key bytes. `decrypt::DecryptKeys` follows
|
||||
// the same policy by omitting `Debug` entirely; here we keep `Debug` because
|
||||
// these are `PartialEq`/`Eq` value types used in `assert_eq!` and nested inside
|
||||
// other `#[derive(Debug)]` structs, so the trait must exist — just not leak.
|
||||
// Guarded by `redaction_tests` below.
|
||||
|
||||
impl std::fmt::Debug for DeviceKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DeviceKey")
|
||||
.field("key", &"<redacted>")
|
||||
.field("node", &self.node)
|
||||
.field("uv", &self.uv)
|
||||
.field("u_mask_shift", &self.u_mask_shift)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HostCert {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HostCert")
|
||||
.field("private_key", &"<redacted>")
|
||||
.field("certificate_len", &self.certificate.len())
|
||||
.field("private_key_v2", &self.private_key_v2.map(|_| "<redacted>"))
|
||||
.field(
|
||||
"certificate_v2_len",
|
||||
&self.certificate_v2.as_ref().map(|c| c.len()),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Vid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Vid(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MediaKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("MediaKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Vuk {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Vuk(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProcessingKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("ProcessingKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UnitKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UnitKey")
|
||||
.field("idx", &self.idx)
|
||||
.field("key", &"<redacted>")
|
||||
.field("index_number", &self.index_number)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DiscEntry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DiscEntry")
|
||||
.field("disc_hash", &self.disc_hash)
|
||||
.field("title", &self.title)
|
||||
.field("media_key", &self.media_key.map(|_| "<redacted>"))
|
||||
.field("disc_id", &self.disc_id.map(|_| "<redacted>"))
|
||||
.field("vuk", &self.vuk.map(|_| "<redacted>"))
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod redaction_tests {
|
||||
use super::*;
|
||||
|
||||
// Sentinel key byte 0xD5 = decimal 213. A derived `Debug` prints `[u8;N]`
|
||||
// as decimal, so a leaked key surfaces the substring "213"; the redacting
|
||||
// impls must not. No non-secret field below is 213, so "213" appearing means
|
||||
// key bytes leaked. Each type must also carry a "redacted" marker (or omit
|
||||
// the secret entirely) so re-adding `#[derive(Debug)]` fails this test.
|
||||
const S: u8 = 0xD5;
|
||||
|
||||
fn assert_redacted(what: &str, dbg: &str) {
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"{what}: Debug leaked key bytes (found decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"{what}: Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_key_debug_is_redacted() {
|
||||
let d = DeviceKey {
|
||||
key: [S; 16],
|
||||
node: 1,
|
||||
uv: 2,
|
||||
u_mask_shift: 3,
|
||||
};
|
||||
assert_redacted("DeviceKey", &format!("{d:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cert_debug_is_redacted() {
|
||||
let h = HostCert {
|
||||
private_key: [S; 20],
|
||||
certificate: vec![0u8; 92],
|
||||
private_key_v2: Some([S; 32]),
|
||||
certificate_v2: None,
|
||||
};
|
||||
assert_redacted("HostCert", &format!("{h:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_keys_debug_is_redacted() {
|
||||
assert_redacted("Vid", &format!("{:?}", Vid([S; 16])));
|
||||
assert_redacted("MediaKey", &format!("{:?}", MediaKey([S; 16])));
|
||||
assert_redacted("Vuk", &format!("{:?}", Vuk([S; 16])));
|
||||
assert_redacted("ProcessingKey", &format!("{:?}", ProcessingKey([S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_key_debug_is_redacted() {
|
||||
assert_redacted("UnitKey", &format!("{:?}", UnitKey::new(0, [S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_entry_debug_is_redacted() {
|
||||
let e = DiscEntry {
|
||||
disc_hash: "0xAA".into(),
|
||||
title: "T".into(),
|
||||
media_key: Some([S; 16]),
|
||||
disc_id: Some([S; 16]),
|
||||
vuk: Some([S; 16]),
|
||||
unit_keys: vec![(1, [S; 16])],
|
||||
};
|
||||
assert_redacted("DiscEntry", &format!("{e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -236,7 +236,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
}
|
||||
|
||||
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
|
||||
/// language, codec sub-fields). Layout per BD spec / libbluray
|
||||
/// language, codec sub-fields). Layout per the BD CLPI spec
|
||||
/// clpi_parse.c:
|
||||
///
|
||||
/// ```text
|
||||
@@ -394,7 +394,7 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// Stream PID entry — bit-packed per BD spec (libbluray clpi_parse.c):
|
||||
// Stream PID entry — bit-packed per the BD CLPI spec:
|
||||
// stream_PID: 16 bits → ep_map[2..4]
|
||||
// reserved: 10 bits ┐
|
||||
// EP_stream_type: 4 bits │ ep_map[4..14] = 80 bits
|
||||
@@ -821,7 +821,7 @@ mod tests {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Added hardening tests. Grounded in the BD-ROM CLPI spec
|
||||
// (https://github.com/lw/BluRay/wiki/CLPI) and libbluray clpi_parse.c.
|
||||
// (https://github.com/lw/BluRay/wiki/CLPI).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a ProgramInfo section. `streams` = Vec<(pid, sci_bytes)>.
|
||||
|
||||
@@ -66,6 +66,10 @@ pub mod coding_type {
|
||||
pub const MPEG2_VIDEO: u8 = 0x02;
|
||||
/// H.264 / AVC video (ISO/IEC 13818-1 Table 2-34).
|
||||
pub const H264: u8 = 0x1B;
|
||||
/// H.264 / MVC dependent view (Blu-ray 3D right-eye substream). Carried in
|
||||
/// the SSIF interleaved stream under its own PID; the base view is [`H264`].
|
||||
/// ISO/IEC 13818-1 stream_type 0x20 (MVC video sub-bitstream).
|
||||
pub const H264_MVC: u8 = 0x20;
|
||||
/// HEVC / H.265 video (ISO/IEC 13818-1 Table 2-34, 2015 amendment).
|
||||
pub const HEVC: u8 = 0x24;
|
||||
/// SMPTE VC-1 video (BD-ROM convention, ISO user-private range).
|
||||
|
||||
+120
-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:
|
||||
//! - LFSR1: 17-bit state (9-bit lo + 8-bit hi register, seeded from
|
||||
//! key[0..2]), driven by TAB2/TAB3
|
||||
//! - LFSR0: 24-bit feedback register (seeded from key[2..5] XOR seed[2..5],
|
||||
//! masked to 0xFFFFFF), driven by a feedback polynomial through TAB4
|
||||
//! The algorithm is the one recovered and published in Frank A. Stevenson's
|
||||
//! 1999 cryptanalysis ("Cryptanalysis of Contents Scrambling System") and
|
||||
//! described in the open CSS literature. It is implemented here from that public
|
||||
//! description; its constants (see [`super::tables`]) are the cipher's own
|
||||
//! defined values. Nothing in this file is copied or translated from any
|
||||
//! particular CSS software.
|
||||
//!
|
||||
//! The keystream is the bytewise sum (with carry) of both LFSR outputs.
|
||||
//! Content descrambling computes plain = TAB1[cipher] ^ keystream — a TAB1
|
||||
//! substitution of each ciphertext byte followed by an XOR with the keystream
|
||||
//! (NOT a plain XOR; the cipher is not its own inverse).
|
||||
//! The cipher uses two table-driven linear-feedback circuits:
|
||||
//! - **LFSR1** — a 17-bit register (a 9-bit and an 8-bit half seeded from
|
||||
//! `key[0..2] XOR seed[0..2]`), stepped through `TAB2`/`TAB3`/`TAB5`.
|
||||
//! - **LFSR0** — a 24-bit feedback register (seeded from `key[2..5] XOR
|
||||
//! seed[2..5]`), stepped through a feedback polynomial and `TAB4`.
|
||||
//!
|
||||
//! Algorithm: Frank A. Stevenson's divide-and-conquer attack (1999).
|
||||
//! Tables: CSS specification constants.
|
||||
//! Each output byte is the sum-with-carry of the two register outputs. A body
|
||||
//! byte is recovered as `plain = TAB1[cipher] ^ keystream` — a `TAB1`
|
||||
//! substitution of the ciphertext byte followed by an XOR with the keystream
|
||||
//! (so the cipher is deliberately not its own inverse).
|
||||
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
/// Descramble a CSS-encrypted DVD sector in place.
|
||||
///
|
||||
/// Exact port of libdvdcss `dvdcss_unscramble` (css.c). The two content
|
||||
/// LFSRs are seeded **directly** from `title_key XOR sector_seed` — there is
|
||||
/// no `decrypt_key` mangling on this path (that is the disc/title-key
|
||||
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
|
||||
/// `*p = TAB1[*p] ^ (i_t5 & 0xff)`.
|
||||
/// The two feedback registers are seeded **directly** from
|
||||
/// `title_key XOR sector_seed` (bytes `0x54..0x59`) — there is no title-key
|
||||
/// mangling on the content path (that belongs to the disc/title-key hierarchy,
|
||||
/// not the sector cipher). Only the body, bytes `0x80..0x800`, is transformed:
|
||||
/// `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`.
|
||||
///
|
||||
/// The scramble flag at byte 0x14 (bits 4-5) indicates encryption. Like
|
||||
/// libdvdcss, the flag byte is NOT modified here — the caller treats a
|
||||
/// nonzero `sector[0x14] & 0x30` as "needs unscrambling" and the descramble
|
||||
/// is its own inverse, so re-running it on plaintext would re-scramble.
|
||||
/// (freemkv historically cleared the flag; we keep clearing it so callers
|
||||
/// and the existing tests can distinguish a descrambled sector. This does
|
||||
/// not affect the recovered body.)
|
||||
/// The scramble flag at byte `0x14` (bits 4-5) marks an encrypted sector. This
|
||||
/// routine CLEARS that flag after unscrambling, so a descrambled sector reads as
|
||||
/// `sector[0x14] & 0x30 == 0`; callers and tests use that to tell it from
|
||||
/// ciphertext, and re-running descramble on an already-cleared sector is a no-op
|
||||
/// (the flag guard below skips it). Clearing does not affect the recovered body.
|
||||
///
|
||||
/// No-op (returns without modifying `sector`) in two cases:
|
||||
/// - `sector.len() < 2048`: the encrypted region (0x80..0x800) is not
|
||||
/// fully present. Callers chunk by 2048, so a trailing partial chunk is
|
||||
/// left untouched. The `debug_assert!` flags this misuse in debug/test
|
||||
/// builds; a DVD sector is always exactly 2048 bytes.
|
||||
/// - `sector.len() < 2048`: the encrypted region (`0x80..0x800`) is not fully
|
||||
/// present. Callers chunk by 2048, so a trailing partial chunk is left
|
||||
/// untouched. The `debug_assert!` flags this misuse in debug/test builds; a
|
||||
/// DVD sector is always exactly 2048 bytes.
|
||||
/// - scramble flags are zero: the sector is not CSS-encrypted.
|
||||
///
|
||||
/// Design reference: libdvdcss `dvdcss_unscramble`. The combiner mirrors
|
||||
/// `css.c` line-for-line:
|
||||
/// ```text
|
||||
/// i_t1 = (key[0] ^ sec[0x54]) | 0x100;
|
||||
/// i_t2 = key[1] ^ sec[0x55];
|
||||
/// i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ (sec[0x56]|sec[0x57]<<8|sec[0x58]<<16);
|
||||
/// i_t4 = i_t3 & 7; i_t3 = i_t3*2 + 8 - i_t4;
|
||||
/// // per byte over 0x80..0x800:
|
||||
/// i_t4 = TAB2[i_t2] ^ TAB3[i_t1];
|
||||
/// i_t2 = i_t1 >> 1; i_t1 = ((i_t1 & 1) << 8) ^ i_t4; i_t4 = TAB5[i_t4];
|
||||
/// i_t6 = (((((((i_t3>>3)^i_t3)>>1)^i_t3)>>8)^i_t3)>>5) & 0xff;
|
||||
/// i_t3 = (i_t3 << 8) | i_t6; i_t6 = TAB4[i_t6];
|
||||
/// i_t5 += i_t6 + i_t4; *p = TAB1[*p] ^ (i_t5 & 0xff); i_t5 >>= 8;
|
||||
/// ```
|
||||
pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
debug_assert!(
|
||||
sector.len() >= 2048,
|
||||
@@ -62,102 +50,103 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// libdvdcss: `if( !(p_sec[0x14] & 0x30) ) return;`
|
||||
// Not scrambled (flag bits 4-5 clear) → nothing to do.
|
||||
if sector[0x14] & 0x30 == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// LFSR1: seeded directly from (key ^ seed) — NO decrypt_key.
|
||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
// LFSR1 halves, seeded from (key ^ seed) bytes 0-1. The 9-bit half carries a
|
||||
// set bit 8 (`| 0x100`) as its running marker.
|
||||
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
|
||||
// LFSR0 (i_t3): 24-bit feedback register seeded from the remaining three
|
||||
// key/seed bytes, then transformed `i_t3 = i_t3*2 + 8 - (i_t3 & 7)`.
|
||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
||||
// LFSR0 (24-bit), seeded from the remaining three key/seed bytes, then
|
||||
// pre-conditioned `r0 = r0*2 + 8 - (r0 & 7)`.
|
||||
let mut r0: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
let i_t4_seed = i_t3 & 7;
|
||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
||||
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||
|
||||
let mut i_t5: u32 = 0;
|
||||
// Keystream accumulator; the low byte is the current keystream byte and the
|
||||
// high bits carry into the next iteration.
|
||||
let mut acc: u32 = 0;
|
||||
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
// Advance LFSR1.
|
||||
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
// Step LFSR1: its output byte `o1`.
|
||||
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||
r1b = r1a >> 1;
|
||||
r1a = ((r1a & 1) << 8) ^ o1;
|
||||
o1 = TAB5[o1 as usize] as u32;
|
||||
|
||||
// Advance LFSR0 (i_t3) and fold both outputs into i_t5.
|
||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
// Step LFSR0: its output byte `o0`.
|
||||
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||
r0 = (r0 << 8) | o0;
|
||||
o0 = TAB4[o0 as usize] as u32;
|
||||
|
||||
*byte = TAB1[*byte as usize] ^ (i_t5 & 0xFF) as u8;
|
||||
i_t5 >>= 8;
|
||||
// Combine (sum with carry) and recover the plaintext byte.
|
||||
acc += o0 + o1;
|
||||
*byte = TAB1[*byte as usize] ^ (acc & 0xFF) as u8;
|
||||
acc >>= 8;
|
||||
}
|
||||
|
||||
// libdvdcss leaves byte 0x14 untouched; freemkv clears the scramble bits
|
||||
// so downstream code and tests can tell a sector was descrambled.
|
||||
// Clear the scramble bits so downstream code and tests can tell a sector was
|
||||
// descrambled; bits 6-7 of byte 0x14 are preserved.
|
||||
sector[0x14] &= 0xCF;
|
||||
}
|
||||
|
||||
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
|
||||
/// CSS ciphertext under `title_key`.
|
||||
///
|
||||
/// Descramble computes `plain = TAB1[cipher] ^ (i_t5 & 0xff)`, so the
|
||||
/// inverse is `cipher = TAB1_INV[plain ^ (i_t5 & 0xff)]` with the identical
|
||||
/// LFSR keystream. The keystream derivation is byte-for-byte the same as
|
||||
/// `descramble_sector` (libdvdcss `dvdcss_unscramble`); only the final
|
||||
/// substitution differs. Bytes 0x80..0x800 are rewritten in place; the
|
||||
/// scramble flag is set to 0x10 so a subsequent descramble runs.
|
||||
/// Descramble computes `plain = TAB1[cipher] ^ (keystream & 0xff)`, so the
|
||||
/// inverse is `cipher = TAB1_INV[plain ^ (keystream & 0xff)]` with the identical
|
||||
/// keystream. The keystream derivation is the same as [`descramble_sector`];
|
||||
/// only the final substitution differs. Bytes `0x80..0x800` are rewritten in
|
||||
/// place; the scramble flag is set to `0x10` so a subsequent descramble runs.
|
||||
///
|
||||
/// Not on any production read path — it exists so the key-recovery tests
|
||||
/// (and any caller that needs to produce a known CSS-encrypted sector) can
|
||||
/// build genuine ciphertext rather than approximating it.
|
||||
/// Not on any production read path — it exists so the key-recovery tests (and
|
||||
/// any caller that needs a known CSS-encrypted sector) can build genuine
|
||||
/// ciphertext rather than approximating it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
if sector.len() < 2048 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
||||
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
let mut r0: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
let i_t4_seed = i_t3 & 7;
|
||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
||||
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||
|
||||
let mut i_t5: u32 = 0;
|
||||
let mut acc: u32 = 0;
|
||||
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||
r1b = r1a >> 1;
|
||||
r1a = ((r1a & 1) << 8) ^ o1;
|
||||
o1 = TAB5[o1 as usize] as u32;
|
||||
|
||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||
r0 = (r0 << 8) | o0;
|
||||
o0 = TAB4[o0 as usize] as u32;
|
||||
acc += o0 + o1;
|
||||
|
||||
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
|
||||
*byte = (*TAB1_INV)[(*byte ^ (i_t5 & 0xFF) as u8) as usize];
|
||||
i_t5 >>= 8;
|
||||
*byte = (*TAB1_INV)[(*byte ^ (acc & 0xFF) as u8) as usize];
|
||||
acc >>= 8;
|
||||
}
|
||||
|
||||
// Mark the sector scrambled so the descrambler will process it.
|
||||
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
|
||||
}
|
||||
|
||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a
|
||||
/// bijection on 0..256, so `TAB1_INV[TAB1[x]] == x`.
|
||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a bijection on
|
||||
/// `0..256`, so `TAB1_INV[TAB1[x]] == x`.
|
||||
#[cfg(test)]
|
||||
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
|
||||
let mut inv = [0u8; 256];
|
||||
@@ -181,14 +170,15 @@ mod tests {
|
||||
assert_eq!(sector, original);
|
||||
}
|
||||
|
||||
/// Cross-check `descramble_sector` against the EXACT output of libdvdcss
|
||||
/// `dvdcss_unscramble` (css.c) for a fixed sector, computed from the
|
||||
/// reference C semantics with the reference tables. Pins the content
|
||||
/// cipher to libdvdcss byte-for-byte.
|
||||
/// Regression vector: the deterministic output of the CSS content cipher for
|
||||
/// a fixed key/seed/body. The value is generated by this implementation and
|
||||
/// is self-consistent with the scramble/descramble round-trip below — any
|
||||
/// correct CSS descrambler yields the same bytes, since the cipher is
|
||||
/// deterministic. Pins the implementation against accidental change.
|
||||
///
|
||||
/// key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA.
|
||||
#[test]
|
||||
fn descramble_matches_libdvdcss_unscramble_vector() {
|
||||
fn descramble_produces_the_reference_css_vector() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
@@ -200,12 +190,12 @@ mod tests {
|
||||
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
|
||||
0xA0, 0x31
|
||||
],
|
||||
"descramble body head must match libdvdcss dvdcss_unscramble"
|
||||
"descramble body head must match the reference CSS vector"
|
||||
);
|
||||
assert_eq!(
|
||||
§or[0x7F8..0x800],
|
||||
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
|
||||
"descramble body tail must match libdvdcss dvdcss_unscramble"
|
||||
"descramble body tail must match the reference CSS vector"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -241,8 +231,8 @@ mod tests {
|
||||
|
||||
/// Test 2: descramble inverts scramble over the body.
|
||||
///
|
||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to
|
||||
/// the ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to the
|
||||
/// ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
|
||||
/// with the same key must reproduce the original body exactly.
|
||||
#[test]
|
||||
@@ -279,9 +269,9 @@ mod tests {
|
||||
|
||||
/// css_tab1_relationship
|
||||
///
|
||||
/// Verify the structure of TAB1: it is a substitution table used in
|
||||
/// key mangling. Check that no two inputs map to the same output
|
||||
/// (TAB1 is a permutation of 0..255).
|
||||
/// Verify the structure of TAB1: it is a substitution table used in key
|
||||
/// mangling. Check that no two inputs map to the same output (TAB1 is a
|
||||
/// permutation of 0..255).
|
||||
#[test]
|
||||
fn css_tab1_is_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
@@ -332,8 +322,8 @@ mod tests {
|
||||
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
|
||||
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
|
||||
///
|
||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live
|
||||
/// in bits 4-5; the masked value 0 means not scrambled.
|
||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live in
|
||||
/// bits 4-5; the masked value 0 means not scrambled.
|
||||
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
|
||||
/// seen as scrambled and the body would change.
|
||||
#[test]
|
||||
@@ -352,11 +342,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each individual scramble bit (4 and 5) independently marks the sector
|
||||
/// as encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||
/// Each individual scramble bit (4 and 5) independently marks the sector as
|
||||
/// encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||
///
|
||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both
|
||||
/// nonzero.
|
||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both nonzero.
|
||||
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
|
||||
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
|
||||
#[test]
|
||||
@@ -381,8 +370,8 @@ mod tests {
|
||||
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
|
||||
///
|
||||
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
|
||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved
|
||||
/// high bits assert fails.
|
||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved high
|
||||
/// bits assert fails.
|
||||
#[test]
|
||||
fn descramble_clear_preserves_high_bits_of_0x14() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
@@ -398,11 +387,11 @@ mod tests {
|
||||
|
||||
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
|
||||
|
||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80
|
||||
/// (the header) must NOT be modified by the keystream — except byte 0x14
|
||||
/// whose flag is cleared. In particular the sector-seed bytes 0x54..0x59
|
||||
/// (which live inside the header) must survive untouched, since the
|
||||
/// descrambler reads them but never writes them.
|
||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80 (the
|
||||
/// header) must NOT be modified by the keystream — except byte 0x14 whose
|
||||
/// flag is cleared. In particular the sector-seed bytes 0x54..0x59 (which
|
||||
/// live inside the header) must survive untouched, since the descrambler
|
||||
/// reads them but never writes them.
|
||||
///
|
||||
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
|
||||
/// 128..2048 only.
|
||||
@@ -429,16 +418,15 @@ mod tests {
|
||||
assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive");
|
||||
}
|
||||
|
||||
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a
|
||||
/// prefix. With a constant body and constant key, the keystream is
|
||||
/// non-degenerate enough that the very last sector byte (index 2047) is
|
||||
/// altered. This guards the loop bound `.take(2048)` against an
|
||||
/// off-by-one that would leave the final byte(s) scrambled.
|
||||
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a prefix.
|
||||
/// With a constant body and constant key, the keystream is non-degenerate
|
||||
/// enough that the very last sector byte (index 2047) is altered. This guards
|
||||
/// the loop bound `.take(2048)` against an off-by-one that would leave the
|
||||
/// final byte(s) scrambled.
|
||||
///
|
||||
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
|
||||
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
|
||||
/// assert fires (keystream byte for the last position is verified nonzero
|
||||
/// below by the round-trip, and this body is all-zero so any XOR shows).
|
||||
/// assert fires (this body is all-zero so any keystream XOR shows).
|
||||
#[test]
|
||||
fn descramble_covers_final_body_byte() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
@@ -447,9 +435,7 @@ mod tests {
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
|
||||
// the keystream reaches the final byte. (If the last keystream byte
|
||||
// happened to be 0 this could be a flaky test, so assert the run-end
|
||||
// region as a whole differs from zero.)
|
||||
// the keystream reaches the final byte.
|
||||
assert_ne!(
|
||||
§or[2040..2048],
|
||||
&[0u8; 8][..],
|
||||
@@ -458,13 +444,13 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Descramble is keyed by `title_key XOR seed`: two different title keys
|
||||
/// produce two different bodies for the same scrambled input. A cipher
|
||||
/// that ignored the title key (or mixed it in wrongly) would yield
|
||||
/// identical output — silent wrong-key decryption.
|
||||
/// produce two different bodies for the same scrambled input. A cipher that
|
||||
/// ignored the title key (or mixed it in wrongly) would yield identical
|
||||
/// output — silent wrong-key decryption.
|
||||
///
|
||||
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
|
||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both
|
||||
/// keys give the same body, assert fires.
|
||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both keys
|
||||
/// give the same body, assert fires.
|
||||
#[test]
|
||||
fn descramble_output_depends_on_title_key() {
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
|
||||
+344
-15
@@ -29,7 +29,7 @@ use crate::sector::SectorSource;
|
||||
const CSS_LOCKED_BAIL: u32 = 64;
|
||||
|
||||
/// CSS decryption state for a DVD title.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct CssState {
|
||||
/// 5-byte CSS title key (from SCSI auth or the crack fallback).
|
||||
pub title_key: [u8; 5],
|
||||
@@ -43,10 +43,22 @@ pub struct CssState {
|
||||
pub crack_span: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `CssState` is reachable via the public `Disc.css` field, so
|
||||
// a `{:?}` on a `Disc` would otherwise print the raw CSS title key. Print only
|
||||
// the (non-secret) crack span. Guarded by `css_state_debug_is_redacted`.
|
||||
impl std::fmt::Debug for CssState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CssState")
|
||||
.field("title_key", &"<redacted>")
|
||||
.field("crack_span", &self.crack_span)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
|
||||
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
|
||||
///
|
||||
/// The crib comes from `AttackPattern`: a scrambled sector's cleartext region
|
||||
/// The crib comes from the periodic-run detector: a scrambled sector's cleartext region
|
||||
/// (bytes 0x00..0x80) often ends in a short-period repeating run (stuffing /
|
||||
/// constant fill); the attack assumes that run continues across the 0x80
|
||||
/// boundary into the encrypted region, giving the known plaintext the 2^16
|
||||
@@ -54,12 +66,16 @@ pub struct CssState {
|
||||
/// extents and return the first sector that yields a key — no player keys, no
|
||||
/// disc-key crack. Works on a live drive (after bus-auth unlocks reads) and on
|
||||
/// disc images alike.
|
||||
/// This convenience form runs to completion (no cancellation) and returns just
|
||||
/// the key; callers needing an operator-Stop / watchdog cancel, or the three-way
|
||||
/// [`CrackOutcome`] (to distinguish "unencrypted" from "encrypted-but-uncracked"),
|
||||
/// use [`crack_key_outcome`], which takes a `halt` token.
|
||||
pub fn crack_key(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
batch_sectors: u16,
|
||||
) -> Option<CssState> {
|
||||
crack_key_halt(reader, extents, batch_sectors, None)
|
||||
crack_key_scan(reader, extents, batch_sectors, None, false).into_state()
|
||||
}
|
||||
|
||||
/// Outcome of a CSS crack scan that distinguishes the THREE cases the bare
|
||||
@@ -105,6 +121,12 @@ impl CrackOutcome {
|
||||
/// ScrambledUncracked) so callers can distinguish "genuinely unencrypted" from
|
||||
/// "encrypted but uncrackable" — the latter must become a hard error, never a
|
||||
/// silent fall-through to plaintext.
|
||||
///
|
||||
/// Takes an optional cooperative-cancellation token. "No silent hangs": the
|
||||
/// crack scans up to 50_000 sectors, which on a live drive hitting bad sectors
|
||||
/// can take a long time, so it polls `halt` once per batch (the same cadence
|
||||
/// sweep/patch use) and emits a `freemkv::heartbeat` beat ("css_crack") each
|
||||
/// batch so a stuck scan is visible in the log.
|
||||
pub fn crack_key_outcome(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
@@ -114,27 +136,69 @@ pub fn crack_key_outcome(
|
||||
crack_key_scan(reader, extents, batch_sectors, halt, true)
|
||||
}
|
||||
|
||||
/// [`crack_key`] with an optional cooperative-cancellation token.
|
||||
/// Resolve a DVD title's CSS descramble key from the reader when the caller
|
||||
/// supplied none — the SINGLE place every DVD read path obtains a title key, so
|
||||
/// the file-backed mux highway ([`crate::build_iso_pipeline`]) and the
|
||||
/// live-drive single-pass [`crate::DiscStream`] descramble a DVD identically
|
||||
/// ("reading is reading"). CSS keys are per-VTS and crackable from the scrambled
|
||||
/// data itself, so a `None`/MPEG-PS title cracks its own key here, in playback
|
||||
/// order over `extents`. Everything else is left untouched:
|
||||
/// - AACS keys (HD-DVD `.evo` is also MPEG-PS but arrives as `Aacs`) — no CSS.
|
||||
/// - a title that already carries a key — nothing to resolve.
|
||||
/// - a genuinely clear DVD (no scrambled sector) — stays `None`, a mux no-op.
|
||||
///
|
||||
/// "No silent hangs": the crack scans up to 50_000 sectors, which on a live
|
||||
/// drive hitting bad sectors can take a long time. This variant polls `halt`
|
||||
/// once per batch (the same cadence sweep/patch use) so an operator Stop or a
|
||||
/// scan-level watchdog can interrupt the scan, and emits a
|
||||
/// `freemkv::heartbeat` beat ("css_crack") each batch so a stuck scan is
|
||||
/// visible in the log.
|
||||
pub fn crack_key_halt(
|
||||
/// A scrambled-but-uncrackable title is a hard [`crate::error::Error::CssKeyMissing`],
|
||||
/// never a silent scrambled-passthrough mux.
|
||||
pub(crate) fn resolve_dvd_title_key(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
keys: &mut crate::decrypt::DecryptKeys,
|
||||
batch_sectors: u16,
|
||||
format: crate::disc::ContentFormat,
|
||||
raw: bool,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> Option<CssState> {
|
||||
crack_key_scan(reader, extents, batch_sectors, halt, false).into_state()
|
||||
) -> std::io::Result<()> {
|
||||
// `--raw` = deliberate ciphertext passthrough: never crack or descramble, and
|
||||
// never hard-fail on scrambled-uncrackable — the user asked for the scrambled
|
||||
// bytes. (In raw mode the caller hands us `None` on purpose; without this
|
||||
// guard we'd install a real key and silently DECRYPT, or abort a raw mux.)
|
||||
if raw {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(keys, crate::decrypt::DecryptKeys::None)
|
||||
&& format == crate::disc::ContentFormat::MpegPs
|
||||
{
|
||||
// `halt` threads the caller's cancellation token so /api/stop can
|
||||
// interrupt a long crack scan (the old scan-time crack honored it too).
|
||||
let outcome = crack_key_outcome(reader, extents, batch_sectors, halt);
|
||||
// A cancelled crack breaks out early, so its outcome is a TRUNCATED scan
|
||||
// — not a real verdict. Interpreting it would either hard-fail a good disc
|
||||
// as `ScrambledUncracked` (quarantining staging on a Stop) or, worse,
|
||||
// read a half-scanned title as `Unencrypted` and mux scrambled bytes as
|
||||
// plaintext. Surface the cancellation as `Halted` so the caller takes its
|
||||
// graceful-stop path instead of trusting the partial outcome.
|
||||
if halt.map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
return Err(crate::error::Error::Halted.into());
|
||||
}
|
||||
match outcome {
|
||||
CrackOutcome::Cracked(state) => {
|
||||
*keys = crate::decrypt::DecryptKeys::Css {
|
||||
title_key: state.title_key,
|
||||
};
|
||||
}
|
||||
CrackOutcome::ScrambledUncracked => {
|
||||
return Err(crate::error::Error::CssKeyMissing.into());
|
||||
}
|
||||
CrackOutcome::Unencrypted => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
|
||||
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
|
||||
/// from a genuinely-unencrypted one (the [`crack_key`] / [`crack_key_halt`]
|
||||
/// `Option` wrappers collapse both to `None`).
|
||||
/// from a genuinely-unencrypted one (the [`crack_key`] `Option` wrapper
|
||||
/// collapses both to `None` via [`CrackOutcome::into_state`]).
|
||||
fn crack_key_scan(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
@@ -271,6 +335,52 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
|
||||
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.
|
||||
///
|
||||
/// This is the RAW flag test — bits 4-5 of the sub-header byte 0x14 — used by
|
||||
@@ -318,6 +428,26 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// `CssState` is reachable via the public `Disc.css` field, so a `{:?}` on a
|
||||
/// `Disc` must not print the raw CSS title key. Sentinel byte 213 (0xD5);
|
||||
/// `crack_span` is non-secret and none of its values are 213.
|
||||
#[test]
|
||||
fn css_state_debug_is_redacted() {
|
||||
let s = CssState {
|
||||
title_key: [0xD5; 5],
|
||||
crack_span: Some((10, 20)),
|
||||
};
|
||||
let dbg = format!("{s:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"CssState Debug leaked the title key: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"CssState Debug missing marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── is_scrambled ───────────────────────────────────────────────────────
|
||||
|
||||
/// is_scrambled returns false for any buffer shorter than one sector,
|
||||
@@ -822,6 +952,205 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `resolve_dvd_title_key` is the SINGLE shared per-title CSS step both read
|
||||
/// paths (`build_iso_pipeline` multi-pass and `DiscStream::new` single-pass)
|
||||
/// call, so these pin its full contract at the shared boundary.
|
||||
///
|
||||
/// Crack path: a `None`-keyed MPEG-PS title with a crackable scrambled sector
|
||||
/// installs a `Css` key that round-trips the sector.
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_cracks_none_mpegps() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||
let crackable = crackable_sector(&title_key, &seed, 8);
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.crackable = Some((1003, crackable));
|
||||
let extents = [Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 50,
|
||||
}];
|
||||
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||
resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("crackable title resolves");
|
||||
match keys {
|
||||
crate::decrypt::DecryptKeys::Css { title_key: got } => {
|
||||
assert_eq!(got, title_key, "installed key must be the cracked key")
|
||||
}
|
||||
_ => panic!("expected Css key"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard-fail path: a scrambled-but-uncrackable `None`-keyed MPEG-PS title must
|
||||
/// return `CssKeyMissing`, never leave `keys` as `None` (which would mux
|
||||
/// scrambled bytes as plaintext — the 328k-decode-error corruption).
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_scrambled_uncrackable_hard_fails() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.lock_all = true; // every read CSS-locked → ScrambledUncracked
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||
let err = resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect_err("scrambled-uncrackable must hard-fail");
|
||||
// The Error::CssKeyMissing flattens into io::Error carrying its E-code
|
||||
// (7023) in the message — assert that specific code survived.
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains(&format!("E{}", crate::error::E_CSS_KEY_MISSING)),
|
||||
"must surface CssKeyMissing (E{}), got: {err}",
|
||||
crate::error::E_CSS_KEY_MISSING
|
||||
);
|
||||
assert!(
|
||||
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||
"keys must stay None on hard-fail (never a scrambled-passthrough key)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `raw` is deliberate ciphertext passthrough: even a scrambled-uncrackable
|
||||
/// title must return `Ok` and leave `keys` untouched (`None`) — no crack, no
|
||||
/// hard-fail. This is the `--raw` guarantee.
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_raw_skips_crack_and_never_fails() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.lock_all = true;
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||
resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
true, // raw
|
||||
None,
|
||||
)
|
||||
.expect("raw must never hard-fail");
|
||||
assert!(
|
||||
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||
"raw must leave keys None (no descramble)"
|
||||
);
|
||||
assert!(
|
||||
src.reads.borrow().is_empty(),
|
||||
"raw must not read any sector for a crack"
|
||||
);
|
||||
}
|
||||
|
||||
/// AACS gate: an MPEG-PS title carrying `Aacs` keys (HD-DVD `.evo`) must be
|
||||
/// left untouched — resolve only fires on `None` keys, never overwriting a
|
||||
/// real key set or cracking AACS ciphertext as CSS.
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_leaves_aacs_untouched() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.lock_all = true; // would hard-fail IF it ran the crack
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let mut keys = crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0u8; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::MpegPs,
|
||||
};
|
||||
resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("AACS title must be left untouched, not cracked");
|
||||
assert!(
|
||||
matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. }),
|
||||
"Aacs keys must survive unchanged"
|
||||
);
|
||||
assert!(
|
||||
src.reads.borrow().is_empty(),
|
||||
"must not read for a crack when keys are already Aacs"
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear DVD: a `None`-keyed MPEG-PS title with no scrambled sector stays
|
||||
/// `None` (a mux no-op) and returns `Ok` — genuinely-unencrypted DVDs pass.
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_clear_dvd_stays_none() {
|
||||
let mut src = MockSource::new(0x00); // all-clear sectors
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||
resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.expect("clear DVD passes");
|
||||
assert!(
|
||||
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||
"a clear DVD must keep None keys"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cancelled crack (user Stop mid-scan) must surface as `Halted`, NOT be
|
||||
/// misread from the truncated scan as `Unencrypted` (→ scrambled passthrough,
|
||||
/// corruption) or `ScrambledUncracked` (→ CssKeyMissing, which quarantines a
|
||||
/// good disc). This pins the halt-outcome fix.
|
||||
#[test]
|
||||
fn resolve_dvd_title_key_halt_surfaces_as_halted_not_a_verdict() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.lock_all = true; // without the halt guard this would be ScrambledUncracked
|
||||
let extents = [Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let halt = crate::halt::Halt::new();
|
||||
halt.cancel(); // Stop already pressed
|
||||
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||
let err = resolve_dvd_title_key(
|
||||
&mut src,
|
||||
&extents,
|
||||
&mut keys,
|
||||
4,
|
||||
crate::disc::ContentFormat::MpegPs,
|
||||
false,
|
||||
Some(&halt),
|
||||
)
|
||||
.expect_err("a cancelled crack must return an error");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains(&format!("E{}", crate::error::E_HALTED)),
|
||||
"cancelled crack must surface Halted (E{}), got: {err}",
|
||||
crate::error::E_HALTED
|
||||
);
|
||||
}
|
||||
|
||||
/// CSS_ERROR WIRING (audit §2 / §5 #7): an all-locked synthetic ISO (every
|
||||
/// VOB read returns CSS-locked sense `05/6F/03` across MULTIPLE extents, as a
|
||||
/// real encrypted-but-unauthenticated disc image does) must produce the exact
|
||||
|
||||
+42
-61
@@ -1,48 +1,37 @@
|
||||
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
|
||||
//! (1999), ported exactly from libdvdcss `RecoverTitleKey` + `AttackPattern`
|
||||
//! (css.c).
|
||||
//!
|
||||
//! Recovers the 5-byte CSS title key from a single scrambled DVD sector with
|
||||
//! no player keys and no disc-key crack, using only known plaintext.
|
||||
//! (1999), implemented from his published cryptanalysis ("Cryptanalysis of
|
||||
//! Contents Scrambling System"). It recovers the 5-byte CSS title key from a
|
||||
//! single scrambled DVD sector with no player keys and no disc-key crack, using
|
||||
//! only known plaintext. Implemented from that public description; nothing here
|
||||
//! is copied or translated from any particular CSS software.
|
||||
//!
|
||||
//! # The cipher this attacks
|
||||
//!
|
||||
//! The content descrambler ([`super::lfsr::descramble_sector`], = libdvdcss
|
||||
//! `dvdcss_unscramble`) seeds its two LFSRs **directly** from
|
||||
//! `key = title_key XOR sector_seed` (seed = `sector[0x54..0x59]`):
|
||||
//!
|
||||
//! ```text
|
||||
//! i_t1 = (key[0] ^ sec[0x54]) | 0x100; // LFSR1 low (9-bit)
|
||||
//! i_t2 = key[1] ^ sec[0x55]; // LFSR1 high
|
||||
//! i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ seed3; // LFSR0 (24-bit feedback)
|
||||
//! i_t3 = i_t3*2 + 8 - (i_t3 & 7);
|
||||
//! // per byte: *p = TAB1[*p] ^ (i_t5 & 0xff)
|
||||
//! ```
|
||||
//!
|
||||
//! There is NO `decrypt_key` mangling on the content path. So the recovery
|
||||
//! is a single inversion of `dvdcss_unscramble`, not the multi-stage
|
||||
//! working-key inversion the previous (non-CSS) implementation used.
|
||||
//! The content descrambler ([`super::lfsr::descramble_sector`]) seeds its two
|
||||
//! LFSRs **directly** from `key = title_key XOR sector_seed` (seed =
|
||||
//! `sector[0x54..0x59]`): LFSR1 from key/seed bytes 0-1, LFSR0 (24-bit) from
|
||||
//! bytes 2-4 with the pre-conditioning `r0 = r0*2 + 8 - (r0 & 7)`, and each body
|
||||
//! byte recovered as `plain = TAB1[cipher] ^ (keystream & 0xff)`. There is no
|
||||
//! title-key mangling on the content path, so the recovery is a single inversion
|
||||
//! of the sector cipher.
|
||||
//!
|
||||
//! # The attack
|
||||
//!
|
||||
//! 1. **Known plaintext → keystream.** Because the descramble applies TAB1
|
||||
//! to the ciphertext, the per-byte keystream is
|
||||
//! `buf[i] = TAB1[cipher[i]] ^ plain[i]` (matching libdvdcss
|
||||
//! `RecoverTitleKey`'s `p_buffer`).
|
||||
//! 1. **Known plaintext → keystream.** Because descramble applies TAB1 to the
|
||||
//! ciphertext, the per-byte keystream is `TAB1[cipher[i]] ^ plain[i]`.
|
||||
//! 2. **Brute the 16-bit LFSR1 seed.** For each of 2^16 seeds, run LFSR1
|
||||
//! forward; for the first four steps deduce the LFSR0 output bytes from
|
||||
//! the keystream (carry-tracked), reconstructing `i_t3`. For the next six
|
||||
//! steps clock LFSR0 normally and check it reproduces the keystream — a
|
||||
//! wrong LFSR1 seed fails fast.
|
||||
//! 3. **Back-clock LFSR0.** Run four backward `i_t3` steps (each a 256-way
|
||||
//! search for the byte shifted in) to reach the initial state, then undo
|
||||
//! `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
||||
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]` (plain XOR —
|
||||
//! the descramble seeds directly, so there is no inversion).
|
||||
//! forward; for the first four steps deduce the LFSR0 output bytes from the
|
||||
//! keystream (carry-tracked), reconstructing LFSR0's state. For the next six
|
||||
//! steps clock LFSR0 normally and check it reproduces the keystream — a wrong
|
||||
//! LFSR1 seed fails fast.
|
||||
//! 3. **Back-clock LFSR0.** Run four backward steps (each a 256-way search for
|
||||
//! the byte shifted in) to reach the initial state, then undo the
|
||||
//! `r0*2 + 8 - (r0 & 7)` pre-conditioning to recover key[2..5].
|
||||
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]`.
|
||||
//!
|
||||
//! `AttackPattern` finds known plaintext for step 1: the longest periodic
|
||||
//! run in the cleartext `sec[0x00..0x80]`, assumed to continue into the
|
||||
//! encrypted region at 0x80.
|
||||
//! Known plaintext for step 1 comes from the longest periodic run in the
|
||||
//! cleartext `sec[0x00..0x80]`, assumed to continue into the encrypted region at
|
||||
//! 0x80.
|
||||
|
||||
use super::lfsr::descramble_sector;
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
@@ -52,13 +41,11 @@ const ENCRYPTED_START: usize = 0x80; // byte 128
|
||||
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
||||
const FLAG_BYTE: usize = 0x14;
|
||||
|
||||
/// RecoverTitleKey: recover the title key from cipher + known plaintext.
|
||||
///
|
||||
/// Exact port of libdvdcss `RecoverTitleKey` (css.c). `crypted` is the
|
||||
/// ciphertext starting at sector byte 0x80; `decrypted` is the matching
|
||||
/// known plaintext; `seed` is `sector[0x54..0x59]`. On success returns the
|
||||
/// recovered 5-byte title key; `None` if no LFSR seed reproduces the
|
||||
/// keystream.
|
||||
/// Recover the title key from cipher + known plaintext (the core of Stevenson's
|
||||
/// attack). `crypted` is the ciphertext starting at sector byte 0x80;
|
||||
/// `decrypted` is the matching known plaintext; `seed` is `sector[0x54..0x59]`.
|
||||
/// On success returns the recovered 5-byte title key; `None` if no LFSR seed
|
||||
/// reproduces the keystream.
|
||||
///
|
||||
/// At least 10 bytes of `crypted`/`decrypted` are required (the cipher is
|
||||
/// iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).
|
||||
@@ -222,16 +209,13 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
|
||||
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
|
||||
}
|
||||
|
||||
/// AttackPattern: find a repeating pattern just before the encrypted region
|
||||
/// and assume the plaintext at 0x80 continues it.
|
||||
///
|
||||
/// Functionally-equivalent port of libdvdcss `AttackPattern` (css.c) — finds the
|
||||
/// same periodic cribs on real DVD data, though its byte-comparison anchor
|
||||
/// differs from the C on phase-misaligned runs. Scans cleartext
|
||||
/// `sec[0x00..0x80]` for the longest run that repeats with a cycle length in
|
||||
/// 2..0x2F. If the run is long enough (`plen > 3` and at least two full
|
||||
/// cycles), the known plaintext at 0x80 is taken to be the periodic run
|
||||
/// continuing forward, and [`recover_title_key_from_plain`] is applied.
|
||||
/// Find a repeating pattern just before the encrypted region and assume the
|
||||
/// plaintext at 0x80 continues it — the known-plaintext step of Stevenson's
|
||||
/// attack. Scans cleartext `sec[0x00..0x80]` for the longest run that repeats
|
||||
/// with a cycle length in 2..0x2F. If the run is long enough (`plen > 3` and at
|
||||
/// least two full cycles), the known plaintext at 0x80 is taken to be the
|
||||
/// periodic run continuing forward, and [`recover_title_key_from_plain`] is
|
||||
/// applied.
|
||||
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
if sector.len() < SECTOR_BYTES {
|
||||
return None;
|
||||
@@ -260,10 +244,7 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Inner body of [`crack_title_key`] — the actual AttackPattern search. Split
|
||||
/// out so the public entry point can wall-clock the whole attempt for the
|
||||
/// runaway guard without threading a timer through every return path.
|
||||
/// AttackPattern crib: the predicted 10-byte plaintext at byte 0x80.
|
||||
/// Crib: the predicted 10-byte plaintext at byte 0x80.
|
||||
///
|
||||
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
|
||||
/// run that repeats with a cycle length in 2..0x2F. If the run is long enough
|
||||
@@ -369,7 +350,7 @@ mod tests {
|
||||
|
||||
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
|
||||
/// in a periodic run that continues into the encrypted region — the case
|
||||
/// `AttackPattern` (crack_title_key) is designed to crack.
|
||||
/// `crack_title_key` is designed to crack.
|
||||
fn synth_periodic_sector(
|
||||
title_key: &[u8; 5],
|
||||
seed: &[u8; 5],
|
||||
@@ -382,7 +363,7 @@ mod tests {
|
||||
// (RUN_START..0x80) and continuing into the encrypted region. This
|
||||
// mirrors a real VOB: a periodic data run just before the scrambled
|
||||
// part. The run must NOT overlap the seed bytes (0x54..0x59), or the
|
||||
// AttackPattern detector would break mid-run. The phase is anchored to
|
||||
// the crib detector would break mid-run. The phase is anchored to
|
||||
// offset 0 so the run is consistent across the 0x80 boundary.
|
||||
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
|
||||
// — enough for >=2 cycles of every tested period (<=19).
|
||||
@@ -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
|
||||
/// cleartext ends in a periodic run that continues into 0x80.
|
||||
#[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
|
||||
/// key (XOR-back included) reproduces the plaintext.
|
||||
#[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,
|
||||
];
|
||||
|
||||
/// Table 2: LFSR1 high-byte feedback permutation.
|
||||
///
|
||||
/// Byte-identical to libdvdcss `p_css_tab2` (csstables.h).
|
||||
/// Table 2: LFSR1 high-byte feedback permutation — a fixed constant of the CSS
|
||||
/// cipher (per the published algorithm).
|
||||
pub const TAB2: [u8; 256] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09, 0x08, 0x0b, 0x0a, 0x0d, 0x0c, 0x0f, 0x0e,
|
||||
0x12, 0x13, 0x10, 0x11, 0x16, 0x17, 0x14, 0x15, 0x1b, 0x1a, 0x19, 0x18, 0x1f, 0x1e, 0x1d, 0x1c,
|
||||
@@ -46,12 +45,12 @@ pub const TAB2: [u8; 256] = [
|
||||
0xed, 0xec, 0xef, 0xee, 0xe9, 0xe8, 0xeb, 0xea, 0xe4, 0xe5, 0xe6, 0xe7, 0xe0, 0xe1, 0xe2, 0xe3,
|
||||
];
|
||||
|
||||
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries).
|
||||
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries) — a fixed constant
|
||||
/// of the CSS cipher (per the published algorithm).
|
||||
///
|
||||
/// Byte-identical to libdvdcss `p_css_tab3` (csstables.h): the 8-value
|
||||
/// block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step indexes this
|
||||
/// table with the 9-bit low register (0x100..=0x1FF), but only the low 3
|
||||
/// bits select the output — the high bits are ignored, hence the constant
|
||||
/// It is the 8-value block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step
|
||||
/// indexes this table with the 9-bit low register (0x100..=0x1FF), but only the
|
||||
/// low 3 bits select the output — the high bits are ignored, hence the constant
|
||||
/// blocks. The 512-entry width simply lets the 9-bit index be used without
|
||||
/// masking.
|
||||
pub const TAB3: [u8; 512] = [
|
||||
@@ -197,12 +196,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// TAB3 is the libdvdcss `p_css_tab3`: the 8-value feedback block
|
||||
/// TAB3 is the CSS LFSR1 low-word table: the 8-value feedback block
|
||||
/// BASE = [0x00,0x24,0x49,0x6d,0x92,0xb6,0xdb,0xff]
|
||||
/// repeated 64 times — `TAB3[i] == BASE[i & 7]`. The high bits of the
|
||||
/// 9-bit index do not affect the output (libdvdcss's LFSR1 step indexes
|
||||
/// with the full 9-bit low register but only `& 7` matters). This pins
|
||||
/// all 512 entries to the published table.
|
||||
/// 9-bit index do not affect the output (the LFSR1 step indexes with the
|
||||
/// full 9-bit low register but only `& 7` matters). This pins all 512
|
||||
/// entries to the published cipher's table.
|
||||
///
|
||||
/// Mutation: flip any single byte in the TAB3 literal -> the formula
|
||||
/// check fails at that index.
|
||||
|
||||
+721
-771
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -336,7 +336,7 @@ fn frame_record(track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> V
|
||||
/// `track_number` is the 1-based MKV track number; `track` is the built
|
||||
/// [`crate::mux::mkv::MkvTrack`] whose fields map one-to-one onto the emitted
|
||||
/// elements (see `MkvMuxer::new`). No-op unless the diag target is on.
|
||||
pub fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) {
|
||||
pub(crate) fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) {
|
||||
if !diag_enabled() {
|
||||
return;
|
||||
}
|
||||
@@ -530,7 +530,7 @@ fn dump_aacs(disc: &Disc) {
|
||||
a.bus_encryption,
|
||||
a.mkb_version,
|
||||
a.disc_hash,
|
||||
a.key_source.name(),
|
||||
a.key_source,
|
||||
a.vuk.is_some(),
|
||||
a.unit_keys.len(),
|
||||
a.uk_ro.len(),
|
||||
|
||||
+134
-256
@@ -6,6 +6,18 @@ use crate::mpls;
|
||||
use crate::sector::SectorSource;
|
||||
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 {
|
||||
/// Scan Blu-ray titles from MPLS playlists.
|
||||
pub(super) fn scan_bluray_titles(
|
||||
@@ -60,6 +72,11 @@ impl Disc {
|
||||
// Parse each clip for size, duration, and sector extents
|
||||
let mut extents = Vec::new();
|
||||
let mut total_size: u64 = 0;
|
||||
// Set when any clip resolves to a STREAM/SSIF/<clip>.ssif — a Blu-ray 3D
|
||||
// interleaved stream carrying both the base (left) and MVC dependent
|
||||
// (right) views. Drives reading the SSIF for both eyes and adding the
|
||||
// dependent-view stream below.
|
||||
let mut is_3d = false;
|
||||
let mut clips = Vec::with_capacity(parsed.play_items.len());
|
||||
// BD playlists legally reference the same .m2ts clip_id from
|
||||
// multiple PlayItems (multi-angle, seamless splits, looped
|
||||
@@ -73,22 +90,49 @@ impl Disc {
|
||||
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 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);
|
||||
if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) {
|
||||
if let Ok(clip_info) = clpi::parse(&clpi_data) {
|
||||
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
|
||||
// total size the first time this clip_id is seen.
|
||||
if first_ref {
|
||||
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.
|
||||
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 {
|
||||
if sectors > 0 && lba > 0 {
|
||||
extents.push(Extent {
|
||||
@@ -112,7 +156,7 @@ impl Disc {
|
||||
}
|
||||
|
||||
// Build streams from STN table
|
||||
let streams: Vec<Stream> = parsed
|
||||
let mut streams: Vec<Stream> = parsed
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
@@ -206,6 +250,42 @@ impl Disc {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3D: add the MVC dependent (right-eye) video stream. The base STN table
|
||||
// lists only the left-eye video; the dependent view is a second video
|
||||
// PID (stream_type 0x20) carried in the SSIF. The on-disc PAT/PMT are
|
||||
// AACS-encrypted (unreadable pre-key) and the base STN omits the
|
||||
// dependent view (it lives in the MPLS STN_table_SS), so we use the
|
||||
// BD-3D PID convention: dependent = base-view video PID + 1
|
||||
// (e.g. 0x1011 -> 0x1012). Reading the SSIF (above) provides its packets.
|
||||
//
|
||||
// Limitation: `is_3d` latches per PLAYLIST, not per clip. A playlist that
|
||||
// mixed a 3D clip (has an SSIF) with a 2D clip (no SSIF) would tag the
|
||||
// whole title 3D; the 2D clip's frames then mux as plain Blocks (no
|
||||
// dependent PID → no BlockAdditional) under a track that still advertises
|
||||
// the mvcC mapping. That output is valid (per-frame BlockAdditional is
|
||||
// optional) but over-claims 3D for those frames. Real 3D main-feature
|
||||
// playlists are single-clip or uniformly 3D, so this is not exercised;
|
||||
// per-clip 3D would need per-clip stream sets (a larger change).
|
||||
if is_3d {
|
||||
if let Some(base) = streams.iter().find_map(|s| match s {
|
||||
Stream::Video(v) => Some(v.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
let dep_pid = base.pid.wrapping_add(1);
|
||||
let have_dep = streams
|
||||
.iter()
|
||||
.any(|s| matches!(s, Stream::Video(v) if v.pid == dep_pid));
|
||||
if !have_dep {
|
||||
streams.push(Stream::Video(VideoStream {
|
||||
pid: dep_pid,
|
||||
secondary: true,
|
||||
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
|
||||
..base
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert marks to chapters. mark_type == 1 is an entry-mark
|
||||
// (chapter); type 2 is a link point and type 0 is reserved, so
|
||||
// neither is a chapter.
|
||||
@@ -314,256 +394,7 @@ impl Disc {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sector::SectorSource;
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
use crate::udf::fixture::*;
|
||||
// ---------------------------------------------------------------
|
||||
// MPLS builder (BD-ROM PlayList spec). Mirrors the layout the
|
||||
// `mpls::parse` consumer reads (header@0, PlayList@playlist_start,
|
||||
@@ -797,13 +628,29 @@ mod tests {
|
||||
u32, /*packets*/
|
||||
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 {
|
||||
// Layout LBAs: pick widely separated values to avoid collisions.
|
||||
let mut stream_files = Vec::new();
|
||||
let mut clipinf_files = Vec::new();
|
||||
let mut icb = 100u32;
|
||||
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).
|
||||
let size = sectors * 2048;
|
||||
stream_files.push(file(&m2ts, icb, *data_lba, size, true));
|
||||
@@ -881,6 +728,37 @@ mod tests {
|
||||
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
|
||||
/// from multiple PlayItems (seamless split / looped segment) must count
|
||||
/// the physical extents and packet bytes EXACTLY ONCE — mux reads
|
||||
|
||||
+74
-37
@@ -7,7 +7,6 @@ use crate::udf;
|
||||
|
||||
/// Result of SCSI AACS handshake (ECDH authentication).
|
||||
/// Only available when scanning from a real drive, not ISO images.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct HandshakeResult {
|
||||
pub volume_id: [u8; 16],
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
@@ -29,6 +28,19 @@ pub(super) struct HandshakeResult {
|
||||
pub drive_unlocked: bool,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `volume_id` and `read_data_key` (the AACS 2.0 bus key) are
|
||||
// secret; print only shape. Guarded by `handshake_result_debug_is_redacted`.
|
||||
impl std::fmt::Debug for HandshakeResult {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HandshakeResult")
|
||||
.field("volume_id", &"<redacted>")
|
||||
.field("read_data_key", &self.read_data_key.map(|_| "<redacted>"))
|
||||
.field("read_data_key_err", &self.read_data_key_err)
|
||||
.field("drive_unlocked", &self.drive_unlocked)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Single source of truth for "is AACS bus encryption gone for this scan?". The
|
||||
/// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus
|
||||
/// encryption is gone when ANY of these holds:
|
||||
@@ -86,9 +98,9 @@ impl AacsCertUnlocker<'_> {
|
||||
// MKB generation (best-effort) — forwarded to each source's
|
||||
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
|
||||
// (the default impl ignores it). A read failure leaves it `None`.
|
||||
let mkb_gen = aacs::read_mkb_from_drive(session.scsi_mut())
|
||||
let mkb_gen = aacs::inf::read_mkb_from_drive(session.scsi_mut())
|
||||
.ok()
|
||||
.and_then(|m| aacs::mkb_version(&m));
|
||||
.and_then(|m| aacs::mkb::mkb_version(&m));
|
||||
|
||||
// Host certs are keysource-served, never compiled in — unioned from the
|
||||
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
|
||||
@@ -111,13 +123,13 @@ impl AacsCertUnlocker<'_> {
|
||||
// through method calls, so clone the (cheap) identity first.
|
||||
let drive_id = session.drive_id.clone();
|
||||
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(),
|
||||
&drive_id,
|
||||
freemkv_unlock::DiscKind::Aacs,
|
||||
&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.
|
||||
let Some(volume_id) = unlocked.vid else {
|
||||
return Err(CertUnlockFailure::Unlock(UnlockError::VidUnavailable));
|
||||
@@ -152,10 +164,10 @@ fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::UnlockOutcome`]
|
||||
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::trace::UnlockOutcome`]
|
||||
/// for the resolution trace (English-free).
|
||||
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::trace::UnlockOutcome {
|
||||
use crate::aacs::trace::UnlockOutcome;
|
||||
use freemkv_unlock::UnlockError;
|
||||
match e {
|
||||
CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
||||
@@ -230,7 +242,10 @@ impl Disc {
|
||||
/// `mkb` is the disc's MKB generation when known, forwarded to each source's
|
||||
/// [`crate::KeySource::host_certs`] so a source MAY return only
|
||||
/// generation-appropriate certs (the default ignores it).
|
||||
fn collect_host_certs(opts: &ScanOptions, mkb: Option<u32>) -> Vec<crate::aacs::HostCert> {
|
||||
fn collect_host_certs(
|
||||
opts: &ScanOptions,
|
||||
mkb: Option<u32>,
|
||||
) -> Vec<crate::aacs::types::HostCert> {
|
||||
// Delegates to the shared cert primitive (the external freemkv-unlock-aacs
|
||||
// plugin uses the same one). Kept as a thin Disc method so the existing
|
||||
// collect_host_certs_* unit tests and call sites are unchanged.
|
||||
@@ -308,18 +323,19 @@ impl Disc {
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs;
|
||||
|
||||
let uk_ro_data = udf_fs
|
||||
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let dh = aacs::disc_hash(&uk_ro_data);
|
||||
let uk_ro_data =
|
||||
aacs::read_first(&aacs::role_paths(udf_fs, aacs::AacsRole::UnitKey), |p| {
|
||||
udf_fs.read_file(reader, p)
|
||||
})?;
|
||||
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
||||
|
||||
let cc = udf_fs
|
||||
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
||||
let cc = aacs::read_first(
|
||||
&aacs::role_paths(udf_fs, aacs::AacsRole::ContentCert),
|
||||
|p| udf_fs.read_file(reader, p),
|
||||
)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::parse_content_cert);
|
||||
.and_then(aacs::inf::parse_content_cert);
|
||||
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
|
||||
// No-cert default = UHD (V20 stride), matching `read_aacs_version` so the
|
||||
// scanned `AacsState.version` and the out-of-band fetch agree. A wrong
|
||||
@@ -328,7 +344,7 @@ impl Disc {
|
||||
let version = cc
|
||||
.as_ref()
|
||||
.map(|c| c.version.major())
|
||||
.unwrap_or(aacs::AACS_MAJOR_UHD);
|
||||
.unwrap_or(aacs::mkb::AACS_MAJOR_UHD);
|
||||
|
||||
// Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content
|
||||
// Certificate bus-encryption bit set) carries bus encryption on its
|
||||
@@ -394,12 +410,12 @@ impl Disc {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let mkb_ver = aacs::mkb_version(&mkb_bytes);
|
||||
let mkb_ver = aacs::mkb::mkb_version(&mkb_bytes);
|
||||
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_vid_only",
|
||||
disc_hash = %aacs::disc_hash_hex(&dh),
|
||||
disc_hash = %aacs::inf::disc_hash_hex(&dh),
|
||||
version,
|
||||
bus_encryption,
|
||||
has_vid = handshake.is_some(),
|
||||
@@ -410,7 +426,7 @@ impl Disc {
|
||||
version,
|
||||
bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&dh),
|
||||
disc_hash: aacs::inf::disc_hash_hex(&dh),
|
||||
key_source: KeyOrigin::ExternalUk,
|
||||
vuk: None,
|
||||
unit_keys: vec![],
|
||||
@@ -429,6 +445,27 @@ mod tests {
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `HandshakeResult` carries the Volume ID and the AACS 2.0 bus (read-data)
|
||||
/// key; `Debug` must redact both. Sentinel 213 (0xD5).
|
||||
#[test]
|
||||
fn handshake_result_debug_is_redacted() {
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0xD5; 16],
|
||||
read_data_key: Some([0xD5; 16]),
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
let d = format!("{hs:?}");
|
||||
assert!(
|
||||
!d.contains("213"),
|
||||
"HandshakeResult leaked VID/bus key: {d}"
|
||||
);
|
||||
assert!(
|
||||
d.contains("redacted"),
|
||||
"HandshakeResult missing marker: {d}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// In-memory disc + minimal UDF image with a single physical
|
||||
// partition (metadata_start == partition_start). Offsets cited
|
||||
@@ -569,7 +606,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 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`).
|
||||
fn build_content_cert(cert_type: u8, bus_encryption: bool) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 20];
|
||||
@@ -581,7 +618,7 @@ mod tests {
|
||||
/// 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
|
||||
/// 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> {
|
||||
let mut v = Vec::new();
|
||||
// Type 0x10 record, length 16 (>= 12 so version is read).
|
||||
@@ -687,14 +724,14 @@ mod tests {
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
assert_eq!(
|
||||
st.version,
|
||||
aacs::AACS_MAJOR_UHD,
|
||||
aacs::mkb::AACS_MAJOR_UHD,
|
||||
"no cert → default UHD (major 2)"
|
||||
);
|
||||
assert!(!st.bus_encryption);
|
||||
}
|
||||
|
||||
/// disc_hash is SHA1 of the Unit_Key_RO.inf bytes, hex with 0x prefix
|
||||
/// and uppercase (aacs::disc_hash + disc_hash_hex). The state's
|
||||
/// and uppercase (aacs::inf::disc_hash + disc_hash_hex). The state's
|
||||
/// disc_hash must match independently computing it over the same bytes.
|
||||
#[test]
|
||||
fn resolve_vid_only_disc_hash_is_sha1_of_unit_key_ro() {
|
||||
@@ -710,7 +747,7 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
let expected = aacs::disc_hash_hex(&aacs::disc_hash(&uk));
|
||||
let expected = aacs::inf::disc_hash_hex(&aacs::inf::disc_hash(&uk));
|
||||
assert_eq!(st.disc_hash, expected);
|
||||
assert!(st.disc_hash.starts_with("0x"));
|
||||
// uk_ro must be stashed verbatim for the external resolver.
|
||||
@@ -746,7 +783,7 @@ mod tests {
|
||||
// Real record stream is the single 16-byte type-0x10 record.
|
||||
assert_eq!(
|
||||
st.mkb.len(),
|
||||
aacs::mkb_content_len(&mkb),
|
||||
aacs::mkb::mkb_content_len(&mkb),
|
||||
"MKB must be trimmed to record-stream length, not the zero-pad"
|
||||
);
|
||||
assert_eq!(st.mkb.len(), 16);
|
||||
@@ -943,7 +980,7 @@ mod tests {
|
||||
// disc_hash must be computed over the DUPLICATE bytes.
|
||||
assert_eq!(
|
||||
st.disc_hash,
|
||||
aacs::disc_hash_hex(&aacs::disc_hash(&uk)),
|
||||
aacs::inf::disc_hash_hex(&aacs::inf::disc_hash(&uk)),
|
||||
"fallback must hash the DUPLICATE Unit_Key_RO.inf"
|
||||
);
|
||||
assert_eq!(st.uk_ro, uk);
|
||||
@@ -965,8 +1002,8 @@ mod tests {
|
||||
// the route fails gracefully (AacsNoHostCert), never panics.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
fn fake_cert(tag: u8) -> aacs::HostCert {
|
||||
aacs::HostCert {
|
||||
fn fake_cert(tag: u8) -> aacs::types::HostCert {
|
||||
aacs::types::HostCert {
|
||||
private_key: [tag; 20],
|
||||
certificate: vec![tag; 92],
|
||||
private_key_v2: None,
|
||||
@@ -975,15 +1012,15 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
||||
struct CertSource(Vec<aacs::HostCert>);
|
||||
struct CertSource(Vec<aacs::types::HostCert>);
|
||||
impl crate::KeySource for CertSource {
|
||||
fn get_uk(
|
||||
fn get_unit_keys(
|
||||
&self,
|
||||
_ctx: &dyn crate::keysource::ResolveCtx,
|
||||
) -> Result<Vec<crate::aacs::UnitKey>> {
|
||||
) -> Result<Vec<crate::aacs::types::UnitKey>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::HostCert> {
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::types::HostCert> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
@@ -1077,7 +1114,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cert_unlock_outcome_maps_to_structured_trace_step() {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
use crate::aacs::trace::UnlockOutcome;
|
||||
use freemkv_unlock::UnlockError;
|
||||
// The libfreemkv-side no-cert case carries the MKB generation.
|
||||
assert_eq!(
|
||||
|
||||
+126
-102
@@ -1,6 +1,7 @@
|
||||
//! `Disc::extract_tree` — decrypted file-tree extraction (`dir://`).
|
||||
//!
|
||||
//! Sibling of [`Disc::copy`](super::Disc::copy) (disc → ISO sector dump),
|
||||
//! Sibling of the disc→ISO sector dump (the sweep/patch recovery passes, which
|
||||
//! now live in the `freemkv-engine` crate),
|
||||
//! specialized to write **per file** rather than a whole image, applying
|
||||
//! decryption on the way out, and **without** any multipass / recovery
|
||||
//! orchestration. 1-shot, decrypt-only.
|
||||
@@ -22,7 +23,6 @@ use crate::sector::{DecryptingSectorSource, SectorSource};
|
||||
use crate::udf::{self, DirEntry, UdfFs};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
||||
/// AACS aligned unit = 3 sectors / 6144 bytes. Content reads are issued in
|
||||
@@ -66,10 +66,9 @@ pub struct FileResult {
|
||||
pub path: PathBuf,
|
||||
/// Bytes written that decrypted cleanly.
|
||||
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,
|
||||
/// Bytes lost to undecryptable AACS/CSS units (still ciphertext / dropped).
|
||||
pub bytes_undecryptable: u64,
|
||||
/// True when the file was fully written (renamed from `.partial`).
|
||||
pub complete: bool,
|
||||
}
|
||||
@@ -81,10 +80,8 @@ pub struct ExtractResult {
|
||||
pub files: Vec<FileResult>,
|
||||
/// Aggregate good bytes across all files.
|
||||
pub bytes_good: u64,
|
||||
/// Aggregate unreadable (bad-sector) bytes.
|
||||
/// Aggregate lost bytes — bad sectors AND undecryptable units (one bucket).
|
||||
pub bytes_unreadable: u64,
|
||||
/// Aggregate undecryptable (decrypt-loss) bytes.
|
||||
pub bytes_undecryptable: u64,
|
||||
/// True when every file completed and no loss was recorded.
|
||||
pub complete: bool,
|
||||
/// True when the run stopped early on an interrupt / progress halt.
|
||||
@@ -92,11 +89,10 @@ pub struct ExtractResult {
|
||||
}
|
||||
|
||||
impl ExtractResult {
|
||||
/// Total bytes lost (unreadable + undecryptable). A non-zero value means
|
||||
/// the extraction is holed; the CLI exits non-zero so a script can re-run
|
||||
/// through the `iso://` multipass path.
|
||||
/// Total bytes lost. A non-zero value means the extraction is holed; the CLI
|
||||
/// exits non-zero so a script can re-run through the `iso://` multipass path.
|
||||
pub fn bytes_lost(&self) -> u64 {
|
||||
self.bytes_unreadable + self.bytes_undecryptable
|
||||
self.bytes_unreadable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,14 +186,51 @@ impl Disc {
|
||||
// Per-VTS CSS key map (DVD only): "VTS_xx" -> DecryptKeys. Built lazily
|
||||
// when a scrambled VOB group needs it. AACS / None discs keep the
|
||||
// disc-wide keys for every file.
|
||||
let base_keys = self.decrypt_keys();
|
||||
let mut base_keys = self.decrypt_keys();
|
||||
|
||||
// AACS key map for the extract, chosen by CPS-unit count:
|
||||
//
|
||||
// * SINGLE CPS (the overwhelming majority, incl. every single-key UHD): one
|
||||
// Unit Key opens EVERY encrypted unit on the disc — content in a parsed
|
||||
// title AND an orphan clip that no playlist references. A blanket key-0
|
||||
// map over the whole LBA space is exact and covers orphans; clear
|
||||
// filesystem/nav (encrypted-flag off) passes through untouched.
|
||||
//
|
||||
// * MULTI-CPS: each clip is protected by a different Unit Key, so a blanket
|
||||
// key-0 map would mis-decrypt every secondary-CPS file into garbage
|
||||
// (silently, since Phase::All is trust-only). Build the EXACT per-CPS
|
||||
// content map instead (each title's extents → the CPS key that opens a
|
||||
// real sample from it), up front before the decorator takes the reader. A
|
||||
// content unit whose key the pool lacks fails loud at resolve (extract has
|
||||
// no CPS/forensic fetch source), never emits a wrong-key garble.
|
||||
//
|
||||
// KNOWN LIMITATION (by design): an orphan encrypted clip on a multi-CPS
|
||||
// disc — referenced by no playlist, so in no title extent — is in no range
|
||||
// and passes through as ciphertext. There is no correct key to apply (its
|
||||
// CPS unit is unknown without a playlist reference), and blind trial-decrypt
|
||||
// is exactly what this keymap-only model removes. Single-CPS is unaffected
|
||||
// (the blanket key-0 map above covers orphans).
|
||||
let key_map =
|
||||
match &base_keys {
|
||||
DecryptKeys::Aacs { unit_keys, .. } if unit_keys.len() <= 1 => {
|
||||
Some(std::sync::Arc::new(
|
||||
crate::decrypt::AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]),
|
||||
))
|
||||
}
|
||||
DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
|
||||
self.resolve_content_key_map(reader, &mut base_keys, None, opts.halt.as_ref())?,
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// ── Phase 2: stream each file through the decrypting decorator ────
|
||||
// The decorator owns its inner source for its lifetime. We hand it a
|
||||
// borrowing wrapper (so the caller keeps `reader`), swap keys per CSS
|
||||
// VTS group via `set_keys`; AACS/None keep `base_keys` throughout.
|
||||
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
|
||||
let decrypt_loss = dec.decrypt_loss();
|
||||
if let Some(map) = key_map {
|
||||
dec = dec.with_key_map(map);
|
||||
}
|
||||
|
||||
let mut result = ExtractResult::default();
|
||||
let total_bytes = required;
|
||||
@@ -232,26 +265,15 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire (rather than Relaxed) on these per-file delta loads:
|
||||
// `extract_tree` drives `dec` single-threaded so there is no race
|
||||
// today, and Acquire costs nothing on x86. Note this is only half
|
||||
// the synchronisation: the paired counter store
|
||||
// (sector/decrypting.rs `fetch_add`) is Relaxed, so an Acquire
|
||||
// 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) =
|
||||
// A unit that fails to decrypt fails the read loud (extract runs
|
||||
// non-tolerate), so extract_one_file already zero-filled it and
|
||||
// counted it in bytes_unreadable — one 'lost' bucket covers both
|
||||
// media damage and decrypt failure.
|
||||
let (fr, halted) =
|
||||
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_unreadable = result.bytes_unreadable.saturating_add(fr.bytes_unreadable);
|
||||
result.bytes_undecryptable = result
|
||||
.bytes_undecryptable
|
||||
.saturating_add(fr.bytes_undecryptable);
|
||||
result.files.push(fr);
|
||||
if halted {
|
||||
result.halted = true;
|
||||
@@ -261,7 +283,6 @@ impl Disc {
|
||||
|
||||
result.complete = !result.halted
|
||||
&& result.bytes_unreadable == 0
|
||||
&& result.bytes_undecryptable == 0
|
||||
&& result.files.iter().all(|f| f.complete);
|
||||
Ok(result)
|
||||
}
|
||||
@@ -306,63 +327,6 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the AACS-encrypted stream files (`.m2ts`, `.ssif`). Every other UDF
|
||||
/// file is clear (nav / playlists / filesystem) and needs no decrypt verify.
|
||||
fn is_aacs_clip(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.ends_with(".m2ts") || lower.ends_with(".ssif")
|
||||
}
|
||||
|
||||
/// Enumerate the disc's AACS clip (`.m2ts`/`.ssif`) files as
|
||||
/// [`crate::disc::verify::ClipLayout`]s for the post-read verify gate: each
|
||||
/// clip's declared size plus its absolute disc extents in FILE order. Reads the
|
||||
/// UDF tree through `reader`.
|
||||
///
|
||||
/// FAIL-SAFE: any enumeration error (bad UDF read, name collision, …) yields an
|
||||
/// EMPTY list — the verify gate then covers nothing and the sweep behaves as
|
||||
/// today. Enumeration must never break a rip, so the error is logged, not
|
||||
/// propagated.
|
||||
pub(crate) fn clip_layouts(reader: &mut dyn SectorSource) -> Vec<crate::disc::verify::ClipLayout> {
|
||||
let result = (|| -> Result<Vec<crate::disc::verify::ClipLayout>> {
|
||||
let fs = udf::read_filesystem(reader)?;
|
||||
let mut planned: Vec<PlannedFile> = Vec::new();
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
let mut seen_hosts: std::collections::HashMap<PathBuf, String> =
|
||||
std::collections::HashMap::new();
|
||||
plan_tree(
|
||||
reader,
|
||||
&fs,
|
||||
&fs.root,
|
||||
Path::new(""),
|
||||
"",
|
||||
true,
|
||||
&mut planned,
|
||||
&mut dirs,
|
||||
&mut seen_hosts,
|
||||
)?;
|
||||
Ok(planned
|
||||
.into_iter()
|
||||
.filter(|pf| pf.inline.is_none() && is_aacs_clip(&pf.disc_name))
|
||||
.map(|pf| crate::disc::verify::ClipLayout {
|
||||
size: pf.size,
|
||||
extents: pf.extents,
|
||||
// Every AACS clip we enumerate today is BD-TS (`.m2ts`/`.ssif`).
|
||||
// HD-DVD `.evo` (program stream) maps to `ContainerKind::Ps` here
|
||||
// once `is_aacs_clip` recognises it — the one-line HD-DVD hook.
|
||||
container: crate::disc::verify::ContainerKind::Ts,
|
||||
})
|
||||
.collect())
|
||||
})();
|
||||
result.unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
target: "freemkv::verify",
|
||||
error = %e,
|
||||
"clip enumeration failed; post-read verify disabled for this pass"
|
||||
);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an
|
||||
/// inner source for its lifetime while the caller keeps the underlying
|
||||
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
|
||||
@@ -492,7 +456,6 @@ fn extract_one_file<S: SectorSource>(
|
||||
path: pf.host_rel.clone(),
|
||||
bytes_good: 0,
|
||||
bytes_unreadable: 0,
|
||||
bytes_undecryptable: 0,
|
||||
complete: false,
|
||||
};
|
||||
|
||||
@@ -851,8 +814,11 @@ mod tests {
|
||||
|
||||
struct MemDisc {
|
||||
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>,
|
||||
/// 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 {
|
||||
@@ -860,6 +826,7 @@ mod tests {
|
||||
Self {
|
||||
sectors: HashMap::new(),
|
||||
bad: std::collections::HashSet::new(),
|
||||
decrypt_fail: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
||||
@@ -891,6 +858,9 @@ mod tests {
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
if self.decrypt_fail.contains(&(lba + i)) {
|
||||
return Err(Error::DecryptFailed);
|
||||
}
|
||||
}
|
||||
for i in 0..count as u32 {
|
||||
let off = i as usize * 2048;
|
||||
@@ -1083,13 +1053,13 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Build a clear 6144-byte AACS unit (TS syncs at the 192-byte BD-TS
|
||||
/// stride) then encrypt it under `unit_key` so `aacs::decrypt_unit`
|
||||
/// stride) then encrypt it under `unit_key` so `aacs::content::decrypt_unit`
|
||||
/// recovers it cleanly (zero decrypt loss). Mirrors the encrypt helper in
|
||||
/// `sector/decrypting.rs` tests. `tag` distinguishes two units' payloads.
|
||||
fn encrypt_aacs_unit(unit_key: &[u8; 16], tag: u8) -> Vec<u8> {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < unit.len() {
|
||||
unit[off] = 0x47; // TS sync
|
||||
@@ -1101,14 +1071,14 @@ mod tests {
|
||||
// Flag encrypted via CPI bits (byte 0) before key derivation.
|
||||
unit[0] |= 0xC0;
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
|
||||
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
||||
let mut prev = crate::aacs::decrypt::AACS_IV;
|
||||
let blocks = (crate::aacs::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
let mut prev = crate::aacs::crypto::AACS_IV;
|
||||
let blocks = (crate::aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..blocks {
|
||||
let o = 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
@@ -1124,7 +1094,7 @@ mod tests {
|
||||
|
||||
/// The plaintext that `encrypt_aacs_unit(_, tag)` decrypts back to.
|
||||
fn clear_aacs_unit(tag: u8) -> Vec<u8> {
|
||||
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
|
||||
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < unit.len() {
|
||||
unit[off] = 0x47;
|
||||
@@ -1412,6 +1382,64 @@ mod tests {
|
||||
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.
|
||||
#[test]
|
||||
fn sanitize_rejects_illegal_component() {
|
||||
@@ -1585,10 +1613,6 @@ mod tests {
|
||||
res.bytes_unreadable, 0,
|
||||
"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!(
|
||||
res.complete,
|
||||
"a clean multi-extent AACS file extracts complete"
|
||||
@@ -1600,10 +1624,10 @@ mod tests {
|
||||
/// own batch starts are always unit-aligned; anchoring a later extent
|
||||
/// against the FIRST extent's base mis-aligns whenever the extents' starts
|
||||
/// differ by a non-multiple of 3 sectors. This is the exact arithmetic the
|
||||
/// decrypt-on-read gate (`aacs::is_unit_aligned`) performs.
|
||||
/// decrypt-on-read gate (`aacs::content::is_unit_aligned`) performs.
|
||||
#[test]
|
||||
fn per_extent_base_is_aligned_first_extent_base_is_not() {
|
||||
use crate::aacs::is_unit_aligned;
|
||||
use crate::aacs::content::is_unit_aligned;
|
||||
let ext_a_start = 7000u32; // first extent abs LBA
|
||||
let ext_b_start = 7004u32; // second extent abs LBA (Δ4 — not mult of 3)
|
||||
|
||||
|
||||
+1397
File diff suppressed because it is too large
Load Diff
-1670
File diff suppressed because it is too large
Load Diff
+1116
-2849
File diff suppressed because it is too large
Load Diff
-1705
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
//! Content-based forced-subtitle detection for Blu-ray/UHD PGS tracks.
|
||||
//!
|
||||
//! `freemkv info` and the muxer must agree on which subtitle tracks are forced.
|
||||
//! The muxer derives it from the PGS `forced_on_flag` while muxing a rip; this
|
||||
//! module gives `info` the SAME verdict up front by reading the title's PGS
|
||||
//! streams and feeding them through the one shared classifier
|
||||
//! ([`crate::mux::codec::pgs::ForcedTracker`]) — so the two never diverge.
|
||||
//!
|
||||
//! Cost: a track is only confirmed forced once EVERY display set is seen to be
|
||||
//! forced, so a disc that has a forced track is read through — the
|
||||
//! accuracy-over-speed tradeoff `info` opts into. Full tracks early-exit as soon
|
||||
//! as they show a single non-forced subtitle, and a whole run stops early once
|
||||
//! every track has settled.
|
||||
//!
|
||||
//! Encrypted content: the probe reuses whatever [`SectorSource`] the scan holds.
|
||||
//! With a decrypting source it sees real PGS; without keys it reads ciphertext
|
||||
//! and observes no display sets, in which case it leaves each track's existing
|
||||
//! (vendor-label-derived) forced flag untouched rather than asserting anything.
|
||||
|
||||
use crate::disc::{Codec, DiscTitle, Stream};
|
||||
use crate::mux::codec::CodecParser;
|
||||
use crate::mux::codec::pgs::{ForcedTracker, PgsParser};
|
||||
use crate::mux::ts::TsDemuxer;
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const SECTOR_BYTES: usize = 2048;
|
||||
/// Read the clip in 2 MiB chunks.
|
||||
const CHUNK_SECTORS: u16 = 1024;
|
||||
|
||||
/// Read the title's PGS streams and set `SubtitleStream::forced` from their
|
||||
/// content. Best-effort: any read error ends the probe with whatever verdicts
|
||||
/// have accumulated. Only PGS tracks are touched (DVD VobSub forced comes from
|
||||
/// the IFO/vendor path).
|
||||
pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>(
|
||||
reader: &mut S,
|
||||
title: &mut DiscTitle,
|
||||
) {
|
||||
let pg_pids: Vec<u16> = title
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Subtitle(sub) if sub.codec == Codec::Pgs => Some(sub.pid),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if pg_pids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut demux = TsDemuxer::new(&pg_pids);
|
||||
let mut parsers: HashMap<u16, PgsParser> =
|
||||
pg_pids.iter().map(|&p| (p, PgsParser::new())).collect();
|
||||
let mut trackers: HashMap<u16, ForcedTracker> =
|
||||
pg_pids.iter().map(|&p| (p, ForcedTracker::new())).collect();
|
||||
|
||||
let extents = title.extents.clone();
|
||||
let mut buf = vec![0u8; CHUNK_SECTORS as usize * SECTOR_BYTES];
|
||||
'outer: for ext in &extents {
|
||||
let mut lba = ext.start_lba;
|
||||
let mut remaining = ext.sector_count;
|
||||
while remaining > 0 {
|
||||
let count = remaining.min(CHUNK_SECTORS as u32) as u16;
|
||||
let want = count as usize * SECTOR_BYTES;
|
||||
let n = match reader.read_sectors(lba, count, &mut buf[..want], false) {
|
||||
Ok(n) => n,
|
||||
Err(_) => break 'outer, // best-effort — stop, keep what we have
|
||||
};
|
||||
if n == 0 {
|
||||
break 'outer;
|
||||
}
|
||||
for pes in demux.feed(&buf[..n]) {
|
||||
if let (Some(parser), Some(tracker)) =
|
||||
(parsers.get_mut(&pes.pid), trackers.get_mut(&pes.pid))
|
||||
{
|
||||
for frame in parser.parse(&pes) {
|
||||
tracker.observe(&frame.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every track has already shown a non-forced set → nothing left to
|
||||
// learn; stop reading the (huge) clip.
|
||||
if trackers.values().all(ForcedTracker::settled_not_forced) {
|
||||
break 'outer;
|
||||
}
|
||||
lba += count as u32;
|
||||
remaining -= count as u32;
|
||||
}
|
||||
}
|
||||
|
||||
// Drain any buffered final display set.
|
||||
for (pid, parser) in parsers.iter_mut() {
|
||||
if let Some(tracker) = trackers.get_mut(pid) {
|
||||
for frame in parser.flush() {
|
||||
tracker.observe(&frame.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply verdicts. Only override a track we actually saw content for — an
|
||||
// undecrypted/unread track keeps its vendor-derived flag.
|
||||
for s in &mut title.streams {
|
||||
if let Stream::Subtitle(sub) = s {
|
||||
if sub.codec == Codec::Pgs {
|
||||
if let Some(t) = trackers.get(&sub.pid) {
|
||||
if t.observed() {
|
||||
sub.forced = t.is_forced();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{ContentFormat, Extent, LabelQualifier, SubtitleStream};
|
||||
|
||||
/// A reader that yields all-zeros (an encrypted / unreadable clip) for a
|
||||
/// bounded span, then EOF.
|
||||
struct ZeroReader {
|
||||
served: u32,
|
||||
cap: u32,
|
||||
}
|
||||
impl SectorSource for ZeroReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
if self.served >= self.cap {
|
||||
return Ok(0);
|
||||
}
|
||||
self.served += count as u32;
|
||||
buf.fill(0);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.cap
|
||||
}
|
||||
}
|
||||
|
||||
fn pgs_title(pid: u16, vendor_forced: bool) -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: String::new(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 0.0,
|
||||
size_bytes: 0,
|
||||
clips: vec![],
|
||||
streams: vec![Stream::Subtitle(SubtitleStream {
|
||||
pid,
|
||||
codec: Codec::Pgs,
|
||||
language: "eng".into(),
|
||||
forced: vendor_forced,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_data: None,
|
||||
})],
|
||||
chapters: vec![],
|
||||
extents: vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 4,
|
||||
}],
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: vec![None],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_observed_content_preserves_vendor_forced() {
|
||||
// An unreadable/encrypted clip yields no PGS display sets — the probe must
|
||||
// leave the existing vendor-derived forced flag untouched, never assert
|
||||
// "not forced" from having seen nothing.
|
||||
let mut reader = ZeroReader { served: 0, cap: 4 };
|
||||
let mut title = pgs_title(0x1200, true);
|
||||
probe_and_set_forced(&mut reader, &mut title);
|
||||
let Stream::Subtitle(s) = &title.streams[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert!(s.forced, "no content observed → vendor forced preserved");
|
||||
}
|
||||
|
||||
/// A reader that serves a fixed BD-TS byte stream once (across sequential
|
||||
/// `read_sectors` calls), then EOF — so the probe's demux→parse→observe→apply
|
||||
/// path runs on real synthetic PGS content.
|
||||
struct TsReader {
|
||||
data: Vec<u8>,
|
||||
pos: usize,
|
||||
}
|
||||
impl SectorSource for TsReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
_count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
if self.pos >= self.data.len() {
|
||||
return Ok(0);
|
||||
}
|
||||
let n = buf.len().min(self.data.len() - self.pos);
|
||||
buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
|
||||
self.pos += n;
|
||||
Ok(n)
|
||||
}
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.data.len().div_ceil(SECTOR_BYTES) as u32
|
||||
}
|
||||
}
|
||||
|
||||
// PGS PCS layout (matches the private constants in mux::codec::pgs): a
|
||||
// display-set frame begins with a PCS (segment type 0x16); byte 13 is
|
||||
// number_of_composition_objects; byte 17 is the first object's flags, whose
|
||||
// 0x40 bit is forced_on_flag.
|
||||
const PCS_SEG: u8 = 0x16;
|
||||
const PCS_NUM_OBJECTS_OFF: usize = 13;
|
||||
const PCS_FLAGS_OFF: usize = 17;
|
||||
const PCS_FORCED_FLAG: u8 = 0x40;
|
||||
|
||||
/// One PGS display-set elementary payload with a single composition object;
|
||||
/// `forced` sets forced_on_flag.
|
||||
fn pcs_display(forced: bool) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 18];
|
||||
d[0] = PCS_SEG;
|
||||
d[PCS_NUM_OBJECTS_OFF] = 1;
|
||||
d[PCS_FLAGS_OFF] = if forced { PCS_FORCED_FLAG } else { 0 };
|
||||
d
|
||||
}
|
||||
|
||||
/// Wrap an elementary payload in one 192-byte BD-TS PES packet (PUSI, PTS
|
||||
/// present) on `pid`. `cc` is the 4-bit continuity counter.
|
||||
fn bd_pes_packet(pid: u16, cc: u8, es: &[u8]) -> Vec<u8> {
|
||||
let mut pkt = vec![0u8; 192];
|
||||
// pkt[0..4] = TP_extra_header (zeros). TS packet starts at pkt[4].
|
||||
pkt[4] = 0x47; // sync
|
||||
pkt[5] = 0x40 | ((pid >> 8) & 0x1F) as u8; // PUSI + PID high 5 bits
|
||||
pkt[6] = (pid & 0xFF) as u8; // PID low 8 bits
|
||||
pkt[7] = 0x10 | (cc & 0x0F); // adaptation=payload-only + continuity counter
|
||||
// PES header (at ts payload = pkt[8..]): 00 00 01 stream_id len flags.
|
||||
let p = 8;
|
||||
pkt[p] = 0x00;
|
||||
pkt[p + 1] = 0x00;
|
||||
pkt[p + 2] = 0x01;
|
||||
pkt[p + 3] = 0xBD; // private_stream_1 (carries the standard PES extension)
|
||||
pkt[p + 4] = 0x00; // PES packet length hi (0 = unbounded; ignored by demux)
|
||||
pkt[p + 5] = 0x00; // PES packet length lo
|
||||
pkt[p + 6] = 0x80; // flags1 ('10' marker)
|
||||
pkt[p + 7] = 0x80; // flags2 → PTS present
|
||||
pkt[p + 8] = 0x05; // PES_header_data_length = 5 (one PTS)
|
||||
// 5-byte PTS with the mandatory marker bits (bytes 0,2,4 low bit = 1).
|
||||
pkt[p + 9] = 0x21;
|
||||
pkt[p + 10] = 0x00;
|
||||
pkt[p + 11] = 0x01;
|
||||
pkt[p + 12] = 0x00;
|
||||
pkt[p + 13] = 0x01;
|
||||
let es_off = p + 14; // ES data follows the 14-byte PES header
|
||||
let n = es.len().min(192 - es_off);
|
||||
pkt[es_off..es_off + n].copy_from_slice(&es[..n]);
|
||||
pkt
|
||||
}
|
||||
|
||||
/// Two BD-TS PES on `pid`: the FIRST carries `es` (the observed display set);
|
||||
/// the second (a fresh PUSI) exists only to flush the first PES out of the
|
||||
/// demuxer — the probe never calls `TsDemuxer::flush`, so an open PES stays
|
||||
/// buffered until the next PES start arrives.
|
||||
fn ts_stream(pid: u16, es: &[u8]) -> Vec<u8> {
|
||||
let mut s = bd_pes_packet(pid, 0, es);
|
||||
s.extend_from_slice(&bd_pes_packet(pid, 1, &pcs_display(false)));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_display_sets_apply_forced_verdict() {
|
||||
// Feed REAL synthetic PGS bytes through the full demux→parse→observe→apply
|
||||
// path: a forced display set must flip a vendor-not-forced PGS track to
|
||||
// forced. Mutation guard: inverting ForcedTracker::is_forced flips this.
|
||||
let pid = 0x1200u16;
|
||||
let mut reader = TsReader {
|
||||
data: ts_stream(pid, &pcs_display(true)),
|
||||
pos: 0,
|
||||
};
|
||||
let mut title = pgs_title(pid, false); // vendor label says NOT forced
|
||||
probe_and_set_forced(&mut reader, &mut title);
|
||||
let Stream::Subtitle(s) = &title.streams[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert!(
|
||||
s.forced,
|
||||
"an all-forced PGS track → forced verdict applied onto the stream"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonforced_display_sets_clear_forced_verdict() {
|
||||
// A non-forced display set observed on the wire overrides a vendor-forced
|
||||
// label → the track settles as not-forced.
|
||||
let pid = 0x1200u16;
|
||||
let mut reader = TsReader {
|
||||
data: ts_stream(pid, &pcs_display(false)),
|
||||
pos: 0,
|
||||
};
|
||||
let mut title = pgs_title(pid, true); // vendor label says forced
|
||||
probe_and_set_forced(&mut reader, &mut title);
|
||||
let Stream::Subtitle(s) = &title.streams[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert!(
|
||||
!s.forced,
|
||||
"a non-forced display set observed → forced verdict cleared"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pgs_streams_is_noop() {
|
||||
// A title with no PGS subtitle streams is a no-op (the reader is never
|
||||
// touched — a DVD/VobSub or audio-only title).
|
||||
let mut reader = ZeroReader { served: 0, cap: 0 };
|
||||
let mut title = pgs_title(0x1200, false);
|
||||
// Swap the PGS sub for an audio stream so there are no PGS PIDs.
|
||||
title.streams.clear();
|
||||
probe_and_set_forced(&mut reader, &mut title);
|
||||
assert_eq!(reader.served, 0, "no PGS PIDs → no reads");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,232 +0,0 @@
|
||||
//! `Disc::sweep`'s consumer-side `Sink<WorkItem>`.
|
||||
//!
|
||||
//! Background: the original sweep loop runs strictly serialised —
|
||||
//! SCSI read → decrypt → seek + write → mapfile.record → next iter.
|
||||
//! On a healthy disc the SCSI read costs ~5-12 ms per 64 KB batch and
|
||||
//! the post-read work (decrypt 1-3 ms + file write + mapfile fsync
|
||||
//! 5-15 ms) adds another batch's worth of latency. The drive idles
|
||||
//! during the post-read work; throughput tops out at the *sum* of
|
||||
//! both costs.
|
||||
//!
|
||||
//! A producer/consumer split overlaps the two stages on the generic
|
||||
//! [`crate::io::Pipeline`] + [`crate::io::Sink`] primitive. This module
|
||||
//! is the sweep-specific `Sink` impl; the producer-side state machine
|
||||
//! (read_error context, decrypt, set_speed, halt) stays in
|
||||
//! `Disc::sweep` in `disc/mod.rs`.
|
||||
//!
|
||||
//! Correctness invariants preserved:
|
||||
//! - Mapfile is single-writer (consumer-only). No locking.
|
||||
//! - All `read_error::ReadCtx` state stays on the producer thread.
|
||||
//! - `set_speed` calls happen on the producer thread (same thread that
|
||||
//! owns the `SectorSource`). No new SCSI concurrency.
|
||||
//! - Per-iteration ordering of file-write → mapfile-record is kept
|
||||
//! intact in the consumer (write before record), so the on-disk
|
||||
//! invariant "mapfile only marks Finished what the file has
|
||||
//! received" survives a crash mid-pass.
|
||||
//! - Only one SCSI command is in flight at a time; error-path timing
|
||||
//! is identical and no new retry logic is introduced.
|
||||
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::io::{Flow, Sink};
|
||||
|
||||
use super::mapfile::{MapStats, Mapfile, SectorStatus};
|
||||
|
||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. 64 KB
|
||||
/// matches the existing zero_gap chunk size used by the pre-split
|
||||
/// sweep loop.
|
||||
const ZERO_CHUNK: usize = 64 * 1024;
|
||||
|
||||
/// Producer → Consumer messages. The consumer applies these in FIFO
|
||||
/// order; ordering of file writes and mapfile records across items is
|
||||
/// preserved.
|
||||
pub(super) enum WorkItem {
|
||||
/// Successful batch read. Producer has already decrypted `buf` if
|
||||
/// `opts.decrypt` was set. Consumer writes `buf` at `pos` and
|
||||
/// records the range as `Finished`.
|
||||
Good { pos: u64, buf: Vec<u8> },
|
||||
|
||||
/// Bisect inner-loop good single sector (already decrypted by the
|
||||
/// producer). 2048 bytes.
|
||||
BisectGood { pos: u64, buf: Box<[u8; 2048]> },
|
||||
|
||||
/// Bisect inner-loop bad single sector. Consumer writes 2048
|
||||
/// zeros at `pos` and records the sector as `NonTrimmed`.
|
||||
BisectBad { pos: u64 },
|
||||
|
||||
/// Whole-batch zero-fill (failed batch on `SkipBlock`, or the
|
||||
/// failed batch portion of `JumpAhead`). Consumer streams zeros
|
||||
/// across `[pos, pos+len)` and records the range as `NonTrimmed`.
|
||||
SkipFill { pos: u64, len: u64 },
|
||||
|
||||
/// Gap fill following a `JumpAhead`. Same effect as `SkipFill`;
|
||||
/// distinguished only so future logging / instrumentation can
|
||||
/// tell them apart without parsing a flag.
|
||||
GapFill { pos: u64, len: u64 },
|
||||
|
||||
/// Post-read verify downgrade. The producer's `UnitVerifier` found that the
|
||||
/// just-`Finished` clip unit at `[pos, pos+len)` is confidently undecryptable
|
||||
/// (a silent bad read). The consumer re-records the range as `NonTrimmed` so
|
||||
/// the patch pass re-reads it — the ISO bytes (ciphertext) already written by
|
||||
/// the preceding `Good` are left in place for the patch to overwrite. FIFO
|
||||
/// pipe ordering guarantees this arrives AFTER the `Good` that wrote them.
|
||||
MarkBad { pos: u64, len: u64 },
|
||||
|
||||
/// Producer wants the latest mapfile stats for the progress
|
||||
/// callback. Consumer responds on `prog_tx` with a fresh
|
||||
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
||||
/// drained the previous snapshot, the new one is silently
|
||||
/// dropped — the producer's local cache stays current enough.
|
||||
StatsRequest,
|
||||
}
|
||||
|
||||
/// Snapshot the consumer sends back to the producer for the progress
|
||||
/// callback.
|
||||
pub(super) struct ProgressSnapshot {
|
||||
pub stats: MapStats,
|
||||
pub bad_ranges: Vec<(u64, u64)>,
|
||||
}
|
||||
|
||||
/// Final summary returned by the consumer thread on shutdown — what
|
||||
/// `SweepSink::close` produces, surfaced to the producer via
|
||||
/// `Pipeline::finish`.
|
||||
pub(super) struct ConsumerSummary {
|
||||
pub stats: MapStats,
|
||||
}
|
||||
|
||||
/// Drain any pending progress snapshots from the consumer. Returns
|
||||
/// the most recent one, if any. The producer caches it and uses it
|
||||
/// for subsequent progress callbacks until a fresh one arrives.
|
||||
pub(super) fn try_recv_progress(rx: &Receiver<ProgressSnapshot>) -> Option<ProgressSnapshot> {
|
||||
let mut latest = None;
|
||||
while let Ok(snap) = rx.try_recv() {
|
||||
latest = Some(snap);
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
/// `Sink<WorkItem>` for sweep. Owns the writeback file + mapfile +
|
||||
/// progress back-channel. `apply` carries the file-write +
|
||||
/// mapfile.record per item; `close` drains the writeback pipeline,
|
||||
/// fsyncs the ISO, and flushes the mapfile.
|
||||
pub(super) struct SweepSink {
|
||||
file: crate::io::WritebackFile,
|
||||
map: Mapfile,
|
||||
/// `sync_all`-on-failure-is-an-error iff the output is a regular
|
||||
/// file. `/dev/null` and pipes always fail `sync_all`; that's not
|
||||
/// a real error.
|
||||
is_regular: bool,
|
||||
/// Back-channel for `StatsRequest` responses. The producer caches
|
||||
/// the latest snapshot and uses it for the progress callback;
|
||||
/// dropped sends on a full channel are by design.
|
||||
prog_tx: SyncSender<ProgressSnapshot>,
|
||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. Held
|
||||
/// in the sink so each apply call doesn't reallocate.
|
||||
zero: Box<[u8; ZERO_CHUNK]>,
|
||||
}
|
||||
|
||||
impl SweepSink {
|
||||
/// Construct a new `SweepSink` plus the matching progress
|
||||
/// receiver. Channel depth on the back-channel is `1` — the
|
||||
/// producer's cache is the source of truth between snapshots.
|
||||
pub(super) fn new(
|
||||
file: crate::io::WritebackFile,
|
||||
map: Mapfile,
|
||||
is_regular: bool,
|
||||
) -> (Self, Receiver<ProgressSnapshot>) {
|
||||
let (prog_tx, prog_rx) = sync_channel::<ProgressSnapshot>(1);
|
||||
let sink = SweepSink {
|
||||
file,
|
||||
map,
|
||||
is_regular,
|
||||
prog_tx,
|
||||
zero: Box::new([0u8; ZERO_CHUNK]),
|
||||
};
|
||||
(sink, prog_rx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sink<WorkItem> for SweepSink {
|
||||
type Output = ConsumerSummary;
|
||||
|
||||
fn apply(&mut self, item: WorkItem) -> Result<Flow, Error> {
|
||||
match item {
|
||||
WorkItem::Good { pos, buf } => {
|
||||
// Decrypt is on the producer; consumer assumes plaintext.
|
||||
let len = buf.len() as u64;
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
self.file.write_all(&buf)?;
|
||||
self.map.record(pos, len, SectorStatus::Finished)?;
|
||||
}
|
||||
WorkItem::BisectGood { pos, buf } => {
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
self.file.write_all(&buf[..])?;
|
||||
self.map.record(pos, 2048, SectorStatus::Finished)?;
|
||||
}
|
||||
WorkItem::BisectBad { pos } => {
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
self.file.write_all(&self.zero[..2048])?;
|
||||
self.map.record(pos, 2048, SectorStatus::NonTrimmed)?;
|
||||
}
|
||||
WorkItem::SkipFill { pos, len } | WorkItem::GapFill { pos, len } => {
|
||||
self.file.seek(SeekFrom::Start(pos))?;
|
||||
// Subsequent writes are sequential; `WritebackFile`'s
|
||||
// seek-elision keeps them on the writeback pipeline path.
|
||||
let mut filled = 0u64;
|
||||
while filled < len {
|
||||
let chunk = (len - filled).min(self.zero.len() as u64) as usize;
|
||||
self.file.write_all(&self.zero[..chunk])?;
|
||||
filled += chunk as u64;
|
||||
}
|
||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
||||
}
|
||||
WorkItem::MarkBad { pos, len } => {
|
||||
// Verify downgrade: the ISO bytes are already written by the
|
||||
// preceding Good; only the mapfile status changes so patch
|
||||
// re-reads this range. No file write.
|
||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
||||
}
|
||||
WorkItem::StatsRequest => {
|
||||
let stats = self.map.stats();
|
||||
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
|
||||
// ahead of the sweep head, not damage; including it made the live
|
||||
// located drilldown (at-risk movie time + range count) treat the
|
||||
// whole unread disc as confirmed damage, so at sweep start it
|
||||
// showed ~full-movie at-risk and melted to 0 as the sweep
|
||||
// progressed. Matches the one-shot progress path, which already
|
||||
// excludes NonTried.
|
||||
let bad_ranges = self.map.ranges_with(&[
|
||||
SectorStatus::NonTrimmed,
|
||||
SectorStatus::Unreadable,
|
||||
SectorStatus::NonScraped,
|
||||
]);
|
||||
// Best-effort: drop on backpressure; producer's cache
|
||||
// stays current enough.
|
||||
let _ = self
|
||||
.prog_tx
|
||||
.try_send(ProgressSnapshot { stats, bad_ranges });
|
||||
}
|
||||
}
|
||||
Ok(Flow::Continue)
|
||||
}
|
||||
|
||||
fn close(mut self) -> Result<Self::Output, Error> {
|
||||
// Drain the writeback pipeline + fsync the ISO, then persist
|
||||
// any pending mapfile state. Same finalisation order as the
|
||||
// pre-Pipeline consumer loop.
|
||||
if let Err(e) = self.file.sync_all() {
|
||||
if self.is_regular {
|
||||
return Err(Error::IoError { source: e });
|
||||
}
|
||||
// Non-regular outputs (/dev/null, pipes) always fail
|
||||
// sync_all; that's not a real error.
|
||||
}
|
||||
self.map.flush()?;
|
||||
|
||||
Ok(ConsumerSummary {
|
||||
stats: self.map.stats(),
|
||||
})
|
||||
}
|
||||
}
|
||||
-1021
File diff suppressed because it is too large
Load Diff
+203
-10
@@ -4,7 +4,7 @@
|
||||
//! optionally unlocks/initializes via the `freemkv-unlock` dispatch
|
||||
//! (through [`crate::unlock_bridge`]), and reads sectors.
|
||||
|
||||
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
|
||||
pub fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
|
||||
match e {
|
||||
Error::ScsiError { status, sense, .. } => (*status, *sense),
|
||||
Error::DiscRead { status, sense, .. } => (status.unwrap_or(0), *sense),
|
||||
@@ -61,8 +61,28 @@ const SPIN_UP_SETTLE_SECS: u64 = 10;
|
||||
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
|
||||
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
|
||||
const SCSI_MODE_SENSE: u8 = 0x5A;
|
||||
const SCSI_MODE_SELECT: u8 = 0x55;
|
||||
const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
|
||||
/// SBC/MMC Read-Write Error Recovery mode page (page code 0x01). We flip the
|
||||
/// `PER` bit to make the drive REPORT a recovered read (via CHECK CONDITION +
|
||||
/// sense key RECOVERED ERROR) instead of silently returning best-effort data as
|
||||
/// GOOD status. On marginal/dirty media that silent-GOOD data can be
|
||||
/// mis-corrected — a rip that "passed clean" but decoded with errors. With PER
|
||||
/// on, freemkv sees the marginal read and re-reads it in Pass N (a loud miss,
|
||||
/// never a silent commit). See `build_error_recovery_select_payload`.
|
||||
const MODE_PAGE_ERROR_RECOVERY: u8 = 0x01;
|
||||
/// Bit masks in the Read-Write Error Recovery flags byte (page byte 2).
|
||||
const ERP_FLAG_TB: u8 = 0x20; // Transfer Block: still deliver the recovered data
|
||||
const ERP_FLAG_PER: u8 = 0x04; // Post Error: report recovered errors
|
||||
const ERP_FLAG_DTE: u8 = 0x02; // Data Terminate on Error: MUST be off (we want the data)
|
||||
/// `Parameters Saveable` bit in a mode page's byte 0 — valid only on MODE SENSE;
|
||||
/// must be cleared before echoing the page back in a MODE SELECT.
|
||||
const MODE_PAGE_PS_BIT: u8 = 0x80;
|
||||
/// MODE SENSE(10) parameter header length (bytes), preceding any block
|
||||
/// descriptors and the mode pages.
|
||||
const MODE10_HEADER_LEN: usize = 8;
|
||||
|
||||
/// Optical disc drive session -- open, identify, unlock, and read.
|
||||
pub struct Drive {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
@@ -433,15 +453,14 @@ impl Drive {
|
||||
// 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.
|
||||
self.init_ran = true;
|
||||
let r: Result<()> = match crate::unlock_bridge::run_unlockers(
|
||||
self.scsi.as_mut(),
|
||||
&self.drive_id,
|
||||
freemkv_unlock::DiscKind::Unknown,
|
||||
&[],
|
||||
) {
|
||||
let (matched, unlock_res) =
|
||||
crate::unlock_bridge::run_features(self.scsi.as_mut(), &self.drive_id);
|
||||
let r: Result<()> = match unlock_res {
|
||||
Ok(unlocked) => {
|
||||
self.unlocker_name =
|
||||
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
|
||||
// Record WHICH drive-prep unlocker actually ran — "LibreDrive"
|
||||
// (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
|
||||
// handshake phase (do_handshake reads it via `oem_vid()`). A
|
||||
// drive-prep unlocker always carries a VID; guard anyway.
|
||||
@@ -464,7 +483,13 @@ impl Drive {
|
||||
// the stock riplock). A stock-mode drive with no firmware unlocker still
|
||||
// wants max speed. Best-effort: a failure here must NOT fail the rip.
|
||||
if r.is_ok() {
|
||||
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
|
||||
self.set_speed(Self::SPEED_MAX_KBPS);
|
||||
// Ask the drive to REPORT recovered/marginal reads rather than
|
||||
// silently commit best-effort data as GOOD (the dirty-disc
|
||||
// "passed-clean-but-decodes-with-errors" trap). Best-effort: a drive
|
||||
// that doesn't honor it just keeps its defaults — no regression, and
|
||||
// on a clean disc it changes nothing.
|
||||
self.enable_recovered_error_reporting();
|
||||
}
|
||||
tracing::info!(
|
||||
target: "freemkv::drive",
|
||||
@@ -600,6 +625,53 @@ impl Drive {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the drive to REPORT recovered/marginal reads instead of silently
|
||||
/// returning best-effort data as GOOD status. MODE SENSE the Read-Write
|
||||
/// Error Recovery page, flip `PER` (and `TB` on / `DTE` off so we still get
|
||||
/// the data), and MODE SELECT it back — preserving the drive's own retry
|
||||
/// count and other bits.
|
||||
///
|
||||
/// Best-effort: a drive that doesn't support the page, or rejects the SELECT,
|
||||
/// simply keeps its default behaviour — no regression, the rip proceeds. On a
|
||||
/// clean disc this changes nothing (no recovered errors fire); it only
|
||||
/// surfaces the marginal reads that a dirty disc would otherwise commit
|
||||
/// silently. Returns whether the page was successfully written.
|
||||
pub fn enable_recovered_error_reporting(&mut self) -> bool {
|
||||
let Some(sense) = self.mode_sense_page(MODE_PAGE_ERROR_RECOVERY) else {
|
||||
tracing::debug!(target: "freemkv::drive", "MODE SENSE error-recovery page unavailable; leaving drive defaults");
|
||||
return false;
|
||||
};
|
||||
let Some(payload) = build_error_recovery_select_payload(&sense) else {
|
||||
tracing::debug!(target: "freemkv::drive", "error-recovery page malformed/short; leaving drive defaults");
|
||||
return false;
|
||||
};
|
||||
// MODE SELECT(10): PF=1 (page format), parameter list length = payload.
|
||||
let len = payload.len() as u16;
|
||||
let cdb = [
|
||||
SCSI_MODE_SELECT,
|
||||
0x10, // PF=1, SP=0 (don't persist across power cycles)
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
(len >> 8) as u8,
|
||||
len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = payload;
|
||||
match self.checked_exec(&cdb, crate::scsi::DataDirection::ToDevice, &mut buf, 5_000) {
|
||||
Ok(_) => {
|
||||
tracing::info!(target: "freemkv::drive", phase = "error_recovery", "recovered-error reporting enabled (PER=1) — marginal reads will surface instead of committing silently");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(target: "freemkv::drive", error = %e, "MODE SELECT error-recovery page rejected; leaving drive defaults");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read vendor-specific READ BUFFER data.
|
||||
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
|
||||
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
|
||||
@@ -879,6 +951,9 @@ impl Drive {
|
||||
decode_read_capacity(&buf, result.bytes_transferred)
|
||||
}
|
||||
|
||||
/// SET CD SPEED "use the drive's maximum" sentinel (0xFFFF KB/s per MMC).
|
||||
pub const SPEED_MAX_KBPS: u16 = 0xFFFF;
|
||||
|
||||
pub fn set_speed(&mut self, speed_kbs: u16) {
|
||||
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
|
||||
let mut dummy = [0u8; 0];
|
||||
@@ -1102,6 +1177,46 @@ fn select_drive_with_media(drives: impl Iterator<Item = Drive>) -> Option<Drive>
|
||||
fallback
|
||||
}
|
||||
|
||||
/// Turn a MODE SENSE(10) Read-Write Error Recovery page response into the
|
||||
/// payload for a MODE SELECT(10) that enables recovered-error REPORTING —
|
||||
/// preserving every other bit (notably the drive's own read-retry count).
|
||||
///
|
||||
/// Pure so the bit-twiddling is unit-tested without a drive. Steps:
|
||||
/// - locate the page after the 8-byte header + block descriptors (bytes 6-7);
|
||||
/// - verify it is page 0x01 with a flags byte present;
|
||||
/// - in the flags byte: set `PER` (report) and `TB` (still deliver the data),
|
||||
/// clear `DTE` (don't terminate the transfer on the recovered error);
|
||||
/// - clear the page's `PS` bit (valid only on SENSE) and zero the header's
|
||||
/// mode-data-length field (reserved on SELECT).
|
||||
///
|
||||
/// Returns `None` (caller leaves the drive at its defaults) when the response is
|
||||
/// too short or isn't the error-recovery page — never panics on adversarial
|
||||
/// bytes.
|
||||
fn build_error_recovery_select_payload(sense: &[u8]) -> Option<Vec<u8>> {
|
||||
if sense.len() < MODE10_HEADER_LEN {
|
||||
return None;
|
||||
}
|
||||
let block_desc_len = u16::from_be_bytes([sense[6], sense[7]]) as usize;
|
||||
let page_off = MODE10_HEADER_LEN.checked_add(block_desc_len)?;
|
||||
// Need page byte 0 (code), byte 1 (length), byte 2 (flags).
|
||||
if page_off.checked_add(3)? > sense.len() {
|
||||
return None;
|
||||
}
|
||||
if sense[page_off] & 0x3F != MODE_PAGE_ERROR_RECOVERY {
|
||||
return None;
|
||||
}
|
||||
let mut payload = sense.to_vec();
|
||||
// Header: mode-data-length is reserved on SELECT — zero it.
|
||||
payload[0] = 0;
|
||||
payload[1] = 0;
|
||||
// Page byte 0: clear PS (SENSE-only).
|
||||
payload[page_off] &= !MODE_PAGE_PS_BIT;
|
||||
// Flags byte: PER on, TB on, DTE off. Retry count (next byte) untouched.
|
||||
payload[page_off + 2] |= ERP_FLAG_PER | ERP_FLAG_TB;
|
||||
payload[page_off + 2] &= !ERP_FLAG_DTE;
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
/// Decode a READ CAPACITY (10) response into a sector count.
|
||||
///
|
||||
/// A short transfer (`bytes_transferred < 4`, which would leave the high
|
||||
@@ -1277,6 +1392,84 @@ mod command_tests {
|
||||
use super::*;
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// A minimal MODE SENSE(10) response carrying the Read-Write Error Recovery
|
||||
/// page (0x01) with the given flags byte and retry count, no block
|
||||
/// descriptors. `ps` sets the page's PS bit (SENSE-only), which the SELECT
|
||||
/// payload must clear.
|
||||
fn mode_sense_error_recovery(flags: u8, retry: u8, ps: bool) -> Vec<u8> {
|
||||
let mut v = vec![0u8; MODE10_HEADER_LEN + 12];
|
||||
// Header: nonzero mode-data-length (must be zeroed on SELECT); no block
|
||||
// descriptors.
|
||||
v[0] = 0x00;
|
||||
v[1] = 0x22;
|
||||
v[6] = 0x00;
|
||||
v[7] = 0x00; // block descriptor length = 0
|
||||
let po = MODE10_HEADER_LEN;
|
||||
v[po] = MODE_PAGE_ERROR_RECOVERY | if ps { MODE_PAGE_PS_BIT } else { 0 };
|
||||
v[po + 1] = 0x0A; // page length
|
||||
v[po + 2] = flags; // error-recovery flags
|
||||
v[po + 3] = retry; // read retry count
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_recovery_payload_sets_per_tb_clears_dte_ps_preserves_retry() {
|
||||
// Start with PER off, DTE on, PS on, a specific retry count. The SELECT
|
||||
// payload must flip PER on, TB on, DTE off, clear PS, zero the header
|
||||
// mode-data-length, and leave the retry count untouched.
|
||||
let sense = mode_sense_error_recovery(ERP_FLAG_DTE, 0x2C, true);
|
||||
let out = build_error_recovery_select_payload(&sense).expect("valid page");
|
||||
let po = MODE10_HEADER_LEN;
|
||||
assert_eq!(out[0], 0, "header mode-data-length zeroed for SELECT");
|
||||
assert_eq!(out[1], 0);
|
||||
assert_eq!(out[po] & MODE_PAGE_PS_BIT, 0, "PS cleared for SELECT");
|
||||
assert_eq!(out[po] & 0x3F, MODE_PAGE_ERROR_RECOVERY, "still page 0x01");
|
||||
assert_eq!(out[po + 2] & ERP_FLAG_PER, ERP_FLAG_PER, "PER set");
|
||||
assert_eq!(
|
||||
out[po + 2] & ERP_FLAG_TB,
|
||||
ERP_FLAG_TB,
|
||||
"TB set (still get data)"
|
||||
);
|
||||
assert_eq!(
|
||||
out[po + 2] & ERP_FLAG_DTE,
|
||||
0,
|
||||
"DTE cleared (don't terminate)"
|
||||
);
|
||||
assert_eq!(out[po + 3], 0x2C, "read retry count preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_recovery_payload_honors_block_descriptor_offset() {
|
||||
// With an 8-byte block descriptor between header and page, the function
|
||||
// must locate the page at header+desc, not a fixed offset.
|
||||
let mut sense = vec![0u8; MODE10_HEADER_LEN + 8 + 12];
|
||||
sense[7] = 8; // block descriptor length
|
||||
let po = MODE10_HEADER_LEN + 8;
|
||||
sense[po] = MODE_PAGE_ERROR_RECOVERY;
|
||||
sense[po + 1] = 0x0A;
|
||||
sense[po + 2] = 0x00;
|
||||
let out = build_error_recovery_select_payload(&sense).expect("valid");
|
||||
assert_eq!(
|
||||
out[po + 2] & ERP_FLAG_PER,
|
||||
ERP_FLAG_PER,
|
||||
"PER set at the descriptor-offset page"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_recovery_payload_rejects_wrong_or_short_page() {
|
||||
// Wrong page code → None (leave drive at defaults).
|
||||
let mut wrong = mode_sense_error_recovery(0, 0, false);
|
||||
wrong[MODE10_HEADER_LEN] = 0x08; // page 0x08 (caching), not 0x01
|
||||
assert!(build_error_recovery_select_payload(&wrong).is_none());
|
||||
// Too short to hold the header → None, no panic.
|
||||
assert!(build_error_recovery_select_payload(&[0u8; 4]).is_none());
|
||||
// Header claims a block descriptor that runs off the buffer → None.
|
||||
let mut bad = mode_sense_error_recovery(0, 0, false);
|
||||
bad[7] = 0xF0; // descriptor length way past the buffer
|
||||
assert!(build_error_recovery_select_payload(&bad).is_none());
|
||||
}
|
||||
|
||||
/// Mock transport: returns a fixed data payload (copied into the
|
||||
/// caller's buffer, truncated to fit) on every `execute()`.
|
||||
struct FixedTransport {
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
//!
|
||||
//! Byte layout follows the DVD-Video specification (VMGI/VTSI headers,
|
||||
//! PGC/cell tables, PCI/HLI button packets); the VM command decoder is
|
||||
//! verified against libdvdnav's decoder.
|
||||
//! verified against real discs.
|
||||
//!
|
||||
//! Current contents: [`vmcmd`] — the VM command decoder (proven against the
|
||||
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
|
||||
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! An 8-byte navigation command as found in PGC command tables (pre/post/cell)
|
||||
//! and PCI button info. Decoded per the DVD-Video VM instruction set and
|
||||
//! verified against libdvdnav's command decoder.
|
||||
//! verified against real discs.
|
||||
//!
|
||||
//! Bit model: the 8 bytes are a big-endian 64-bit word. `byte0` bits 7-5 are the
|
||||
//! command **type**; for type 1, `byte0` bit 4 selects Link (0) vs Jump (1), and
|
||||
@@ -133,7 +133,7 @@ const JP_JUMP_SS: u8 = 6;
|
||||
const JP_CALL_SS: u8 = 8;
|
||||
|
||||
// Link (type 1, direct=0) sub-commands. NOTE: sub-op 0 is NOP/no-link and 1 is
|
||||
// the LinkSub form (libdvdnav `decoder.c` `eval_link_instruction`).
|
||||
// the LinkSub form (the DVD-Video VM link instruction).
|
||||
const LK_SUB: u8 = 1;
|
||||
const LK_PGCN: u8 = 4;
|
||||
const LK_PTTN: u8 = 5;
|
||||
@@ -159,7 +159,7 @@ fn be16(b: &[u8; 8], o: usize) -> u16 {
|
||||
((b[o] as u16) << 8) | b[o + 1] as u16
|
||||
}
|
||||
|
||||
// Compare-operand layouts ("if_version"s) per libdvdnav `decoder.c`. The op
|
||||
// Compare-operand layouts ("if_version"s) per the DVD-Video VM. The op
|
||||
// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The
|
||||
// operand *offsets* differ by command family.
|
||||
//
|
||||
@@ -205,7 +205,7 @@ pub fn decode(b: &[u8; 8]) -> Command {
|
||||
let cmd = b[1] & 0x0F;
|
||||
|
||||
// Compare predicate, with the operand layout for this command family
|
||||
// (libdvdnav `decoder.c` `vm_eval_command` type dispatch).
|
||||
// (the DVD-Video VM command type dispatch).
|
||||
let compare = match (typ, direct) {
|
||||
(TYPE_SPECIAL, _) => if_v1(b),
|
||||
(TYPE_LINK_JUMP, 1) => if_v2(b), // jump
|
||||
@@ -372,7 +372,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the libdvdnav cross-check: link sub-op 0 = NOP, 1 = LinkSub.
|
||||
// Regression for the link sub-op decode: 0 = NOP, 1 = LinkSub.
|
||||
#[test]
|
||||
fn link_subop_zero_is_nop_one_is_linksub() {
|
||||
assert_eq!(decode(&h("2000000000000000")).instr, Instr::Nop);
|
||||
|
||||
+135
@@ -59,7 +59,9 @@ pub const E_MKV_INVALID: u16 = 6008;
|
||||
pub const E_NO_STREAMS: u16 = 6009;
|
||||
pub const E_HALTED: u16 = 6010;
|
||||
pub const E_MAPFILE_INVALID: u16 = 6011;
|
||||
pub const E_SELECTION_PID_UNKNOWN: u16 = 6014;
|
||||
pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012;
|
||||
pub const E_UDF_NOT_FILESYSTEM: u16 = 6013;
|
||||
|
||||
// AACS (7xxx)
|
||||
pub const E_AACS_NO_KEYS: u16 = 7000;
|
||||
@@ -88,6 +90,7 @@ pub const E_NO_DISC_KEY: u16 = 7022;
|
||||
pub const E_CSS_KEY_MISSING: u16 = 7023;
|
||||
pub const E_AACS_NO_HOST_CERT: u16 = 7024;
|
||||
pub const E_AACS_BUS_KEY_UNAVAILABLE: u16 = 7025;
|
||||
pub const E_FMTS_KEY_MISSING: u16 = 7026;
|
||||
|
||||
// Keydb (8xxx)
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
@@ -149,6 +152,14 @@ pub const E_NETWORK_ADDR_BLOCKED: u16 = 9022;
|
||||
/// frame dropped before the first keyframe) cannot report success.
|
||||
pub const E_MUX_EMPTY: u16 = 9023;
|
||||
pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030;
|
||||
/// `mp4://` output but the title has no (primary) video track to carry.
|
||||
pub const E_MP4_NO_VIDEO_TRACK: u16 = 9048;
|
||||
/// `mp4://` SOURCE file is malformed/truncated (bad box structure, sample table,
|
||||
/// or offsets) — the MP4 demuxer could not parse it.
|
||||
pub const E_MP4_INVALID: u16 = 9049;
|
||||
/// `mp4://` video track is missing its codec-configuration record
|
||||
/// (`hvcC`/`avcC`), without which the sample entry can't be written.
|
||||
pub const E_MP4_MISSING_CODEC_PRIVATE: u16 = 9050;
|
||||
/// READ CAPACITY returned a short or overflowing transfer.
|
||||
pub const E_DISC_CAPACITY_MALFORMED: u16 = 9047;
|
||||
|
||||
@@ -263,6 +274,14 @@ pub enum Error {
|
||||
UdfNotFound {
|
||||
path: String,
|
||||
},
|
||||
/// The reader was addressable but the bytes are structurally NOT a UDF
|
||||
/// filesystem — a deterministic tag/format mismatch (e.g. no Anchor Volume
|
||||
/// Descriptor Pointer at sector 256, no partition descriptor, no File Set
|
||||
/// Descriptor). Distinct from [`Error::DiscRead`] (a transient I/O fault):
|
||||
/// this is a stable property of the media, not something a retry fixes. Lets
|
||||
/// callers (notably FMTS key resolution) treat "not a UDF/FMTS disc" as a
|
||||
/// clean negative while still failing loud on a real read fault.
|
||||
UdfNotFilesystem,
|
||||
/// A `SectorSource` caller passed a destination buffer smaller than one
|
||||
/// 2048-byte sector. A contract violation on the public reader API —
|
||||
/// returned instead of panicking on the slice.
|
||||
@@ -274,6 +293,12 @@ pub enum Error {
|
||||
IfoParse,
|
||||
MkvInvalid,
|
||||
NoStreams,
|
||||
/// A [`crate::StreamSelection`] listed a PID that does not exist in the
|
||||
/// title's declared streams — a caller bug (e.g. a stale scan), reported
|
||||
/// loudly rather than silently producing an MKV missing a requested track.
|
||||
SelectionPidUnknown {
|
||||
pid: u16,
|
||||
},
|
||||
/// ddrescue mapfile parse failed. `kind` is a stable, language-neutral
|
||||
/// identifier (e.g. `"status_char"`, `"hex"`); not a translatable
|
||||
/// English message.
|
||||
@@ -360,6 +385,13 @@ pub enum Error {
|
||||
/// time and no handshake runs.
|
||||
AacsBusKeyUnavailable,
|
||||
|
||||
/// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment
|
||||
/// (variant) key is available to open them. 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. (The mux
|
||||
/// resolves the full forensic key set up front; a resolution gap fails here.)
|
||||
FmtsKeyMissing,
|
||||
|
||||
// Keydb (8xxx)
|
||||
KeydbConnect {
|
||||
host: String,
|
||||
@@ -410,6 +442,12 @@ pub enum Error {
|
||||
/// every frame dropped before the first keyframe — fails loudly. The
|
||||
/// `m2ts://` analogue of [`Error::MkvInvalid`]'s zero-frame guard.
|
||||
MuxEmpty,
|
||||
/// `mp4://` target title has no primary video track to mux.
|
||||
Mp4NoVideoTrack,
|
||||
/// `mp4://` source file is malformed/truncated — the MP4 demuxer failed.
|
||||
Mp4Invalid,
|
||||
/// `mp4://` video track is missing its `hvcC`/`avcC` configuration record.
|
||||
Mp4MissingCodecPrivate,
|
||||
PesFrameTooLarge {
|
||||
size: usize,
|
||||
},
|
||||
@@ -542,11 +580,13 @@ impl Error {
|
||||
Error::MplsParse => E_MPLS_PARSE,
|
||||
Error::ClpiParse => E_CLPI_PARSE,
|
||||
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
|
||||
Error::UdfNotFilesystem => E_UDF_NOT_FILESYSTEM,
|
||||
Error::UdfBufferTooSmall => E_UDF_BUFFER_TOO_SMALL,
|
||||
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
|
||||
Error::IfoParse => E_IFO_PARSE,
|
||||
Error::MkvInvalid => E_MKV_INVALID,
|
||||
Error::NoStreams => E_NO_STREAMS,
|
||||
Error::SelectionPidUnknown { .. } => E_SELECTION_PID_UNKNOWN,
|
||||
Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
|
||||
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
||||
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
||||
@@ -573,6 +613,7 @@ impl Error {
|
||||
Error::CssKeyMissing => E_CSS_KEY_MISSING,
|
||||
Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT,
|
||||
Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE,
|
||||
Error::FmtsKeyMissing => E_FMTS_KEY_MISSING,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||
@@ -588,6 +629,9 @@ impl Error {
|
||||
Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT,
|
||||
Error::NetworkAddrBlocked { .. } => E_NETWORK_ADDR_BLOCKED,
|
||||
Error::MuxEmpty => E_MUX_EMPTY,
|
||||
Error::Mp4NoVideoTrack => E_MP4_NO_VIDEO_TRACK,
|
||||
Error::Mp4Invalid => E_MP4_INVALID,
|
||||
Error::Mp4MissingCodecPrivate => E_MP4_MISSING_CODEC_PRIVATE,
|
||||
Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
|
||||
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
|
||||
Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE,
|
||||
@@ -750,6 +794,9 @@ impl std::fmt::Display for Error {
|
||||
Error::InvalidCdbLength { len, max } => {
|
||||
write!(f, "E{}: {}/{}", self.code(), len, max)
|
||||
}
|
||||
Error::SelectionPidUnknown { pid } => {
|
||||
write!(f, "E{}: 0x{:04x}", self.code(), pid)
|
||||
}
|
||||
_ => write!(f, "E{}", self.code()),
|
||||
}
|
||||
}
|
||||
@@ -822,6 +869,12 @@ impl From<Error> for std::io::Error {
|
||||
// 9023 MuxEmpty: finish() reached with zero frames — the output
|
||||
// would be a header-only container. Treat as invalid output.
|
||||
E_MUX_EMPTY => std::io::ErrorKind::InvalidData,
|
||||
// mp4:// demux errors: a malformed/truncated source file
|
||||
// (E_MP4_INVALID), or a source whose tracks the mux can't use — no
|
||||
// video track / missing codec-private config. All are invalid data.
|
||||
E_MP4_NO_VIDEO_TRACK | E_MP4_INVALID | E_MP4_MISSING_CODEC_PRIVATE => {
|
||||
std::io::ErrorKind::InvalidData
|
||||
}
|
||||
// 9030 ExtentNotUnitAligned: a malformed/non-AACS-aligned
|
||||
// extent was handed to the prefetch producer.
|
||||
9030 => std::io::ErrorKind::InvalidInput,
|
||||
@@ -847,6 +900,61 @@ impl From<Error> for std::io::Error {
|
||||
/// Convenience alias for `Result<T, Error>`.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// The numeric error code carried by an [`io::Error`](std::io::Error) that was
|
||||
/// produced from an [`Error`], or `None` if it carries none.
|
||||
///
|
||||
/// [`From<Error> for io::Error`] is the ONLY path from a typed [`Error`] to an
|
||||
/// `io::Error` in this crate, and it stringifies (`io::Error::new(kind, msg)`
|
||||
/// where `msg` is the `Error`'s `E<code>[: …]` [`Display`](std::fmt::Display)
|
||||
/// string) rather than boxing the typed value — no code path constructs an
|
||||
/// `io::Error` that still holds a `crate::error::Error` via `get_ref`. So the
|
||||
/// only recognised shape is the round-tripped `E<code>` message prefix.
|
||||
fn io_error_code(e: &std::io::Error) -> Option<u16> {
|
||||
// Round-tripped: `From<Error> for io::Error` stringifies as "E<code>[: …]".
|
||||
let s = e.to_string();
|
||||
let digits = s.strip_prefix('E')?;
|
||||
let end = digits
|
||||
.find(|c: char| !c.is_ascii_digit())
|
||||
.unwrap_or(digits.len());
|
||||
digits.get(..end)?.parse::<u16>().ok()
|
||||
}
|
||||
|
||||
/// Whether a per-title mux failure is a *skippable title stub* — a
|
||||
/// copy-protected-but-uncrackable title ([`Error::CssKeyMissing`]) or a title
|
||||
/// that produced no muxable frames ([`Error::MkvInvalid`], an empty nav/menu
|
||||
/// PGC stub). An all-titles rip skips such a title and finishes the rest;
|
||||
/// every other error stays fatal.
|
||||
///
|
||||
/// This replaces the CLI's `E7023`/`E6008` string-match with a typed check on
|
||||
/// the [`io::Error`](std::io::Error) `mux_stream` returns.
|
||||
pub fn is_skippable_title_stub(e: &std::io::Error) -> bool {
|
||||
matches!(io_error_code(e), Some(E_MKV_INVALID | E_CSS_KEY_MISSING))
|
||||
}
|
||||
|
||||
/// Whether an [`io::Error`](std::io::Error) is a cooperative user stop
|
||||
/// ([`Error::Halted`], code [`E_HALTED`]) — vs a structural failure. A stop is
|
||||
/// resumable, not a rip failure: `mux_stream` maps a mid-run halt to
|
||||
/// `completed = false`, and consumers preserve staging rather than quarantining.
|
||||
/// Typed replacement for the consumers' `E<code>`-leading-token string match.
|
||||
pub fn is_halt(e: &std::io::Error) -> bool {
|
||||
io_error_code(e) == Some(E_HALTED)
|
||||
}
|
||||
|
||||
/// Whether an [`io::Error`](std::io::Error) is a **disc-level** key failure —
|
||||
/// the disc as a whole cannot be decrypted, so EVERY title will fail the same
|
||||
/// way. Distinct from a per-title skippable stub
|
||||
/// ([`is_skippable_title_stub`]): `E_NO_DISC_KEY` (keydb present but no entry
|
||||
/// for this disc), `E_KEYDB_LOAD` (no keydb at all), and `E_AACS_NO_KEYS` (no
|
||||
/// usable AACS key material) are all whole-disc conditions. A multi-title rip
|
||||
/// loop should stop immediately on this (fail-fast) rather than iterate every
|
||||
/// title re-printing the same error.
|
||||
pub fn is_disc_level_no_key(e: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
io_error_code(e),
|
||||
Some(E_NO_DISC_KEY | E_KEYDB_LOAD | E_AACS_NO_KEYS)
|
||||
)
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Borrow the drive-returned SPC-4 sense triple if this error is a
|
||||
/// [`Error::ScsiError`] carrying sense data. `None` for any other
|
||||
@@ -941,6 +1049,25 @@ mod tests {
|
||||
//! match arms in `code()` / the From impl could silently miscategorize.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_skippable_title_stub_matches_only_the_two_stub_codes() {
|
||||
// The two skippable per-title stub codes, round-tripped through io::Error
|
||||
// exactly as `mux_stream` returns them.
|
||||
let mkv: std::io::Error = Error::MkvInvalid.into();
|
||||
let css: std::io::Error = Error::CssKeyMissing.into();
|
||||
assert!(is_skippable_title_stub(&mkv));
|
||||
assert!(is_skippable_title_stub(&css));
|
||||
|
||||
// A different coded error is NOT skippable (kills a "match anything with
|
||||
// an E-code" mutant).
|
||||
let nostreams: std::io::Error = Error::NoStreams.into();
|
||||
assert!(!is_skippable_title_stub(&nostreams));
|
||||
|
||||
// A plain io::Error with no E-code prefix is not skippable.
|
||||
let plain = std::io::Error::from(std::io::ErrorKind::BrokenPipe);
|
||||
assert!(!is_skippable_title_stub(&plain));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_variants_have_distinct_codes() {
|
||||
let codes = [
|
||||
@@ -1184,6 +1311,7 @@ mod tests {
|
||||
E_HALTED,
|
||||
E_MAPFILE_INVALID,
|
||||
E_UDF_BUFFER_TOO_SMALL,
|
||||
E_UDF_NOT_FILESYSTEM,
|
||||
E_AACS_NO_KEYS,
|
||||
E_AACS_CERT_SHORT,
|
||||
E_AACS_AGID_ALLOC,
|
||||
@@ -1209,6 +1337,7 @@ mod tests {
|
||||
E_CSS_KEY_MISSING,
|
||||
E_AACS_NO_HOST_CERT,
|
||||
E_AACS_BUS_KEY_UNAVAILABLE,
|
||||
E_FMTS_KEY_MISSING,
|
||||
E_KEYDB_CONNECT,
|
||||
E_KEYDB_HTTP,
|
||||
E_KEYDB_INVALID,
|
||||
@@ -1224,6 +1353,9 @@ mod tests {
|
||||
E_STREAM_URL_MISSING_PORT,
|
||||
E_NETWORK_ADDR_BLOCKED,
|
||||
E_MUX_EMPTY,
|
||||
E_MP4_NO_VIDEO_TRACK,
|
||||
E_MP4_INVALID,
|
||||
E_MP4_MISSING_CODEC_PRIVATE,
|
||||
E_PES_FRAME_TOO_LARGE,
|
||||
E_PES_INVALID_MAGIC,
|
||||
E_PES_TRACK_TOO_LARGE,
|
||||
@@ -1311,6 +1443,9 @@ mod tests {
|
||||
(Error::PipelineConsumerGone, E_PIPELINE_CONSUMER_GONE),
|
||||
(Error::DiscCapacityOverflow, E_DISC_CAPACITY_OVERFLOW),
|
||||
(Error::MuxEmpty, E_MUX_EMPTY),
|
||||
(Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK),
|
||||
(Error::Mp4Invalid, E_MP4_INVALID),
|
||||
(Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),
|
||||
(Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED),
|
||||
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
|
||||
(Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED),
|
||||
|
||||
+46
-4
@@ -16,7 +16,7 @@
|
||||
/// (case-insensitive), then requires an even run of ASCII hex digits. Any
|
||||
/// non-hex byte, or an odd length, yields `None`.
|
||||
pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
|
||||
let body = strip_prefix(s.trim());
|
||||
let body = strip_hex_prefix(s.trim());
|
||||
let bytes = body.as_bytes();
|
||||
// Empty → empty Vec (a legitimately-empty variable-length field); odd length
|
||||
// is malformed. (`parse_hex_fixed` enforces a concrete length separately.)
|
||||
@@ -34,7 +34,7 @@ pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
|
||||
/// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any
|
||||
/// non-hex byte or a length mismatch.
|
||||
pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
let body = strip_prefix(s.trim());
|
||||
let body = strip_hex_prefix(s.trim());
|
||||
let bytes = body.as_bytes();
|
||||
if bytes.len() != 2 * N {
|
||||
return None;
|
||||
@@ -46,8 +46,33 @@ pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Strip a single leading `0x` / `0X` if present (case-insensitive).
|
||||
fn strip_prefix(s: &str) -> &str {
|
||||
/// Parse a hex string into a `u16`. Accepts an optional `0x`/`0X` prefix
|
||||
/// (case-insensitive) via the same [`strip_hex_prefix`] the byte parsers use.
|
||||
/// `None` on any non-hex content or overflow.
|
||||
///
|
||||
/// Exists so callers never hand-roll `from_str_radix(s.trim_start_matches("0x"), 16)`
|
||||
/// — a **case-sensitive** strip that silently dropped an uppercase-`0X` value.
|
||||
/// (That reintroduced-in-keydb bug is exactly what this module was built to kill;
|
||||
/// the integer fields now share the one prefix rule.)
|
||||
pub fn parse_hex_u16(s: &str) -> Option<u16> {
|
||||
u16::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Parse a hex string into a `u32`. See [`parse_hex_u16`].
|
||||
pub fn parse_hex_u32(s: &str) -> Option<u32> {
|
||||
u32::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Parse a hex string into a `u8`. See [`parse_hex_u16`].
|
||||
pub fn parse_hex_u8(s: &str) -> Option<u8> {
|
||||
u8::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Strip a single leading `0x` / `0X` if present (case-insensitive). Public so
|
||||
/// callers that only need the prefix rule (e.g. normalizing a disc hash) reuse
|
||||
/// the one definition instead of hand-rolling a case-sensitive
|
||||
/// `trim_start_matches("0x")`.
|
||||
pub fn strip_hex_prefix(s: &str) -> &str {
|
||||
s.strip_prefix("0x")
|
||||
.or_else(|| s.strip_prefix("0X"))
|
||||
.unwrap_or(s)
|
||||
@@ -95,6 +120,23 @@ mod tests {
|
||||
assert_eq!(parse_hex_fixed::<16>(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_ints_accept_both_prefix_cases_and_bare() {
|
||||
// The regression the keydb device-key bug hit: uppercase `0X` must parse
|
||||
// identically to `0x` and to a bare value.
|
||||
assert_eq!(parse_hex_u16("0x0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16("0X0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16("0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16(" 0XABCD "), Some(0xABCD));
|
||||
assert_eq!(parse_hex_u32("0X00000002"), Some(2));
|
||||
assert_eq!(parse_hex_u32("deadbeef"), Some(0xDEAD_BEEF));
|
||||
assert_eq!(parse_hex_u8("0X03"), Some(3));
|
||||
assert_eq!(parse_hex_u8("ff"), Some(0xFF));
|
||||
// Overflow / non-hex → None.
|
||||
assert_eq!(parse_hex_u8("0x1FF"), None);
|
||||
assert_eq!(parse_hex_u16("0xzz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_variable_length_and_odd_rejected() {
|
||||
assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));
|
||||
|
||||
+8
-8
@@ -58,7 +58,7 @@ pub struct DvdTitle {
|
||||
pub struct DvdCell {
|
||||
pub first_sector: u32,
|
||||
pub last_sector: u32,
|
||||
/// Raw cell-category byte at `cell_playback + 0` (libdvdread layout).
|
||||
/// Raw cell-category byte at `cell_playback + 0` (DVD-Video IFO layout).
|
||||
/// Packs block_mode (bits 7-6), block_type (bits 5-4), seamless_play
|
||||
/// (bit 3), interleaved (bit 2), stc_discontinuity (bit 1),
|
||||
/// seamless_angle (bit 0). Carried so the extent builder can recognise
|
||||
@@ -72,7 +72,7 @@ pub struct DvdCell {
|
||||
}
|
||||
|
||||
/// Decoded view of a cell-category byte (`cell_playback + 0`), per the
|
||||
/// DVD-Video spec / libdvdread `cell_playback_t` layout. Byte-0 bitfields,
|
||||
/// DVD-Video IFO cell-playback layout. Byte-0 bitfields,
|
||||
/// MSB-first: `block_mode`(7-6), `block_type`(5-4), `seamless_play`(3),
|
||||
/// `interleaved`(2), `stc_discontinuity`(1), `seamless_angle`(0). (The real
|
||||
/// `cell_type` is a karaoke-only field in byte 1, not used here.)
|
||||
@@ -93,7 +93,7 @@ pub struct CellCategory {
|
||||
}
|
||||
|
||||
impl CellCategory {
|
||||
/// Decode the raw `cell_playback + 0` byte (libdvdread `read_cell_playback`).
|
||||
/// Decode the raw `cell_playback + 0` byte (DVD-Video IFO cell playback).
|
||||
pub fn decode(raw: u8) -> Self {
|
||||
CellCategory {
|
||||
block_mode: (raw >> 6) & 0x03,
|
||||
@@ -423,7 +423,7 @@ fn parse_vts(
|
||||
}
|
||||
|
||||
// VTSI_MAT (VTS_xx_0.IFO header) field offsets — fixed by the DVD-Video
|
||||
// spec (libdvdread `vtsi_mat_t`). The offsets are constant; the sector
|
||||
// spec (the VTSI management table). The offsets are constant; the sector
|
||||
// values they point to are per-disc.
|
||||
const VTSTT_VOBS_OFFSET: usize = 0xC4; // VTS title VOBS start sector (feature)
|
||||
const VTS_PGCIT_OFFSET: usize = 0xCC; // VTS_PGCIT sector pointer
|
||||
@@ -580,7 +580,7 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
|
||||
_ => Codec::Unknown(coding_mode),
|
||||
};
|
||||
|
||||
let sample_rate_flag = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (libdvdread audio_attr_t)
|
||||
let sample_rate_flag = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (DVD-Video audio attributes)
|
||||
let sample_rate = match sample_rate_flag {
|
||||
0 => 48000,
|
||||
1 => 96000,
|
||||
@@ -1071,7 +1071,7 @@ mod tests {
|
||||
/// reads with — a co-edit of constant + helper would silently re-introduce
|
||||
/// the PAL-as-NTSC bug and every test would still pass. This test feeds
|
||||
/// `parse_video_attr` HARDCODED bytes captured from real DVD-Video layouts
|
||||
/// (DVD spec / libdvdread `video_attr_t`: mpeg_version[7-6] video_format[5-4]
|
||||
/// (DVD-Video video attributes: mpeg_version[7-6] video_format[5-4]
|
||||
/// display_aspect[3-2] permitted_df[1-0]) — no `v_atr_byte`. If the parser's
|
||||
/// bit positions drift, these fail.
|
||||
#[test]
|
||||
@@ -1238,7 +1238,7 @@ mod tests {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Added hardening tests. Grounded in the DVD-Video IFO spec
|
||||
// (dvd_udf / libdvdread ifo_types.h; http://dvd.sourceforge.net).
|
||||
// (DVD-Video IFO format; http://dvd.sourceforge.net).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// BCD frame-rate flag: bits 7-6 of byte[3]. 0b01 = 25fps (PAL),
|
||||
@@ -1593,7 +1593,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// CellCategory decodes the libdvdread byte-0 bitfields: block_mode (7-6),
|
||||
/// CellCategory decodes the DVD-Video cell-category byte-0 bitfields: block_mode (7-6),
|
||||
/// block_type (5-4), seamless_play (3), interleaved (2),
|
||||
/// stc_discontinuity (1), seamless_angle (0).
|
||||
#[test]
|
||||
|
||||
@@ -78,8 +78,8 @@ use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
||||
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
||||
/// cache stays bounded the same way the write side does.
|
||||
///
|
||||
/// 32 MiB is the empirically tuned value on the rip1 test bed (single
|
||||
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the
|
||||
/// 32 MiB is the empirically tuned value on a 7200rpm HDD via SATA:
|
||||
/// smaller windows (8 / 16 MiB) shorten the
|
||||
/// kernel-readahead overlap and slow the producer; larger windows
|
||||
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
||||
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
||||
|
||||
+2
-3
@@ -40,9 +40,8 @@ pub(crate) mod platform_macos;
|
||||
|
||||
pub mod pipeline;
|
||||
|
||||
pub(crate) use writeback_file::WritebackFile;
|
||||
pub use writeback_file::WritebackFile;
|
||||
|
||||
pub use pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH,
|
||||
};
|
||||
|
||||
+9
-8
@@ -173,14 +173,9 @@ fn finish_with_grace<R: Send + 'static>(
|
||||
|
||||
/// Default channel depth for callers without a specific reason to
|
||||
/// pick another value. Kept conservative (4) — most callers should
|
||||
/// use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
||||
/// use WRITE_PIPELINE_DEPTH instead.
|
||||
pub const DEFAULT_PIPELINE_DEPTH: usize = 4;
|
||||
|
||||
/// Read pipeline depth. Larger buffer compensates for drive variability
|
||||
/// and NFS sync_file_range stalls; keeps ISO reader thread fed even when
|
||||
/// consumer blocks on write.
|
||||
pub const READ_PIPELINE_DEPTH: usize = 32;
|
||||
|
||||
/// Write pipeline depth. Smaller buffer reduces backpressure risk when
|
||||
/// sync_file_range blocks; prevents producer from accumulating too much
|
||||
/// work while consumer waits for NFS to drain.
|
||||
@@ -449,7 +444,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
} else {
|
||||
// Benign per-item OK: trace-level (L4) only; the
|
||||
// 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(())
|
||||
@@ -464,7 +462,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} 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)
|
||||
|
||||
@@ -97,7 +97,7 @@ fn writeback_chunk_bytes() -> u64 {
|
||||
.unwrap_or(WRITEBACK_CHUNK_BYTES_DEFAULT)
|
||||
}
|
||||
|
||||
pub(crate) struct WritebackFile {
|
||||
pub struct WritebackFile {
|
||||
file: File,
|
||||
pipeline: WritebackPipeline,
|
||||
pos: u64,
|
||||
@@ -115,7 +115,7 @@ impl WritebackFile {
|
||||
/// once so the pipeline starts tracking from wherever the file
|
||||
/// already is (typically 0 for fresh files; non-zero for resumed
|
||||
/// or appended files).
|
||||
pub(crate) fn new(mut file: File) -> io::Result<Self> {
|
||||
pub fn new(mut file: File) -> io::Result<Self> {
|
||||
let pos = file.stream_position()?;
|
||||
let pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes());
|
||||
Ok(Self {
|
||||
@@ -136,7 +136,7 @@ impl WritebackFile {
|
||||
/// [`Self::create_with_size_hint`] so the kernel can pre-reserve
|
||||
/// extents.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn create(path: &Path) -> io::Result<Self> {
|
||||
pub fn create(path: &Path) -> io::Result<Self> {
|
||||
let file = File::create(path)?;
|
||||
Self::new(file)
|
||||
}
|
||||
@@ -153,7 +153,7 @@ impl WritebackFile {
|
||||
/// On platforms without an extent-preallocation primitive this is
|
||||
/// equivalent to `create` — the size hint is dropped after a debug
|
||||
/// log.
|
||||
pub(crate) fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
|
||||
pub fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
|
||||
let file = File::create(path)?;
|
||||
platform::preallocate(&file, size_bytes);
|
||||
Self::new(file)
|
||||
@@ -163,7 +163,7 @@ impl WritebackFile {
|
||||
/// wrap it. Mirrors `File::open` semantics for the writable case
|
||||
/// — used by patch / resume paths that mutate an existing ISO in
|
||||
/// place.
|
||||
pub(crate) fn open(path: &Path) -> io::Result<Self> {
|
||||
pub fn open(path: &Path) -> io::Result<Self> {
|
||||
let file = OpenOptions::new().write(true).open(path)?;
|
||||
Self::new(file)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ impl WritebackFile {
|
||||
/// completed. Callers needing crash-consistency (e.g. mux-finish
|
||||
/// then external commit/DB update) must not treat `Ok(())` as a
|
||||
/// durability barrier.
|
||||
pub(crate) fn sync_all(&mut self) -> io::Result<()> {
|
||||
pub fn sync_all(&mut self) -> io::Result<()> {
|
||||
if self.seek_count > 0 {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
|
||||
+622
-75
@@ -3,12 +3,13 @@
|
||||
//! libfreemkv performs NO key lookup. An application resolves a disc's keys
|
||||
//! through one or more [`KeySource`]s, each an adapter over a backing store (a
|
||||
//! keydb file, a key server, the mapfile cache). A source's job is to return the
|
||||
//! disc's terminal **Unit Keys** ([`crate::aacs::UnitKey`]). It knows what
|
||||
//! disc's terminal **Unit Keys** ([`crate::aacs::types::UnitKey`]). It knows what
|
||||
//! material it holds (a DK / MK / VUK / pre-decrypted UK) and what it must fetch
|
||||
//! from the disc (VID, MKB, encrypted title keys, content samples) to get there;
|
||||
//! it orchestrates the derivation by calling libfreemkv's own boil-down crypto
|
||||
//! primitives ([`crate::aacs::mk_from_dk`] / [`crate::aacs::vuk_from_mk`] /
|
||||
//! [`crate::aacs::uk_from_vuk`]) through the [`ResolveCtx`] handed to it.
|
||||
//! it orchestrates the derivation by calling libfreemkv's own derivation
|
||||
//! primitives ([`crate::aacs::derive::derive_media_key_from_dk`] /
|
||||
//! [`crate::aacs::derive::derive_vuk`] / [`crate::aacs::derive::decrypt_unit_key`])
|
||||
//! through the [`ResolveCtx`] handed to it.
|
||||
//!
|
||||
//! libfreemkv still OWNS the crypto: the boil-down primitives and the AES live
|
||||
//! here. A source owns only PATH ORCHESTRATION — deciding which primitive to
|
||||
@@ -17,10 +18,69 @@
|
||||
//! keeping key *policy* (which store, which order, online vs local) out of the
|
||||
//! library.
|
||||
|
||||
use crate::aacs::{HostCert, UnitKey, Vid};
|
||||
use crate::aacs::types::HostCert;
|
||||
use crate::aacs::types::{UnitKey, Vid};
|
||||
use crate::disc::Key;
|
||||
use crate::error::Error;
|
||||
|
||||
/// Minimum encrypted-content unit samples a single online key request must carry.
|
||||
///
|
||||
/// The key service identifies a key by which of the submitted units it decrypts,
|
||||
/// so too few samples — especially on FMTS, where a segment interleaves several
|
||||
/// variants at the unit level — can return a key that matches an incidental unit
|
||||
/// rather than the one asked about (a false positive). This many distinct units
|
||||
/// make the request unambiguous.
|
||||
///
|
||||
/// Canonical here (the base crate) so BOTH consumers agree on one value: the
|
||||
/// online source in `freemkv-keysources` (which refuses to send an under-sampled
|
||||
/// request) re-exports it, and libfreemkv's own FMTS forensic query
|
||||
/// ([`crate::mux`]) sizes its per-segment batch by it. Layering forbids the
|
||||
/// reverse import (keysources depends on libfreemkv, not vice versa), so the
|
||||
/// value lives at the lower layer both share.
|
||||
pub const MIN_SAMPLE_UNITS: usize = 8;
|
||||
|
||||
/// 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
|
||||
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
||||
/// structures a source or key server may key on.
|
||||
@@ -55,7 +115,7 @@ pub struct DiscInputs {
|
||||
pub volume_label: Option<String>,
|
||||
}
|
||||
|
||||
/// A lazy view of a disc's AACS material, handed to [`KeySource::get_uk`] so a
|
||||
/// A lazy view of a disc's AACS material, handed to [`KeySource::get_unit_keys`] so a
|
||||
/// source can drive the derivation chain without holding the disc reader.
|
||||
///
|
||||
/// "Lazy" by contract: each accessor returns only what the source asks for, so a
|
||||
@@ -74,8 +134,8 @@ pub trait ResolveCtx {
|
||||
/// Raw MKB bytes (may be empty when not captured).
|
||||
fn mkb(&self) -> Result<&[u8], Error>;
|
||||
/// The disc's encrypted title keys, parsed from `Unit_Key_RO.inf` the same
|
||||
/// way the library's resolver parses them ([`crate::aacs::parse_unit_key_ro`]),
|
||||
/// in on-disc order. Feed straight into [`crate::aacs::uk_from_vuk`].
|
||||
/// way the library's resolver parses them ([`crate::aacs::inf::parse_unit_key_ro`]),
|
||||
/// in on-disc order. Feed straight into [`crate::aacs::derive::decrypt_unit_key`].
|
||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error>;
|
||||
/// Up to `n` encrypted on-disc content sample units, for a source that
|
||||
/// validates a candidate server-side against real ciphertext.
|
||||
@@ -113,7 +173,8 @@ impl<'a> DiscInputsCtx<'a> {
|
||||
/// title keys — the parse failure is swallowed here, not surfaced as an
|
||||
/// error.
|
||||
pub fn new(inputs: &'a DiscInputs) -> Self {
|
||||
use crate::aacs::{AacsVersion, parse_unit_key_ro};
|
||||
use crate::aacs::inf::parse_unit_key_ro;
|
||||
use crate::aacs::mkb::AacsVersion;
|
||||
let enc_keys = if inputs.unit_key_ro.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -164,18 +225,42 @@ impl ResolveCtx for DiscInputsCtx<'_> {
|
||||
/// holds, orchestrates the derivation down to Unit Keys using the library's
|
||||
/// 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
|
||||
/// [`crate::aacs::uk_from_vuk`]; one that holds device keys calls
|
||||
/// [`crate::aacs::mk_from_dk`] → [`crate::aacs::vuk_from_mk`] → `uk_from_vuk`.
|
||||
/// [`crate::aacs::derive::decrypt_unit_key`]; one that holds device keys calls
|
||||
/// [`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
|
||||
/// `Err` means the source itself failed (I/O, parse, network). The caller
|
||||
/// ([`resolve_and_apply`]) tries each source in order and validates the returned
|
||||
/// keys against real ciphertext before committing them, so a wrong key from one
|
||||
/// source transparently falls through to the next.
|
||||
///
|
||||
/// Two explicit resolve operations, one per key kind — never one overloaded call
|
||||
/// whose meaning depends on how many keys came back:
|
||||
/// * [`get_unit_keys`](Self::get_unit_keys) — the disc's base per-CPS-unit Unit
|
||||
/// Keys (index space = CPS-unit number). The common path for every disc.
|
||||
/// * [`get_fmts_indexes`](Self::get_fmts_indexes) — the AACS 2.1 forensic index
|
||||
/// keys (index space = forensic index 1..N). Defaults to empty: a source with
|
||||
/// no forensic material opts out, and only an FMTS disc ever asks.
|
||||
///
|
||||
/// What each source must do to answer is the source's own business: a keydb keys
|
||||
/// on `disc_hash` and reads no samples; the online source submits the ctx's
|
||||
/// content samples (a base batch for `get_unit_keys`, an index-1 anchor batch for
|
||||
/// `get_fmts_indexes`) to the key service.
|
||||
pub trait KeySource {
|
||||
/// Resolve this disc's terminal Unit Keys from this source. An empty `Vec`
|
||||
/// is a genuine "no key here"; `Err` is a source failure.
|
||||
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>;
|
||||
/// Resolve this disc's base per-CPS-unit Unit Keys from this source. An empty
|
||||
/// `Vec` is a genuine "no key here"; `Err` is a source failure.
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>;
|
||||
|
||||
/// Resolve this disc's AACS 2.1 forensic index keys — the per-index keys the
|
||||
/// base Unit Key cannot open (see [`crate::aacs::segment`]) — ordered by
|
||||
/// forensic index (element `i` carries `UnitKey.idx == i`, forensic index
|
||||
/// `i + 1`). The source hands back the COMPLETE set it holds; the caller
|
||||
/// trusts any non-empty result as all of them and never assumes a fixed count.
|
||||
/// Defaults to empty: a source with no forensic material (a plain keydb, the
|
||||
/// mapfile) opts out, and only an FMTS disc's mux ever calls this.
|
||||
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// The AACS host certificate(s) this source can supply for the live-drive
|
||||
/// SCSI mutual-auth handshake (the OEM/AACS baseline route). `mkb` is the
|
||||
@@ -209,18 +294,18 @@ pub fn resolve_and_apply(
|
||||
}
|
||||
|
||||
/// Like [`resolve_and_apply`] but also returns a structured
|
||||
/// [`crate::aacs::ResolutionTrace`] recording, per source, what happened — for
|
||||
/// [`crate::aacs::trace::ResolutionTrace`] recording, per source, what happened — for
|
||||
/// applications to render. ZERO English; the trace is typed enums only.
|
||||
///
|
||||
/// One-shot per source: each source's [`KeySource::get_uk`] is called exactly
|
||||
/// One-shot per source: each source's [`KeySource::get_unit_keys`] is called exactly
|
||||
/// once with a [`DiscInputsCtx`] over `inputs`. Non-empty Unit Keys are mapped
|
||||
/// to terminal [`Key::Unit`]s and applied via [`crate::Disc::decrypt_with`],
|
||||
/// which validates them against `inputs.samples` and only mutates the disc on
|
||||
/// 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
|
||||
/// from [`crate::aacs::uk_from_vuk`]; the library's canonical CPS-unit number is
|
||||
/// `position + 1` (matching [`crate::aacs::parse_unit_key_ro`]'s `(i + 1)`), so
|
||||
/// 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
|
||||
/// 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
|
||||
/// tries every key) but is kept faithful to the resolver's convention.
|
||||
@@ -228,10 +313,10 @@ pub fn resolve_and_apply_traced(
|
||||
sources: &[Box<dyn KeySource>],
|
||||
inputs: &DiscInputs,
|
||||
disc: &mut crate::Disc,
|
||||
) -> (bool, crate::aacs::ResolutionTrace) {
|
||||
) -> (bool, crate::aacs::trace::ResolutionTrace) {
|
||||
use crate::aacs::trace::{KeyNode, KeyOutcome, KeyStep};
|
||||
|
||||
let mut trace = crate::aacs::ResolutionTrace::new();
|
||||
let mut trace = crate::aacs::trace::ResolutionTrace::new();
|
||||
|
||||
// The ctx parses Unit_Key_RO.inf at the stride for `inputs.version` (the
|
||||
// disc's own AACS major), so the stride is the disc's single source of truth.
|
||||
@@ -240,7 +325,7 @@ pub fn resolve_and_apply_traced(
|
||||
for source in sources {
|
||||
// `who` is the source's own stable identifier — no enum to map back to.
|
||||
let who = source.label().to_string();
|
||||
match source.get_uk(&ctx) {
|
||||
match source.get_unit_keys(&ctx) {
|
||||
Ok(uks) if !uks.is_empty() => {
|
||||
// Positional index → canonical CPS-unit number (position + 1).
|
||||
let unit_keys: Vec<(u32, [u8; 16])> = uks
|
||||
@@ -290,45 +375,156 @@ pub fn resolve_and_apply_traced(
|
||||
/// [`resolve_and_apply`] this does not validate/commit to a disc — the read's
|
||||
/// decorator re-decrypts with the returned keys, which is the validation.
|
||||
pub fn fetch_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||
for source in sources {
|
||||
if let Ok(uks) = source.get_uk(ctx) {
|
||||
if !uks.is_empty() {
|
||||
return uks;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
drive_unit_keys(sources, ctx).keys
|
||||
}
|
||||
|
||||
/// Build the read-time key-fetch closure from the disc's public AACS inputs and
|
||||
/// a way to (re)build the application's key sources. The decorator calls it with
|
||||
/// the still-scrambled unit ciphertext when no held key opens that unit; it runs
|
||||
/// [`fetch_unit_keys`] with those bytes as `samples` and returns any keys.
|
||||
/// Whether a driver run resolved keys, and — when it did NOT — whether the miss
|
||||
/// was a genuine "no source holds this key" (`errored == false`) or at least one
|
||||
/// source FAILED (`errored == true`, e.g. a network source was unreachable). The
|
||||
/// distinction gates negative-result memoization: an empty-because-absent result
|
||||
/// is safe to cache, an empty-because-a-source-was-down result is transient and
|
||||
/// must NOT be cached (the key may resolve once the source recovers).
|
||||
struct FetchOutcome {
|
||||
keys: Vec<UnitKey>,
|
||||
errored: bool,
|
||||
}
|
||||
|
||||
/// [`fetch_unit_keys`] plus the error signal: drive `sources` in order, return the
|
||||
/// first source's non-empty Unit Keys, and flag whether any source that failed to
|
||||
/// answer did so with an `Err` (a source failure) rather than an empty `Ok`
|
||||
/// (genuine absence — see [`KeySource::get_unit_keys`]).
|
||||
fn drive_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> FetchOutcome {
|
||||
let mut errored = false;
|
||||
for source in sources {
|
||||
match source.get_unit_keys(ctx) {
|
||||
Ok(uks) if !uks.is_empty() => {
|
||||
return FetchOutcome {
|
||||
keys: uks,
|
||||
errored: false,
|
||||
};
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => errored = true,
|
||||
}
|
||||
}
|
||||
FetchOutcome {
|
||||
keys: Vec::new(),
|
||||
errored,
|
||||
}
|
||||
}
|
||||
|
||||
/// The forensic counterpart to [`fetch_unit_keys`]: drive `sources` in order and
|
||||
/// return the first source's non-empty AACS 2.1 forensic index set. `ctx` carries
|
||||
/// the index-1 anchor batch (the mux, which owns disc geometry, gathers it and
|
||||
/// injects it as the ctx's samples); a source that needs no samples (a keydb
|
||||
/// keying on `disc_hash`) ignores them. Whatever the winning source returns —
|
||||
/// ≥ 1 key — is trusted as the COMPLETE ordered set; no fixed count is assumed.
|
||||
pub fn fetch_fmts_indexes(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||
drive_fmts_indexes(sources, ctx).keys
|
||||
}
|
||||
|
||||
/// [`fetch_fmts_indexes`] plus the error signal (see [`drive_unit_keys`]): the
|
||||
/// forensic counterpart that flags whether any source `Err`ed during the miss.
|
||||
fn drive_fmts_indexes(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> FetchOutcome {
|
||||
let mut errored = false;
|
||||
for source in sources {
|
||||
match source.get_fmts_indexes(ctx) {
|
||||
Ok(uks) if !uks.is_empty() => {
|
||||
return FetchOutcome {
|
||||
keys: uks,
|
||||
errored: false,
|
||||
};
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => errored = true,
|
||||
}
|
||||
}
|
||||
FetchOutcome {
|
||||
keys: Vec::new(),
|
||||
errored,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the read-time [`crate::sector::KeyFetch`] from the disc's public AACS
|
||||
/// inputs and a way to (re)build the application's key sources. The returned
|
||||
/// resolver has the two explicit operations the mux and recovery decorator call:
|
||||
/// [`unit_keys`](crate::sector::KeyFetch::unit_keys) drives [`fetch_unit_keys`]
|
||||
/// (base per-CPS-unit keys), [`fmts_indexes`](crate::sector::KeyFetch::fmts_indexes)
|
||||
/// drives [`fetch_fmts_indexes`] (the AACS 2.1 forensic set). Each is handed the
|
||||
/// caller's sample batch as the ctx's `samples`, so a source pulls whatever
|
||||
/// material it needs.
|
||||
///
|
||||
/// One builder, used by every read path (sweep / patch / mux) and by every
|
||||
/// consumer (CLI, autorip) — neither application contains the fetch logic, only
|
||||
/// its key-source config. Returns a **shared, stateless** [`crate::sector::KeyFetch`]
|
||||
/// (`Arc<Fn>`): build it once, clone it into each read path. `make_sources` is
|
||||
/// invoked per fetch (the cold path, ~once per CPS unit) so the closure stays
|
||||
/// One builder, used by every read path (sweep / patch / mux) and every consumer
|
||||
/// (CLI, autorip) — neither application contains the fetch logic, only its
|
||||
/// key-source config. Cheap to clone; build once, clone into each read path.
|
||||
/// `make_sources` is invoked per fetch (the cold path) so the resolver stays
|
||||
/// `Send + Sync` without requiring `KeySource: Send`.
|
||||
pub fn key_fetch(
|
||||
inputs: DiscInputs,
|
||||
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
||||
) -> crate::sector::KeyFetch {
|
||||
// One driver behind both operations: rebuild the sources, inject `samples`
|
||||
// as the ctx's content samples, run `drive` (the per-kind fetch), map the
|
||||
// resolved UnitKeys to raw keys. Memoized by the fingerprint of the sample
|
||||
// batch: the resolved keys are disc-level (a clip's index / CPS keys are
|
||||
// identical for every title that references it), so the first batch resolves
|
||||
// over the network and every repeat is answered from the cache with no
|
||||
// request. A GENUINELY-empty reply (every source ran and none held the key)
|
||||
// is cached too — the key the service lacks for a batch won't appear on a
|
||||
// re-ask, so re-hitting the network buys nothing. But an empty reply caused
|
||||
// by a source FAILURE (network down, source unreachable) is NOT cached: that
|
||||
// is a transient miss, and caching it would permanently drop a unit that
|
||||
// could be recovered once the source recovers — the `errored` flag on
|
||||
// `FetchOutcome` draws exactly that line. Each operation gets its OWN cache:
|
||||
// a base batch and a forensic anchor never collide, and the same bytes could
|
||||
// legitimately resolve differently per op.
|
||||
// The per-kind driver: `drive_unit_keys` or `drive_fmts_indexes`.
|
||||
type FetchDriver = fn(&[Box<dyn KeySource>], &dyn ResolveCtx) -> FetchOutcome;
|
||||
fn make_op(
|
||||
inputs: DiscInputs,
|
||||
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
||||
drive: FetchDriver,
|
||||
) -> crate::sector::KeyFetchFn {
|
||||
let cache: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<u64, Vec<[u8; 16]>>>> =
|
||||
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
|
||||
let fp = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
samples.len().hash(&mut h);
|
||||
for s in samples {
|
||||
s.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
};
|
||||
if let Some(hit) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&fp) {
|
||||
return hit.clone();
|
||||
}
|
||||
let sources = make_sources();
|
||||
let mut di = inputs.clone();
|
||||
di.samples = samples.to_vec();
|
||||
// Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`):
|
||||
// an online /decode reply that returns a VUK (not a terminal UK) then
|
||||
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
|
||||
// 48-byte stride — hardcoding the V20 stride here corrupted them.
|
||||
// derives unit keys from `enc_title_keys`, which a V10 disc parses at
|
||||
// the 48-byte stride — hardcoding the V20 stride here corrupted them.
|
||||
let ctx = DiscInputsCtx::new(&di);
|
||||
fetch_unit_keys(&sources, &ctx)
|
||||
.into_iter()
|
||||
.map(|u| u.key)
|
||||
.collect()
|
||||
let outcome = drive(&sources, &ctx);
|
||||
let keys: Vec<[u8; 16]> = outcome.keys.into_iter().map(|u| u.key).collect();
|
||||
// Memoize a positive result always; memoize a NEGATIVE (empty) result
|
||||
// only when it is a genuine absence, never when a source errored — a
|
||||
// transient outage must not permanently poison this fingerprint.
|
||||
if !keys.is_empty() || !outcome.errored {
|
||||
cache
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(fp, keys.clone());
|
||||
}
|
||||
keys
|
||||
})
|
||||
}
|
||||
let unit = make_op(inputs.clone(), make_sources.clone(), drive_unit_keys);
|
||||
let fmts = make_op(inputs, make_sources, drive_fmts_indexes);
|
||||
crate::sector::KeyFetch::new(unit, fmts)
|
||||
}
|
||||
|
||||
/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no
|
||||
@@ -341,18 +537,24 @@ pub fn key_fetch(
|
||||
/// `start_lba`), which the library owns. A key source is *handed* these bytes
|
||||
/// via `DiscInputs.samples`; it never reads the disc itself.
|
||||
///
|
||||
/// "Encrypted" is decided by [`crate::aacs::ts_sync_destroyed`] — the SAME
|
||||
/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear
|
||||
/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a
|
||||
/// clear unit proves nothing, so this collects only scrambled ones — 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.
|
||||
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
|
||||
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
|
||||
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `is_clean` TS-sync
|
||||
/// heuristic: a unit lacking clean TS syncs does not imply encryption (an FMTS
|
||||
/// variant frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
|
||||
/// unit sent to a key server yields nothing to validate against — the "0
|
||||
/// 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(
|
||||
reader: &mut dyn crate::sector::SectorSource,
|
||||
title: &crate::disc::DiscTitle,
|
||||
n: usize,
|
||||
) -> Vec<Vec<u8>> {
|
||||
use crate::aacs::{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
|
||||
// Probe several evenly-spaced points across EACH extent rather than only the
|
||||
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
|
||||
@@ -398,7 +600,7 @@ pub fn read_encrypted_units(
|
||||
break;
|
||||
}
|
||||
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());
|
||||
if out.len() >= n {
|
||||
return out;
|
||||
@@ -413,9 +615,49 @@ pub fn read_encrypted_units(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::UnitKey;
|
||||
use crate::aacs::types::UnitKey;
|
||||
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::host_certs() defaults to empty regardless of the MKB argument.
|
||||
@@ -427,7 +669,7 @@ mod tests {
|
||||
fn key_source_host_certs_defaults_to_empty() {
|
||||
struct MinimalSource;
|
||||
impl KeySource for MinimalSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
@@ -454,7 +696,7 @@ mod tests {
|
||||
let inputs = DiscInputs {
|
||||
disc_hash: "0xABC".into(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::AACS_MAJOR_BD,
|
||||
version: crate::aacs::mkb::AACS_MAJOR_BD,
|
||||
mkb: vec![1, 2, 3],
|
||||
unit_key_ro: uk_ro,
|
||||
samples: vec![vec![9u8; 4], vec![8u8; 4], vec![7u8; 4]],
|
||||
@@ -484,7 +726,7 @@ mod tests {
|
||||
fn trace_who_is_the_source_label_verbatim() {
|
||||
struct LabeledSource(&'static str);
|
||||
impl KeySource for LabeledSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
@@ -510,7 +752,7 @@ mod tests {
|
||||
let inputs = DiscInputs {
|
||||
disc_hash: "0x00".into(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::AACS_MAJOR_UHD,
|
||||
version: crate::aacs::mkb::AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
@@ -531,7 +773,7 @@ mod tests {
|
||||
DiscInputs {
|
||||
disc_hash: String::new(),
|
||||
volume_id: [0u8; 16],
|
||||
version: crate::aacs::AACS_MAJOR_UHD,
|
||||
version: crate::aacs::mkb::AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
@@ -541,23 +783,20 @@ mod tests {
|
||||
|
||||
struct EmptySource;
|
||||
impl KeySource for EmptySource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
struct ErroringSource;
|
||||
impl KeySource for ErroringSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Err(Error::AacsNoKeys)
|
||||
}
|
||||
}
|
||||
struct HasKey([u8; 16]);
|
||||
impl KeySource for HasKey {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey {
|
||||
idx: 0,
|
||||
key: self.0,
|
||||
}])
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, self.0)])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,14 +838,11 @@ mod tests {
|
||||
seen: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||
}
|
||||
impl KeySource for Probe {
|
||||
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
if let Ok(s) = ctx.samples(8) {
|
||||
self.seen.lock().unwrap().extend(s);
|
||||
}
|
||||
Ok(vec![UnitKey {
|
||||
idx: 0,
|
||||
key: self.key,
|
||||
}])
|
||||
Ok(vec![UnitKey::new(0, self.key)])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,8 +857,8 @@ mod tests {
|
||||
});
|
||||
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0xEEu8; crate::aacs::ALIGNED_UNIT_LEN]];
|
||||
let got = cb(&samples);
|
||||
let samples = vec![vec![0xEEu8; crate::aacs::content::ALIGNED_UNIT_LEN]];
|
||||
let got = cb.unit_keys(&samples);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![key],
|
||||
@@ -636,6 +872,225 @@ mod tests {
|
||||
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
|
||||
}
|
||||
|
||||
/// `key_fetch` memoizes each operation by the fingerprint of the sample batch:
|
||||
/// identical samples reuse the cached keys (no rebuild), different samples miss,
|
||||
/// the two operations keep independent caches, and even an empty reply is cached.
|
||||
#[test]
|
||||
fn key_fetch_memoizes_per_op_by_sample_fingerprint() {
|
||||
let builds = Arc::new(Mutex::new(0usize));
|
||||
let builds_c = Arc::clone(&builds);
|
||||
let key = [0x11u8; 16];
|
||||
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
|
||||
*builds_c.lock().unwrap() += 1;
|
||||
vec![Box::new(HasKey(key)) as Box<dyn KeySource>]
|
||||
});
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let a = vec![vec![0xAAu8; 8]];
|
||||
let b = vec![vec![0xBBu8; 8]];
|
||||
|
||||
// First resolve for `a` builds sources; the identical repeat is cached.
|
||||
assert_eq!(cb.unit_keys(&a), vec![key]);
|
||||
assert_eq!(cb.unit_keys(&a), vec![key]);
|
||||
assert_eq!(
|
||||
*builds.lock().unwrap(),
|
||||
1,
|
||||
"identical samples reuse the cache"
|
||||
);
|
||||
|
||||
// A different sample batch is a cache miss → one more build.
|
||||
assert_eq!(cb.unit_keys(&b), vec![key]);
|
||||
assert_eq!(
|
||||
*builds.lock().unwrap(),
|
||||
2,
|
||||
"different samples miss the cache"
|
||||
);
|
||||
|
||||
// The forensic op has its OWN cache (HasKey has no forensic keys → empty),
|
||||
// so `a` builds once more here; its empty reply is then cached too.
|
||||
assert!(cb.fmts_indexes(&a).is_empty());
|
||||
assert_eq!(
|
||||
*builds.lock().unwrap(),
|
||||
3,
|
||||
"unit/fmts caches are independent"
|
||||
);
|
||||
assert!(cb.fmts_indexes(&a).is_empty());
|
||||
assert_eq!(
|
||||
*builds.lock().unwrap(),
|
||||
3,
|
||||
"an empty reply is cached, not re-asked"
|
||||
);
|
||||
}
|
||||
|
||||
/// A transient source outage must NOT be memoized as a permanent "no key":
|
||||
/// a fingerprint whose first fetch failed because the source errored must be
|
||||
/// re-asked, and once the source recovers the key resolves. Regression guard
|
||||
/// for the negative-result memoization fix — caching the errored empty would
|
||||
/// permanently drop a recoverable unit for the rest of the op.
|
||||
#[test]
|
||||
fn errored_empty_is_not_cached_and_retries_when_source_recovers() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let key = [0x77u8; 16];
|
||||
// Shared across every `make_sources()` rebuild: call 0 errors (source
|
||||
// down), every later call succeeds (source recovered).
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct Flaky {
|
||||
calls: Arc<AtomicUsize>,
|
||||
key: [u8; 16],
|
||||
}
|
||||
impl KeySource for Flaky {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
Err(Error::AacsNoKeys) // first attempt: source unreachable
|
||||
} else {
|
||||
Ok(vec![UnitKey::new(0, self.key)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let calls_c = Arc::clone(&calls);
|
||||
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
|
||||
vec![Box::new(Flaky {
|
||||
calls: Arc::clone(&calls_c),
|
||||
key,
|
||||
}) as Box<dyn KeySource>]
|
||||
});
|
||||
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0xCDu8; 8]];
|
||||
|
||||
// First fetch: the source errors → empty, but the miss must NOT be cached.
|
||||
assert!(
|
||||
cb.unit_keys(&samples).is_empty(),
|
||||
"source down → empty this time"
|
||||
);
|
||||
// Second fetch, SAME samples: not blocked by a cached empty → the now-
|
||||
// recovered source resolves the key.
|
||||
assert_eq!(
|
||||
cb.unit_keys(&samples),
|
||||
vec![key],
|
||||
"recovered source resolves — errored empty was not memoized"
|
||||
);
|
||||
}
|
||||
|
||||
/// A GENUINE absence (a source that runs and returns an empty `Ok`) is still
|
||||
/// memoized — the benefit the fix preserves. A source counting its calls must
|
||||
/// be asked exactly once for a fingerprint whose first (clean) reply was empty.
|
||||
#[test]
|
||||
fn genuine_empty_is_still_memoized() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
struct AlwaysEmpty {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
impl KeySource for AlwaysEmpty {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Vec::new()) // ran fine, genuinely holds no key
|
||||
}
|
||||
}
|
||||
|
||||
let calls_c = Arc::clone(&calls);
|
||||
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
|
||||
vec![Box::new(AlwaysEmpty {
|
||||
calls: Arc::clone(&calls_c),
|
||||
}) as Box<dyn KeySource>]
|
||||
});
|
||||
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0xEFu8; 8]];
|
||||
|
||||
assert!(cb.unit_keys(&samples).is_empty());
|
||||
assert!(cb.unit_keys(&samples).is_empty());
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"a clean empty reply is cached — the source is asked only once"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two `KeyFetch` operations route to the two DISTINCT trait methods:
|
||||
/// `unit_keys` drives `get_unit_keys`, `fmts_indexes` drives
|
||||
/// `get_fmts_indexes`. A source that returns different keys per method proves
|
||||
/// the seam no longer collapses "1 base key" and "the forensic set" into one
|
||||
/// overloaded call — the operation, not the return length, decides which.
|
||||
#[test]
|
||||
fn key_fetch_routes_unit_and_fmts_to_distinct_source_methods() {
|
||||
const BASE: [u8; 16] = [0xB0; 16];
|
||||
const F1: [u8; 16] = [0xF1; 16];
|
||||
const F2: [u8; 16] = [0xF2; 16];
|
||||
|
||||
struct TwoOp;
|
||||
impl KeySource for TwoOp {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, BASE)])
|
||||
}
|
||||
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, F1), UnitKey::new(1, F2)])
|
||||
}
|
||||
}
|
||||
|
||||
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> =
|
||||
Arc::new(|| vec![Box::new(TwoOp) as Box<dyn KeySource>]);
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0x01u8; 4]];
|
||||
|
||||
assert_eq!(
|
||||
cb.unit_keys(&samples),
|
||||
vec![BASE],
|
||||
"unit_keys resolves the base Unit Key via get_unit_keys"
|
||||
);
|
||||
assert_eq!(
|
||||
cb.fmts_indexes(&samples),
|
||||
vec![F1, F2],
|
||||
"fmts_indexes resolves the forensic set (any length) via get_fmts_indexes"
|
||||
);
|
||||
}
|
||||
|
||||
/// `KeyFetch::unit_only` serves base keys but NEVER a forensic set — the
|
||||
/// contract the sweep/patch recovery decorator relies on (it resolves CPS
|
||||
/// units only). Its `fmts_indexes` is unconditionally empty.
|
||||
#[test]
|
||||
fn key_fetch_unit_only_never_serves_forensic() {
|
||||
let f = crate::sector::KeyFetch::unit_only(std::sync::Arc::new(|_| vec![[0xAA; 16]]));
|
||||
assert_eq!(f.unit_keys(&[vec![0u8; 4]]), vec![[0xAA; 16]]);
|
||||
assert!(
|
||||
f.fmts_indexes(&[vec![0u8; 4]]).is_empty(),
|
||||
"unit_only resolver yields no forensic keys"
|
||||
);
|
||||
}
|
||||
|
||||
/// `get_fmts_indexes` defaults to empty, so a base-only source (a keydb) opts
|
||||
/// out of the forensic path without implementing it. `fetch_fmts_indexes` then
|
||||
/// falls through to the next source, exactly like the unit-key driver.
|
||||
#[test]
|
||||
fn fetch_fmts_indexes_skips_default_optout_source() {
|
||||
struct BaseOnly; // uses the default (empty) get_fmts_indexes
|
||||
impl KeySource for BaseOnly {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, [0x11; 16])])
|
||||
}
|
||||
}
|
||||
struct Forensic;
|
||||
impl KeySource for Forensic {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, [0x77; 16])])
|
||||
}
|
||||
}
|
||||
let inputs = empty_inputs();
|
||||
let ctx = DiscInputsCtx::new(&inputs);
|
||||
let sources: Vec<Box<dyn KeySource>> = vec![Box::new(BaseOnly), Box::new(Forensic)];
|
||||
let got = fetch_fmts_indexes(&sources, &ctx);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].key, [0x77; 16], "the base-only source is skipped");
|
||||
}
|
||||
|
||||
/// #4 regression: encrypted content NOT at the extent midpoint (a late-
|
||||
/// starting feature, or a midpoint landing in clear nav) must still be
|
||||
/// sampled — empty samples make `decrypt_with` skip wrong-key validation.
|
||||
@@ -643,7 +1098,7 @@ mod tests {
|
||||
/// finds the early scrambled band.
|
||||
#[test]
|
||||
fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() {
|
||||
use crate::aacs::{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::sector::SectorSource;
|
||||
|
||||
@@ -713,7 +1168,99 @@ mod tests {
|
||||
"the probe-spread must sample the early scrambled band the midpoint misses"
|
||||
);
|
||||
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 clarity heuristic. Half the units lack TS syncs but are
|
||||
/// 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 selecting by TS-sync clarity
|
||||
/// 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.
|
||||
// Neither has clean TS syncs, so `is_clean` is FALSE for BOTH;
|
||||
// `aacs_unit_encrypted` flags only the odd units.
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,7 +1272,7 @@ mod tests {
|
||||
/// prior single-key fixtures passed regardless of stride.
|
||||
#[test]
|
||||
fn disc_inputs_ctx_parses_unit_keys_at_the_version_stride() {
|
||||
use crate::aacs::{AACS_MAJOR_BD, AACS_MAJOR_UHD};
|
||||
use crate::aacs::mkb::{AACS_MAJOR_BD, AACS_MAJOR_UHD};
|
||||
const UK_POS: usize = 64;
|
||||
let mut inf = vec![0u8; 200];
|
||||
inf[0..4].copy_from_slice(&(UK_POS as u32).to_be_bytes()); // uk_pos
|
||||
|
||||
+35
-21
@@ -16,7 +16,7 @@ use std::collections::HashMap;
|
||||
|
||||
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
|
||||
/// 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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// would mislabel tracks.)
|
||||
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_sub: Vec<u16> = Vec::new();
|
||||
for info in infos {
|
||||
if let Some(&n) = stream_map.get(&info.id) {
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
match info.stream_type {
|
||||
StreamLabelType::Audio => taken_audio.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());
|
||||
for info in infos {
|
||||
let n = match stream_map.get(&info.id).copied() {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
Some(n) if n != 0 => n,
|
||||
_ => {
|
||||
let (idx, taken) = match info.stream_type {
|
||||
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
|
||||
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
|
||||
@@ -277,27 +284,34 @@ mod tests {
|
||||
assert_eq!(nums[3], 2); // subtitle 2
|
||||
}
|
||||
|
||||
/// Spec: map stream_num=0 is explicitly rejected (apply_labels uses 1-based).
|
||||
/// This is documented in parse_playback_config: `if stream_num != 0`.
|
||||
/// Mutation: remove the `!= 0` guard → zero is stored in map.
|
||||
/// Spec: a map value of 0 is unmatchable (apply_labels is 1-based), so
|
||||
/// assign_stream_numbers must treat it as unmapped and synthesize a real
|
||||
/// 1-based number rather than emit an orphan 0.
|
||||
/// Mutation: read the map value verbatim → stream_number 0 leaks out.
|
||||
#[test]
|
||||
fn map_zero_stream_num_is_skipped() {
|
||||
// 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.
|
||||
fn map_zero_stream_num_is_synthesized_not_emitted() {
|
||||
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)];
|
||||
// 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);
|
||||
// If the map has 0 for a0, assign_stream_numbers returns 0 (map wins).
|
||||
// This is a known limitation — the guard lives in parse_playback_config.
|
||||
// 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.
|
||||
assert_eq!(nums[0], 0);
|
||||
// 0 is treated as unmapped → the fallback counter assigns 1.
|
||||
assert_eq!(nums[0], 1);
|
||||
}
|
||||
|
||||
/// A stream genuinely mapped to 1 plus another stream whose map value is 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.
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ use std::collections::HashMap;
|
||||
|
||||
/// Cheap signature check: a CTRM disc ships `menu_base.prop` and/or
|
||||
/// `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, "language_streams.txt")
|
||||
}
|
||||
|
||||
+10
-8
@@ -35,14 +35,16 @@ use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// dbp detect can't peek inside a jar without a SectorSource (the
|
||||
/// trait function only takes `&UdfFs`), so we trigger on the cheap
|
||||
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every
|
||||
/// BD-J disc, but parse() does the real `com/dbp/` check and
|
||||
/// returns None on a mismatch — so this parser only ever consumes
|
||||
/// time on discs that fell through every earlier parser.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
jar::has_any_top_level_jar(udf)
|
||||
/// The real dbp signal is the `com/dbp/` package prefix inside a top-level
|
||||
/// jar's central directory. With a reader in `detect`, we check that directly
|
||||
/// (a cheap central-directory scan, no class decode) so this parser claims
|
||||
/// only dbp discs instead of firing on every BD-J disc. `parse()` repeats the
|
||||
/// check as belt-and-suspenders.
|
||||
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||
jar::for_each_jar(reader, udf, |_entry, archive| {
|
||||
jar::has_path_prefix(archive, "com/dbp/").then_some(())
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// 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 std::collections::{HashMap, HashSet};
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
// Cheap pre-check at the dir level; the real signal is
|
||||
// `com/bydeluxe/` inside any top-level jar's central directory,
|
||||
// which `parse()` confirms when given a `SectorSource`.
|
||||
jar::has_any_top_level_jar(udf)
|
||||
pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
|
||||
// The real signal is `com/bydeluxe/` inside a top-level jar's central
|
||||
// directory. With a reader in detect we check it directly (cheap
|
||||
// central-directory scan, no bytecode walk) so this parser claims only
|
||||
// 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> {
|
||||
|
||||
@@ -26,21 +26,6 @@ const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// etc.
|
||||
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
|
||||
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
|
||||
/// 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 paramount;
|
||||
mod pixelogic;
|
||||
mod png_filenames;
|
||||
pub(crate) mod text;
|
||||
pub mod vocab;
|
||||
pub(crate) mod xml;
|
||||
@@ -91,7 +92,11 @@ pub enum LabelQualifier {
|
||||
// the registry picks the highest-confidence parse result, falling back
|
||||
// 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>;
|
||||
|
||||
/// 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),
|
||||
("pixelogic", pixelogic::detect, pixelogic::parse),
|
||||
("ctrm", ctrm::detect, ctrm::parse),
|
||||
// dbp and deluxe both detect on "any top-level .jar in /BDMV/JAR/"
|
||||
// (every BD-J disc trips that) and do the real vendor-prefix check
|
||||
// in parse(). Order between them is the tiebreaker on equal
|
||||
// confidence; dbp goes first because its parse path is cheaper
|
||||
// (constant-pool iteration vs. deluxe's bytecode walking).
|
||||
// dbp and deluxe now detect via the real `com/<vendor>/` central-directory
|
||||
// prefix (reader-backed), so they claim only their own discs. Order between
|
||||
// them is the tiebreaker on equal confidence; dbp goes first because its
|
||||
// parse path is cheaper (constant-pool iteration vs. deluxe's bytecode
|
||||
// walking).
|
||||
("dbp", dbp::detect, dbp::parse),
|
||||
("deluxe", deluxe::detect, deluxe::parse),
|
||||
// Universal MPLS fallback. Returns Confidence::Low so framework
|
||||
@@ -176,6 +181,11 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
||||
mpls_universal::detect,
|
||||
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.
|
||||
@@ -521,7 +531,7 @@ fn generate_audio_label_inner(
|
||||
fn extract(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
let mut best: Option<(&'static str, ParseResult)> = None;
|
||||
for (name, detect, parse) in PARSERS {
|
||||
if !detect(udf) {
|
||||
if !detect(reader, udf) {
|
||||
continue;
|
||||
}
|
||||
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();
|
||||
|
||||
for (name, detect, parse) in PARSERS {
|
||||
if !detect(udf) {
|
||||
if !detect(reader, udf) {
|
||||
continue;
|
||||
}
|
||||
tracing::info!(parser = name, "label parser detected");
|
||||
@@ -961,8 +971,9 @@ pub struct ChapterSummary {
|
||||
|
||||
/// List filenames found under any `/BDMV/JAR/<x>/` subdirectory of
|
||||
/// the disc. Deduped, sorted. Returns an empty vec if no JAR dir is
|
||||
/// present.
|
||||
fn jar_inventory(udf: &UdfFs) -> Vec<String> {
|
||||
/// present. `pub(crate)` so filename-based parsers (e.g. `png_filenames`)
|
||||
/// 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 {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -1042,12 +1053,14 @@ mod registry_tests {
|
||||
"dbp",
|
||||
"deluxe",
|
||||
"mpls_universal",
|
||||
"png_filenames",
|
||||
],
|
||||
"PARSERS array order changed — confirm dbp + deluxe stay just \
|
||||
before mpls_universal (loose detect, real check in parse), \
|
||||
stricter parsers (paramount/criterion/pixelogic/ctrm — all \
|
||||
file-presence gated detect) stay first, and mpls_universal \
|
||||
stays LAST as the universal Low-confidence fallback."
|
||||
"PARSERS array order changed — file-presence/reader-gated High \
|
||||
parsers (paramount/criterion/pixelogic/ctrm) stay first; dbp + \
|
||||
deluxe (now real com/<vendor>/ prefix detect) stay before \
|
||||
mpls_universal; mpls_universal stays the universal Low 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
|
||||
/// `.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 {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelT
|
||||
use crate::sector::SectorSource;
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const REGIONS: &[&str] = &[
|
||||
"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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
/// 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Classify a free-form English label string into a [`LabelPurpose`].
|
||||
|
||||
+42
-22
@@ -98,7 +98,7 @@ pub const VERSION_LABEL: &str = concat!(env!("FREEMKV_VERSION"), env!("GIT_SUFFI
|
||||
|
||||
/// The muxing/writing-application string written into MKV output
|
||||
/// (`"freemkv <version> (g<hash>)"`).
|
||||
pub const MUX_APP: &str = concat!("freemkv ", env!("FREEMKV_VERSION"), env!("GIT_SUFFIX"));
|
||||
pub(crate) const MUX_APP: &str = concat!("freemkv ", env!("FREEMKV_VERSION"), env!("GIT_SUFFIX"));
|
||||
|
||||
pub mod aacs;
|
||||
pub(crate) mod clpi;
|
||||
@@ -125,7 +125,7 @@ pub(crate) mod platform;
|
||||
pub mod progress;
|
||||
pub mod scsi;
|
||||
pub mod sector;
|
||||
pub(crate) mod speed;
|
||||
pub mod session;
|
||||
pub(crate) mod udf;
|
||||
pub(crate) mod unlock_bridge;
|
||||
|
||||
@@ -137,38 +137,57 @@ pub(crate) mod unlock_bridge;
|
||||
pub use drive::capture::{
|
||||
CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string,
|
||||
};
|
||||
pub use drive::{Drive, DriveStatus, find_drive};
|
||||
pub use drive::{Drive, DriveStatus, extract_scsi_context, find_drive};
|
||||
|
||||
// ─── Disc session (drive open + SCSI bring-up hoist) ─────────────────────────
|
||||
//
|
||||
// One entry point that opens a drive and brings the transport up, so consumers
|
||||
// stop hand-rolling `open → wait_ready → init → probe_disc → identify → scan`.
|
||||
// Owns the `Drive` by value; forwards consumer-built key material into
|
||||
// `ScanOptions` (the library derives no certs — see `KeySpec`).
|
||||
pub use session::{
|
||||
DeviceTarget, DiscSession, KeySourceFactory, KeySpec, ResolvedKeys, resolve_keys_for, scan_iso,
|
||||
};
|
||||
|
||||
// ─── Errors ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// All fallible APIs return `Result<T, Error>`. `Error` is a typed enum with a
|
||||
// numeric `code()`; **no English text in the library** — applications map
|
||||
// codes to localized messages. See `error.rs` for the full taxonomy.
|
||||
pub use error::{Error, Result};
|
||||
pub use error::{Error, Result, is_disc_level_no_key, is_halt, is_skippable_title_stub};
|
||||
|
||||
// ─── Cooperative cancellation ───────────────────────────────────────────────
|
||||
//
|
||||
// One-bit cooperative cancellation token, shared by every long-running loop
|
||||
// in libfreemkv (sweep, patch, mux). Clone it cheaply; pass it by value into
|
||||
// each component; poll `is_cancelled()` inside the loop body.
|
||||
// One-bit cooperative cancellation token, shared by every long-running loop —
|
||||
// libfreemkv's mux, and the recovery passes (sweep/patch) that now live in the
|
||||
// freemkv-engine crate. Clone it cheaply; pass it by value into each component;
|
||||
// poll `is_cancelled()` inside the loop body.
|
||||
pub use halt::Halt;
|
||||
|
||||
// Generic bounded producer/consumer primitive used by sweep, patch, and
|
||||
// mux to overlap reads with writes via a dedicated consumer thread.
|
||||
// Generic bounded producer/consumer primitive used by the mux pipeline (and,
|
||||
// via this re-export, by the engine's sweep/patch recovery passes) to overlap
|
||||
// reads with writes via a dedicated consumer thread.
|
||||
// `Pipeline::spawn(name, depth, sink)` spawns a named consumer; `pipe.send(item)`
|
||||
// pushes one item with back-pressure; `pipe.finish()` joins the
|
||||
// consumer and surfaces its `close()` output. Callers implement `Sink`
|
||||
// to define per-item behaviour and end-of-stream finalisation.
|
||||
//
|
||||
// `DEFAULT_PIPELINE_DEPTH` (=4) is for callers without specific needs;
|
||||
// most should use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
||||
// most should use WRITE_PIPELINE_DEPTH instead.
|
||||
// Patch uses `WRITE_THROUGH_DEPTH` (=1). Returning `Flow::Stop` from
|
||||
// `apply` ends the consumer cleanly (still calls `close()`).
|
||||
pub use io::pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH,
|
||||
};
|
||||
|
||||
// ─── Bounded-cache buffered file writer ─────────────────────────────────────
|
||||
//
|
||||
// Drop-in `std::fs::File` replacement used everywhere the lib writes large
|
||||
// sequential output (mux, extract, sweep, patch) — drains dirty pages
|
||||
// continuously instead of bursting. General I/O infra, not recovery policy;
|
||||
// promoted to `pub` so freemkv-engine's relocated sweep/patch can use it too.
|
||||
pub use io::WritebackFile;
|
||||
|
||||
// ─── Drive events (low-level callbacks) ─────────────────────────────────────
|
||||
pub use event::{BatchSizeReason, Event, EventKind};
|
||||
pub use identity::DriveId;
|
||||
@@ -187,7 +206,7 @@ pub use identity::DriveId;
|
||||
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
||||
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
||||
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
||||
pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads};
|
||||
pub use decrypt::{AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads};
|
||||
|
||||
// ─── Disc structure ─────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -201,11 +220,10 @@ pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_thr
|
||||
// different concepts, the same short name; the trait gets the `Pes`
|
||||
// prefix at the crate root to keep both addressable.
|
||||
pub use disc::{
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
|
||||
Disc, DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, ExtractOptions, ExtractResult,
|
||||
FileResult, FrameRate, HdrFormat, Key, KeyOrigin, LabelPurpose, LabelQualifier, PatchOptions,
|
||||
PatchOutcome, Resolution, SampleRate, ScanOptions, Stream, SubtitleStream, SweepOptions,
|
||||
VideoStream, classify_damage,
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
|
||||
DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, ExtractOptions, ExtractResult,
|
||||
FileResult, FrameRate, HdrFormat, Key, KeyOrigin, LabelPurpose, LabelQualifier, Resolution,
|
||||
SampleRate, ScanOptions, Stream, SubtitleStream, VideoStream,
|
||||
};
|
||||
pub use keysource::{DiscInputs, KeySource, read_encrypted_units, resolve_and_apply};
|
||||
|
||||
@@ -238,6 +256,7 @@ pub use mux::NullStream;
|
||||
pub use mux::StdioStream;
|
||||
pub use mux::WriteSeek;
|
||||
pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
pub use mux::{Mp4FitReport, Mp4SkipReason, mp4_fit_report};
|
||||
|
||||
// ─── Lower-level surfaces ───────────────────────────────────────────────────
|
||||
//
|
||||
@@ -248,10 +267,11 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
||||
// `SectorSource` to get plaintext sectors out.
|
||||
pub use mux::build_iso_pipeline;
|
||||
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
||||
pub use mux::resolve_mux_key_map;
|
||||
pub use mux::select::{PidFilter, StreamSelection};
|
||||
pub use mux::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream};
|
||||
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, SenseFamily, drive_has_disc, list_drives};
|
||||
pub use sector::{
|
||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
|
||||
SectorSink, SectorSource,
|
||||
DecryptingSectorSource, FileSectorSource, KeyFetch, PrefetchedSectorSource, SectorSource,
|
||||
};
|
||||
pub use speed::DriveSpeed;
|
||||
pub use udf::{UdfFs, read_filesystem};
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+241
-48
@@ -41,6 +41,11 @@ pub struct Ac3Parser {
|
||||
/// the running per-frame PTS at the point the partial tail was retained.
|
||||
/// Used by `flush()` to time the final buffered frame at EOS.
|
||||
flush_pts_ns: i64,
|
||||
/// Keep/drop bookkeeping for the CRC decodability gate. A frame that fails
|
||||
/// its native CRC is dropped rather than shipped as a decoder-choking glitch;
|
||||
/// the running PTS is advanced across it (see the emit loop) so the drop is a
|
||||
/// silence gap, never a shift of the following audio.
|
||||
tally: super::dropgate::DropTally,
|
||||
}
|
||||
|
||||
impl Default for Ac3Parser {
|
||||
@@ -54,8 +59,99 @@ impl Ac3Parser {
|
||||
Self {
|
||||
buf: Vec::with_capacity(4096),
|
||||
flush_pts_ns: 0,
|
||||
tally: super::dropgate::DropTally::new("ac3"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Access units dropped as undecodable so far — surfaced to the CLI/mux.
|
||||
pub fn dropped_frames(&self) -> u64 {
|
||||
self.tally.dropped_frames()
|
||||
}
|
||||
|
||||
/// Total decoded duration (ns) of dropped access units.
|
||||
pub fn dropped_duration_ns(&self) -> u64 {
|
||||
self.tally.dropped_duration_ns()
|
||||
}
|
||||
|
||||
/// Emit the final buffered frame at EOS, through the decodability gate.
|
||||
/// During streaming a final frame may sit in `buf` with no following PES to
|
||||
/// complete it; without this drain the last ~32 ms of audio is lost. Only a
|
||||
/// fully-sized frame at a syncword is considered; a partial/garbage tail is
|
||||
/// discarded, and a corrupt (CRC-failing) final frame is dropped.
|
||||
fn flush_tail(&mut self) -> Vec<Frame> {
|
||||
let buf = std::mem::take(&mut self.buf);
|
||||
let Some(off) = find_ac3_sync(&buf) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let frame_all = &buf[off..];
|
||||
if frame_all.len() < 6 {
|
||||
return Vec::new();
|
||||
}
|
||||
let bsid = get_bsid(frame_all);
|
||||
let frame_size = if bsid >= 11 {
|
||||
eac3_frame_size(frame_all)
|
||||
} else {
|
||||
ac3_frame_size(frame_all)
|
||||
};
|
||||
if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) || off + frame_size > buf.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let frame = &buf[off..off + frame_size];
|
||||
let duration_ns = frame_duration_ns(frame, bsid);
|
||||
if let Some(reason) = ac3_drop_reason(&self.tally, frame, bsid) {
|
||||
self.tally
|
||||
.record_drop(self.flush_pts_ns, duration_ns as i64, frame.len(), reason);
|
||||
return Vec::new();
|
||||
}
|
||||
self.tally.record_kept();
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: self.flush_pts_ns,
|
||||
keyframe: true,
|
||||
data: frame.to_vec(),
|
||||
duration_ns: Some(duration_ns),
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
use super::crc::crc16_ansi;
|
||||
|
||||
/// Whether a fully-buffered (E-)AC-3 frame passes its native CRC. Per ETSI TS
|
||||
/// 102 366 (ATSC A/52) the frame carries a CRC-16/ANSI (poly 0x8005, init 0,
|
||||
/// non-reflected) over the bytes after the 2-byte syncword — i.e. `crc16_ansi(
|
||||
/// &buf[2..]) == 0` covers `frame_size - 2` bytes; the trailing crc word makes a
|
||||
/// clean frame's residue zero. A nonzero residue is a ~1-in-65536-certain sign
|
||||
/// of payload corruption, so we drop the frame (silence gap) rather than ship a
|
||||
/// glitch. `frame` must be exactly the frame bytes (syncword .. frame_size).
|
||||
fn frame_crc_ok(frame: &[u8]) -> bool {
|
||||
// Need the syncword (2) plus at least one covered byte; the caller only
|
||||
// invokes this on a fully-sized frame, so this is defensive.
|
||||
if frame.len() < 4 {
|
||||
return true;
|
||||
}
|
||||
crc16_ansi(&frame[2..]) == 0
|
||||
}
|
||||
|
||||
/// Decodability verdict for a fully-sized (E-)AC-3 frame: `Some(reason)` when it
|
||||
/// must be dropped, `None` when it decodes. Drops (in order): a poisoned track
|
||||
/// (mostly-undecodable → drop the rest), an out-of-range bitstream id (`bsid >
|
||||
/// 16`; ETSI TS 102 366 defines no bsid above 16), or a failed native frame CRC.
|
||||
fn ac3_drop_reason(
|
||||
tally: &super::dropgate::DropTally,
|
||||
frame: &[u8],
|
||||
bsid: u8,
|
||||
) -> Option<&'static str> {
|
||||
if tally.is_poisoned() {
|
||||
Some("track-poisoned")
|
||||
} else if bsid > 16 {
|
||||
Some("bsid")
|
||||
} else if !frame_crc_ok(frame) {
|
||||
Some("crc")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for Ac3Parser {
|
||||
@@ -91,10 +187,12 @@ impl CodecParser for Ac3Parser {
|
||||
// in practice, so this is defense-in-depth.
|
||||
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(self.flush_pts_ns);
|
||||
|
||||
// Prepend leftover from previous PES
|
||||
// Prepend leftover from previous PES, then take the whole buffer into a
|
||||
// local so the emit loop can call `self.tally` (the bytes are no longer
|
||||
// borrowed from `self`). The unconsumed tail is written back at the end.
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
|
||||
let data = &self.buf;
|
||||
let buf = std::mem::take(&mut self.buf);
|
||||
let data = &buf;
|
||||
let mut frames = Vec::new();
|
||||
let mut pos = 0;
|
||||
// Running PTS for the next frame to emit in this call.
|
||||
@@ -134,15 +232,26 @@ impl CodecParser for Ac3Parser {
|
||||
}
|
||||
|
||||
let duration_ns = frame_duration_ns(remaining, bsid);
|
||||
let frame = &data[start..start + frame_size];
|
||||
// Decodability gate: drop a frame with an out-of-range bsid (> 16)
|
||||
// or whose native CRC fails (payload corruption). `frame_pts_ns` is
|
||||
// advanced BELOW whether or not the frame survives, so a drop is a
|
||||
// silence gap and the following frames keep their true PTS.
|
||||
if let Some(reason) = ac3_drop_reason(&self.tally, frame, bsid) {
|
||||
self.tally
|
||||
.record_drop(frame_pts_ns, duration_ns as i64, frame.len(), reason);
|
||||
} else {
|
||||
self.tally.record_kept();
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: frame_pts_ns,
|
||||
keyframe: true,
|
||||
data: data[start..start + frame_size].to_vec(),
|
||||
data: frame.to_vec(),
|
||||
duration_ns: Some(duration_ns),
|
||||
});
|
||||
}
|
||||
frame_pts_ns += duration_ns as i64;
|
||||
pos = start + frame_size;
|
||||
}
|
||||
@@ -198,38 +307,10 @@ impl CodecParser for Ac3Parser {
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
// End of stream: emit a complete final frame still buffered. During
|
||||
// streaming a final frame may sit in `buf` with no following PES to
|
||||
// complete/confirm it; without this drain the last ~32 ms of audio is
|
||||
// dropped at EOS (mirrors dts.rs::flush). Only a fully-sized frame at a
|
||||
// syncword is emitted; a partial/garbage tail is discarded.
|
||||
let buf = std::mem::take(&mut self.buf);
|
||||
let Some(off) = find_ac3_sync(&buf) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let frame = &buf[off..];
|
||||
if frame.len() < 6 {
|
||||
return Vec::new();
|
||||
}
|
||||
let bsid = get_bsid(frame);
|
||||
let frame_size = if bsid >= 11 {
|
||||
eac3_frame_size(frame)
|
||||
} else {
|
||||
ac3_frame_size(frame)
|
||||
};
|
||||
if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) || off + frame_size > buf.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let duration_ns = frame_duration_ns(frame, bsid);
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: self.flush_pts_ns,
|
||||
keyframe: true,
|
||||
data: buf[off..off + frame_size].to_vec(),
|
||||
duration_ns: Some(duration_ns),
|
||||
}]
|
||||
let out = self.flush_tail();
|
||||
// Aggregate drop report at end-of-stream (warn-level, always visible).
|
||||
self.tally.log_summary();
|
||||
out
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
@@ -304,8 +385,8 @@ const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 4, 4, 5];
|
||||
///
|
||||
/// This is the AUTHORITATIVE channel count for the track header: the DVD IFO
|
||||
/// `audio_attr_t.channels` nibble is a well-known unreliable/stale field, so
|
||||
/// the muxer prefers this over the IFO-claimed count (mirrors MakeMKV /
|
||||
/// HandBrake, which never trust the IFO audio nibble). LFE adds one channel
|
||||
/// the muxer prefers this over the IFO-claimed count (the bitstream acmod is
|
||||
/// authoritative; the IFO audio nibble is not trusted). LFE adds one channel
|
||||
/// (e.g. acmod=7 + lfeon → 6 = 5.1).
|
||||
///
|
||||
/// Bit layout from the syncword (A/52 §5.3.2 BSI):
|
||||
@@ -460,9 +541,24 @@ mod tests {
|
||||
frame[1] = 0x77;
|
||||
frame[4] = (fscod << 6) | frmsizecod;
|
||||
frame[5] = 0x08 << 3; // bsid = 8 (AC-3)
|
||||
finalize_ac3_crc(&mut frame);
|
||||
frame
|
||||
}
|
||||
|
||||
/// Set the trailing CRC word so the whole-frame residue over `[2..]` is zero
|
||||
/// — i.e. the frame passes the decodability gate. Relies on the CRC-16/ANSI
|
||||
/// residue property: appending `crc16([2..n-2])` (big-endian) zeroes the
|
||||
/// register over `[2..n]`. Leaves the crc1 field (bytes 2-3) untouched.
|
||||
fn finalize_ac3_crc(frame: &mut [u8]) {
|
||||
let n = frame.len();
|
||||
if n < 4 {
|
||||
return;
|
||||
}
|
||||
let c = crc16_ansi(&frame[2..n - 2]);
|
||||
frame[n - 2] = (c >> 8) as u8;
|
||||
frame[n - 1] = (c & 0xFF) as u8;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
@@ -532,7 +628,7 @@ mod tests {
|
||||
// (PES marked discontinuity) carrying a fresh complete frame. The
|
||||
// truncated partial must be DROPPED, not spliced — otherwise the parser
|
||||
// emits one corrupt frame built from [stale partial | head of fresh] and
|
||||
// strands the tail (FFmpeg: "incomplete frame" / wrong sync).
|
||||
// strands the tail (decoders report "incomplete frame" / wrong sync).
|
||||
let mut parser = Ac3Parser::new();
|
||||
let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B77
|
||||
|
||||
@@ -1090,27 +1186,33 @@ mod tests {
|
||||
// --- frame acceptance / rejection at the size boundaries ---
|
||||
|
||||
#[test]
|
||||
fn eac3_frame_at_min_frame_bytes_is_accepted() {
|
||||
// The smallest acceptable (E-)AC-3 frame is MIN_FRAME_BYTES = 6.
|
||||
// Build an E-AC-3 frame whose frmsiz sizes it to exactly 6 bytes
|
||||
// (frmsiz=2). bsid >= 11 selects E-AC-3 sizing. The parser must emit it.
|
||||
fn eac3_frame_at_min_frame_bytes_passes_sizing_then_crc_gate() {
|
||||
// The smallest frame the SIZING layer accepts is MIN_FRAME_BYTES = 6
|
||||
// (frmsiz=2). A synthetic all-zero 6-byte frame passes sizing (so it
|
||||
// reaches the decodability gate — proven by it being COUNTED as a drop,
|
||||
// not silently size-skipped) but fails the CRC gate and is dropped; the
|
||||
// following real AC-3 frame (valid CRC) is emitted.
|
||||
let mut parser = Ac3Parser::new();
|
||||
// 0x0B 0x77 | byte2=0 byte3=2 (frmsiz=2 → 6 bytes) | byte4=0 | byte5 bsid
|
||||
let mut data = vec![0x0B, 0x77, 0x00, 0x02, 0x00, 16 << 3];
|
||||
// pad to exactly 6 bytes (already 6). Then a trailing real AC-3 frame so
|
||||
// the 6-byte frame isn't a tail that needs more data.
|
||||
data.truncate(6);
|
||||
data.extend_from_slice(&make_ac3_frame(0, 2));
|
||||
let f = parser.parse(&make_eac3_pes(data));
|
||||
assert_eq!(f.len(), 2, "6-byte E-AC-3 frame accepted + following AC-3");
|
||||
assert_eq!(f[0].data.len(), 6);
|
||||
assert_eq!(f.len(), 1, "6-byte frame dropped (CRC), real AC-3 emitted");
|
||||
assert_eq!(f[0].data.len(), 160, "the surviving frame is the real AC-3");
|
||||
assert_eq!(
|
||||
parser.dropped_frames(),
|
||||
1,
|
||||
"the 6-byte frame reached the gate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_max_frmsiz_frame_within_window_accepted() {
|
||||
// E-AC-3 frmsiz is an 11-bit field (3 bits of byte2 + 8 bits of byte3),
|
||||
// so its maximum value is 0x7FF = 2047 → (2048)*2 = 4096 bytes, which is
|
||||
// inside the MIN_FRAME_BYTES..=8192 accept window and must be emitted.
|
||||
// inside the MIN_FRAME_BYTES..=8192 accept window and, with a valid CRC,
|
||||
// must be emitted.
|
||||
let mut parser = Ac3Parser::new();
|
||||
let mut frame = vec![0u8; 4096];
|
||||
frame[0] = 0x0B;
|
||||
@@ -1118,6 +1220,7 @@ mod tests {
|
||||
frame[2] = 0x07; // frmsiz high
|
||||
frame[3] = 0xFF; // frmsiz low → 0x7FF = 2047 → 4096 bytes
|
||||
frame[5] = 16 << 3; // bsid 16 (E-AC-3)
|
||||
finalize_ac3_crc(&mut frame); // pass the decodability gate
|
||||
let f = parser.parse(&make_eac3_pes(frame));
|
||||
assert_eq!(f.len(), 1, "4096-byte E-AC-3 frame within window accepted");
|
||||
assert_eq!(f[0].data.len(), 4096);
|
||||
@@ -1296,6 +1399,96 @@ mod tests {
|
||||
assert_eq!(acmod_channels(&frame), Some(2));
|
||||
}
|
||||
|
||||
// --- decodability (CRC) gate: keep clean frames, drop corrupt ones ---
|
||||
|
||||
/// A structurally-valid AC-3 frame with one payload byte corrupted so its
|
||||
/// native CRC fails (header/size intact, so the framer delimits it normally).
|
||||
fn make_corrupt_ac3_frame(fscod: u8, frmsizecod: u8) -> Vec<u8> {
|
||||
let mut f = make_ac3_frame(fscod, frmsizecod);
|
||||
f[20] ^= 0xFF; // flip a payload byte → CRC no longer zero
|
||||
assert!(!frame_crc_ok(&f), "corruption must break the CRC");
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc16_residue_zero_after_finalize_nonzero_after_corruption() {
|
||||
// The CRC-16/ANSI residue property the gate relies on: a finalized frame
|
||||
// has residue 0 over [2..]; flipping any covered byte makes it nonzero.
|
||||
let good = make_ac3_frame(0, 2);
|
||||
assert!(frame_crc_ok(&good));
|
||||
let bad = make_corrupt_ac3_frame(0, 2);
|
||||
assert!(!frame_crc_ok(&bad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_fail_frame_is_dropped_survivors_kept() {
|
||||
// good / corrupt / good in one PES: the corrupt middle frame is dropped
|
||||
// (CRC), the two clean frames are emitted, and the drop is counted.
|
||||
let mut parser = Ac3Parser::new();
|
||||
let mut data = make_ac3_frame(0, 2);
|
||||
data.extend_from_slice(&make_corrupt_ac3_frame(0, 2));
|
||||
data.extend_from_slice(&make_ac3_frame(0, 2));
|
||||
let f = parser.parse(&make_eac3_pes(data));
|
||||
// Only two of three survive; flush has nothing (all closed in-call).
|
||||
assert_eq!(f.len(), 2, "corrupt frame dropped, two clean survive");
|
||||
assert_eq!(parser.dropped_frames(), 1);
|
||||
assert_eq!(
|
||||
parser.dropped_duration_ns(),
|
||||
32_000_000,
|
||||
"one 32ms frame of silence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_drop_preserves_pts_sync_no_shift() {
|
||||
// THE INVARIANT: dropping a corrupt frame must not shift the audio after
|
||||
// it. good / corrupt / good in one PES — the corrupt frame is dropped but
|
||||
// the trailing clean frame keeps the EXACT PTS it would have had with no
|
||||
// drop (base + 2 frame durations): a silence gap, not a shift.
|
||||
let mut parser = Ac3Parser::new();
|
||||
let mut data = make_ac3_frame(0, 2); // f0
|
||||
data.extend_from_slice(&make_corrupt_ac3_frame(0, 2)); // dropped
|
||||
data.extend_from_slice(&make_ac3_frame(0, 2)); // f2
|
||||
let f = parser.parse(&make_eac3_pes(data));
|
||||
assert_eq!(f.len(), 2);
|
||||
let base = pts_to_ns(90000);
|
||||
let frame_dur = 32_000_000i64; // 1536 @ 48k
|
||||
assert_eq!(f[0].pts_ns, base, "f0 at PES base");
|
||||
assert_eq!(
|
||||
f[1].pts_ns,
|
||||
base + 2 * frame_dur,
|
||||
"surviving frame keeps its true timeline (base + 2 frames) — gap, not shift"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsid_over_16_is_dropped() {
|
||||
// bsid > 16 is out of range (ETSI TS 102 366 defines no bsid above 16).
|
||||
// A frame with bsid = 17 that still sizes must be dropped, not emitted.
|
||||
let mut frame = vec![0u8; 128];
|
||||
frame[0] = 0x0B;
|
||||
frame[1] = 0x77;
|
||||
frame[3] = 63; // frmsiz = 63 → (63+1)*2 = 128 bytes (E-AC-3 sizing)
|
||||
frame[5] = 17 << 3; // bsid = 17 (> 16)
|
||||
assert_eq!(get_bsid(&frame), 17);
|
||||
let tally = super::super::dropgate::DropTally::new("ac3");
|
||||
assert_eq!(ac3_drop_reason(&tally, &frame, 17), Some("bsid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_stream_drops_nothing() {
|
||||
// A stream of valid frames passes untouched — zero false positives.
|
||||
let mut parser = Ac3Parser::new();
|
||||
let mut data = Vec::new();
|
||||
for _ in 0..5 {
|
||||
data.extend_from_slice(&make_ac3_frame(0, 2));
|
||||
}
|
||||
let mut f = parser.parse(&make_eac3_pes(data));
|
||||
f.extend(parser.flush());
|
||||
assert_eq!(f.len(), 5);
|
||||
assert_eq!(parser.dropped_frames(), 0);
|
||||
}
|
||||
|
||||
// helper: PES with a generic pts for E-AC-3 tests
|
||||
fn make_eac3_pes(data: Vec<u8>) -> PesPacket {
|
||||
PesPacket {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! AAC ADTS decodability gate.
|
||||
//!
|
||||
//! Per the ADTS framing defined in ISO/IEC 13818-7 / ISO/IEC 14496-3, a header
|
||||
//! is structurally invalid in exactly three ways this gate treats as hard
|
||||
//! rejects: syncword != 0xFFF, a reserved `sampling_frequency_index` (the sample
|
||||
//! rate table has 13 valid entries, so index ≥ 13 is reserved), and
|
||||
//! `aac_frame_length < 7` (shorter than the fixed+variable header itself). The
|
||||
//! optional 16-bit ADTS CRC is not verified here — it is simply skipped. So the
|
||||
//! gate enforces those three rejects: a packet that begins with the ADTS sync
|
||||
//! but is otherwise malformed is dropped; a packet with no ADTS sync is raw AAC
|
||||
//! (e.g. from an MP4 container, which carries no ADTS header) or a continuation
|
||||
//! and passes through unchanged — never false-dropped. Raw AAC has no per-frame
|
||||
//! integrity data, so like LPCM it cannot be gated.
|
||||
|
||||
use super::dropgate::DropTally;
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// ADTS `sampling_frequency_index` table (ISO/IEC 14496-3) — 13 valid entries;
|
||||
/// indices 13/14/15 are 0 (reserved) and constitute a hard reject.
|
||||
const ADTS_SAMPLE_RATE_VALID: [u32; 16] = [
|
||||
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, 0, 0,
|
||||
0,
|
||||
];
|
||||
|
||||
/// ADTS header verdict for the packet head.
|
||||
enum AdtsVerdict {
|
||||
/// No 12-bit ADTS sync at the head — not an ADTS frame we can validate.
|
||||
NoSync,
|
||||
/// Sync present and the three structural fields are legal.
|
||||
Valid,
|
||||
/// Sync present but a reserved sample-rate index or a sub-header
|
||||
/// frame-length — structurally invalid per the ADTS spec.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
fn adts_verdict(data: &[u8]) -> AdtsVerdict {
|
||||
// Need the full 7-byte fixed+variable header to read frame_length.
|
||||
if data.len() < 7 {
|
||||
return AdtsVerdict::NoSync;
|
||||
}
|
||||
// 12-bit syncword 0xFFF: byte0 == 0xFF and top nibble of byte1 == 0xF.
|
||||
if data[0] != 0xFF || (data[1] & 0xF0) != 0xF0 {
|
||||
return AdtsVerdict::NoSync;
|
||||
}
|
||||
// sampling_frequency_index: byte2 bits 5..2.
|
||||
let sr_index = ((data[2] >> 2) & 0x0F) as usize;
|
||||
if ADTS_SAMPLE_RATE_VALID[sr_index] == 0 {
|
||||
return AdtsVerdict::Invalid;
|
||||
}
|
||||
// aac_frame_length: 13 bits = byte3[1:0] | byte4 | byte5[7:5].
|
||||
let frame_length =
|
||||
((u32::from(data[3]) & 0x03) << 11) | (u32::from(data[4]) << 3) | (u32::from(data[5]) >> 5);
|
||||
if frame_length < 7 {
|
||||
return AdtsVerdict::Invalid;
|
||||
}
|
||||
AdtsVerdict::Valid
|
||||
}
|
||||
|
||||
pub struct AdtsParser {
|
||||
tally: DropTally,
|
||||
/// Last emitted PTS (ns). A PES with no PTS (legal for audio, e.g. a
|
||||
/// post-discontinuity continuation) carries this forward rather than resetting
|
||||
/// the timeline to 0 — matching the AC-3/DTS parsers and preserving A/V sync.
|
||||
last_pts_ns: i64,
|
||||
}
|
||||
|
||||
impl Default for AdtsParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AdtsParser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tally: DropTally::new("aac"),
|
||||
last_pts_ns: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dropped_frames(&self) -> u64 {
|
||||
self.tally.dropped_frames()
|
||||
}
|
||||
|
||||
pub fn dropped_duration_ns(&self) -> u64 {
|
||||
self.tally.dropped_duration_ns()
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for AdtsParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes
|
||||
.pts
|
||||
.or(pes.dts)
|
||||
.map(pts_to_ns)
|
||||
.unwrap_or(self.last_pts_ns);
|
||||
self.last_pts_ns = pts_ns;
|
||||
|
||||
let drop =
|
||||
self.tally.is_poisoned() || matches!(adts_verdict(&pes.data), AdtsVerdict::Invalid);
|
||||
if drop {
|
||||
let reason = if self.tally.is_poisoned() {
|
||||
"track-poisoned"
|
||||
} else {
|
||||
"header"
|
||||
};
|
||||
self.tally.record_drop(pts_ns, 0, pes.data.len(), reason);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.tally.record_kept();
|
||||
vec![Frame {
|
||||
discontinuity: pes.discontinuity,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
self.tally.log_summary();
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid ADTS header (AAC-LC, 44.1 kHz, stereo) + payload, with
|
||||
/// aac_frame_length set to the total size.
|
||||
fn adts_frame(payload: usize) -> Vec<u8> {
|
||||
let total = 7 + payload;
|
||||
let mut f = vec![0u8; total];
|
||||
f[0] = 0xFF;
|
||||
f[1] = 0xF1; // sync + MPEG-4 + no CRC (protection_absent=1)
|
||||
f[2] = 0x50; // profile=AAC-LC, sr_index=4 (44.1 kHz)
|
||||
f[3] = 0x80; // channel_config low + start of frame_length
|
||||
// frame_length (13 bits) = total.
|
||||
let fl = total as u32;
|
||||
f[3] = (f[3] & 0xFC) | ((fl >> 11) & 0x03) as u8;
|
||||
f[4] = ((fl >> 3) & 0xFF) as u8;
|
||||
f[5] = (((fl & 0x07) << 5) as u8) | 0x1F; // low 3 bits of len + buffer-fullness bits
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_adts_is_kept() {
|
||||
let mut p = AdtsParser::new();
|
||||
let f = p.parse(&make_pes(adts_frame(400), Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000));
|
||||
assert_eq!(p.dropped_frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pes_without_pts_carries_last_timestamp_not_zero() {
|
||||
// A PES with no PTS (legal for audio, e.g. after a discontinuity) must
|
||||
// carry the last known timestamp forward — resetting to 0 would corrupt
|
||||
// A/V sync.
|
||||
let mut p = AdtsParser::new();
|
||||
p.parse(&make_pes(adts_frame(400), Some(90000)));
|
||||
let f = p.parse(&make_pes(adts_frame(400), None));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(
|
||||
f[0].pts_ns,
|
||||
pts_to_ns(90000),
|
||||
"carried forward, not reset to 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_sample_rate_index_is_dropped() {
|
||||
// sr_index = 13 (reserved). byte2 bits5..2 = 1101 → 0x34.
|
||||
let mut p = AdtsParser::new();
|
||||
let mut f = adts_frame(400);
|
||||
f[2] = (f[2] & 0xC3) | (13 << 2); // set sr_index = 13
|
||||
assert!(p.parse(&make_pes(f, Some(0))).is_empty());
|
||||
assert_eq!(p.dropped_frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subheader_frame_length_is_dropped() {
|
||||
// frame_length < 7 (here 0) is a sub-header length → reject.
|
||||
let mut p = AdtsParser::new();
|
||||
let mut f = adts_frame(400);
|
||||
f[3] &= 0xFC; // clear len high bits
|
||||
f[4] = 0;
|
||||
f[5] &= 0x1F; // clear len low bits → frame_length = 0
|
||||
assert!(p.parse(&make_pes(f, Some(0))).is_empty());
|
||||
assert_eq!(p.dropped_frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_aac_without_sync_passes_through() {
|
||||
// No ADTS sync (e.g. raw AAC from mp4) → cannot validate → keep.
|
||||
let mut p = AdtsParser::new();
|
||||
let f = p.parse(&make_pes(
|
||||
vec![0x21, 0x00, 0x03, 0x40, 0x00, 0x00, 0x00],
|
||||
Some(0),
|
||||
));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(p.dropped_frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_preserves_sync_via_own_pts() {
|
||||
let mut p = AdtsParser::new();
|
||||
let mut bad = adts_frame(400);
|
||||
bad[2] = (bad[2] & 0xC3) | (14 << 2); // reserved sr_index
|
||||
assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
|
||||
let f = p.parse(&make_pes(adts_frame(400), Some(96000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(
|
||||
f[0].pts_ns,
|
||||
pts_to_ns(96000),
|
||||
"next frame keeps its own PTS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_packet_passes_through() {
|
||||
let mut p = AdtsParser::new();
|
||||
let f = p.parse(&make_pes(vec![0xFF, 0xF1, 0x50], Some(0)));
|
||||
assert_eq!(f.len(), 1, "too short to validate → kept");
|
||||
}
|
||||
}
|
||||
@@ -206,8 +206,8 @@ impl PictureInfo {
|
||||
}
|
||||
|
||||
/// Number of field-display periods this picture occupies — the basis for
|
||||
/// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10,
|
||||
/// ffmpeg `nb_fields = repeat_pict + 2`): a field picture occupies 1 field,
|
||||
/// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10):
|
||||
/// a field picture occupies 1 field,
|
||||
/// a normal frame 2, a `repeat_first_field` progressive-frame 3 (or 4/6 in a
|
||||
/// progressive sequence); an rff bit on a non-progressive interlaced frame is
|
||||
/// spec-forbidden (§6.3.10) and is treated as 2. Codecs without pulldown
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Bit-exact CRC helpers shared by the audio codec decodability gates.
|
||||
//!
|
||||
//! Each matches the CRC defined by its format's bitstream specification, so a
|
||||
//! frame these routines flag as a CRC mismatch is exactly the frame a
|
||||
//! spec-conformant decoder would reject. All are MSB-first (non-reflected),
|
||||
//! init 0, no final XOR — the big-endian CRC variants. Each format transmits
|
||||
//! its CRC so that the residue over `data + transmitted_crc` is zero, which is
|
||||
//! exactly how these are used: compute over the whole frame (including its
|
||||
//! trailing CRC) and check `== 0`.
|
||||
|
||||
/// CRC-16/ANSI (a.k.a. CRC-16/BUYPASS): polynomial 0x8005, init 0x0000,
|
||||
/// MSB-first, no reflection, no final XOR. Called by the AC-3/E-AC-3 frame-CRC
|
||||
/// gate (ETSI TS 102 366) and the FLAC frame footer. (The MPEG-audio and
|
||||
/// AAC-ADTS gates validate the header structurally and do not verify their
|
||||
/// optional CRC, so they do not call this.)
|
||||
pub(crate) fn crc16_ansi(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0;
|
||||
for &b in data {
|
||||
crc ^= (b as u16) << 8;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000 != 0 {
|
||||
(crc << 1) ^ 0x8005
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// CRC-16 with polynomial 0x002D, init 0, MSB-first, used by the MLP / Dolby
|
||||
/// TrueHD major-sync header checksum. NOTE: MLP's checksum is the "reversed"
|
||||
/// scheme — the stored trailer word is the little-endian-read CRC, so this
|
||||
/// standard CRC must be compared against the stored bytes read big-endian.
|
||||
/// The caller handles that comparison (see `truehd::mlp_major_sync_ok`).
|
||||
/// Verified against real MLP/TrueHD bitstreams (225/225 major-sync AUs).
|
||||
pub(crate) fn crc16_mlp(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0;
|
||||
for &b in data {
|
||||
crc ^= (b as u16) << 8;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000 != 0 {
|
||||
(crc << 1) ^ 0x002D
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// CRC-8/ATM (a.k.a. CRC-8/ITU without the final XOR): polynomial 0x07, init 0,
|
||||
/// MSB-first, no reflection — the FLAC frame-header CRC-8 (RFC 9639). Available
|
||||
/// as a primitive; the FLAC gate currently validates only the frame footer CRC-16.
|
||||
pub(crate) fn crc8_atm(data: &[u8]) -> u8 {
|
||||
let mut crc: u8 = 0;
|
||||
for &b in data {
|
||||
crc ^= b;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x80 != 0 {
|
||||
(crc << 1) ^ 0x07
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crc16_residue_property_holds() {
|
||||
// Appending the big-endian CRC-16 of a message zeroes the residue over
|
||||
// message+crc — the property every frame gate relies on.
|
||||
let msg = [0x12u8, 0x34, 0x56, 0x78, 0x9A];
|
||||
let c = crc16_ansi(&msg);
|
||||
let mut framed = msg.to_vec();
|
||||
framed.push((c >> 8) as u8);
|
||||
framed.push((c & 0xFF) as u8);
|
||||
assert_eq!(crc16_ansi(&framed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc16_known_vector_check_bytes() {
|
||||
// CRC-16/BUYPASS check value for the ASCII string "123456789" is 0xFEE8
|
||||
// (the standard catalogue check value for poly 0x8005, init 0).
|
||||
assert_eq!(crc16_ansi(b"123456789"), 0xFEE8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc16_mlp_known_vector_check_bytes() {
|
||||
// Independent known-answer for CRC-16 poly 0x002D, init 0, MSB-first over
|
||||
// the catalogue string "123456789" is 0x4FF7 — computed by a separate
|
||||
// reference implementation (NOT by crc16_mlp), so a wrong polynomial or
|
||||
// shift direction here fails this test even though every truehd fixture
|
||||
// (which derives its trailer from crc16_mlp itself) would still pass.
|
||||
assert_eq!(crc16_mlp(b"123456789"), 0x4FF7);
|
||||
assert_eq!(crc16_mlp(&[0x00, 0x01, 0x02, 0x03]), 0x5E26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc16_mlp_residue_property_holds() {
|
||||
// Appending the big-endian CRC zeroes the residue over message+crc — the
|
||||
// scheme `truehd::mlp_major_sync_ok` relies on.
|
||||
let msg = [0xF8u8, 0x72, 0x6F, 0xBA];
|
||||
let c = crc16_mlp(&msg);
|
||||
let mut framed = msg.to_vec();
|
||||
framed.push((c >> 8) as u8);
|
||||
framed.push((c & 0xFF) as u8);
|
||||
assert_eq!(crc16_mlp(&framed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc8_residue_property_holds() {
|
||||
// Appending the CRC-8 of a message zeroes the residue over message+crc —
|
||||
// how FLAC's header CRC-8 is verified.
|
||||
let msg = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||
let c = crc8_atm(&msg);
|
||||
let mut framed = msg.to_vec();
|
||||
framed.push(c);
|
||||
assert_eq!(crc8_atm(&framed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc8_known_vector_check_byte() {
|
||||
// CRC-8/SMBUS (poly 0x07, init 0, no reflection) check value for
|
||||
// "123456789" is 0xF4 — the catalogue check value.
|
||||
assert_eq!(crc8_atm(b"123456789"), 0xF4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Shared "keep what decodes, drop what doesn't" bookkeeping for the audio
|
||||
//! codec parsers.
|
||||
//!
|
||||
//! The user's rule: a clean mux keeps every frame it can and drops the ones it
|
||||
//! can't — video always survives (it's inter-frame predicted; a per-frame drop
|
||||
//! would cascade, so video resyncs/conceals instead), audio keeps every
|
||||
//! decodable access unit, and a damaged audio AU is dropped rather than shipped
|
||||
//! as a decoder-choking glitch.
|
||||
//!
|
||||
//! The DETECTION is inherently per-codec — each format carries its own
|
||||
//! authoritative corruption check (DTS: the core sync/header parse per ETSI TS
|
||||
//! 102 114; AC-3: the header CRC per ETSI TS 102 366; FLAC: the frame CRC-16; …).
|
||||
//! This type only carries the UNIFORM
|
||||
//! response so every audio parser behaves identically:
|
||||
//!
|
||||
//! 1. **Count** kept vs dropped AUs and the dropped duration.
|
||||
//! 2. **Log** every drop (fail-loud, never silent) — a per-drop trace plus a
|
||||
//! once-per-track aggregate at `warn` so it surfaces without debug logging.
|
||||
//! 3. **Whole-track fallback**: once a track is judged mostly undecodable, latch
|
||||
//! a poison flag so the remainder is dropped too (a track that damaged isn't
|
||||
//! worth muxing).
|
||||
//!
|
||||
//! **Sync preservation is the caller's responsibility**, not this type's: the
|
||||
//! parser must advance its PTS clock across a dropped AU exactly as it would for
|
||||
//! an emitted one, so a drop becomes a silence gap and never a shift of the
|
||||
//! following audio. See `DtsParser`'s `stamp_pts` call ordering for the pattern.
|
||||
|
||||
/// Minimum access units observed before the whole-track drop verdict can fire.
|
||||
/// Below this, a short damaged burst can't poison an otherwise-good track.
|
||||
const TRACK_VERDICT_MIN_AUS: u64 = 200;
|
||||
|
||||
/// Per-track drop bookkeeping shared by the audio codec parsers.
|
||||
pub(crate) struct DropTally {
|
||||
/// Static codec label for log lines (e.g. `"dts"`, `"ac3"`).
|
||||
codec: &'static str,
|
||||
kept: u64,
|
||||
dropped: u64,
|
||||
/// AUs dropped because they were INDIVIDUALLY verified undecodable (a failed
|
||||
/// CRC/header/parity check). Only these feed the whole-track poison verdict.
|
||||
/// Distinct from `dropped`, which also counts *collateral* drops — AUs
|
||||
/// discarded as a consequence of one corruption (TrueHD's resync-forward run,
|
||||
/// or a poisoned track), which must NOT amplify a few real errors into a
|
||||
/// false whole-track loss.
|
||||
verified_dropped: u64,
|
||||
dropped_dur_ns: u64,
|
||||
poisoned: bool,
|
||||
}
|
||||
|
||||
impl DropTally {
|
||||
pub(crate) fn new(codec: &'static str) -> Self {
|
||||
Self {
|
||||
codec,
|
||||
kept: 0,
|
||||
dropped: 0,
|
||||
verified_dropped: 0,
|
||||
dropped_dur_ns: 0,
|
||||
poisoned: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the track has been judged too damaged to mux. Once `true`, the
|
||||
/// caller should drop every remaining AU (passing them to [`record_drop`]
|
||||
/// with a poison reason) rather than emit them.
|
||||
pub(crate) fn is_poisoned(&self) -> bool {
|
||||
self.poisoned
|
||||
}
|
||||
|
||||
/// Access units dropped as undecodable so far — surfaced to the CLI/mux.
|
||||
pub(crate) fn dropped_frames(&self) -> u64 {
|
||||
self.dropped
|
||||
}
|
||||
|
||||
/// Total decoded duration (ns) of dropped AUs — the audio silence introduced.
|
||||
pub(crate) fn dropped_duration_ns(&self) -> u64 {
|
||||
self.dropped_dur_ns
|
||||
}
|
||||
|
||||
/// Record an emitted (decodable) access unit.
|
||||
pub(crate) fn record_kept(&mut self) {
|
||||
self.kept += 1;
|
||||
}
|
||||
|
||||
/// Record a dropped access unit that was INDIVIDUALLY verified undecodable
|
||||
/// (a failed CRC/header/parity check). Counts toward the whole-track poison
|
||||
/// verdict. `reason` is a short static label for the check that failed.
|
||||
pub(crate) fn record_drop(&mut self, pts_ns: i64, dur_ns: i64, bytes: usize, reason: &str) {
|
||||
self.verified_dropped += 1;
|
||||
self.record_drop_common(pts_ns, dur_ns, bytes, reason);
|
||||
self.maybe_poison();
|
||||
}
|
||||
|
||||
/// Record a COLLATERAL drop — an AU discarded as a consequence of another
|
||||
/// corruption rather than being individually undecodable (TrueHD's
|
||||
/// resync-forward run to the next major sync, or an already-poisoned track).
|
||||
/// Counted and logged for the drop report, but deliberately does NOT feed the
|
||||
/// poison verdict, so one corruption event can't amplify into a false
|
||||
/// whole-track loss.
|
||||
pub(crate) fn record_collateral_drop(
|
||||
&mut self,
|
||||
pts_ns: i64,
|
||||
dur_ns: i64,
|
||||
bytes: usize,
|
||||
reason: &str,
|
||||
) {
|
||||
self.record_drop_common(pts_ns, dur_ns, bytes, reason);
|
||||
}
|
||||
|
||||
fn record_drop_common(&mut self, pts_ns: i64, dur_ns: i64, bytes: usize, reason: &str) {
|
||||
self.dropped += 1;
|
||||
self.dropped_dur_ns += dur_ns.max(0) as u64;
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"{}: dropped undecodable AU #{} pts_ns={} dur_ns={} bytes={} reason={}",
|
||||
self.codec,
|
||||
self.dropped,
|
||||
pts_ns,
|
||||
dur_ns,
|
||||
bytes,
|
||||
reason
|
||||
);
|
||||
}
|
||||
|
||||
/// Whole-track fallback: after enough AUs to judge, if more than half were
|
||||
/// dropped the track is too damaged to be worth muxing — latch `poisoned`
|
||||
/// and log it loudly once. The minimum-sample gate keeps a short damaged
|
||||
/// burst from poisoning an otherwise-good track.
|
||||
fn maybe_poison(&mut self) {
|
||||
if self.poisoned {
|
||||
return;
|
||||
}
|
||||
// Judge on VERIFIED drops vs all AUs seen: a track is only poisoned when
|
||||
// a majority of its access units are individually undecodable — not when
|
||||
// a couple of corruption events forced long collateral resync runs.
|
||||
let total = self.kept + self.dropped;
|
||||
if total >= TRACK_VERDICT_MIN_AUS && self.verified_dropped * 2 > total {
|
||||
self.poisoned = true;
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"{}: track too damaged to mux — {}/{} AUs individually undecodable (>50%); dropping the whole track",
|
||||
self.codec,
|
||||
self.verified_dropped,
|
||||
total
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// End-of-stream aggregate report, logged at `warn` so a track's dropped
|
||||
/// audio is never hidden even without debug logging. No-op if nothing was
|
||||
/// dropped.
|
||||
pub(crate) fn log_summary(&self) {
|
||||
if self.dropped > 0 {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"{}: dropped {} undecodable AU(s) totaling {} ns of audio ({} kept)",
|
||||
self.codec,
|
||||
self.dropped,
|
||||
self.dropped_dur_ns,
|
||||
self.kept
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counts_kept_and_dropped() {
|
||||
let mut t = DropTally::new("test");
|
||||
t.record_kept();
|
||||
t.record_drop(0, 1000, 512, "bad");
|
||||
t.record_kept();
|
||||
assert_eq!(t.dropped_frames(), 1);
|
||||
assert_eq!(t.dropped_duration_ns(), 1000);
|
||||
assert!(!t.is_poisoned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poisons_after_min_aus_over_half_dropped() {
|
||||
let mut t = DropTally::new("test");
|
||||
// 199 AUs, all dropped: below the min-AU gate, must NOT poison yet.
|
||||
for _ in 0..199 {
|
||||
t.record_drop(0, 1000, 512, "bad");
|
||||
}
|
||||
assert!(!t.is_poisoned(), "below the 200-AU minimum, no verdict");
|
||||
// The 200th drop reaches the minimum with >50% dropped → poison.
|
||||
t.record_drop(0, 1000, 512, "bad");
|
||||
assert!(t.is_poisoned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_poison_a_mostly_good_track() {
|
||||
let mut t = DropTally::new("test");
|
||||
// 400 AUs, 1 dropped: nowhere near 50%.
|
||||
t.record_drop(0, 1000, 512, "bad");
|
||||
for _ in 0..399 {
|
||||
t.record_kept();
|
||||
}
|
||||
assert!(!t.is_poisoned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collateral_drops_never_poison_the_track() {
|
||||
// A TrueHD resync-forward run collaterally drops a long burst of AUs, but
|
||||
// none are individually undecodable — the whole-track verdict must stay
|
||||
// clean so one corruption event can't amplify into a false total loss.
|
||||
let mut t = DropTally::new("test");
|
||||
for _ in 0..(TRACK_VERDICT_MIN_AUS * 3) {
|
||||
t.record_collateral_drop(0, 1000, 512, "resync-forward");
|
||||
}
|
||||
assert!(t.dropped_frames() >= TRACK_VERDICT_MIN_AUS, "drops counted");
|
||||
assert!(!t.is_poisoned(), "collateral drops must not poison");
|
||||
}
|
||||
}
|
||||
+996
-80
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
//! FLAC elementary-stream decodability gate.
|
||||
//!
|
||||
//! FLAC frames carry no length field, so a raw stream is delimited only by
|
||||
//! sync-scanning + CRC validation. In freemkv, though, FLAC never arrives raw:
|
||||
//! it comes from mp4/mkv, where each packet is exactly one container-delimited
|
||||
//! FLAC frame (a complete, pre-delimited frame per packet). So this parser
|
||||
//! is a per-packet gate, not a framer: every FLAC frame ends with a 16-bit CRC
|
||||
//! (poly 0x8005, init 0, non-reflected) computed so the residue over the whole
|
||||
//! frame — footer CRC included — is zero (per the FLAC format specification,
|
||||
//! RFC 9639, frame footer). A
|
||||
//! nonzero residue is definitive corruption → drop the frame (a silence gap,
|
||||
//! never a shift — each packet keeps its own PTS), logged via the shared tally.
|
||||
//!
|
||||
//! A packet that does not begin with the FLAC frame sync is not a delimited
|
||||
//! frame we can validate, so it is passed through unchanged (never false-dropped).
|
||||
|
||||
use super::crc::crc16_ansi;
|
||||
use super::dropgate::DropTally;
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// FLAC frame sync: 14-bit code `0x3FFE` + a mandatory-0 reserved bit; the next
|
||||
/// bit (blocking strategy) is masked off. Test the top 15 bits of the first two
|
||||
/// bytes: `(be16 & 0xFFFE) == 0xFFF8` (per RFC 9639, frame header).
|
||||
fn has_flac_sync(data: &[u8]) -> bool {
|
||||
data.len() >= 2 && ((u16::from(data[0]) << 8 | u16::from(data[1])) & 0xFFFE) == 0xFFF8
|
||||
}
|
||||
|
||||
/// Block-size code → samples (RFC 9639 block-size table; 0 = reserved/explicit).
|
||||
const FLAC_BLOCKSIZE_TABLE: [u32; 16] = [
|
||||
0, 192, 576, 1152, 2304, 4608, 0, 0, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768,
|
||||
];
|
||||
/// Sample-rate code → Hz (RFC 9639 sample-rate table; 0 = STREAMINFO/explicit).
|
||||
const FLAC_SAMPLE_RATE_TABLE: [u32; 16] = [
|
||||
0, 88_200, 176_400, 192_000, 8_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 96_000, 0,
|
||||
0, 0, 0,
|
||||
];
|
||||
|
||||
/// Best-effort duration (ns) of a FLAC frame from its header block-size and
|
||||
/// sample-rate codes (byte 2). Only the table-coded cases are resolved; the
|
||||
/// explicit-in-trailing-bytes codes (block 6/7, rate 12/13/14) and
|
||||
/// STREAMINFO-derived (code 0) return `None`. Used only for the dropped-audio
|
||||
/// accounting, so a `None` (→ 0) is harmless.
|
||||
fn flac_frame_duration_ns(frame: &[u8]) -> Option<i64> {
|
||||
if frame.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let bs_code = (frame[2] >> 4) & 0x0F;
|
||||
let sr_code = frame[2] & 0x0F;
|
||||
let blocksize = FLAC_BLOCKSIZE_TABLE[bs_code as usize];
|
||||
let rate = FLAC_SAMPLE_RATE_TABLE[sr_code as usize];
|
||||
if blocksize == 0 || rate == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((blocksize as i64 * 1_000_000_000 + rate as i64 / 2) / rate as i64)
|
||||
}
|
||||
|
||||
pub struct FlacParser {
|
||||
tally: DropTally,
|
||||
/// Last emitted PTS (ns), carried forward across a PES with no PTS rather than
|
||||
/// resetting the timeline to 0 (see the AC-3/DTS parsers) — preserves A/V sync.
|
||||
last_pts_ns: i64,
|
||||
}
|
||||
|
||||
impl Default for FlacParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FlacParser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tally: DropTally::new("flac"),
|
||||
last_pts_ns: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Access units dropped as undecodable so far.
|
||||
pub fn dropped_frames(&self) -> u64 {
|
||||
self.tally.dropped_frames()
|
||||
}
|
||||
|
||||
/// Total decoded duration (ns) of dropped access units.
|
||||
pub fn dropped_duration_ns(&self) -> u64 {
|
||||
self.tally.dropped_duration_ns()
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for FlacParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes
|
||||
.pts
|
||||
.or(pes.dts)
|
||||
.map(pts_to_ns)
|
||||
.unwrap_or(self.last_pts_ns);
|
||||
self.last_pts_ns = pts_ns;
|
||||
|
||||
// Gate: a packet that begins with a FLAC frame sync but whose whole-frame
|
||||
// CRC-16 residue is nonzero is corrupt → drop. Anything else passes
|
||||
// through (a non-sync packet is not a frame we can validate; a poisoned
|
||||
// track drops everything).
|
||||
let corrupt = has_flac_sync(&pes.data) && crc16_ansi(&pes.data) != 0;
|
||||
if self.tally.is_poisoned() || corrupt {
|
||||
let reason = if self.tally.is_poisoned() {
|
||||
"track-poisoned"
|
||||
} else {
|
||||
"crc"
|
||||
};
|
||||
let dur = flac_frame_duration_ns(&pes.data).unwrap_or(0);
|
||||
self.tally.record_drop(pts_ns, dur, pes.data.len(), reason);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.tally.record_kept();
|
||||
vec![Frame {
|
||||
discontinuity: pes.discontinuity,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
self.tally.log_summary();
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal FLAC-frame-shaped buffer: sync `0xFFF8`, a plausible header
|
||||
/// (block code 1 = 192 samples, rate code 9 = 44.1 kHz), some payload, and a
|
||||
/// trailing CRC-16 so the whole-frame residue is zero (a valid frame).
|
||||
fn make_flac_frame(payload_len: usize) -> Vec<u8> {
|
||||
let mut f = vec![0u8; 6 + payload_len + 2];
|
||||
f[0] = 0xFF;
|
||||
f[1] = 0xF8; // sync + fixed blocksize
|
||||
f[2] = (1 << 4) | 9; // bs_code=1 (192), sr_code=9 (44100)
|
||||
// bytes 3..end-2 arbitrary; last two bytes carry the CRC-16.
|
||||
let n = f.len();
|
||||
let c = crc16_ansi(&f[..n - 2]);
|
||||
f[n - 2] = (c >> 8) as u8;
|
||||
f[n - 1] = (c & 0xFF) as u8;
|
||||
assert_eq!(crc16_ansi(&f), 0, "finalized frame has zero residue");
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_frame_is_kept() {
|
||||
let mut p = FlacParser::new();
|
||||
let f = p.parse(&make_pes(make_flac_frame(100), Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000));
|
||||
assert_eq!(p.dropped_frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pes_without_pts_carries_last_timestamp_not_zero() {
|
||||
// A PES with no PTS (legal for audio, e.g. after a discontinuity) must
|
||||
// carry the last known timestamp forward — resetting to 0 would corrupt
|
||||
// A/V sync. Mirrors the adts.rs guard test.
|
||||
let mut p = FlacParser::new();
|
||||
p.parse(&make_pes(make_flac_frame(100), Some(90000)));
|
||||
let f = p.parse(&make_pes(make_flac_frame(100), None));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(
|
||||
f[0].pts_ns,
|
||||
pts_to_ns(90000),
|
||||
"carried forward, not reset to 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_frame_is_dropped() {
|
||||
let mut p = FlacParser::new();
|
||||
let mut frame = make_flac_frame(100);
|
||||
frame[20] ^= 0xFF; // corrupt a payload byte → CRC residue nonzero
|
||||
assert!(crc16_ansi(&frame) != 0);
|
||||
let f = p.parse(&make_pes(frame, Some(90000)));
|
||||
assert!(f.is_empty(), "corrupt FLAC frame dropped");
|
||||
assert_eq!(p.dropped_frames(), 1);
|
||||
// 192 samples @ 44.1 kHz ≈ 4.354 ms of silence accounted.
|
||||
assert_eq!(
|
||||
p.dropped_duration_ns(),
|
||||
(192u64 * 1_000_000_000 + 44_100 / 2) / 44_100
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_drop_preserves_sync_via_own_pts() {
|
||||
// Each packet carries its own PTS, so dropping one leaves the next frame
|
||||
// on its true timeline — a gap, not a shift.
|
||||
let mut p = FlacParser::new();
|
||||
let mut bad = make_flac_frame(100);
|
||||
bad[20] ^= 0xFF;
|
||||
assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
|
||||
let f = p.parse(&make_pes(make_flac_frame(100), Some(96000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(
|
||||
f[0].pts_ns,
|
||||
pts_to_ns(96000),
|
||||
"surviving frame keeps its own container PTS — the drop is a gap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_flac_packet_passes_through() {
|
||||
// A packet without the FLAC sync isn't a frame we can validate — never
|
||||
// false-drop it.
|
||||
let mut p = FlacParser::new();
|
||||
let f = p.parse(&make_pes(vec![0x00, 0x01, 0x02, 0x03], Some(0)));
|
||||
assert_eq!(f.len(), 1, "unrecognized packet passed through");
|
||||
assert_eq!(p.dropped_frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pes_emits_nothing() {
|
||||
let mut p = FlacParser::new();
|
||||
assert!(p.parse(&make_pes(Vec::new(), Some(0))).is_empty());
|
||||
}
|
||||
}
|
||||
+214
-6
@@ -52,6 +52,17 @@ pub struct H264Parser {
|
||||
// the stale avcC copy after a mid-title redefinition.
|
||||
cur_sps: 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 {
|
||||
@@ -68,6 +79,35 @@ impl H264Parser {
|
||||
pps: None,
|
||||
cur_sps: 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 —
|
||||
// DTS presents B-frames in decode order (visible judder) and breaks
|
||||
// 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
|
||||
// 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).
|
||||
let mut frame_data = Vec::with_capacity(pes.data.len() + 64);
|
||||
|
||||
// MVC dependent-view passthrough: keep ALL param sets in-band (the frame
|
||||
// is a self-contained BlockAdditional access unit), never strip/re-assert.
|
||||
let mvc = self.mvc_passthrough;
|
||||
|
||||
for nal in NalIterator::new(&pes.data) {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
|
||||
match nal_type {
|
||||
// Param sets: seed avcC, strip if unchanged vs the active set,
|
||||
// emit in-band on any change (incl. reverting to the avcC copy).
|
||||
NAL_SPS => {
|
||||
// In MVC passthrough these fall through to the default arm so the
|
||||
// subset SPS / PPS stay in-band (self-contained dependent AU).
|
||||
NAL_SPS if !mvc => {
|
||||
emitted_sps |=
|
||||
handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data)
|
||||
}
|
||||
NAL_PPS => {
|
||||
NAL_PPS if !mvc => {
|
||||
emitted_pps |=
|
||||
handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data)
|
||||
}
|
||||
@@ -226,7 +273,7 @@ impl CodecParser for H264Parser {
|
||||
// ahead of the slices (even when unchanged vs codecPrivate) so a decoder
|
||||
// that dropped the set at a reset recovers, and a stale avcC re-apply
|
||||
// can't revert it. Skipped per-type only when this AU already carried it.
|
||||
if keyframe {
|
||||
if keyframe && !mvc {
|
||||
let mut prefix = Vec::new();
|
||||
reassert_active(&mut prefix, &self.cur_sps, emitted_sps);
|
||||
reassert_active(&mut prefix, &self.cur_pps, emitted_pps);
|
||||
@@ -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
|
||||
// `field_order()` stays `None` — honestly absent, never guessed.
|
||||
coding: coding_type.map(PictureInfo::coding_type_only),
|
||||
@@ -248,7 +295,15 @@ impl CodecParser for H264Parser {
|
||||
discontinuity: pes.discontinuity,
|
||||
data: frame_data,
|
||||
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>> {
|
||||
@@ -619,6 +674,94 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_passthrough_keeps_param_sets_inband() {
|
||||
// A dependent-view access unit: subset SPS (NAL 15) + PPS (NAL 8) +
|
||||
// coded-slice-extension (NAL 20). No IDR (type 5), so keyframe stays
|
||||
// false and there is no keyframe re-assertion.
|
||||
let au = || {
|
||||
let mut d = Vec::new();
|
||||
d.extend_from_slice(&h264_nal(0x6F, &[0x80, 0x00, 0x33, 0xAA])); // subset SPS (15)
|
||||
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
|
||||
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
|
||||
d
|
||||
};
|
||||
let nal_types =
|
||||
|f: &Frame| -> Vec<u8> { h264_nals_in(&f.data).iter().map(|n| n[0] & 0x1F).collect() };
|
||||
|
||||
// Normal parser strips the PPS from a non-keyframe AU (it is captured for
|
||||
// the avcC and, without an IDR, never re-asserted in-band).
|
||||
let mut normal = H264Parser::new();
|
||||
let f = normal.parse(&make_pes(au(), Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert!(
|
||||
!nal_types(&f[0]).contains(&8),
|
||||
"normal parser strips PPS from a non-keyframe AU: {:?}",
|
||||
nal_types(&f[0])
|
||||
);
|
||||
|
||||
// Passthrough keeps EVERY parameter set in-band, so each dependent frame
|
||||
// is a self-contained access unit for a BlockAdditional.
|
||||
let mut pt = H264Parser::new().with_mvc_passthrough(true);
|
||||
let f = pt.parse(&make_pes(au(), Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
let types = nal_types(&f[0]);
|
||||
assert!(types.contains(&15), "subset SPS kept in-band: {types:?}");
|
||||
assert!(
|
||||
types.contains(&8),
|
||||
"PPS kept in-band under passthrough: {types:?}"
|
||||
);
|
||||
assert!(types.contains(&20), "slice kept: {types:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_for_mvc_dependent_h264_is_passthrough() {
|
||||
// The dependent-view stream must get a passthrough parser: a PPS in a
|
||||
// non-keyframe AU is kept in-band, not stripped like the base parser.
|
||||
let mut p = crate::mux::codec::parser_for_mvc_dependent(crate::disc::Codec::H264, false);
|
||||
let mut d = Vec::new();
|
||||
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
|
||||
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
|
||||
let f = p.parse(&make_pes(d, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
let types: Vec<u8> = h264_nals_in(&f[0].data)
|
||||
.iter()
|
||||
.map(|n| n[0] & 0x1F)
|
||||
.collect();
|
||||
assert!(
|
||||
types.contains(&8),
|
||||
"dependent parser keeps PPS in-band (passthrough): {types:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_passthrough_with_idr_does_not_reassert_param_sets() {
|
||||
// With an IDR present (keyframe=true), passthrough must NOT re-assert the
|
||||
// param sets (the `keyframe && !mvc` guard), so SPS/PPS appear exactly
|
||||
// once — a duplicate would corrupt the dependent BlockAdditional.
|
||||
let mut p = H264Parser::new().with_mvc_passthrough(true);
|
||||
let mut d = Vec::new();
|
||||
d.extend_from_slice(&h264_nal(0x67, &[0x42, 0x00, 0x1E, 0x01])); // SPS (7)
|
||||
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
|
||||
d.extend_from_slice(&h264_nal(0x65, &[0x88, 0x00])); // IDR slice (5)
|
||||
let f = p.parse(&make_pes(d, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
let types: Vec<u8> = h264_nals_in(&f[0].data)
|
||||
.iter()
|
||||
.map(|n| n[0] & 0x1F)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
types.iter().filter(|&&t| t == 7).count(),
|
||||
1,
|
||||
"exactly one SPS, no keyframe re-assert under passthrough: {types:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
types.iter().filter(|&&t| t == 8).count(),
|
||||
1,
|
||||
"exactly one PPS, no keyframe re-assert under passthrough: {types:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_populates_measured_coding_type_and_source() {
|
||||
use super::super::coding::CodingType;
|
||||
@@ -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),
|
||||
/// 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
|
||||
|
||||
+45
-10
@@ -99,7 +99,8 @@ fn hevc_first_slice_coding_type(nal: &[u8], nal_type: u8, num_extra: u32) -> Opt
|
||||
pub struct HevcParser {
|
||||
// First-seen parameter set of each type → seeds the MKV codecPrivate (hvcC).
|
||||
// This is the ONLY copy the player gets out-of-band, and a player re-applies
|
||||
// it at every keyframe (ffmpeg's hvcC→Annex-B insertion). A stream may
|
||||
// it at every keyframe (the hvcC→Annex-B parameter-set insertion a decoder
|
||||
// performs). A stream may
|
||||
// redefine a parameter set mid-title under the SAME id with a different body
|
||||
// (some discs redefine PPS id 0 partway through). Any occurrence whose body
|
||||
// DIFFERS from this codecPrivate copy must therefore be emitted IN-BAND at
|
||||
@@ -116,8 +117,9 @@ pub struct HevcParser {
|
||||
// set mid-title (e.g. PPS id 0 body changes partway through, then the
|
||||
// source STOPS repeating it at later IRAPs and relies on the decoder
|
||||
// retaining it), a raw decode is fine — but an hvcC/MKV decode is NOT: a
|
||||
// player re-applies the codecPrivate set at EVERY keyframe (ffmpeg's
|
||||
// hvcC→Annex-B insertion), reverting id 0 to the stale FIRST body. We must
|
||||
// player re-applies the codecPrivate set at EVERY keyframe (the
|
||||
// hvcC→Annex-B parameter-set insertion), reverting id 0 to the stale FIRST
|
||||
// body. We must
|
||||
// therefore re-emit the active set IN-BAND at every keyframe whenever it
|
||||
// differs from the codecPrivate copy and the access unit didn't already
|
||||
// carry it. See `parse`.
|
||||
@@ -166,6 +168,10 @@ pub struct HevcParser {
|
||||
// colour-volume metadata is ever fabricated.
|
||||
sei_mastering: Option<MasteringDisplay>,
|
||||
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),
|
||||
@@ -234,6 +240,25 @@ impl HevcParser {
|
||||
pts_wrap_offset: 0,
|
||||
sei_mastering: 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],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,9 +366,10 @@ impl HevcParser {
|
||||
/// codecPrivate copy (`first`). The two player behaviours for hvcC-in-MKV
|
||||
/// diverge exactly here:
|
||||
///
|
||||
/// - A *seek-capable / Annex-B* player (e.g. ffmpeg's `hevc_mp4toannexb`)
|
||||
/// re-applies the hvcC sets at every keyframe. `reassert_active` handles it.
|
||||
/// - A *streaming* decode (ffmpeg decoding the MKV directly — what most
|
||||
/// - A *seek-capable / Annex-B* player (one that converts hvcC to Annex-B by
|
||||
/// inserting the parameter sets) re-applies the hvcC sets at every keyframe.
|
||||
/// `reassert_active` handles it.
|
||||
/// - A *streaming* decode (a decoder consuming the MKV directly — what most
|
||||
/// integrity checkers do) applies hvcC ONCE at init and thereafter updates a
|
||||
/// parameter set ONLY from an in-band NAL.
|
||||
///
|
||||
@@ -404,7 +430,7 @@ fn handle_param_set(
|
||||
/// or SPS event), nothing re-sends it and every subsequent slice fails with
|
||||
/// "PPS id out of range" until the next genuine change (observed as a ~24 min
|
||||
/// corrupt band on one dual-layer UHD title). Re-asserting the active set at
|
||||
/// EVERY keyframe — what compliant muxers (mkvmerge) do at every IRAP — makes
|
||||
/// EVERY keyframe — what compliant Matroska muxers do at every IRAP — makes
|
||||
/// streaming decode self-healing. Re-sending an identical param set is benign
|
||||
/// (decoders expect it at IRAPs); cost is a few hundred bytes per keyframe.
|
||||
/// This strictly supersets the earlier change-only re-assert, so the
|
||||
@@ -443,7 +469,8 @@ impl CodecParser for HevcParser {
|
||||
// block timecode monotonic in storage order, which presents B-frames in
|
||||
// decode order (visible judder / wrong frames) and breaks PTS-based
|
||||
// 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
|
||||
// reads a BD title's clips as ONE concatenated sector stream and the
|
||||
@@ -651,7 +678,7 @@ impl CodecParser for HevcParser {
|
||||
// from the first coded picture before writing the track header). `None`
|
||||
// until both SEI present → SDR / no-SEI tracks carry nothing.
|
||||
let hdr10 = self.hdr10();
|
||||
vec![Frame {
|
||||
let frame = Frame {
|
||||
// Coding-type only: HEVC field order (pic_struct, from a pic_timing
|
||||
// SEI) is not decoded here, so field_order() stays None — honestly
|
||||
// absent, never guessed. HDR10 metadata is attached when measured.
|
||||
@@ -666,7 +693,15 @@ impl CodecParser for HevcParser {
|
||||
discontinuity: pes.discontinuity,
|
||||
data: frame_data,
|
||||
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>> {
|
||||
|
||||
+69
-17
@@ -9,12 +9,19 @@
|
||||
|
||||
/// AC-3 / E-AC-3 (Dolby Digital / Digital Plus) elementary-stream parser.
|
||||
pub mod ac3;
|
||||
|
||||
pub mod adts;
|
||||
/// Codec-agnostic per-picture coding carrier (`PictureInfo` + accessors).
|
||||
pub mod coding;
|
||||
/// DTS / DTS-HD elementary-stream parser.
|
||||
pub(crate) mod crc;
|
||||
pub(crate) mod dropgate;
|
||||
|
||||
pub mod dts;
|
||||
/// DVD bitmap subtitle (VobSub) parser.
|
||||
pub mod dvdsub;
|
||||
|
||||
pub mod flac;
|
||||
/// H.264 (AVC) Annex-B elementary-stream parser.
|
||||
pub mod h264;
|
||||
/// HEVC (H.265) Annex-B elementary-stream parser.
|
||||
@@ -23,8 +30,12 @@ pub mod hevc;
|
||||
pub mod lpcm;
|
||||
/// MPEG-2 Video elementary-stream parser.
|
||||
pub mod mpeg2;
|
||||
|
||||
pub mod mpegaudio;
|
||||
/// HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
||||
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.
|
||||
pub(crate) mod startcode;
|
||||
/// Dolby TrueHD / Atmos elementary-stream parser.
|
||||
@@ -110,11 +121,11 @@ pub trait CodecParser: Send {
|
||||
|
||||
/// Passthrough parser — treats each PES as one frame, no parsing.
|
||||
///
|
||||
/// Used for the audio codecs that have no dedicated parser and whose PES
|
||||
/// boundaries already line up with frame boundaries (Aac, Mp2, Mp3, Flac,
|
||||
/// Opus). AC3/DTS/TrueHD have their own parsers; PGS/DvdSub have their own
|
||||
/// subtitle parsers. Video codecs must NOT use the all-keyframe form of this
|
||||
/// parser — see `parser_for_codec`.
|
||||
/// Used for Opus (and any audio codec with no dedicated parser) whose PES
|
||||
/// boundaries already line up with frame boundaries. AC3/E-AC3, DTS, TrueHD,
|
||||
/// AAC(ADTS), MP2/MP3 and FLAC now have their own gating parsers; PGS/DvdSub
|
||||
/// have their own subtitle parsers. Video codecs must NOT use the all-keyframe
|
||||
/// form of this parser — see `parser_for_codec`.
|
||||
pub struct PassthroughParser {
|
||||
keyframe: bool,
|
||||
}
|
||||
@@ -152,6 +163,25 @@ impl CodecParser for PassthroughParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop-on-undecodable policy across codecs ("clean muxes always"):
|
||||
///
|
||||
/// - **Audio with independent access units** (DTS, AC-3/E-AC-3, …) gates each AU
|
||||
/// through a per-codec corruption check and drops the ones that fail, keeping
|
||||
/// A/V sync (a drop is a silence gap, never a shift) and logging every drop
|
||||
/// via the shared [`dropgate::DropTally`]. DTS validates via its core-frame
|
||||
/// header (ETSI TS 102 114); AC-3 uses its native frame CRC.
|
||||
/// - **LPCM is excluded on purpose**: raw PCM carries no framing or integrity
|
||||
/// data, so a corrupt sample is indistinguishable from a quiet one — there is
|
||||
/// nothing to detect, so nothing can be honestly dropped.
|
||||
/// - **Video is excluded on purpose**: H.264/HEVC/MPEG-2/VC-1 are inter-frame
|
||||
/// predicted, so dropping one frame corrupts every frame that references it
|
||||
/// until the next keyframe. Video instead resyncs at GOP/IDR boundaries (the
|
||||
/// ResyncGate) and lets the decoder conceal — a fundamentally different model
|
||||
/// than per-frame audio dropping.
|
||||
/// - TrueHD/MLP, FLAC, MP2/MP3 and AAC-ADTS also gate undecodable frames via a
|
||||
/// `DropTally` (poison/drop-forward for MLP's inter-AU restart state on a
|
||||
/// major-sync boundary; CRC/sync-verdict drops for the passthrough codecs).
|
||||
///
|
||||
/// Create the appropriate parser for a codec, with optional codec private data.
|
||||
///
|
||||
/// For DvdSub, `codec_data` should be the pre-formatted VobSub .idx palette header.
|
||||
@@ -166,11 +196,18 @@ pub fn parser_for_codec(
|
||||
is_dvd_ps: bool,
|
||||
) -> Box<dyn CodecParser> {
|
||||
match codec {
|
||||
Codec::H264 => Box::new(h264::H264Parser::new()),
|
||||
Codec::Hevc => Box::new(hevc::HevcParser::new()),
|
||||
// `is_dvd_ps` marks a program-stream source (DVD VOB / HD-DVD EVO), whose
|
||||
// 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::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::Flac => Box::new(flac::FlacParser::new()),
|
||||
Codec::Mp2 | Codec::Mp3 => Box::new(mpegaudio::MpegAudioParser::new()),
|
||||
Codec::Aac => Box::new(adts::AdtsParser::new()),
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
||||
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
||||
Codec::Pgs => Box::new(pgs::PgsParser::new()),
|
||||
@@ -191,16 +228,30 @@ pub fn parser_for_codec(
|
||||
);
|
||||
Box::new(PassthroughParser::new(false))
|
||||
}
|
||||
// Remaining audio-only codecs (Aac, Mp2, Mp3, Flac, Opus) where PES =
|
||||
// frame: all-keyframe passthrough is correct. Subtitle/Unknown also land
|
||||
// here; keyframe flag is irrelevant for them.
|
||||
Codec::Aac | Codec::Mp2 | Codec::Mp3 | Codec::Flac | Codec::Opus => {
|
||||
Box::new(PassthroughParser::new(true))
|
||||
}
|
||||
// Opus (PES = frame): all-keyframe passthrough is correct. Subtitle/Unknown
|
||||
// also land here; the keyframe flag is irrelevant for them. (Aac/Mp2/Mp3/Flac
|
||||
// have dedicated parsers dispatched earlier in the match.)
|
||||
Codec::Opus => Box::new(PassthroughParser::new(true)),
|
||||
Codec::Srt | Codec::Ssa | Codec::Unknown(_) => Box::new(PassthroughParser::new(true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the codec parser for a Blu-ray 3D **MVC dependent (right-eye)** video
|
||||
/// stream. Same codec space as the base view (H.264), but in param-set
|
||||
/// passthrough mode so each emitted frame is a self-contained dependent access
|
||||
/// unit for a Matroska `BlockAdditional`. Non-H.264 (unexpected) falls back to
|
||||
/// the ordinary parser.
|
||||
pub fn parser_for_mvc_dependent(codec: Codec, is_dvd_ps: bool) -> Box<dyn CodecParser> {
|
||||
match codec {
|
||||
Codec::H264 => Box::new(
|
||||
h264::H264Parser::new()
|
||||
.with_ps_reorder(is_dvd_ps)
|
||||
.with_mvc_passthrough(true),
|
||||
),
|
||||
_ => parser_for_codec(codec, None, is_dvd_ps),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -234,9 +285,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhandled_audio_codecs_use_keyframe_passthrough() {
|
||||
// PES = frame audio codecs: every frame is independently decodable, so
|
||||
// all-keyframe passthrough is correct.
|
||||
fn audio_codecs_emit_keyframe_frames() {
|
||||
// PES = frame audio: every frame is independently decodable → keyframe.
|
||||
// Aac/Mp2/Mp3/Flac go through their dedicated gating parsers (which pass a
|
||||
// non-sync/too-short payload straight through); Opus uses PassthroughParser.
|
||||
for codec in [Codec::Aac, Codec::Mp2, Codec::Mp3, Codec::Flac, Codec::Opus] {
|
||||
let mut parser = parser_for_codec(codec, None, false);
|
||||
let frames = parser.parse(&pes(Some(0), vec![0x01, 0x02]));
|
||||
|
||||
+96
-230
@@ -26,13 +26,10 @@
|
||||
//! - Extension (seq/pic):00 00 01 B5
|
||||
//! - GOP header: 00 00 01 B8
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use super::coding::{CodingType, Mpeg2Coding, PictureInfo};
|
||||
use super::startcode::find_start_code;
|
||||
use super::{CodecParser, Frame, pts_to_ns};
|
||||
use crate::mux::ts::PesPacket;
|
||||
use crate::pes::SourcePos;
|
||||
|
||||
/// Sequence header start code suffix.
|
||||
const SEQ_HEADER_CODE: u8 = 0xB3;
|
||||
@@ -49,10 +46,10 @@ const PICTURE_CODE: u8 = 0x00;
|
||||
/// Picture coding type: I-frame.
|
||||
const PICTURE_TYPE_I: u8 = 1;
|
||||
|
||||
/// Hard cap on the access-unit reassembly buffer. A real MPEG-2 frame is well
|
||||
/// under 1 MiB (DVD I-frames ~100 KB); past this cap a corrupt stream that
|
||||
/// never produces a second access-unit boundary is force-flushed as a single
|
||||
/// frame rather than driving unbounded allocation.
|
||||
/// The access-unit reassembly cap now lives in [`crate::mux::au_assembly`] (the
|
||||
/// `AuAssembler` owns cross-PES buffering); this mirror exists only so the
|
||||
/// force-flush test below can size an over-cap fixture against the same bound.
|
||||
#[cfg(test)]
|
||||
const MAX_AU_BUFFER: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// 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
|
||||
/// present), captured for MKV codecPrivate.
|
||||
seq_header: Option<Vec<u8>>,
|
||||
/// Unemitted elementary-stream bytes: the in-progress access unit plus any
|
||||
/// lookahead needed to detect the next AU boundary.
|
||||
buf: Vec<u8>,
|
||||
/// Absolute ES byte offset of `buf[0]`. Used to associate PES PTS marks
|
||||
/// (recorded by absolute offset) with the access units they belong to.
|
||||
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)>,
|
||||
/// Reassembles PES fragments into complete access units (one coded picture
|
||||
/// with its leading sequence/GOP headers) and carries each AU's start
|
||||
/// timing / source / discontinuity forward — the shared machinery the
|
||||
/// H.264/HEVC/VC-1 parsers also use, in its MPEG-2 mode.
|
||||
au_asm: crate::mux::au_assembly::AuAssembler,
|
||||
/// Full-frame presentation interval (ns) at the sequence-header display rate
|
||||
/// (`1/frame_rate`). The field period is half this. Per-frame durations are
|
||||
/// `nb_fields × field_period`, so 2:3-telecined frames alternate 2- and
|
||||
@@ -122,6 +110,10 @@ pub struct Mpeg2Parser {
|
||||
/// without ever reordering emitted blocks (B-frames keep decode order; only
|
||||
/// their PTS is lower).
|
||||
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
|
||||
/// order — the running base for each new frame's display time.
|
||||
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
|
||||
/// audio. None until the first PES timestamp is seen.
|
||||
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`).
|
||||
@@ -165,16 +148,13 @@ impl Mpeg2Parser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
seq_header: None,
|
||||
buf: Vec::with_capacity(128 * 1024),
|
||||
base_offset: 0,
|
||||
pts_marks: VecDeque::new(),
|
||||
source_marks: VecDeque::new(),
|
||||
au_asm: crate::mux::au_assembly::AuAssembler::mpeg2(),
|
||||
frame_duration_ns: 0,
|
||||
progressive_sequence: false,
|
||||
gop_buf: Vec::new(),
|
||||
gop_bytes: 0,
|
||||
emitted_fields: 0,
|
||||
origin_pts_ns: None,
|
||||
disc_marks: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,88 +179,21 @@ impl Mpeg2Parser {
|
||||
parse_aspect_ratio(hdr)
|
||||
}
|
||||
|
||||
/// Drain every complete access unit from `buf`, returning one Frame each.
|
||||
/// When `force` is true (EOF flush, or buffer-cap backstop) the trailing
|
||||
/// in-progress access unit is emitted even without a following boundary.
|
||||
fn drain_complete_aus(&mut self, force: bool) -> Vec<Frame> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
// An access unit must contain a coded picture; without one there is
|
||||
// nothing to emit yet (leading sequence/GOP headers wait for it).
|
||||
let Some(pic) = find_code(&self.buf, 0, PICTURE_CODE) else {
|
||||
// No coded picture in an over-cap buffer means we are
|
||||
// accumulating unparseable data (a stream with no picture
|
||||
// start codes). Drop all but a 3-byte tail — enough to catch a
|
||||
// start-code prefix straddling the boundary — and advance the
|
||||
// absolute offset so the PES-mark invariant holds. Mirrors the
|
||||
// post-picture buffer backstop in the AU-boundary search below.
|
||||
if self.buf.len() > MAX_AU_BUFFER {
|
||||
let drop = self.buf.len() - 3;
|
||||
self.base_offset += drop as u64;
|
||||
self.buf.drain(..drop);
|
||||
let cutoff = self.base_offset;
|
||||
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;
|
||||
/// Process one reassembled access unit (from [`AuAssembler`]): decode its
|
||||
/// per-picture coding info, capture a new sequence header, and buffer the
|
||||
/// picture into the current GOP for display-order timestamping. The AU's
|
||||
/// timing / source / discontinuity were already attributed by the assembler.
|
||||
fn process_au(&mut self, au: crate::mux::au_assembly::AssembledAu, out: &mut Vec<Frame>) {
|
||||
let data = au.data;
|
||||
// An access unit must contain a coded picture; a fragment that assembled
|
||||
// without one (only headers, or truncated at EOF) yields nothing.
|
||||
let Some(pic) = find_code(&data, 0, PICTURE_CODE) else {
|
||||
return;
|
||||
};
|
||||
// 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 {
|
||||
let end = data.len();
|
||||
// Capture a sequence header for codecPrivate; a new one replaces the
|
||||
// stored value and re-locks the frame duration.
|
||||
if let Some(h) = extract_seq_header(&data) {
|
||||
self.progressive_sequence = parse_progressive_sequence(&h);
|
||||
self.seq_header = Some(h);
|
||||
if let Some((num, den)) = self.frame_rate() {
|
||||
@@ -289,12 +202,30 @@ impl Mpeg2Parser {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A GOP header (0xB8) or a fresh sequence header (0xB3) starts a new GOP,
|
||||
// resetting temporal_reference to 0.
|
||||
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.
|
||||
// `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),
|
||||
@@ -308,86 +239,38 @@ impl Mpeg2Parser {
|
||||
);
|
||||
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.
|
||||
// pictures display before the next GOP's), so flush it before starting
|
||||
// the new one. `temporal_reference` resets to 0 at the boundary, keeping
|
||||
// each GOP's display order self-contained.
|
||||
if gop_boundary && !self.gop_buf.is_empty() {
|
||||
self.flush_gop(&mut out);
|
||||
self.flush_gop(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_bytes += data.len();
|
||||
self.gop_buf.push(BufferedPicture {
|
||||
tr,
|
||||
info,
|
||||
explicit_pts: explicit,
|
||||
explicit_pts: au.pts,
|
||||
frame: Frame {
|
||||
pts_ns: 0,
|
||||
keyframe,
|
||||
discontinuity,
|
||||
// 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: src,
|
||||
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.
|
||||
if self.gop_buf.len() >= MAX_PENDING_FRAMES {
|
||||
self.flush_gop(&mut out);
|
||||
// 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);
|
||||
}
|
||||
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.
|
||||
if force {
|
||||
self.flush_gop(&mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit the buffered GOP. Each frame's PTS is the display-order prefix-sum of
|
||||
@@ -403,6 +286,8 @@ impl Mpeg2Parser {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
// The GOP is fully drained below; reset the running byte counter.
|
||||
self.gop_bytes = 0;
|
||||
let field_period = self.frame_duration_ns / 2;
|
||||
if field_period <= 0 {
|
||||
// No sequence header / frame rate yet (malformed lead-in): emit in
|
||||
@@ -448,31 +333,31 @@ impl CodecParser for Mpeg2Parser {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Record this PES's timestamp against the absolute offset of its first
|
||||
// ES byte, BEFORE appending. MKV block timecodes are presentation
|
||||
// timestamps; prefer PTS (DTS shows B-frames in decode order — judder
|
||||
// and broken seeking), falling back to DTS only when PTS is absent.
|
||||
let off = self.base_offset + self.buf.len() as u64;
|
||||
if let Some(ts) = pes.pts.or(pes.dts) {
|
||||
self.pts_marks.push_back((off, pts_to_ns(ts)));
|
||||
// Feed the fragment to the assembler, which reframes the elementary
|
||||
// stream on picture boundaries and hands back each complete access unit
|
||||
// with its start timing. MKV block timecodes are presentation timestamps;
|
||||
// prefer PTS (DTS shows B-frames in decode order — judder and broken
|
||||
// seeking), falling back to DTS only when PTS is absent.
|
||||
let pts = pes.pts.or(pes.dts).map(pts_to_ns);
|
||||
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 {
|
||||
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)
|
||||
out
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
// drain_complete_aus(true) force-completes the trailing access unit and
|
||||
// flushes the final GOP, so nothing is left buffered at EOF.
|
||||
self.drain_complete_aus(true)
|
||||
// Force-complete the trailing access unit, then flush the final GOP so
|
||||
// nothing is left buffered at EOF.
|
||||
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>> {
|
||||
@@ -518,25 +403,6 @@ fn find_code(data: &[u8], from: usize, want: u8) -> Option<usize> {
|
||||
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.
|
||||
/// The sequence header must start with 00 00 01 B3.
|
||||
fn parse_resolution(hdr: &[u8]) -> Option<(u16, u16)> {
|
||||
@@ -617,7 +483,8 @@ fn coding_type_from_raw(raw: u8) -> CodingType {
|
||||
|
||||
/// Number of field-display periods a coded picture occupies, from its picture
|
||||
/// coding extension (`00 00 01 B5`, ext-id `1000`), per ISO/IEC 13818-2 §6.3.10
|
||||
/// and ffmpeg `mpeg_field_start` (`nb_fields = repeat_pict + 2`). This is what
|
||||
/// (`nb_fields = repeat_pict + 2`, the field count the spec's repeat rules
|
||||
/// yield). This is what
|
||||
/// times soft-telecined (2:3 pulldown) DVD video correctly: a
|
||||
/// `repeat_first_field` frame occupies 3 fields, a normal frame 2, so honoring
|
||||
/// it spreads the ~23.976 coded frames across the 29.97 display span with no
|
||||
@@ -1546,13 +1413,12 @@ mod tests {
|
||||
let mut data = make_picture_header(PICTURE_TYPE_I);
|
||||
// > MAX_AU_BUFFER of slice bytes with no following picture/seq/GOP.
|
||||
data.extend(std::iter::repeat_n(0xAA, MAX_AU_BUFFER + 1024));
|
||||
let frames = parser.parse(&make_pes(data, Some(0)));
|
||||
assert!(
|
||||
frames.is_empty(),
|
||||
"over-cap AU is force-COMPLETED (bounded) but buffered in its GOP"
|
||||
);
|
||||
let frames = parser.flush();
|
||||
assert_eq!(frames.len(), 1, "force-flushed at EOF, not dropped");
|
||||
// The AU assembler force-completes the ~8 MiB AU (no boundary), and the
|
||||
// GOP byte cap (MAX_PENDING_BYTES) then force-flushes that oversized GOP
|
||||
// during parse rather than buffering it unbounded.
|
||||
let mut frames = parser.parse(&make_pes(data, Some(0)));
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(frames.len(), 1, "over-cap AU force-flushed, not dropped");
|
||||
assert!(frames[0].keyframe);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! MPEG-1/2/2.5 audio (MP1/MP2/MP3) decodability gate.
|
||||
//!
|
||||
//! Per ISO/IEC 11172-3 / ISO/IEC 13818-3, an MPEG-audio frame is validated by
|
||||
//! header sanity + framing resync, not a payload CRC (the optional 16-bit CRC in
|
||||
//! the header protects only the side-information and is absent unless the
|
||||
//! protection bit says otherwise). The gate mirrors that header-only check and
|
||||
//! ACCEPTS free-format (`bitrate_index == 0`) as a legal decodable mode — it
|
||||
//! deliberately does NOT apply the stricter free-format reject that a full
|
||||
//! decoder would (see the note at the `bitrate_index` check). So the gate rejects
|
||||
//! only the truly invalid headers: a packet that begins with the 11-bit
|
||||
//! MPEG-audio sync but whose version / layer / sample-rate fields (or the
|
||||
//! reserved bitrate index 15) are reserved/invalid is undecodable → drop it (a
|
||||
//! silence gap; each packet keeps its own PTS). A packet with no leading sync is
|
||||
//! not a frame we can validate (raw payload / continuation), so it passes through
|
||||
//! unchanged — never false-dropped.
|
||||
|
||||
use super::dropgate::DropTally;
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// Decoded validity of a candidate MPEG-audio header.
|
||||
enum MpaVerdict {
|
||||
/// No 11-bit sync at the packet head — not a frame we can validate.
|
||||
NoSync,
|
||||
/// Sync present and every field is legal — decodable.
|
||||
Valid,
|
||||
/// Sync present but a field is reserved/invalid — a conformant header parser
|
||||
/// rejects this exactly.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// Header-only validity check per ISO/IEC 11172-3 / ISO/IEC 13818-3 (which
|
||||
/// ACCEPTS free-format, `bitrate_index == 0`) — deliberately NOT the stricter
|
||||
/// free-format reject a full decoder applies. A dropped MPEG-audio frame has a
|
||||
/// corrupt header, so no duration is computed (the fields it would come from are
|
||||
/// the invalid ones).
|
||||
fn mpa_verdict(data: &[u8]) -> MpaVerdict {
|
||||
if data.len() < 4 {
|
||||
return MpaVerdict::NoSync;
|
||||
}
|
||||
let h = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
|
||||
// 11-bit sync (0x7FF at the top).
|
||||
if (h & 0xffe0_0000) != 0xffe0_0000 {
|
||||
return MpaVerdict::NoSync;
|
||||
}
|
||||
// Reject per spec: version field 01, layer field 00, bitrate_index 15,
|
||||
// sample-rate field 3.
|
||||
if (h & (3 << 19)) == (1 << 19)
|
||||
|| (h & (3 << 17)) == 0
|
||||
|| (h & (0xf << 12)) == (0xf << 12)
|
||||
|| (h & (3 << 10)) == (3 << 10)
|
||||
{
|
||||
return MpaVerdict::Invalid;
|
||||
}
|
||||
// NOTE: bitrate_index == 0 (free format) is NOT rejected. It is a legal,
|
||||
// decodable MPEG-audio mode (the spec permits it and a decoder derives the
|
||||
// frame size from the sync spacing). Dropping it would be a false positive on
|
||||
// a clean stream, so it passes the gate.
|
||||
MpaVerdict::Valid
|
||||
}
|
||||
|
||||
pub struct MpegAudioParser {
|
||||
tally: DropTally,
|
||||
/// Last emitted PTS (ns), carried forward across a PES with no PTS rather than
|
||||
/// resetting the timeline to 0 (see the AC-3/DTS parsers) — preserves A/V sync.
|
||||
last_pts_ns: i64,
|
||||
}
|
||||
|
||||
impl Default for MpegAudioParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MpegAudioParser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tally: DropTally::new("mpegaudio"),
|
||||
last_pts_ns: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dropped_frames(&self) -> u64 {
|
||||
self.tally.dropped_frames()
|
||||
}
|
||||
|
||||
pub fn dropped_duration_ns(&self) -> u64 {
|
||||
self.tally.dropped_duration_ns()
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for MpegAudioParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes
|
||||
.pts
|
||||
.or(pes.dts)
|
||||
.map(pts_to_ns)
|
||||
.unwrap_or(self.last_pts_ns);
|
||||
self.last_pts_ns = pts_ns;
|
||||
|
||||
let drop =
|
||||
self.tally.is_poisoned() || matches!(mpa_verdict(&pes.data), MpaVerdict::Invalid);
|
||||
if drop {
|
||||
let reason = if self.tally.is_poisoned() {
|
||||
"track-poisoned"
|
||||
} else {
|
||||
"header"
|
||||
};
|
||||
self.tally.record_drop(pts_ns, 0, pes.data.len(), reason);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.tally.record_kept();
|
||||
vec![Frame {
|
||||
discontinuity: pes.discontinuity,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
self.tally.log_summary();
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts,
|
||||
dts: None,
|
||||
data,
|
||||
discontinuity: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid MPEG-1 Layer III header: sync 0xFFF, version MPEG-1 (11), layer
|
||||
/// III (01), bitrate_index 9, sample-rate 0 (44.1 kHz), no CRC. Bytes:
|
||||
/// 0xFF 0xFB 0x90 0x00 — the canonical MP3 frame header.
|
||||
fn mp3_frame(payload: usize) -> Vec<u8> {
|
||||
let mut f = vec![0xFF, 0xFB, 0x90, 0x00];
|
||||
f.extend(std::iter::repeat(0xAA).take(payload));
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_header_is_kept() {
|
||||
let mut p = MpegAudioParser::new();
|
||||
let f = p.parse(&make_pes(mp3_frame(400), Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000));
|
||||
assert_eq!(p.dropped_frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pes_without_pts_carries_last_timestamp_not_zero() {
|
||||
// A PES with no PTS (legal for audio, e.g. after a discontinuity) must
|
||||
// carry the last known timestamp forward — resetting to 0 would corrupt
|
||||
// A/V sync. Mirrors the adts.rs guard test.
|
||||
let mut p = MpegAudioParser::new();
|
||||
p.parse(&make_pes(mp3_frame(400), Some(90000)));
|
||||
let f = p.parse(&make_pes(mp3_frame(400), None));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(
|
||||
f[0].pts_ns,
|
||||
pts_to_ns(90000),
|
||||
"carried forward, not reset to 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_version_field_is_dropped() {
|
||||
// version field = 01 (reserved) → rejected. byte1 = 111_01_01_1 = 0xEB
|
||||
// keeps the 11-bit sync (0xFF + top 3 bits 111) but sets version bits to 01.
|
||||
let mut p = MpegAudioParser::new();
|
||||
let mut frame = mp3_frame(400);
|
||||
frame[1] = 0xEB;
|
||||
let f = p.parse(&make_pes(frame, Some(90000)));
|
||||
assert!(f.is_empty(), "reserved version dropped");
|
||||
assert_eq!(p.dropped_frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_sample_rate_is_dropped() {
|
||||
// Sync present but sample-rate field = 3 (reserved) → rejected per spec.
|
||||
// 0xFF 0xFB then byte2 with bits 11..10 = 11: 0x9C.
|
||||
let mut p = MpegAudioParser::new();
|
||||
let mut frame = mp3_frame(400);
|
||||
frame[2] = 0x9C; // freq field = 3
|
||||
let f = p.parse(&make_pes(frame, Some(90000)));
|
||||
assert!(f.is_empty(), "reserved sample rate dropped");
|
||||
assert_eq!(p.dropped_frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_layer_is_dropped() {
|
||||
// Layer field 00 (reserved). byte1 bits 2..1 = 00 → 0xF9 keeps sync
|
||||
// (0xFFF needs byte1 top 3 bits set) and sets layer=00.
|
||||
let mut p = MpegAudioParser::new();
|
||||
let mut frame = mp3_frame(400);
|
||||
frame[1] = 0xF9; // 1111_1001: sync ok (top 3 =111), version 11, layer 00
|
||||
let f = p.parse(&make_pes(frame, Some(0)));
|
||||
assert!(f.is_empty(), "reserved layer dropped");
|
||||
assert_eq!(p.dropped_frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_bitrate_index_15_is_dropped() {
|
||||
let mut p = MpegAudioParser::new();
|
||||
let mut frame = mp3_frame(400);
|
||||
frame[2] = 0xF0; // bitrate_index = 1111
|
||||
assert!(p.parse(&make_pes(frame, Some(0))).is_empty());
|
||||
assert_eq!(p.dropped_frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_format_bitrate_zero_is_kept() {
|
||||
// Free format (bitrate_index == 0) is legal and decodable — it must NOT
|
||||
// be dropped (that would be a false positive on a clean stream).
|
||||
let mut p = MpegAudioParser::new();
|
||||
let mut frame = mp3_frame(400);
|
||||
frame[2] = 0x00; // bitrate_index = 0000 (free format); sync/layer/rate ok
|
||||
let f = p.parse(&make_pes(frame, Some(0)));
|
||||
assert_eq!(f.len(), 1, "free-format frame kept");
|
||||
assert_eq!(p.dropped_frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_sync_packet_passes_through() {
|
||||
// No 11-bit sync → not a validatable frame → keep (conservative).
|
||||
let mut p = MpegAudioParser::new();
|
||||
let f = p.parse(&make_pes(vec![0x00, 0x11, 0x22, 0x33, 0x44], Some(0)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(p.dropped_frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_preserves_sync_via_own_pts() {
|
||||
let mut p = MpegAudioParser::new();
|
||||
let mut bad = mp3_frame(400);
|
||||
bad[2] = 0x9C; // reserved sample rate
|
||||
assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
|
||||
let f = p.parse(&make_pes(mp3_frame(400), Some(96000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(
|
||||
f[0].pts_ns,
|
||||
pts_to_ns(96000),
|
||||
"next frame keeps its own PTS"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,90 @@ const MAX_PGS_PENDING_BYTES: usize = 4 * 1024 * 1024;
|
||||
// (video_w/h, frame_rate, comp_num, comp_state, palette_update,
|
||||
// palette_id_ref) = 13.
|
||||
const PCS_NUM_OBJECTS_OFFSET: usize = 13;
|
||||
// Offset of the first composition_object's flags byte within a PCS PES payload:
|
||||
// PCS header(13) + number_of_composition_objects(1) + object_id_ref(2) +
|
||||
// window_id_ref(1) = 17. `forced_on_flag` is bit 0x40 of that byte (HDMV PCS).
|
||||
const PCS_FIRST_OBJECT_FLAGS_OFFSET: usize = 17;
|
||||
const PCS_FORCED_ON_FLAG: u8 = 0x40;
|
||||
|
||||
/// Whether an emitted PGS display-set frame is a FORCED subtitle — the
|
||||
/// `forced_on_flag` (0x40) on its first composition object. The frame data an
|
||||
/// emitted PGS block carries begins with the display PCS (segment type 0x16), so
|
||||
/// the flag is read directly from it. Returns `None` when the block is not a
|
||||
/// display PCS with a composition object (nothing to classify — a clear PCS, a
|
||||
/// non-PCS segment, or a truncated header).
|
||||
///
|
||||
/// The mux uses this to detect a *forced-narrative track* (every displayed
|
||||
/// subtitle forced) without relying on the disc's vendor label metadata, so
|
||||
/// forced subs are flagged `FlagForced` even on discs that carry no such blob.
|
||||
pub fn display_set_is_forced(frame_data: &[u8]) -> Option<bool> {
|
||||
if frame_data.first() != Some(&SEGMENT_PCS) {
|
||||
return None;
|
||||
}
|
||||
if *frame_data.get(PCS_NUM_OBJECTS_OFFSET)? == 0 {
|
||||
return None; // clear PCS — no composition to classify
|
||||
}
|
||||
let flags = *frame_data.get(PCS_FIRST_OBJECT_FLAGS_OFFSET)?;
|
||||
Some(flags & PCS_FORCED_ON_FLAG != 0)
|
||||
}
|
||||
|
||||
/// Accumulates the "is this PGS subtitle track a forced-narrative track?" verdict
|
||||
/// from its display sets. A track is forced iff it displayed at least one subtitle
|
||||
/// and EVERY display set carried the forced_on_flag — a dedicated forced track,
|
||||
/// as opposed to a full track that merely has occasional forced signs.
|
||||
///
|
||||
/// This is the SINGLE classification used by both the MKV muxer (accumulating a
|
||||
/// track's frames during a rip) and the `info`-time forced probe (feeding the
|
||||
/// demuxed display sets), so both reach the identical verdict.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ForcedTracker {
|
||||
has_display: bool,
|
||||
all_forced: bool,
|
||||
}
|
||||
|
||||
impl Default for ForcedTracker {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
has_display: false,
|
||||
all_forced: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ForcedTracker {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Fold one emitted PGS block into the verdict. Non-display blocks (clear
|
||||
/// PCS, other segments) are ignored.
|
||||
pub fn observe(&mut self, frame_data: &[u8]) {
|
||||
if let Some(forced) = display_set_is_forced(frame_data) {
|
||||
self.has_display = true;
|
||||
self.all_forced &= forced;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the track has already shown a NON-forced subtitle — i.e. its
|
||||
/// verdict is settled at "not forced" and further observation can be skipped
|
||||
/// (the early-exit the probe uses to avoid reading the whole clip).
|
||||
pub fn settled_not_forced(&self) -> bool {
|
||||
self.has_display && !self.all_forced
|
||||
}
|
||||
|
||||
/// Whether ANY display set was observed. When false the track's forced state
|
||||
/// is unknown (no PGS content seen — e.g. an undecrypted/unread stream), so a
|
||||
/// probe should leave any existing (vendor-derived) flag untouched rather
|
||||
/// than assert "not forced".
|
||||
pub fn observed(&self) -> bool {
|
||||
self.has_display
|
||||
}
|
||||
|
||||
/// Final verdict: forced iff it displayed subtitles and every one was forced.
|
||||
pub fn is_forced(&self) -> bool {
|
||||
self.has_display && self.all_forced
|
||||
}
|
||||
}
|
||||
|
||||
/// Stateful parser that collapses PGS display/clear PCS pairs into
|
||||
/// duration-bearing Matroska frames. Implements [`CodecParser`].
|
||||
@@ -222,6 +306,41 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
/// A PCS display-set block with one composition object; `forced` sets the
|
||||
/// forced_on_flag (0x40) in its flags byte at offset 17.
|
||||
fn pcs_display(forced: bool) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 18];
|
||||
d[0] = SEGMENT_PCS;
|
||||
d[PCS_NUM_OBJECTS_OFFSET] = 1;
|
||||
d[PCS_FIRST_OBJECT_FLAGS_OFFSET] = if forced { PCS_FORCED_ON_FLAG } else { 0 };
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_set_forced_flag_detection() {
|
||||
assert_eq!(display_set_is_forced(&pcs_display(true)), Some(true));
|
||||
assert_eq!(display_set_is_forced(&pcs_display(false)), Some(false));
|
||||
// Other flag bits set but not forced_on_flag → still not forced.
|
||||
let mut cropped = pcs_display(false);
|
||||
cropped[PCS_FIRST_OBJECT_FLAGS_OFFSET] = 0x80; // object_cropped_flag only
|
||||
assert_eq!(display_set_is_forced(&cropped), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_set_forced_none_for_non_display() {
|
||||
// Clear PCS (0 objects) → None.
|
||||
let mut clear = pcs_display(false);
|
||||
clear[PCS_NUM_OBJECTS_OFFSET] = 0;
|
||||
assert_eq!(display_set_is_forced(&clear), None);
|
||||
// Non-PCS segment → None.
|
||||
let mut ods = pcs_display(true);
|
||||
ods[0] = 0x15; // ODS
|
||||
assert_eq!(display_set_is_forced(&ods), None);
|
||||
// Truncated (no flags byte) → None, no panic.
|
||||
assert_eq!(display_set_is_forced(&pcs_display(true)[..15]), None);
|
||||
assert_eq!(display_set_is_forced(&[]), None);
|
||||
}
|
||||
|
||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||
PesPacket {
|
||||
source: None,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+655
-16
@@ -12,6 +12,8 @@
|
||||
//! AC-3 frames (interleaved, same PID): start with sync word 0x0B77.
|
||||
//! We skip AC-3 frames and only emit TrueHD access units.
|
||||
|
||||
use super::crc::crc16_mlp;
|
||||
use super::dropgate::DropTally;
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
use crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS;
|
||||
|
||||
@@ -43,6 +45,18 @@ pub struct TrueHdParser {
|
||||
/// yet seen (head of stream) — preserving byte-identical timing for the
|
||||
/// common 48 kHz case.
|
||||
au_duration_ns: i64,
|
||||
/// Keep/drop bookkeeping for the decodability gate.
|
||||
tally: DropTally,
|
||||
/// `num_substreams` from the most recent major sync — needed to size the
|
||||
/// substream directory for the per-AU parity check. `None` until the first
|
||||
/// major sync is seen (before which no AU can be parity-checked).
|
||||
num_substreams: Option<u8>,
|
||||
/// True while dropping forward to the next clean resync point. MLP/TrueHD
|
||||
/// carries filter/predictor + restart state ACROSS access units, so a corrupt
|
||||
/// AU cannot be excised in place — it poisons decoding until the next major
|
||||
/// sync re-initialises state. On corruption we set this and drop every AU
|
||||
/// until a major sync whose header CRC validates, which we then emit.
|
||||
resync_pending: bool,
|
||||
}
|
||||
|
||||
impl Default for TrueHdParser {
|
||||
@@ -57,6 +71,82 @@ impl TrueHdParser {
|
||||
buf: Vec::with_capacity(32768),
|
||||
next_pts_ns: 0,
|
||||
au_duration_ns: AU_DURATION_NS,
|
||||
tally: DropTally::new("truehd"),
|
||||
num_substreams: None,
|
||||
resync_pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Access units dropped as undecodable so far.
|
||||
pub fn dropped_frames(&self) -> u64 {
|
||||
self.tally.dropped_frames()
|
||||
}
|
||||
|
||||
/// Total decoded duration (ns) of dropped access units.
|
||||
pub fn dropped_duration_ns(&self) -> u64 {
|
||||
self.tally.dropped_duration_ns()
|
||||
}
|
||||
|
||||
/// Decide whether an access unit is corrupt, updating `num_substreams` from a
|
||||
/// valid major sync. Per the MLP/TrueHD access-unit decode rules: a major sync
|
||||
/// with a bad header CRC, or any AU whose header parity fails, is undecodable.
|
||||
/// Returns `false` (not corrupt) when the AU is too short to judge or no
|
||||
/// major sync has established `num_substreams` yet — we never drop what we
|
||||
/// cannot verify. Verified against real TrueHD streams (3600/3600 AUs).
|
||||
fn au_check(&mut self, au: &[u8], is_major_sync: bool) -> AuCheck {
|
||||
let mut header_size = 4;
|
||||
let mut format_info = None;
|
||||
if is_major_sync {
|
||||
let ms = &au[4..];
|
||||
let Some(mshdr) = mlp_major_sync_header_size(ms) else {
|
||||
// A major sync too short to hold its header can't be CRC-validated
|
||||
// — NOT a safe resync/re-init point. Treat as unverifiable, not a
|
||||
// clean major sync.
|
||||
return AuCheck::Unverifiable;
|
||||
};
|
||||
if !mlp_major_sync_crc_ok(ms, mshdr) {
|
||||
// A failing major-sync checksum is only a TRUSTWORTHY corruption
|
||||
// signal once we already hold a validated baseline (num_substreams
|
||||
// captured from a PRIOR checksum-clean major sync). Before that — at
|
||||
// stream head, when no major sync has validated yet — a checksum
|
||||
// mismatch is far more likely a limitation of our own major-sync
|
||||
// header-size parse than real corruption. Arming the drop-forward
|
||||
// there is catastrophic: every following AU is collateral-dropped
|
||||
// waiting for a "validated" major sync that (if our parse is the
|
||||
// problem) NEVER comes, so the WHOLE TrueHD track is silently dropped
|
||||
// — the AUs are physically emitted late (empty until the mux end),
|
||||
// de-interleaving the track and sending decoders into an unbounded
|
||||
// memory spiral. So until we have a baseline to protect, treat a
|
||||
// checksum-failed major sync as UNVERIFIABLE (keep it) rather than
|
||||
// Corrupt. Once a clean major sync HAS established the baseline, a
|
||||
// later failure is a real re-sync trigger and still drops forward.
|
||||
if self.num_substreams.is_some() {
|
||||
return AuCheck::Corrupt; // real corruption vs a proven baseline
|
||||
}
|
||||
return AuCheck::Unverifiable; // no baseline yet — keep, don't nuke the track
|
||||
}
|
||||
self.num_substreams = mlp_num_substreams(ms);
|
||||
header_size += mshdr;
|
||||
// The rate nibble is only trustworthy once the major sync's CRC has
|
||||
// validated (above), so capture format_info here and refine the PTS
|
||||
// cadence from it ONLY on this validated path.
|
||||
if au.len() >= 12 {
|
||||
format_info = Some(u32::from_be_bytes([au[8], au[9], au[10], au[11]]));
|
||||
}
|
||||
}
|
||||
let Some(nss) = self.num_substreams else {
|
||||
return AuCheck::Unverifiable; // no major sync seen yet — can't check parity
|
||||
};
|
||||
let Some(shs) = mlp_substr_header_size(au, header_size, nss) else {
|
||||
return AuCheck::Unverifiable; // directory runs off the AU — can't judge
|
||||
};
|
||||
if !mlp_parity_ok(au, header_size, shs) {
|
||||
return AuCheck::Corrupt;
|
||||
}
|
||||
if is_major_sync {
|
||||
AuCheck::ValidMajorSync { format_info }
|
||||
} else {
|
||||
AuCheck::Ok
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +204,23 @@ fn ac3_boundary_corroborated(buf: &[u8], frame_bytes: usize) -> bool {
|
||||
next_words != 0 && next_words * 2 <= 32768
|
||||
}
|
||||
|
||||
/// Decodability verdict for one TrueHD/MLP access unit.
|
||||
enum AuCheck {
|
||||
/// Verified undecodable: a major-sync header whose CRC failed, or any AU
|
||||
/// whose substream-directory parity failed. Feeds the poison verdict.
|
||||
Corrupt,
|
||||
/// A CRC-validated major sync — a safe re-init / resync point. `format_info`
|
||||
/// (AU bytes 8..12, present when the AU is long enough) is trustworthy here,
|
||||
/// so the caller refines the PTS cadence ONLY from this validated path.
|
||||
ValidMajorSync { format_info: Option<u32> },
|
||||
/// A valid (parity-OK) non-major-sync access unit.
|
||||
Ok,
|
||||
/// Cannot be judged — a major sync too short to hold/CRC its header, or a
|
||||
/// stream head before any major sync established `num_substreams`. Never
|
||||
/// dropped on its own, and never treated as a clean resync point.
|
||||
Unverifiable,
|
||||
}
|
||||
|
||||
/// Outcome of sizing the AC-3 frame at the TrueHD buffer head.
|
||||
enum Ac3Size {
|
||||
/// fscod/frmsizecod don't map to a real frame size — resync, don't wait.
|
||||
@@ -124,6 +231,92 @@ enum Ac3Size {
|
||||
Frame(usize),
|
||||
}
|
||||
|
||||
// --- MLP/TrueHD access-unit integrity (per the MLP/TrueHD bitstream spec) ---
|
||||
|
||||
/// Major-sync header size in bytes: base 28, plus `2 + extensions*2` when the
|
||||
/// extension flag (major-sync byte 25, bit 0) is set (`extensions` = byte 26
|
||||
/// high nibble). `ms` is the major-sync header, i.e. AU bytes `[4..]`. `None`
|
||||
/// when the AU is too short to contain the full header.
|
||||
fn mlp_major_sync_header_size(ms: &[u8]) -> Option<usize> {
|
||||
if ms.len() < 28 {
|
||||
return None;
|
||||
}
|
||||
let mut size = 28;
|
||||
if ms[25] & 1 != 0 {
|
||||
size += 2 + ((ms[26] >> 4) as usize) * 2;
|
||||
}
|
||||
if ms.len() < size {
|
||||
return None;
|
||||
}
|
||||
Some(size)
|
||||
}
|
||||
|
||||
/// Validate the MLP/TrueHD major-sync header checksum (a CRC-16 with polynomial
|
||||
/// 0x002D). The stored trailer is the last 2 header bytes; because
|
||||
/// MLP's checksum is byte-reversed relative to a standard CRC, a standard CRC of
|
||||
/// the header body XOR the little-endian word before the trailer must equal the
|
||||
/// trailer read big-endian.
|
||||
fn mlp_major_sync_crc_ok(ms: &[u8], mshdr: usize) -> bool {
|
||||
if mshdr < 4 || ms.len() < mshdr {
|
||||
return false;
|
||||
}
|
||||
// The MLP major-sync checksum, `checksum16(buf, buf_size)`, is defined as
|
||||
// crc16_2D(buf, buf_size - 2) ^ read_le16(buf + buf_size - 2)
|
||||
// evaluated with `buf_size = mshdr - 2` and its result compared to
|
||||
// `read_le16(buf + mshdr - 2)` — i.e. the 16-bit CRC (poly 0x2D, MSB-first)
|
||||
// over `ms[..mshdr-4]`, XORed with the LITTLE-ENDIAN word just before the
|
||||
// trailer, must equal the LITTLE-ENDIAN trailer word. `crc16_mlp` uses that
|
||||
// same poly-0x2D MSB-first table but yields its two bytes in the OPPOSITE
|
||||
// order to a standard little-endian CRC readout, so swap them back to match.
|
||||
// (The previous code mixed endianness —
|
||||
// little-endian XOR word but big-endian compare — so the checksum could never
|
||||
// validate any real extended major sync, silently dropping the whole track;
|
||||
// cross-verified byte-exact against real 7.1/Atmos and 5.1 discs.)
|
||||
let checksum = crc16_mlp(&ms[..mshdr - 4]).swap_bytes()
|
||||
^ u16::from_le_bytes([ms[mshdr - 4], ms[mshdr - 3]]);
|
||||
checksum == u16::from_le_bytes([ms[mshdr - 2], ms[mshdr - 1]])
|
||||
}
|
||||
|
||||
/// `num_substreams` from a major-sync header: it sits at bit 128 (byte 16, top
|
||||
/// nibble) for both MLP (0xbb) and TrueHD (0xba) — the fields before it total
|
||||
/// the same 128 bits in either layout.
|
||||
fn mlp_num_substreams(ms: &[u8]) -> Option<u8> {
|
||||
ms.get(16).map(|&b| b >> 4)
|
||||
}
|
||||
|
||||
/// Size in bytes of the substream directory that follows the AU header: each of
|
||||
/// the `num_substreams` entries is 2 bytes, plus 2 more when its extraword flag
|
||||
/// (entry's top bit) is set. `None` if the directory runs past the AU.
|
||||
fn mlp_substr_header_size(au: &[u8], header_size: usize, num_substreams: u8) -> Option<usize> {
|
||||
let mut off = header_size;
|
||||
let mut shs = 0;
|
||||
for _ in 0..num_substreams {
|
||||
if off + 2 > au.len() {
|
||||
return None;
|
||||
}
|
||||
let extraword = au[off] & 0x80 != 0;
|
||||
shs += 2;
|
||||
off += 2;
|
||||
if extraword {
|
||||
shs += 2;
|
||||
off += 2;
|
||||
}
|
||||
}
|
||||
Some(shs)
|
||||
}
|
||||
|
||||
/// MLP/TrueHD AU-header parity check: the XOR of the 4-byte AU header with the
|
||||
/// substream directory, folded, must have its two nibbles XOR to 0xF.
|
||||
fn mlp_parity_ok(au: &[u8], header_size: usize, substr_header_size: usize) -> bool {
|
||||
let end = header_size + substr_header_size;
|
||||
if end > au.len() {
|
||||
return false;
|
||||
}
|
||||
let xor_fold = |d: &[u8]| d.iter().fold(0u8, |a, &b| a ^ b);
|
||||
let p = xor_fold(&au[0..4]) ^ xor_fold(&au[header_size..end]);
|
||||
((p >> 4) ^ p) & 0xF == 0xF
|
||||
}
|
||||
|
||||
impl CodecParser for TrueHdParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
// B1: a concealed/lost gap means the buffered TrueHD AU is TRUNCATED.
|
||||
@@ -256,26 +449,93 @@ impl CodecParser for TrueHdParser {
|
||||
& 0xFFFF_FFFE)
|
||||
== 0xF872_6FBA;
|
||||
|
||||
// On a major sync the 32-bit `format_info` word (immediately after
|
||||
// the 4-byte sync, i.e. AU bytes 8..12) carries the rate nibble.
|
||||
// Refine the per-AU PTS increment to the actual rate family. The
|
||||
// 48 kHz family resolves to the unchanged 833_333 default, so the
|
||||
// common case stays byte-identical; only the 44.1 kHz family shifts.
|
||||
if is_major_sync && unit_bytes >= 12 {
|
||||
let format_info =
|
||||
u32::from_be_bytes([self.buf[8], self.buf[9], self.buf[10], self.buf[11]]);
|
||||
self.au_duration_ns = truehd_au_duration_ns(format_info);
|
||||
// Decodability gate. MLP/TrueHD decode state persists across access
|
||||
// units, so a corrupt AU is dropped FORWARD to the next VALIDATED
|
||||
// major sync (the clean re-init point) rather than excised in place.
|
||||
// The PTS clock advances across every dropped AU so a drop is a
|
||||
// silence gap, never a shift.
|
||||
let au = self.buf[..unit_bytes].to_vec();
|
||||
let pts = self.next_pts_ns;
|
||||
let mut emit_keyframe: Option<bool> = None; // Some(is_keyframe) => emit
|
||||
let mut drop_reason: Option<(&'static str, bool)> = None; // (reason, verified)
|
||||
|
||||
if self.tally.is_poisoned() {
|
||||
// Whole track already judged dead — collateral drop (does not
|
||||
// re-feed the poison verdict).
|
||||
drop_reason = Some(("track-poisoned", false));
|
||||
} else {
|
||||
match self.au_check(&au, is_major_sync) {
|
||||
AuCheck::ValidMajorSync { format_info } => {
|
||||
// The rate nibble is trustworthy only now that the major
|
||||
// sync's CRC has validated. Refine the per-AU PTS
|
||||
// increment (48 kHz family stays the 833_333 default).
|
||||
if let Some(fi) = format_info {
|
||||
self.au_duration_ns = truehd_au_duration_ns(fi);
|
||||
}
|
||||
// A validated major sync is the ONLY clean resync point.
|
||||
self.resync_pending = false;
|
||||
emit_keyframe = Some(true);
|
||||
}
|
||||
AuCheck::Corrupt => {
|
||||
if self.resync_pending {
|
||||
// Part of the current drop-forward run — collateral.
|
||||
drop_reason = Some(("resync", false));
|
||||
} else {
|
||||
// The trigger: one verified corruption that starts the
|
||||
// drop-forward. Only this counts toward poison.
|
||||
let r = if is_major_sync {
|
||||
"major-sync-crc"
|
||||
} else {
|
||||
"parity"
|
||||
};
|
||||
drop_reason = Some((r, true));
|
||||
self.resync_pending = true;
|
||||
}
|
||||
}
|
||||
AuCheck::Ok => {
|
||||
if self.resync_pending {
|
||||
// Decode state is invalid until the next validated
|
||||
// major sync, so even a parity-OK AU is undecodable
|
||||
// here — collateral drop.
|
||||
drop_reason = Some(("resync", false));
|
||||
} else {
|
||||
emit_keyframe = Some(false);
|
||||
}
|
||||
}
|
||||
AuCheck::Unverifiable => {
|
||||
if self.resync_pending {
|
||||
// Not a validated major sync — do NOT clear the resync
|
||||
// on it; keep dropping forward.
|
||||
drop_reason = Some(("resync", false));
|
||||
} else {
|
||||
// Head of stream / too-short AU: keep (never drop what
|
||||
// we cannot verify).
|
||||
emit_keyframe = Some(is_major_sync);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(keyframe) = emit_keyframe {
|
||||
self.tally.record_kept();
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: self.next_pts_ns,
|
||||
keyframe: is_major_sync,
|
||||
data: self.buf[..unit_bytes].to_vec(),
|
||||
pts_ns: pts,
|
||||
keyframe,
|
||||
data: au,
|
||||
duration_ns: None,
|
||||
});
|
||||
} else if let Some((reason, verified)) = drop_reason {
|
||||
if verified {
|
||||
self.tally
|
||||
.record_drop(pts, self.au_duration_ns, au.len(), reason);
|
||||
} else {
|
||||
self.tally
|
||||
.record_collateral_drop(pts, self.au_duration_ns, au.len(), reason);
|
||||
}
|
||||
}
|
||||
self.buf.drain(..unit_bytes);
|
||||
self.next_pts_ns += self.au_duration_ns;
|
||||
}
|
||||
@@ -289,13 +549,18 @@ impl CodecParser for TrueHdParser {
|
||||
frames
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
self.tally.log_summary();
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-bit channel counts for the TrueHD 8-channel and 6-channel presentation
|
||||
/// channel-assignment masks (per the MLP spec / FFmpeg `thd_channels`). Some
|
||||
/// channel-assignment masks (per the MLP/TrueHD bitstream spec). Some
|
||||
/// bits denote a stereo pair (2), others a single channel (1).
|
||||
const THD_8CH: [u8; 13] = [2, 1, 1, 2, 2, 2, 2, 1, 1, 2, 2, 1, 1];
|
||||
const THD_6CH: [u8; 5] = [2, 1, 1, 2, 1];
|
||||
@@ -451,6 +716,372 @@ mod tests {
|
||||
data
|
||||
}
|
||||
|
||||
/// Turn a synthetic major-sync AU (sync bytes already set at offset 4, any
|
||||
/// `format_info` set) into one that passes the decodability gate: 1 substream,
|
||||
/// a clean substream directory, a valid major-sync CRC-16, and a valid header
|
||||
/// parity nibble. Mirrors what a real encoder writes (verified against real
|
||||
/// TrueHD streams). The AU must be ≥ 36 bytes (4 AU header + 28 major-sync
|
||||
/// header + 2 directory + slack), which every `make_truehd_unit(≥200)` is.
|
||||
fn finalize_major_sync(au: &mut [u8]) {
|
||||
const MSHDR: usize = 28; // no extension (byte 25 clear)
|
||||
// num_substreams = 1 → major-sync byte 16 (AU[20]) top nibble.
|
||||
au[20] = (au[20] & 0x0F) | 0x10;
|
||||
// Substream directory entry at AU[4+MSHDR] = AU[32]: extraword flag clear.
|
||||
au[32] &= 0x7F;
|
||||
// Major-sync checksum, built EXACTLY as `mlp_major_sync_crc_ok` verifies it
|
||||
// (the MLP checksum16): swap_bytes(crc16_mlp(body)) ^ LE word before
|
||||
// the trailer, stored little-endian in the trailer.
|
||||
let body_end = 4 + MSHDR - 4; // AU[4..28]
|
||||
let crc = super::crc16_mlp(&au[4..body_end]).swap_bytes()
|
||||
^ u16::from_le_bytes([au[body_end], au[body_end + 1]]);
|
||||
au[4 + MSHDR - 2] = (crc & 0xFF) as u8;
|
||||
au[4 + MSHDR - 1] = (crc >> 8) as u8;
|
||||
// Parity: choose the AU check nibble (AU[0] high bits) so the header +
|
||||
// directory fold to 0xF. The length low nibble (AU[0] low bits) is kept.
|
||||
let hi = au[0] & 0x0F;
|
||||
let p0 = (hi ^ au[1] ^ au[2] ^ au[3]) ^ (au[32] ^ au[33]);
|
||||
let c = ((p0 >> 4) ^ (p0 & 0x0F) ^ 0x0F) & 0x0F;
|
||||
au[0] = (c << 4) | hi;
|
||||
}
|
||||
|
||||
/// Give a synthetic NON-major-sync AU a valid header parity nibble (1
|
||||
/// substream, directory at AU[4..6]), so it passes the gate once a preceding
|
||||
/// major sync has established `num_substreams`.
|
||||
fn finalize_normal_parity(au: &mut [u8]) {
|
||||
au[4] &= 0x7F; // no extraword
|
||||
let hi = au[0] & 0x0F;
|
||||
let p0 = (hi ^ au[1] ^ au[2] ^ au[3]) ^ (au[4] ^ au[5]);
|
||||
let c = ((p0 >> 4) ^ (p0 & 0x0F) ^ 0x0F) & 0x0F;
|
||||
au[0] = (c << 4) | hi;
|
||||
}
|
||||
|
||||
fn valid_major_sync() -> Vec<u8> {
|
||||
let mut u = make_truehd_unit(200);
|
||||
u[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
|
||||
finalize_major_sync(&mut u);
|
||||
u
|
||||
}
|
||||
|
||||
fn valid_normal_au() -> Vec<u8> {
|
||||
let mut u = make_truehd_unit(200);
|
||||
finalize_normal_parity(&mut u);
|
||||
u
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_major_sync_drops_forward_to_next_valid() {
|
||||
// MLP state carries across AUs, so a corrupt AU is dropped FORWARD to the
|
||||
// next valid major sync (the clean re-init point). Sequence: valid MS,
|
||||
// corrupt MS (bad CRC), a normal AU, then a valid MS. Only the two valid
|
||||
// major syncs survive; the corrupt MS and the intervening normal AU are
|
||||
// dropped (the latter because decode state is poisoned until re-init).
|
||||
let mut parser = TrueHdParser::new();
|
||||
let ms1 = valid_major_sync();
|
||||
let mut ms_bad = valid_major_sync();
|
||||
ms_bad[10] ^= 0xFF; // corrupt a CRC-covered header byte
|
||||
let normal = valid_normal_au(); // clean parity, but arrives mid-resync
|
||||
let ms2 = valid_major_sync();
|
||||
|
||||
let mut data = ms1.clone();
|
||||
data.extend_from_slice(&ms_bad);
|
||||
data.extend_from_slice(&normal);
|
||||
data.extend_from_slice(&ms2);
|
||||
let mut frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
|
||||
assert_eq!(frames.len(), 2, "only the two valid major syncs survive");
|
||||
assert!(frames[0].keyframe && frames[1].keyframe);
|
||||
assert_eq!(
|
||||
parser.dropped_frames(),
|
||||
2,
|
||||
"corrupt MS + poisoned normal AU"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_failed_head_major_sync_is_kept_not_track_killed() {
|
||||
// REGRESSION (every TrueHD title muxed after the checksum gate landed): a
|
||||
// real-world major sync whose checksum our own header-size parse can't
|
||||
// validate must NOT, at stream head — before ANY major sync has validated —
|
||||
// arm the drop-forward. Doing so latched `resync_pending` on the very first
|
||||
// AU and collateral-dropped EVERY following AU forever (no "validated" major
|
||||
// sync ever came), silently dropping the entire TrueHD track: its AUs were
|
||||
// emitted only at mux end, de-interleaving the track and spiralling decoders
|
||||
// into unbounded memory ("decoder ran out of memory"). With no baseline to
|
||||
// protect, a checksum-failed major sync is KEPT and the audio flows.
|
||||
let mut parser = TrueHdParser::new();
|
||||
let mut ms_bad = valid_major_sync();
|
||||
ms_bad[10] ^= 0xFF; // break a checksum-covered header byte → checksum fails
|
||||
let mut data = ms_bad;
|
||||
for _ in 0..6 {
|
||||
data.extend_from_slice(&valid_normal_au());
|
||||
}
|
||||
let mut frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
7,
|
||||
"no baseline yet: the CRC-failed head major sync + all following AUs are \
|
||||
kept, not dropped (got {})",
|
||||
frames.len()
|
||||
);
|
||||
assert_eq!(
|
||||
parser.dropped_frames(),
|
||||
0,
|
||||
"nothing dropped without a validated baseline to protect"
|
||||
);
|
||||
// And the invariant still holds ONCE a baseline exists: after a genuinely
|
||||
// valid major sync, a later corrupt one IS dropped (see
|
||||
// `corrupt_major_sync_drops_forward_to_next_valid`).
|
||||
}
|
||||
|
||||
/// Independent bitwise CRC-16 (poly 0x002D, init 0, MSB-first) — a SEPARATE
|
||||
/// oracle from `crc16_mlp`, so a fixture built with it is not tautological
|
||||
/// with the validator under test. Anchored to the catalogue check value
|
||||
/// (0x4FF7 for "123456789") so the oracle itself is proven correct without
|
||||
/// reference to the code under test.
|
||||
fn ref_crc16_2d(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0;
|
||||
for &b in data {
|
||||
crc ^= (b as u16) << 8;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000 != 0 {
|
||||
(crc << 1) ^ 0x002D
|
||||
} else {
|
||||
crc << 1
|
||||
};
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_major_sync_crc_validates_and_rejects() {
|
||||
// COVERAGE GAP (the endianness bug that once "silently dropped the whole
|
||||
// track" on real 7.1/Atmos): the EXTENDED major-sync header path
|
||||
// (ms[25]&1 set, mshdr = 28 + 2 + 2*n) had ZERO test coverage — every
|
||||
// other fixture builds only the basic 28-byte header. Build an extended
|
||||
// header whose trailer is an INDEPENDENTLY-computed oracle (ref_crc16_2d,
|
||||
// NOT crc16_mlp) stored LITTLE-ENDIAN, and assert the validator accepts
|
||||
// it, rejects a body corruption, and rejects the same trailer stored
|
||||
// big-endian (which is exactly the endianness-mix regression).
|
||||
assert_eq!(
|
||||
ref_crc16_2d(b"123456789"),
|
||||
0x4FF7,
|
||||
"oracle anchored to catalogue"
|
||||
);
|
||||
|
||||
// n = 3 extension words → mshdr = 28 + 2 + 2*3 = 36.
|
||||
let n = 3usize;
|
||||
let mshdr = 28 + 2 + 2 * n;
|
||||
assert_eq!(mshdr, 36);
|
||||
let mut ms = vec![0u8; 40]; // slack past the 36-byte header
|
||||
// Non-trivial, varied body so the CRC is a meaningful function of it.
|
||||
for (i, b) in ms.iter_mut().enumerate().take(mshdr - 4) {
|
||||
*b = (0x37u8).wrapping_add((i as u8).wrapping_mul(0x53));
|
||||
}
|
||||
ms[25] |= 1; // extension flag → selects the extended header size
|
||||
ms[26] = (ms[26] & 0x0F) | ((n as u8) << 4); // extension word count in high nibble
|
||||
|
||||
// The 2-byte "penultimate" word (between the CRC-covered body and the
|
||||
// trailer). Chosen non-zero and non-palindromic so the LE/BE distinction
|
||||
// is observable.
|
||||
ms[mshdr - 4] = 0x12;
|
||||
ms[mshdr - 3] = 0x34;
|
||||
|
||||
// Oracle: checksum16 = crc16_2D(body).swap_bytes() ^ le16(penultimate),
|
||||
// computed with the INDEPENDENT ref CRC, then stored LITTLE-ENDIAN.
|
||||
let le_word = u16::from_le_bytes([ms[mshdr - 4], ms[mshdr - 3]]);
|
||||
let trailer = ref_crc16_2d(&ms[..mshdr - 4]).swap_bytes() ^ le_word;
|
||||
ms[mshdr - 2] = (trailer & 0xFF) as u8;
|
||||
ms[mshdr - 1] = (trailer >> 8) as u8;
|
||||
assert_ne!(
|
||||
ms[mshdr - 2],
|
||||
ms[mshdr - 1],
|
||||
"trailer bytes must differ so the LE/BE swap below is a real distinction"
|
||||
);
|
||||
|
||||
// The extended header size is computed from ms[25]/ms[26].
|
||||
assert_eq!(
|
||||
mlp_major_sync_header_size(&ms),
|
||||
Some(mshdr),
|
||||
"extended header size = 28 + 2 + 2*n"
|
||||
);
|
||||
// The validator accepts the independently-built extended major sync.
|
||||
assert!(
|
||||
mlp_major_sync_crc_ok(&ms, mshdr),
|
||||
"valid extended major-sync checksum must validate"
|
||||
);
|
||||
|
||||
// A single corrupted body byte must be rejected.
|
||||
let mut corrupt = ms.clone();
|
||||
corrupt[10] ^= 0xFF;
|
||||
assert!(
|
||||
!mlp_major_sync_crc_ok(&corrupt, mshdr),
|
||||
"a corrupted extended major sync must be rejected"
|
||||
);
|
||||
|
||||
// The endianness regression: the SAME checksum stored big-endian must be
|
||||
// rejected. A validator that reads the trailer big-endian (the shipped
|
||||
// bug) would instead accept this and reject the correct LE form above.
|
||||
let mut swapped = ms.clone();
|
||||
swapped.swap(mshdr - 2, mshdr - 1);
|
||||
assert!(
|
||||
!mlp_major_sync_crc_ok(&swapped, mshdr),
|
||||
"a big-endian-stored trailer must be rejected (little-endian is load-bearing)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_failure_is_dropped() {
|
||||
// A normal AU whose header parity is broken (after a major sync sets
|
||||
// num_substreams) is undecodable → dropped.
|
||||
let mut parser = TrueHdParser::new();
|
||||
let ms1 = valid_major_sync();
|
||||
let mut bad = valid_normal_au();
|
||||
// A single-nibble flip: MLP's nibble-fold parity is blind
|
||||
// to a full-byte flip, which changes both nibbles equally and cancels.
|
||||
bad[2] ^= 0x01;
|
||||
let ms2 = valid_major_sync();
|
||||
let mut data = ms1;
|
||||
data.extend_from_slice(&bad);
|
||||
data.extend_from_slice(&ms2);
|
||||
let mut frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(frames.len(), 2, "the parity-broken AU is dropped");
|
||||
assert_eq!(parser.dropped_frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_forward_preserves_av_sync_no_shift() {
|
||||
// THE INVARIANT: the resumed major sync keeps the exact PTS it would have
|
||||
// had with no drop — base + 3 AU durations (MS1, corrupt-MS, normal, MS2)
|
||||
// — so the drop is a silence gap, never a shift.
|
||||
let mut parser = TrueHdParser::new();
|
||||
let ms1 = valid_major_sync();
|
||||
let mut ms_bad = valid_major_sync();
|
||||
ms_bad[10] ^= 0xFF;
|
||||
let normal = valid_normal_au();
|
||||
let ms2 = valid_major_sync();
|
||||
let mut data = ms1;
|
||||
data.extend_from_slice(&ms_bad);
|
||||
data.extend_from_slice(&normal);
|
||||
data.extend_from_slice(&ms2);
|
||||
let mut frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(
|
||||
frames[1].pts_ns - frames[0].pts_ns,
|
||||
3 * AU_DURATION_NS,
|
||||
"resumed major sync keeps its true timeline (gap, not shift)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_corruptions_do_not_poison_whole_track() {
|
||||
// Regression (audit HIGH): TrueHD drop-forward must NOT amplify a couple
|
||||
// of transient errors into a false whole-track poison. Two corruptions,
|
||||
// each forcing a long collateral resync run past 200 total AUs, must
|
||||
// leave the track un-poisoned and keep the good audio that follows.
|
||||
let mut parser = TrueHdParser::new();
|
||||
let mut data = valid_major_sync();
|
||||
// Corruption #1 then a long run of normal AUs (all collateral-dropped
|
||||
// while resyncing — no major sync to re-init on).
|
||||
let mut bad1 = valid_normal_au();
|
||||
bad1[2] ^= 0x01; // single-nibble parity break
|
||||
data.extend_from_slice(&bad1);
|
||||
for _ in 0..210 {
|
||||
data.extend_from_slice(&valid_normal_au());
|
||||
}
|
||||
// A valid major sync resumes; the good AUs after it MUST be kept.
|
||||
data.extend_from_slice(&valid_major_sync());
|
||||
for _ in 0..5 {
|
||||
data.extend_from_slice(&valid_normal_au());
|
||||
}
|
||||
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
assert!(
|
||||
!parser.tally.is_poisoned(),
|
||||
"two transient errors must not poison the track"
|
||||
);
|
||||
// MS1 + resumed MS2 + the 5 good AUs after it survive.
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
7,
|
||||
"post-resync good audio is kept, not poisoned away"
|
||||
);
|
||||
assert!(
|
||||
parser.dropped_frames() > 200,
|
||||
"the resync run was still counted for reporting"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_major_sync_rate_nibble_does_not_shift_pts() {
|
||||
// Regression (audit MED): a corrupt major sync whose rate nibble decodes
|
||||
// to the 44.1 kHz family must NOT refine au_duration_ns — the rate is only
|
||||
// trustworthy after the CRC validates. Otherwise the resumed 48 kHz audio
|
||||
// is shifted (not gapped).
|
||||
let mut parser = TrueHdParser::new();
|
||||
let ms1 = valid_major_sync(); // 48 kHz
|
||||
let mut ms_bad = valid_major_sync();
|
||||
// Set the rate nibble (top nibble of format_info = au[8]) to 0x8 (44.1k).
|
||||
// au[8] is CRC-covered, so this also breaks the major-sync CRC → corrupt.
|
||||
ms_bad[8] = (ms_bad[8] & 0x0F) | 0x80;
|
||||
let ms2 = valid_major_sync(); // 48 kHz
|
||||
let mut data = ms1;
|
||||
data.extend_from_slice(&ms_bad);
|
||||
data.extend_from_slice(&ms2);
|
||||
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
assert_eq!(frames.len(), 2, "corrupt MS dropped; MS1 and MS2 survive");
|
||||
assert_eq!(
|
||||
frames[1].pts_ns - frames[0].pts_ns,
|
||||
2 * AU_DURATION_NS,
|
||||
"resumed audio keeps the 48 kHz cadence — the corrupt MS's 44.1k rate was ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_short_major_sync_does_not_clear_resync() {
|
||||
// Regression (audit LOW): while resyncing, a major sync too short to hold
|
||||
// (and CRC-validate) its header must NOT be treated as a clean resync
|
||||
// point — the runt is dropped and only a real validated major sync resumes.
|
||||
let mut parser = TrueHdParser::new();
|
||||
let ms1 = valid_major_sync();
|
||||
let mut bad = valid_normal_au();
|
||||
bad[2] ^= 0x01; // parity break → triggers resync
|
||||
// An 8-byte "major sync": length=4 words, sync at bytes 4..8, too short
|
||||
// to hold the 28-byte major-sync header.
|
||||
let runt = vec![0x00, 0x04, 0x00, 0x00, 0xF8, 0x72, 0x6F, 0xBA];
|
||||
let ms2 = valid_major_sync();
|
||||
let mut data = ms1;
|
||||
data.extend_from_slice(&bad);
|
||||
data.extend_from_slice(&runt);
|
||||
data.extend_from_slice(&ms2);
|
||||
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
assert_eq!(frames.len(), 2, "the runt major sync did not resume decode");
|
||||
for f in &frames {
|
||||
assert_eq!(
|
||||
f.data.len(),
|
||||
200,
|
||||
"only the real 200-byte major syncs survive"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_truehd_stream_drops_nothing() {
|
||||
// A run of valid AUs passes untouched — zero false positives (the CRC and
|
||||
// parity are verified against real TrueHD output).
|
||||
let mut parser = TrueHdParser::new();
|
||||
let mut data = valid_major_sync();
|
||||
for _ in 0..5 {
|
||||
data.extend_from_slice(&valid_normal_au());
|
||||
}
|
||||
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
assert_eq!(frames.len(), 6);
|
||||
assert_eq!(parser.dropped_frames(), 0);
|
||||
}
|
||||
|
||||
fn make_ac3_frame() -> Vec<u8> {
|
||||
// Minimal AC-3 frame: sync 0x0B77, fscod=0 (48kHz), frmsizecod=0 (64 words = 128 bytes)
|
||||
let mut data = vec![0u8; 128];
|
||||
@@ -763,7 +1394,7 @@ mod tests {
|
||||
assert_eq!(truehd_channels_from_stream(&data), Some(8));
|
||||
}
|
||||
|
||||
// --- truehd_channels: per-bit mask channel counts (MLP / FFmpeg table) ---
|
||||
// --- truehd_channels: per-bit mask channel counts (MLP channel table) ---
|
||||
|
||||
#[test]
|
||||
fn truehd_channels_8ch_single_bit_counts() {
|
||||
@@ -878,6 +1509,7 @@ mod tests {
|
||||
let mut parser = TrueHdParser::new();
|
||||
let mut unit = make_truehd_unit(200);
|
||||
unit[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
|
||||
finalize_major_sync(&mut unit);
|
||||
let f = parser.parse(&make_pes(unit, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert!(f[0].keyframe, "major-sync AU must be flagged keyframe");
|
||||
@@ -899,6 +1531,7 @@ mod tests {
|
||||
let mut parser = TrueHdParser::new();
|
||||
let mut unit = make_truehd_unit(200);
|
||||
unit[4..8].copy_from_slice(&0xF872_6FBBu32.to_be_bytes());
|
||||
finalize_major_sync(&mut unit);
|
||||
let f = parser.parse(&make_pes(unit, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert!(f[0].keyframe, "major-sync variant 0xFB also a keyframe");
|
||||
@@ -1101,8 +1734,11 @@ mod tests {
|
||||
let mut a1 = make_truehd_unit(200);
|
||||
a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes()); // major sync
|
||||
a1[8..12].copy_from_slice(&format_info_with(0x8).to_be_bytes()); // 44.1 k
|
||||
finalize_major_sync(&mut a1);
|
||||
let mut a2 = make_truehd_unit(200);
|
||||
finalize_normal_parity(&mut a2);
|
||||
let mut data = a1;
|
||||
data.extend_from_slice(&make_truehd_unit(200));
|
||||
data.extend_from_slice(&a2);
|
||||
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(
|
||||
@@ -1120,8 +1756,11 @@ mod tests {
|
||||
let mut a1 = make_truehd_unit(200);
|
||||
a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
|
||||
a1[8..12].copy_from_slice(&format_info_with(0x0).to_be_bytes()); // 48 k
|
||||
finalize_major_sync(&mut a1);
|
||||
let mut a2 = make_truehd_unit(200);
|
||||
finalize_normal_parity(&mut a2);
|
||||
let mut data = a1;
|
||||
data.extend_from_slice(&make_truehd_unit(200));
|
||||
data.extend_from_slice(&a2);
|
||||
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[1].pts_ns - frames[0].pts_ns, 833_333);
|
||||
|
||||
+35
-3
@@ -103,6 +103,10 @@ pub struct Vc1Parser {
|
||||
cur_entry_point: Option<Vec<u8>>,
|
||||
width: 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 {
|
||||
@@ -120,6 +124,25 @@ impl Vc1Parser {
|
||||
cur_entry_point: None,
|
||||
width: 1920,
|
||||
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 —
|
||||
// DTS presents B-frames in decode order (visible judder) and breaks
|
||||
// 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_entry_point = false;
|
||||
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())
|
||||
});
|
||||
|
||||
vec![Frame {
|
||||
let frame = Frame {
|
||||
// Coding-type only: VC-1 field order is not decoded here, so
|
||||
// field_order() stays None — honestly absent, never guessed.
|
||||
coding: coding_type.map(PictureInfo::coding_type_only),
|
||||
@@ -333,7 +357,15 @@ impl CodecParser for Vc1Parser {
|
||||
discontinuity: pes.discontinuity,
|
||||
data: frame_data,
|
||||
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>> {
|
||||
|
||||
+104
-22
@@ -31,6 +31,10 @@ use std::io::{self, BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Filename-naming strategy for the per-track files.
|
||||
// `allow(dead_code)`: the sink honours all variants, but only the `#[default]` is
|
||||
// constructed today (`output()` builds `DemuxOptions::default()`). The alternates
|
||||
// are a staged option surface awaiting the CLI `--naming` flag (not yet wired).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Naming {
|
||||
/// `<base> <track> <lang> <codec> [DELAY <n>ms].<ext>` — human-readable.
|
||||
@@ -45,7 +49,8 @@ pub enum Naming {
|
||||
/// How (and whether) to record audio sync delay.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DelayMode {
|
||||
/// Embed `DELAY <n>ms` in each audio filename (mkvmerge-readable).
|
||||
/// Embed `DELAY <n>ms` in each audio filename (the filename-delay
|
||||
/// convention downstream muxers parse).
|
||||
#[default]
|
||||
Filename,
|
||||
/// Write a `<base> delays.txt` sidecar instead.
|
||||
@@ -55,9 +60,12 @@ pub enum DelayMode {
|
||||
}
|
||||
|
||||
/// Chapter export format.
|
||||
// `allow(dead_code)`: only the `#[default]` XML variant is constructed today (via
|
||||
// `DemuxOptions::default()`); OGM/Both await the CLI `--chapters` flag.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ChaptersFmt {
|
||||
/// mkvmerge chapter XML.
|
||||
/// Matroska chapter XML.
|
||||
#[default]
|
||||
Xml,
|
||||
/// OGM/simple `CHAPTERnn=`/`CHAPTERnnNAME=` text.
|
||||
@@ -81,6 +89,10 @@ pub struct DemuxOptions {
|
||||
pub export_chapters: bool,
|
||||
/// Selected track indices. `None` = all tracks.
|
||||
pub selection: Option<Vec<usize>>,
|
||||
/// Restrict output to one track class. `None` = every class (plain
|
||||
/// `demux://`). `Some(Audio)` is the `audio://` sink; `Some(Subtitle)` is
|
||||
/// `sub://`. Filtered tracks are skipped entirely (no file written).
|
||||
pub kind_filter: Option<TrackKind>,
|
||||
}
|
||||
|
||||
impl Default for DemuxOptions {
|
||||
@@ -92,13 +104,16 @@ impl Default for DemuxOptions {
|
||||
chapters_fmt: ChaptersFmt::default(),
|
||||
export_chapters: true,
|
||||
selection: None,
|
||||
kind_filter: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Track class, used for delay attribution and naming.
|
||||
/// Track class, used for delay attribution and naming — and, via
|
||||
/// [`DemuxOptions::kind_filter`], to restrict a demux to one class (the
|
||||
/// `audio://` / `sub://` sinks are a `demux://` filtered to Audio / Subtitle).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TrackKind {
|
||||
pub enum TrackKind {
|
||||
Video,
|
||||
Audio,
|
||||
Subtitle,
|
||||
@@ -107,7 +122,8 @@ enum TrackKind {
|
||||
// ── Codec → on-disk extension ────────────────────────────────────────────────
|
||||
|
||||
/// File extension (without the dot) for a codec's standalone elementary stream.
|
||||
/// Chosen to match what mkvmerge / x265 / ffmpeg / BDSup2Sub expect.
|
||||
/// Chosen to match the conventional elementary-stream extensions downstream
|
||||
/// muxers and codec tools expect.
|
||||
fn extension_for(codec: Codec) -> &'static str {
|
||||
match codec {
|
||||
Codec::Hevc => "hevc",
|
||||
@@ -136,7 +152,7 @@ fn extension_for(codec: Codec) -> &'static str {
|
||||
}
|
||||
|
||||
/// Short codec label for friendly filenames.
|
||||
fn codec_label(codec: Codec) -> &'static str {
|
||||
pub(crate) fn codec_label(codec: Codec) -> &'static str {
|
||||
match codec {
|
||||
Codec::Hevc => "HEVC",
|
||||
Codec::H264 => "AVC",
|
||||
@@ -241,11 +257,23 @@ impl EsWriter for AnnexBWriter {
|
||||
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
|
||||
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
|
||||
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
|
||||
match codec {
|
||||
Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(),
|
||||
Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
let converted = match codec {
|
||||
Codec::Hevc => hvcc_to_annex_b(record),
|
||||
Codec::H264 => avcc_to_annex_b(record),
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
converted.unwrap_or_else(|| {
|
||||
// A malformed hvcC/avcC record yields no parameter sets. Returning empty
|
||||
// means keyframes ship WITHOUT in-band SPS/PPS — playable from the first
|
||||
// keyframe but broken for seek-to-arbitrary-point and hardware decoders.
|
||||
// Surface it rather than silently degrading the output.
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
?codec,
|
||||
"codec-private (hvcC/avcC) parse failed; keyframes will lack in-band SPS/PPS"
|
||||
);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
|
||||
@@ -414,7 +442,7 @@ impl VobSubWriter {
|
||||
.map(|s| s.trim_end().to_string());
|
||||
// VobSub `id:` lines use a 2-letter code; stream languages are ISO
|
||||
// 639-2 (3-letter). Take the leading two chars — the convention
|
||||
// mkvmerge reads to assign a track language.
|
||||
// downstream muxers read to assign a track language.
|
||||
let lang2: String = lang.chars().take(2).collect();
|
||||
Self {
|
||||
idx_path,
|
||||
@@ -442,8 +470,8 @@ impl EsWriter for VobSubWriter {
|
||||
idx.push('\n');
|
||||
}
|
||||
idx.push_str("langidx: 0\n\n");
|
||||
// The conventional `id: <lang2>, index: 0` line mkvmerge reads to
|
||||
// assign the subtitle track's language. Omit the language token when
|
||||
// The conventional `id: <lang2>, index: 0` line downstream muxers read
|
||||
// to assign the subtitle track's language. Omit the language token when
|
||||
// unknown but still emit the index so the entry list is well-formed.
|
||||
if self.lang2.is_empty() {
|
||||
idx.push_str("id: , index: 0\n");
|
||||
@@ -504,8 +532,8 @@ fn delay_ms(audio_first_pts_ns: i64, ref_video_first_pts_ns: i64) -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELAY <signed-int>ms` — matches mkvmerge's case-insensitive
|
||||
/// `delay\s+(-?\d+)` filename-delay parser.
|
||||
/// `DELAY <signed-int>ms` — matches the conventional case-insensitive
|
||||
/// `delay\s+(-?\d+)` filename-delay convention downstream muxers parse.
|
||||
fn delay_token(ms: i64) -> String {
|
||||
format!("DELAY {ms}ms")
|
||||
}
|
||||
@@ -521,8 +549,8 @@ fn fmt_chapter_time_ns(time_secs: f64) -> String {
|
||||
format!("{h:02}:{m:02}:{s:02}.{ns:09}")
|
||||
}
|
||||
|
||||
/// Serialize chapters as mkvmerge chapter XML.
|
||||
fn chapters_xml(chapters: &[Chapter]) -> String {
|
||||
/// Serialize chapters as Matroska chapter XML.
|
||||
pub(crate) fn chapters_xml(chapters: &[Chapter]) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
s.push_str("<!DOCTYPE Chapters SYSTEM \"matroskachapters.dtd\">\n");
|
||||
@@ -552,7 +580,7 @@ fn chapters_xml(chapters: &[Chapter]) -> String {
|
||||
}
|
||||
|
||||
/// Serialize chapters as OGM/simple chapter text.
|
||||
fn chapters_ogm(chapters: &[Chapter]) -> String {
|
||||
pub(crate) fn chapters_ogm(chapters: &[Chapter]) -> String {
|
||||
let mut s = String::new();
|
||||
for (i, c) in chapters.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
@@ -639,9 +667,18 @@ impl DemuxSink {
|
||||
(TrackKind::Subtitle, s.codec, s.pid, s.language.clone())
|
||||
}
|
||||
};
|
||||
// Record the primary-video reference BEFORE the kind filter: the video
|
||||
// track drives multi-clip PTS-continuity rebasing and the audio DELAY
|
||||
// tag even for `audio://` / `sub://` outputs, where its frames flow
|
||||
// through write() but are not persisted to disk.
|
||||
if kind == TrackKind::Video && ref_video_track.is_none() {
|
||||
ref_video_track = Some(idx);
|
||||
}
|
||||
// Kind filter: `audio://` / `sub://` keep only their class.
|
||||
if opts.kind_filter.is_some_and(|k| k != kind) {
|
||||
tracks.push(None);
|
||||
continue;
|
||||
}
|
||||
|
||||
let ext = extension_for(codec);
|
||||
let stem = Self::stem_for(opts, idx, pid, &lang, codec);
|
||||
@@ -855,6 +892,17 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn subtitle_stream(codec: Codec, lang: &str) -> DiscStream {
|
||||
DiscStream::Subtitle(crate::disc::SubtitleStream {
|
||||
pid: 0x1200,
|
||||
codec,
|
||||
language: lang.to_string(),
|
||||
forced: false,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn title_with(streams: Vec<DiscStream>, privates: Vec<Option<Vec<u8>>>) -> DiscTitle {
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams = streams;
|
||||
@@ -863,6 +911,40 @@ mod tests {
|
||||
t
|
||||
}
|
||||
|
||||
/// `audio://` and `sub://` are `demux://` with a kind filter: only tracks of
|
||||
/// the selected class get a file; every other track is skipped entirely.
|
||||
#[test]
|
||||
fn kind_filter_keeps_only_the_selected_class() {
|
||||
let title = title_with(
|
||||
vec![
|
||||
video_stream(Codec::H264),
|
||||
audio_stream(Codec::Ac3, "eng"),
|
||||
subtitle_stream(Codec::Pgs, "eng"),
|
||||
],
|
||||
vec![None, None, None],
|
||||
);
|
||||
let sub_opts = DemuxOptions {
|
||||
kind_filter: Some(TrackKind::Subtitle),
|
||||
export_chapters: false,
|
||||
..Default::default()
|
||||
};
|
||||
let sub = DemuxSink::create(&tempdir(), &title, &sub_opts).unwrap();
|
||||
assert!(
|
||||
sub.tracks[0].is_none() && sub.tracks[1].is_none() && sub.tracks[2].is_some(),
|
||||
"sub:// keeps only the subtitle track"
|
||||
);
|
||||
let audio_opts = DemuxOptions {
|
||||
kind_filter: Some(TrackKind::Audio),
|
||||
export_chapters: false,
|
||||
..Default::default()
|
||||
};
|
||||
let audio = DemuxSink::create(&tempdir(), &title, &audio_opts).unwrap();
|
||||
assert!(
|
||||
audio.tracks[0].is_none() && audio.tracks[1].is_some() && audio.tracks[2].is_none(),
|
||||
"audio:// keeps only the audio track"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Annex-B reframing ────────────────────────────────────────────────────
|
||||
//
|
||||
// The length-prefixed → Annex-B conversion and the hvcC/avcC param-set
|
||||
@@ -957,7 +1039,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn delay_token_matches_mkvmerge_regex() {
|
||||
// mkvmerge: case-insensitive /delay\s+(-?\d+)/.
|
||||
// Convention: case-insensitive /delay\s+(-?\d+)/.
|
||||
let re = regex_lite_delay;
|
||||
assert_eq!(re("Movie eng AC3 DELAY -248ms.ac3"), Some(-248));
|
||||
assert_eq!(re(&format!("x {}.dts", delay_token(1000))), Some(1000));
|
||||
@@ -965,7 +1047,7 @@ mod tests {
|
||||
assert_eq!(re(&format!("x {}.eac3", delay_token(-5))), Some(-5));
|
||||
}
|
||||
|
||||
/// Minimal stand-in for mkvmerge's `delay\s+(-?\d+)` (case-insensitive).
|
||||
/// Minimal stand-in for the `delay\s+(-?\d+)` convention (case-insensitive).
|
||||
fn regex_lite_delay(name: &str) -> Option<i64> {
|
||||
let lower = name.to_lowercase();
|
||||
let idx = lower.find("delay")?;
|
||||
@@ -1117,7 +1199,7 @@ mod tests {
|
||||
w.finish(&mut sub).unwrap();
|
||||
let idx_text = std::fs::read_to_string(&idx).unwrap();
|
||||
assert!(idx_text.contains("palette: 000000, ffffff"));
|
||||
// The conventional `id:` line mkvmerge reads to assign the language.
|
||||
// The conventional `id:` line downstream muxers read to assign the language.
|
||||
assert!(
|
||||
idx_text.contains("id: en, index: 0"),
|
||||
"missing id: line, got:\n{idx_text}"
|
||||
|
||||
+20
-4
@@ -7,7 +7,7 @@
|
||||
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
|
||||
//! already runs on a producer thread; the *consumer* (main) thread
|
||||
//! still serialises `ts_demuxer.feed` (M2TS parsing) with the codec
|
||||
//! parsers. Profiling on the rip1 testbed showed feed at ~37 % and
|
||||
//! parsers. Profiling showed feed at ~37 % and
|
||||
//! codec parse at ~44 % of consumer wall time — i.e. feed is heavy
|
||||
//! enough that pipelining it with parse pays for itself.
|
||||
//!
|
||||
@@ -204,6 +204,14 @@ impl DemuxThread {
|
||||
}
|
||||
} else {
|
||||
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.
|
||||
@@ -479,7 +487,7 @@ mod tests {
|
||||
#[test]
|
||||
fn no_demuxer_configured_still_recycles_and_eofs() {
|
||||
// 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 (rc_tx, rc_rx) = bounded::<Vec<u8>>(4);
|
||||
let (_dt, rx) = DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), None, None, None).unwrap();
|
||||
@@ -492,8 +500,16 @@ mod tests {
|
||||
drop(pf_tx);
|
||||
|
||||
let batches = collect_batches(&rx, Duration::from_secs(5));
|
||||
assert_eq!(batches.len(), 1, "only the Eof sentinel");
|
||||
assert!(matches!(batches[0], DemuxBatch::Eof));
|
||||
// The no-demuxer branch now forwards an empty Ts batch per buffer for
|
||||
// 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]
|
||||
|
||||
+222
-43
@@ -108,11 +108,6 @@ pub struct DiscStream {
|
||||
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
|
||||
/// (raw / unencrypted disc) makes the decorator a pass-through.
|
||||
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
|
||||
/// Shared decrypt-loss counter, cloned once at construction from
|
||||
/// `reader.decrypt_loss()`. `lost_bytes()` loads it directly so the
|
||||
/// per-frame hot path performs no per-call `Arc::clone` (matching the
|
||||
/// `PipelinedPesStream` pattern).
|
||||
decrypt_loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
title: DiscTitle,
|
||||
/// Mirror of the keys handed in at construction. The decorator
|
||||
/// owns the cryptographic state; this field is kept for
|
||||
@@ -144,14 +139,14 @@ pub struct DiscStream {
|
||||
// Adaptive batch sizer — preferred comes from the caller
|
||||
// (detect_max_batch_sectors), shrinks/grows based on read outcomes.
|
||||
adaptive: AdaptiveBatch,
|
||||
pub errors: u64,
|
||||
errors: u64,
|
||||
/// Cumulative bytes actually skipped (zero-filled) on read error.
|
||||
/// Distinct from `errors`, which counts skip *events*: one event can
|
||||
/// cover a whole AACS unit (`unit_align` sectors = 6144 bytes), so
|
||||
/// `errors * 2048` understates real loss by the alignment factor.
|
||||
/// Consumers estimating lost video time must scale by this, not by
|
||||
/// the event count.
|
||||
pub lost_bytes: u64,
|
||||
lost_bytes: u64,
|
||||
pub skip_errors: bool,
|
||||
/// When set and the token is cancelled, fill_extents returns Err(Halted)
|
||||
/// at the next retry boundary. Unlike skip_errors, this propagates the
|
||||
@@ -214,14 +209,34 @@ impl DiscStream {
|
||||
/// The caller opens the source, scans for titles/keys, and passes them in.
|
||||
/// The stream handles demuxing, decryption, and codec parsing internally.
|
||||
pub fn new(
|
||||
reader: Box<dyn SectorSource>,
|
||||
mut reader: Box<dyn SectorSource>,
|
||||
title: DiscTitle,
|
||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
mut decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
batch_sectors: u16,
|
||||
content_format: crate::disc::ContentFormat,
|
||||
) -> Self {
|
||||
raw: bool,
|
||||
halt: Option<Halt>,
|
||||
) -> std::io::Result<Self> {
|
||||
let mut title = title;
|
||||
let extents = title.extents.clone();
|
||||
|
||||
// Resolve this title's CSS key from the reader if the caller supplied
|
||||
// none — the SAME shared step the file-backed mux highway
|
||||
// (`build_iso_pipeline`) uses, so single-pass and multi-pass descramble a
|
||||
// DVD identically. No-op for AACS / already-keyed / genuinely-clear input
|
||||
// or `raw`; a scrambled-but-uncrackable DVD is a hard `CssKeyMissing`.
|
||||
// `halt` is passed here (not deferred to `with_halt`) so a Stop during the
|
||||
// crack scan is honored — the scan runs at construction, before the caller
|
||||
// can attach a token.
|
||||
crate::css::resolve_dvd_title_key(
|
||||
&mut *reader,
|
||||
&extents,
|
||||
&mut decrypt_keys,
|
||||
batch_sectors,
|
||||
content_format,
|
||||
raw,
|
||||
halt.as_ref(),
|
||||
)?;
|
||||
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
||||
|
||||
// Debug log reader type at construction — critical for diagnosing mux
|
||||
@@ -237,12 +252,12 @@ impl DiscStream {
|
||||
|
||||
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
|
||||
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
|
||||
// MUX path: tolerate decrypt loss — conceal an undecryptable unit (NULL TS
|
||||
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
|
||||
// decode/mux stream (live-drive single-pass / direct), never the
|
||||
// ciphertext-preserving sweep, so concealment is always correct here.
|
||||
let mut reader =
|
||||
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
|
||||
// MUX path (read > decrypt > mux): decrypt every unit in place and pass the
|
||||
// bytes to the muxer; a unit that decrypts to broken TS is the muxer's
|
||||
// concern, never conceal / re-fetch / count as loss (fail loud only on a
|
||||
// genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive
|
||||
// single-pass / direct), never the ciphertext-preserving sweep.
|
||||
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
|
||||
|
||||
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
|
||||
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
|
||||
@@ -293,9 +308,6 @@ impl DiscStream {
|
||||
// the decorator is a pass-through). Reset the unit base the probe read
|
||||
// advanced so the first fill_extents read starts cleanly.
|
||||
reader.set_unit_base(0);
|
||||
// Clone the shared loss counter once here so `lost_bytes()` never
|
||||
// clones an Arc per frame on the mux hot path.
|
||||
let decrypt_loss = reader.decrypt_loss();
|
||||
|
||||
// B1 resync gates: one per stream, video flagged so the gate only
|
||||
// drop-to-keyframes video (audio/subtitle always admit). Computed before
|
||||
@@ -309,9 +321,8 @@ impl DiscStream {
|
||||
.map(|_| super::resync::ResyncGate::new())
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
reader,
|
||||
decrypt_loss,
|
||||
title,
|
||||
decrypt_keys,
|
||||
unit_align,
|
||||
@@ -324,7 +335,7 @@ impl DiscStream {
|
||||
errors: 0,
|
||||
lost_bytes: 0,
|
||||
skip_errors: false,
|
||||
halt: None,
|
||||
halt,
|
||||
event_fn: None,
|
||||
eof: false,
|
||||
dropped_nav_packets: 0,
|
||||
@@ -339,7 +350,7 @@ impl DiscStream {
|
||||
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
|
||||
resync,
|
||||
is_video,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Set event handler for sector-level events (binary search, skip, recover).
|
||||
@@ -360,6 +371,27 @@ impl DiscStream {
|
||||
self
|
||||
}
|
||||
|
||||
/// Install a proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) on the inline
|
||||
/// live-drive path — the counterpart to what
|
||||
/// [`build_iso_pipeline`](crate::mux::resolve::build_iso_pipeline) does for the
|
||||
/// file-backed highway. The map is the title's read plan: it decides which unit
|
||||
/// each LBA is and, for an FMTS forensic segment, which phase is ours. The
|
||||
/// extent walk is rewritten to the read plan so **only our-phase units are read
|
||||
/// off the drive** (the alternate device-group units are never fetched,
|
||||
/// decrypted, or muxed), and the map is installed so each unit decrypts with its
|
||||
/// mapped key. A non-forensic map returns the extents unchanged, so a plain
|
||||
/// single/multi-CPS disc reads exactly as before.
|
||||
pub fn with_key_map(mut self, map: std::sync::Arc<crate::decrypt::AacsKeyMap>) -> Self {
|
||||
self.extents = map.read_plan(&self.extents, self.unit_align.max(1) as u32);
|
||||
self.bytes_total_extents = self
|
||||
.extents
|
||||
.iter()
|
||||
.map(|e| e.sector_count as u64 * 2048)
|
||||
.sum();
|
||||
self.reader.set_key_map(map);
|
||||
self
|
||||
}
|
||||
|
||||
fn is_halted(&self) -> bool {
|
||||
self.halt
|
||||
.as_ref()
|
||||
@@ -1003,14 +1035,12 @@ impl crate::pes::Stream for DiscStream {
|
||||
}
|
||||
|
||||
fn lost_bytes(&self) -> u64 {
|
||||
// Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time
|
||||
// loss — bytes of scrambled AACS units the decorator could not decrypt
|
||||
// and passed through still encrypted (the TS assembler silently drops
|
||||
// them). Both are real missing content the abort gate must see; without
|
||||
// the decrypt term a partial key failure reports lost_bytes=0 and a rip
|
||||
// missing segments passes even under abort_on_lost_secs=0.
|
||||
// Read-error zero-fill loss (counted in fill_extents) — real missing
|
||||
// content the abort gate must see. There is no decrypt-loss term: the
|
||||
// decrypt path passes bad-encoded/undecryptable units through (a broken-TS
|
||||
// unit is the muxer's concern, and a missing key is indistinguishable from
|
||||
// bad authoring here), so only physical read loss is reported.
|
||||
self.lost_bytes
|
||||
.saturating_add(self.decrypt_loss.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1084,7 +1114,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut src: Box<dyn Stream> = Box::new(stream);
|
||||
|
||||
@@ -1119,7 +1152,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_halt(halt.clone());
|
||||
assert!(!stream.is_halted());
|
||||
halt.cancel();
|
||||
@@ -1129,10 +1165,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `with_key_map` on the inline live-drive path applies the same FMTS read plan
|
||||
/// the file-backed highway uses: within a forensic segment only our-phase units
|
||||
/// survive the extent walk, so the alternate device-group units are never read.
|
||||
#[test]
|
||||
fn with_key_map_reads_only_our_phase_units() {
|
||||
use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase};
|
||||
// AACS keys → unit_align = 3, so a unit is 3 sectors and the phase filter
|
||||
// engages. Key contents are irrelevant to the read plan.
|
||||
let aacs = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0u8; 16]), (1, [1u8; 16])],
|
||||
read_data_key: None,
|
||||
format: ContentFormat::BdTs,
|
||||
};
|
||||
// 100 units (300 sectors). A 10-unit Even forensic segment at LBA [30,60):
|
||||
// even units (30,36,42,48,54) are ours; odd (33,39,45,51,57) are dropped.
|
||||
let map = AacsKeyMap::from_ranges_phased(vec![(30, 60, 1, Phase::Even)]);
|
||||
let stream = DiscStream::new(
|
||||
Box::new(ZeroReader { capacity: 300 }),
|
||||
synthetic_title(300),
|
||||
aacs,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_key_map(std::sync::Arc::new(map));
|
||||
let total: u32 = stream.extents.iter().map(|e| e.sector_count).sum();
|
||||
assert_eq!(
|
||||
total,
|
||||
300 - 5 * 3,
|
||||
"exactly the 5 alternate-phase units (15 sectors) are dropped from the read walk"
|
||||
);
|
||||
assert!(
|
||||
stream.extents.len() > 1,
|
||||
"the forensic segment split the single extent into our-phase-only runs"
|
||||
);
|
||||
// The progress denominator tracks the reduced read set.
|
||||
assert_eq!(stream.bytes_total_extents, total as u64 * 2048);
|
||||
}
|
||||
|
||||
/// Recording `SectorSource`: logs every `(lba, count)` request and
|
||||
/// returns `Err` whenever the requested range covers `bad_sector`.
|
||||
/// Successful reads return zeroed sectors (which are NOT
|
||||
/// `ts_sync_destroyed`, so `DecryptingSectorSource` passes them through
|
||||
/// Successful reads return zeroed sectors (which the content-clarity check
|
||||
/// does not flag as scrambled, so `DecryptingSectorSource` passes them through
|
||||
/// even with synthetic AACS keys — no real decrypt is attempted).
|
||||
struct RecordingReader {
|
||||
capacity: u32,
|
||||
@@ -1272,7 +1349,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
// skip_errors=false: if the recovery read did NOT succeed, fill_extents
|
||||
// would return Err — so reaching EOF cleanly proves recovery worked.
|
||||
stream.skip_errors = false;
|
||||
@@ -1385,7 +1465,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
stream.skip_errors = true;
|
||||
|
||||
// Drive fill_extents across batches: the good leading sectors mux fine,
|
||||
@@ -1445,7 +1528,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
stream.skip_errors = true;
|
||||
|
||||
let res = stream.fill_extents();
|
||||
@@ -1489,8 +1575,18 @@ mod tests {
|
||||
let keys = crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0u8; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
|
||||
let mut stream = DiscStream::new(
|
||||
Box::new(reader),
|
||||
title,
|
||||
keys,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
stream.skip_errors = true;
|
||||
assert_eq!(
|
||||
stream.unit_align, ALIGN as u16,
|
||||
@@ -1520,11 +1616,15 @@ mod tests {
|
||||
"read at lba {lba} is not unit-aligned (offset {} % {ALIGN} != 0)",
|
||||
lba - ext_start
|
||||
);
|
||||
// Non-tail reads must be a whole number of units; the only
|
||||
// permitted short read is the final partial unit (here COUNT is a
|
||||
// multiple of ALIGN, so every read should be unit-multiple unless
|
||||
// it shrank below one unit — which is itself a single unit).
|
||||
let _ = count;
|
||||
// Non-tail reads must be a whole number of units; the only permitted
|
||||
// short read is a final partial unit (below one unit). Assert it
|
||||
// rather than documenting it — a mid-stream non-unit-multiple read
|
||||
// would straddle AACS unit boundaries and decrypt under the wrong
|
||||
// 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
|
||||
@@ -1582,7 +1682,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
stream.skip_errors = true;
|
||||
assert_eq!(stream.unit_align, 1, "None keys must leave unit_align=1");
|
||||
|
||||
@@ -1623,7 +1726,10 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.with_halt(Halt::from_arc(arc.clone()));
|
||||
assert!(!stream.is_halted());
|
||||
arc.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -1632,4 +1738,77 @@ mod tests {
|
||||
"with_halt(Halt::from_arc) must observe Arc-side flips"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every read fails CSS-locked (`05/6F/03`) — a scrambled DVD whose title key
|
||||
/// can't be cracked. Drives `resolve_dvd_title_key` to `ScrambledUncracked`.
|
||||
struct LockedReader;
|
||||
impl crate::sector::SectorSource for LockedReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
_count: u16,
|
||||
_buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
Err(crate::error::Error::DiscRead {
|
||||
sector: lba as u64,
|
||||
status: Some(2),
|
||||
sense: Some(crate::scsi::ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x6F,
|
||||
ascq: 0x03,
|
||||
}),
|
||||
})
|
||||
}
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
64
|
||||
}
|
||||
}
|
||||
|
||||
fn mpegps_title(sector_count: u32) -> DiscTitle {
|
||||
let mut t = synthetic_title(sector_count);
|
||||
t.content_format = ContentFormat::MpegPs;
|
||||
t
|
||||
}
|
||||
|
||||
/// PARITY with `build_iso_pipeline_dvd_none_keys_scrambled_hard_fails`: the
|
||||
/// live-drive single-pass constructor must ALSO hard-fail (not build a
|
||||
/// scrambled-passthrough stream) for a `None`-keyed scrambled MPEG-PS DVD —
|
||||
/// the exact 328k-decode-error corruption path, on the single-pass side.
|
||||
#[test]
|
||||
fn disc_stream_new_dvd_none_scrambled_hard_fails() {
|
||||
let res = DiscStream::new(
|
||||
Box::new(LockedReader),
|
||||
mpegps_title(8),
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::MpegPs,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"single-pass DiscStream must hard-fail on a scrambled, keyless CSS DVD"
|
||||
);
|
||||
}
|
||||
|
||||
/// `raw` must bypass the CSS crack at the DiscStream boundary too: the same
|
||||
/// scrambled-uncrackable input that hard-fails above must CONSTRUCT in raw
|
||||
/// mode (ciphertext passthrough), never hard-fail.
|
||||
#[test]
|
||||
fn disc_stream_new_raw_bypasses_css_crack() {
|
||||
let res = DiscStream::new(
|
||||
Box::new(LockedReader),
|
||||
mpegps_title(8),
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::MpegPs,
|
||||
true, // raw
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
res.is_ok(),
|
||||
"raw single-pass must construct without cracking, even on scrambled-uncrackable input"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2128
File diff suppressed because it is too large
Load Diff
@@ -94,6 +94,31 @@ pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete EBML signed-integer element (two's-complement, big-endian,
|
||||
/// minimal width). Used for `ReferenceBlock` (0xFB), whose value is a signed
|
||||
/// tick offset relative to the current block's timestamp.
|
||||
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
// Minimal two's-complement width: shrink while the top byte is pure sign
|
||||
// extension of the next byte's MSB.
|
||||
let be = val.to_be_bytes();
|
||||
let mut start = 0usize;
|
||||
while start < 7 {
|
||||
let sign_ext = if be[start + 1] & 0x80 != 0 {
|
||||
0xFF
|
||||
} else {
|
||||
0x00
|
||||
};
|
||||
if be[start] != sign_ext {
|
||||
break;
|
||||
}
|
||||
start += 1;
|
||||
}
|
||||
let bytes = &be[start..];
|
||||
write_size(w, bytes.len() as u64)?;
|
||||
w.write_all(bytes)
|
||||
}
|
||||
|
||||
/// Write a complete EBML float element (8-byte double).
|
||||
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
@@ -481,6 +506,25 @@ pub const LUMINANCE_MIN: u32 = 0x55DA;
|
||||
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
|
||||
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
|
||||
pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED;
|
||||
/// BlockAddIDValue (RFC 9559) — the value a per-frame `BlockAddID` references
|
||||
/// to select this BlockAdditionMapping. Values ≥ 2 (1 is the default plain
|
||||
/// BlockAdditional). Used by the MVC (`mvcC`) mapping for Blu-ray 3D.
|
||||
pub const BLOCK_ADD_ID_VALUE: u32 = 0x41F0;
|
||||
|
||||
// Block additions carried inside a BlockGroup — per-frame side data. For
|
||||
// Blu-ray 3D (MVC) the dependent (right-eye) view NAL units for an access unit
|
||||
// ride here as a BlockAdditional under the track's `mvcC` mapping (RFC 9559
|
||||
// §5.1.4.1.4; Matroska Codec Specifications §4.1.5).
|
||||
pub const BLOCK_ADDITIONS: u32 = 0x75A1;
|
||||
pub const BLOCK_MORE: u32 = 0xA6;
|
||||
pub const BLOCK_ADDITIONAL: u32 = 0xA5;
|
||||
pub const BLOCK_ADD_ID: u32 = 0xEE;
|
||||
/// ReferenceBlock (RFC 9559 element 0xFB, child of BlockGroup) — signed
|
||||
/// timestamp (in TimestampScale ticks) of a block this one references, relative
|
||||
/// to this block's own timestamp. Its PRESENCE marks the Block as non-keyframe
|
||||
/// (a keyframe Block in a BlockGroup carries none). Written for non-keyframe
|
||||
/// video frames that must live in a BlockGroup to carry an MVC BlockAdditional.
|
||||
pub const REFERENCE_BLOCK: u32 = 0xFB;
|
||||
|
||||
// Audio
|
||||
pub const AUDIO: u32 = 0xE1;
|
||||
@@ -960,6 +1004,31 @@ mod tests {
|
||||
assert_eq!(buf, [0x42, 0x86, 0x81, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_int_minimal_two_complement_width() {
|
||||
// ReferenceBlock (0xFB) signed offsets, minimal two's-complement width.
|
||||
let enc = |v: i64| {
|
||||
let mut b = Vec::new();
|
||||
write_int(&mut b, REFERENCE_BLOCK, v).unwrap();
|
||||
b
|
||||
};
|
||||
assert_eq!(enc(0), [0xFB, 0x81, 0x00], "0 -> 1 byte 0x00");
|
||||
assert_eq!(enc(-1), [0xFB, 0x81, 0xFF], "-1 -> 1 byte 0xFF");
|
||||
assert_eq!(enc(127), [0xFB, 0x81, 0x7F], "127 -> 1 byte");
|
||||
assert_eq!(
|
||||
enc(128),
|
||||
[0xFB, 0x82, 0x00, 0x80],
|
||||
"128 needs 2 bytes (0x80 alone is -128)"
|
||||
);
|
||||
assert_eq!(enc(-128), [0xFB, 0x81, 0x80], "-128 -> 1 byte 0x80");
|
||||
assert_eq!(enc(-129), [0xFB, 0x82, 0xFF, 0x7F], "-129 needs 2 bytes");
|
||||
// i64::MIN is the widest: 8 bytes, size 0x88.
|
||||
let mn = enc(i64::MIN);
|
||||
assert_eq!(mn[0], 0xFB);
|
||||
assert_eq!(mn[1], 0x88);
|
||||
assert_eq!(&mn[2..], &i64::MIN.to_be_bytes());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// write_float — EBML floats here are always 8-byte IEEE-754 doubles,
|
||||
// big-endian (Matroska SamplingFrequency/Duration). size byte = 0x88.
|
||||
|
||||
+4
-16
@@ -157,31 +157,19 @@ impl FviSink {
|
||||
///
|
||||
/// `source_path` / `source_title` record where the index was built from
|
||||
/// (the input URL path + the 0-based title index); they are carried into the
|
||||
/// header's `source` object. The medium defaults to `file` — callers with a
|
||||
/// known medium / playlist / volume use [`FviSink::create_with_source`].
|
||||
/// header's `source` object. The remaining provenance (medium, playlist,
|
||||
/// volume) takes its `SourceInfo` defaults — no caller needs to override them.
|
||||
pub fn create(
|
||||
path: &Path,
|
||||
title: &DiscTitle,
|
||||
source_path: String,
|
||||
source_title: usize,
|
||||
) -> io::Result<Self> {
|
||||
Self::create_with_source(
|
||||
path,
|
||||
title,
|
||||
SourceInfo {
|
||||
let source = SourceInfo {
|
||||
path: source_path,
|
||||
title: source_title,
|
||||
..SourceInfo::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Create the sink with a fully-specified [`SourceInfo`] provenance root.
|
||||
pub fn create_with_source(
|
||||
path: &Path,
|
||||
title: &DiscTitle,
|
||||
source: SourceInfo,
|
||||
) -> io::Result<Self> {
|
||||
};
|
||||
let file = File::create(path)?;
|
||||
|
||||
let video_track = title
|
||||
|
||||
+2
-2
@@ -29,8 +29,8 @@ const HEVC_NAL_TYPE_MASK: u8 = 0x3F;
|
||||
///
|
||||
/// One instance per output stream. Tracks whether parameter sets have
|
||||
/// already been emitted so they're written exactly once at the head of
|
||||
/// the stream, mirroring the convention used by `ffmpeg -c:v copy -f
|
||||
/// hevc`.
|
||||
/// the stream, per the Annex B convention of ITU-T H.265 / ISO/IEC
|
||||
/// 23008-2 (parameter sets precede the coded slices they govern).
|
||||
pub struct HevcMux<W: Write> {
|
||||
writer: W,
|
||||
/// `HEVCDecoderConfigurationRecord` payload (hvcC). Parsed lazily
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Standard MPEG-TS (188-byte packets) muxer — sequential-only.
|
||||
//!
|
||||
//! Distinct from `super::tsmux::TsMuxer` (BD-TS with 192-byte packets
|
||||
//! and the 4-byte TP_extra_header). This muxer emits the IETF / ISO/IEC
|
||||
//! 13818-1 wire format that ffmpeg, VLC, and `m2tsindex` consume
|
||||
//! out of the box. Use it for plain `.ts` / `.m2ts` files over a
|
||||
//! and the 4-byte TP_extra_header). This muxer emits the ITU-T H.222.0 /
|
||||
//! ISO/IEC 13818-1 wire format that any conformant transport-stream
|
||||
//! demuxer or player consumes out of the box. Use it for plain
|
||||
//! `.ts` / `.m2ts` files over a
|
||||
//! [`SequentialSink`](crate::io::sink::SequentialSink), and for
|
||||
//! MPEG-TS-over-UDP via [`UdpSocketSink`](crate::io::sink::UdpSocketSink).
|
||||
//!
|
||||
@@ -44,8 +45,8 @@
|
||||
//! attached to the video PID's adaptation field every
|
||||
//! `PCR_INTERVAL_PACKETS` packets.
|
||||
//! - No language / descriptor tags, no SCTE-35 markers, no per-PID
|
||||
//! PMT version bumps, no SDT/EIT. Sufficient for "ffmpeg can play
|
||||
//! this back", not for full broadcast deployment.
|
||||
//! PMT version bumps, no SDT/EIT. Sufficient for a conformant
|
||||
//! demuxer to play this back, not for full broadcast deployment.
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! `chapters://` and `json://` metadata sinks.
|
||||
//!
|
||||
//! Both ignore the PES stream entirely: everything they emit is already known
|
||||
//! from the [`DiscTitle`] at construction, so each writes its whole file at
|
||||
//! `create()` and treats every `write()` frame as a no-op. They are wired
|
||||
//! through [`super::resolve::output`] like the other write-only sinks; the
|
||||
//! ISO/disc scan that builds the title is all they need.
|
||||
|
||||
use crate::disc::{Chapter, DiscTitle, Stream as DiscStream};
|
||||
use crate::pes::{PesFrame, Stream};
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
// ── chapters:// ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// `HH:MM:SS.mmm` for a WebVTT cue timestamp.
|
||||
fn vtt_time(secs: f64) -> String {
|
||||
let total_ms = (secs.max(0.0) * 1000.0).round() as u64;
|
||||
let ms = total_ms % 1000;
|
||||
let total_s = total_ms / 1000;
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}.{:03}",
|
||||
total_s / 3600,
|
||||
(total_s / 60) % 60,
|
||||
total_s % 60,
|
||||
ms
|
||||
)
|
||||
}
|
||||
|
||||
/// WebVTT chapter cues (`.vtt`). Each chapter spans until the next one starts
|
||||
/// (the last runs to its own start — length is unknown without the title tail).
|
||||
fn chapters_vtt(chapters: &[Chapter]) -> String {
|
||||
let mut s = String::from("WEBVTT\n\n");
|
||||
for (i, c) in chapters.iter().enumerate() {
|
||||
let start = c.time_secs.max(0.0);
|
||||
// Each cue runs until the next chapter. WebVTT drops a cue whose end is not
|
||||
// strictly after its start, so the last chapter (and any degenerate
|
||||
// equal-timestamp pair) gets a 1 s minimum duration rather than being lost.
|
||||
let end = chapters
|
||||
.get(i + 1)
|
||||
.map(|n| n.time_secs.max(0.0))
|
||||
.filter(|&e| e > start)
|
||||
.unwrap_or(start + 1.0);
|
||||
// No localized prose in the library (see Chapter::name): emit the bare
|
||||
// name, or a plain ordinal when unnamed — the app prepends any "Chapter "
|
||||
// prefix in the user's language. Matches chapters_xml / chapters_ogm.
|
||||
let name = if c.name.is_empty() {
|
||||
(i + 1).to_string()
|
||||
} else {
|
||||
c.name.clone()
|
||||
};
|
||||
s.push_str(&format!(
|
||||
"{}\n{} --> {}\n{}\n\n",
|
||||
i + 1,
|
||||
vtt_time(start),
|
||||
vtt_time(end),
|
||||
name
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Chapter content in the format the output extension selects: `.txt`/`.ogm`
|
||||
/// (OGM simple), `.vtt` (WebVTT), else Matroska XML (`.xml` / default).
|
||||
pub(crate) fn chapters_content(chapters: &[Chapter], ext: Option<&str>) -> String {
|
||||
match ext.map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("txt") | Some("ogm") => super::demux_sink::chapters_ogm(chapters),
|
||||
Some("vtt") => chapters_vtt(chapters),
|
||||
_ => super::demux_sink::chapters_xml(chapters),
|
||||
}
|
||||
}
|
||||
|
||||
/// `chapters://` sink: writes the title's chapter markers at construction; the
|
||||
/// PES stream is ignored.
|
||||
pub struct ChaptersSink {
|
||||
title: DiscTitle,
|
||||
}
|
||||
|
||||
impl ChaptersSink {
|
||||
pub fn create(path: &Path, title: &DiscTitle) -> io::Result<Self> {
|
||||
let ext = path.extension().and_then(|e| e.to_str());
|
||||
let content = chapters_content(&title.chapters, ext);
|
||||
File::create(path)?.write_all(content.as_bytes())?;
|
||||
Ok(Self {
|
||||
title: title.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for ChaptersSink {
|
||||
fn read(&mut self) -> io::Result<Option<PesFrame>> {
|
||||
Err(crate::error::Error::StreamWriteOnly.into())
|
||||
}
|
||||
fn write(&mut self, _frame: &PesFrame) -> io::Result<()> {
|
||||
Ok(()) // whole file written at create()
|
||||
}
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.title
|
||||
}
|
||||
}
|
||||
|
||||
// ── json:// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Serialization id for an audio stream's editorial purpose.
|
||||
fn purpose_id(p: crate::labels::LabelPurpose) -> &'static str {
|
||||
use crate::labels::LabelPurpose::*;
|
||||
match p {
|
||||
Normal => "normal",
|
||||
Commentary => "commentary",
|
||||
Descriptive => "descriptive",
|
||||
Score => "score",
|
||||
Ime => "ime",
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialization id for a subtitle stream's qualifier.
|
||||
fn qualifier_id(q: crate::labels::LabelQualifier) -> &'static str {
|
||||
use crate::labels::LabelQualifier::*;
|
||||
match q {
|
||||
None => "none",
|
||||
Sdh => "sdh",
|
||||
DescriptiveService => "descriptive_service",
|
||||
Forced => "forced",
|
||||
}
|
||||
}
|
||||
|
||||
/// One stream as JSON — every field the scan resolved, nothing dropped. This is
|
||||
/// the complete per-stream model (`disc::Stream`), not a summary: consumers get
|
||||
/// resolution/HDR/aspect for video, channels/sample-rate/purpose for audio, and
|
||||
/// the qualifier for subtitles, all in machine-readable form.
|
||||
fn stream_json(s: &DiscStream) -> serde_json::Value {
|
||||
use super::demux_sink::codec_label;
|
||||
use serde_json::json;
|
||||
match s {
|
||||
DiscStream::Video(v) => {
|
||||
let (w, h) = v.resolution.pixels();
|
||||
let (fps_num, fps_den) = v.frame_rate.as_fraction();
|
||||
let mut o = json!({
|
||||
"kind": "video",
|
||||
"codec": codec_label(v.codec),
|
||||
"pid": v.pid,
|
||||
"resolution": v.resolution.to_string(),
|
||||
"width": w,
|
||||
"height": h,
|
||||
"interlaced": v.resolution.is_interlaced(),
|
||||
"frame_rate": v.frame_rate.to_string(),
|
||||
"frame_rate_num": fps_num,
|
||||
"frame_rate_den": fps_den,
|
||||
"hdr": v.hdr.id(),
|
||||
"color_space": v.color_space.id(),
|
||||
"secondary": v.secondary,
|
||||
"mvc_dependent": v.is_mvc_dependent(),
|
||||
});
|
||||
if let Some((num, den)) = v.display_aspect {
|
||||
o["display_aspect"] = json!(format!("{num}:{den}"));
|
||||
}
|
||||
if let Some(c) = v.measured_cicp {
|
||||
o["measured_cicp"] = json!({
|
||||
"matrix": c.matrix,
|
||||
"transfer": c.transfer,
|
||||
"primaries": c.primaries,
|
||||
"range": c.range,
|
||||
});
|
||||
}
|
||||
if !v.label.is_empty() {
|
||||
o["label"] = json!(v.label);
|
||||
}
|
||||
o
|
||||
}
|
||||
DiscStream::Audio(a) => {
|
||||
let mut o = json!({
|
||||
"kind": "audio",
|
||||
"codec": codec_label(a.codec),
|
||||
"pid": a.pid,
|
||||
"language": a.language,
|
||||
"channels": a.channels.to_string(),
|
||||
"channel_count": a.channels.count(),
|
||||
"sample_rate": a.sample_rate.to_string(),
|
||||
"sample_rate_hz": a.sample_rate.hz(),
|
||||
"secondary": a.secondary,
|
||||
"purpose": purpose_id(a.purpose),
|
||||
});
|
||||
if !a.label.is_empty() {
|
||||
o["label"] = json!(a.label);
|
||||
}
|
||||
o
|
||||
}
|
||||
DiscStream::Subtitle(t) => json!({
|
||||
"kind": "subtitle",
|
||||
"codec": codec_label(t.codec),
|
||||
"pid": t.pid,
|
||||
"language": t.language,
|
||||
"forced": t.forced,
|
||||
"qualifier": qualifier_id(t.qualifier),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `json://` document for one title: identity, duration/size, its clips,
|
||||
/// its complete stream models, and its chapter points. A stable, machine-
|
||||
/// readable view of one title — the same information the scan resolved, no loss.
|
||||
pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value {
|
||||
use serde_json::json;
|
||||
let streams: Vec<_> = title.streams.iter().map(stream_json).collect();
|
||||
let clips: Vec<_> = title
|
||||
.clips
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"clip_id": c.clip_id,
|
||||
"duration_secs": c.duration_secs,
|
||||
"source_packets": c.source_packets,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let chapters: Vec<_> = title
|
||||
.chapters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| json!({ "n": i + 1, "start_secs": c.time_secs, "name": c.name }))
|
||||
.collect();
|
||||
json!({
|
||||
"playlist": title.playlist,
|
||||
"playlist_id": title.playlist_id,
|
||||
"duration_secs": title.duration_secs,
|
||||
"size_bytes": title.size_bytes,
|
||||
"format": format!("{:?}", title.content_format),
|
||||
"clips": clips,
|
||||
"streams": streams,
|
||||
"chapters": chapters,
|
||||
})
|
||||
}
|
||||
|
||||
/// `json://` sink: writes the title's structured metadata at construction; the
|
||||
/// PES stream is ignored.
|
||||
pub struct JsonSink {
|
||||
title: DiscTitle,
|
||||
}
|
||||
|
||||
impl JsonSink {
|
||||
pub fn create(path: &Path, title: &DiscTitle) -> io::Result<Self> {
|
||||
// Serializing our own `Value` is infallible in practice (serde_json maps
|
||||
// any non-finite float to `null` at Value construction, so `title_json`
|
||||
// never holds an unencodable value); still, propagate rather than silently
|
||||
// writing "{}" if that ever changes — an empty metadata file must not
|
||||
// masquerade as a successful json:// export.
|
||||
let doc = serde_json::to_string_pretty(&title_json(title))
|
||||
.map_err(|_| crate::error::Error::MkvInvalid)?;
|
||||
let mut f = File::create(path)?;
|
||||
f.write_all(doc.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
Ok(Self {
|
||||
title: title.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for JsonSink {
|
||||
fn read(&mut self) -> io::Result<Option<PesFrame>> {
|
||||
Err(crate::error::Error::StreamWriteOnly.into())
|
||||
}
|
||||
fn write(&mut self, _frame: &PesFrame) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.title
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::Chapter;
|
||||
|
||||
fn chaps() -> Vec<Chapter> {
|
||||
vec![
|
||||
Chapter {
|
||||
time_secs: 0.0,
|
||||
name: "1".into(),
|
||||
},
|
||||
Chapter {
|
||||
time_secs: 62.5,
|
||||
name: "2".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chapters_format_selected_by_extension() {
|
||||
let xml = chapters_content(&chaps(), Some("xml"));
|
||||
assert!(xml.contains("<Chapters>"), "xml chosen for .xml");
|
||||
let ogm = chapters_content(&chaps(), Some("txt"));
|
||||
assert!(ogm.contains("CHAPTER01="), "ogm chosen for .txt");
|
||||
let vtt = chapters_content(&chaps(), Some("vtt"));
|
||||
assert!(
|
||||
vtt.starts_with("WEBVTT") && vtt.contains("00:01:02.500"),
|
||||
"vtt chosen for .vtt, with cue timing"
|
||||
);
|
||||
// Unknown / missing extension defaults to XML.
|
||||
assert!(chapters_content(&chaps(), None).contains("<Chapters>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_json_carries_streams_and_chapters() {
|
||||
use crate::disc::{AudioChannels, AudioStream, Codec, DiscTitle};
|
||||
use crate::disc::{LabelPurpose, SampleRate, Stream as DiscStream};
|
||||
let mut t = DiscTitle::empty();
|
||||
t.playlist = "MAIN".into();
|
||||
t.chapters = chaps();
|
||||
t.streams = vec![DiscStream::Audio(AudioStream {
|
||||
pid: 0x1100,
|
||||
codec: Codec::TrueHd,
|
||||
channels: AudioChannels::Stereo,
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})];
|
||||
let v = title_json(&t);
|
||||
assert_eq!(v["playlist"], "MAIN");
|
||||
let a = &v["streams"][0];
|
||||
assert_eq!(a["kind"], "audio");
|
||||
assert_eq!(a["codec"], "TrueHD");
|
||||
assert_eq!(a["language"], "eng");
|
||||
// Completeness: audio carries channels + sample rate + purpose, not just codec.
|
||||
assert_eq!(a["channels"], "stereo");
|
||||
assert_eq!(a["channel_count"], 2);
|
||||
assert_eq!(a["sample_rate"], "48kHz");
|
||||
assert_eq!(a["sample_rate_hz"], 48000.0);
|
||||
assert_eq!(a["purpose"], "normal");
|
||||
assert_eq!(v["chapters"][1]["n"], 2);
|
||||
assert_eq!(v["chapters"][1]["start_secs"], 62.5);
|
||||
assert_eq!(v["chapters"][1]["name"], "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_json_carries_resolution_and_hdr() {
|
||||
use crate::disc::Codec;
|
||||
use crate::disc::{
|
||||
ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStream,
|
||||
VideoStream,
|
||||
};
|
||||
let mut t = DiscTitle::empty();
|
||||
t.streams = vec![DiscStream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt2020,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
})];
|
||||
let vid = &title_json(&t)["streams"][0];
|
||||
assert_eq!(vid["kind"], "video");
|
||||
assert_eq!(vid["resolution"], "2160p");
|
||||
assert_eq!(vid["width"], 3840);
|
||||
assert_eq!(vid["height"], 2160);
|
||||
assert_eq!(vid["frame_rate"], "23.976");
|
||||
assert_eq!(vid["frame_rate_num"], 24000);
|
||||
assert_eq!(vid["hdr"], "hdr10");
|
||||
assert_eq!(vid["color_space"], "bt2020");
|
||||
}
|
||||
}
|
||||
+840
-66
File diff suppressed because it is too large
Load Diff
+560
-62
@@ -120,36 +120,253 @@ struct PendingMux {
|
||||
video_track: Option<usize>,
|
||||
/// `--log-level 3` opening-capture side-file path (if any).
|
||||
opening_capture_path: Option<std::path::PathBuf>,
|
||||
/// Frames received before activation, replayed in order once built.
|
||||
buffered: Vec<crate::pes::PesFrame>,
|
||||
/// Frames received before activation, replayed in order once built. Each
|
||||
/// carries an optional MVC dependent-view `BlockAdditional` (present only
|
||||
/// for a 3D base-view frame that was already paired before activation).
|
||||
buffered: Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>,
|
||||
}
|
||||
|
||||
/// Matroska container stream.
|
||||
pub struct MkvStream {
|
||||
disc_title: DiscTitle,
|
||||
mode: Mode,
|
||||
/// Blu-ray 3D (MVC) merge state — present iff the title carries an MVC
|
||||
/// dependent (right-eye) view. Folds the dependent stream's frames into the
|
||||
/// base video track as per-frame `BlockAdditional`, paired by PTS, so the
|
||||
/// output is a single MVC track instead of two independent H.264 tracks.
|
||||
mvc: Option<MvcMerge>,
|
||||
}
|
||||
|
||||
/// Largest number of base frames held awaiting their PTS-matching dependent AU
|
||||
/// before the oldest is flushed unpaired (a plain Block). The SSIF interleaves
|
||||
/// base and dependent access units per unit, so a base's dependent normally
|
||||
/// arrives within one or two frames; this window only bounds memory/latency for
|
||||
/// a stream where the pairing drifts.
|
||||
const MVC_PAIR_WINDOW: usize = 32;
|
||||
|
||||
/// A base-view frame (track already remapped to the muxer's base track index)
|
||||
/// awaiting — or already carrying — its dependent-view `BlockAdditional`.
|
||||
struct PendingBase {
|
||||
frame: crate::pes::PesFrame,
|
||||
additional: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// State for folding the MVC dependent (right-eye) view into the base track.
|
||||
struct MvcMerge {
|
||||
/// `title.streams` index of the base (left-eye) video stream.
|
||||
base_stream_idx: usize,
|
||||
/// `title.streams` index of the dependent (right-eye) video stream.
|
||||
dep_stream_idx: usize,
|
||||
/// Muxer track index of the base view — where the dependent AU is attached
|
||||
/// as a `BlockAdditional` and where `mvc_params` (the `mvcC` mapping) lives.
|
||||
base_track_idx: usize,
|
||||
/// `title.streams` index → muxer track index. The dependent maps to `None`
|
||||
/// (it becomes a BlockAdditional, not a track); every other stream shifts
|
||||
/// down by one if it followed the dependent in stream order.
|
||||
stream_to_track: Vec<Option<usize>>,
|
||||
/// Base frames (decode order) awaiting their dependent or a window flush.
|
||||
pending_base: std::collections::VecDeque<PendingBase>,
|
||||
/// Dependent AU data keyed by PTS, waiting for the matching base.
|
||||
dep_by_pts: std::collections::HashMap<i64, Vec<u8>>,
|
||||
/// `(subset_sps, pps)` from the first dependent AU — builds the `mvcC`
|
||||
/// MVCDecoderConfigurationRecord for the base track's BlockAdditionMapping.
|
||||
captured_params: Option<(Vec<u8>, Vec<u8>)>,
|
||||
/// Count of dependent AUs dropped with no matching base (diagnostic).
|
||||
orphan_deps: u64,
|
||||
}
|
||||
|
||||
impl MvcMerge {
|
||||
/// Ingest one incoming frame; returns `(frame, additional)` pairs ready to
|
||||
/// hand to the muxer, in emit order. Base frames buffer briefly to pair with
|
||||
/// their dependent by PTS; the dependent stream produces no frames of its own
|
||||
/// (it becomes `BlockAdditional`); all other streams pass straight through
|
||||
/// with their track index remapped.
|
||||
fn ingest(
|
||||
&mut self,
|
||||
frame: &crate::pes::PesFrame,
|
||||
) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
|
||||
let mut out = Vec::new();
|
||||
if frame.track == self.dep_stream_idx {
|
||||
if self.captured_params.is_none() {
|
||||
self.captured_params = extract_mvc_params(&frame.data);
|
||||
}
|
||||
// Attach to a waiting base of the same PTS, else stash by PTS.
|
||||
if let Some(pb) = self
|
||||
.pending_base
|
||||
.iter_mut()
|
||||
.find(|pb| pb.frame.pts == frame.pts && pb.additional.is_none())
|
||||
{
|
||||
pb.additional = Some(frame.data.clone());
|
||||
} else {
|
||||
// Bound the orphan map BEFORE inserting: if dependents pile up
|
||||
// unpaired (pairing badly drifted), drop the drifted buffer so it
|
||||
// stays bounded — but keep THIS just-arrived dependent, whose base
|
||||
// frame commonly arrives next. Clearing after the insert would
|
||||
// discard it and overcount orphans by one.
|
||||
if self.dep_by_pts.len() >= MVC_PAIR_WINDOW * 4 {
|
||||
self.orphan_deps += self.dep_by_pts.len() as u64;
|
||||
self.dep_by_pts.clear();
|
||||
}
|
||||
// A duplicate-PTS dependent (e.g. a stale repeat after a stream
|
||||
// discontinuity) displaces the prior one — count it as an orphan
|
||||
// rather than losing it silently.
|
||||
if self
|
||||
.dep_by_pts
|
||||
.insert(frame.pts, frame.data.clone())
|
||||
.is_some()
|
||||
{
|
||||
self.orphan_deps += 1;
|
||||
}
|
||||
}
|
||||
} else if frame.track == self.base_stream_idx {
|
||||
let additional = self.dep_by_pts.remove(&frame.pts);
|
||||
let mut remapped = frame.clone();
|
||||
remapped.track = self.base_track_idx;
|
||||
self.pending_base.push_back(PendingBase {
|
||||
frame: remapped,
|
||||
additional,
|
||||
});
|
||||
} else {
|
||||
// Audio / subtitle / other video: remap the track index and forward.
|
||||
let mut remapped = frame.clone();
|
||||
if let Some(Some(t)) = self.stream_to_track.get(frame.track) {
|
||||
remapped.track = *t;
|
||||
out.push((remapped, None));
|
||||
}
|
||||
}
|
||||
self.drain_ready(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit base frames from the FIFO front once each has its dependent attached,
|
||||
/// or flush the oldest unpaired base as a plain Block when the window is full.
|
||||
fn drain_ready(&mut self, out: &mut Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>) {
|
||||
loop {
|
||||
let front_ready = self
|
||||
.pending_base
|
||||
.front()
|
||||
.map(|pb| pb.additional.is_some())
|
||||
.unwrap_or(false);
|
||||
if front_ready || self.pending_base.len() > MVC_PAIR_WINDOW {
|
||||
if let Some(pb) = self.pending_base.pop_front() {
|
||||
out.push((pb.frame, pb.additional));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush every remaining buffered base frame (unpaired → plain Block) at EOF.
|
||||
fn flush(&mut self) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
|
||||
let mut out = Vec::new();
|
||||
for pb in self.pending_base.drain(..) {
|
||||
out.push((pb.frame, pb.additional));
|
||||
}
|
||||
self.orphan_deps += self.dep_by_pts.len() as u64;
|
||||
self.dep_by_pts.clear();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a frame to the muxer, attaching the MVC dependent view as a
|
||||
/// `BlockAdditional` when `additional` is `Some` (a 3D base frame), else a
|
||||
/// plain block.
|
||||
fn emit_to_muxer(
|
||||
m: &mut MkvMuxer<Box<dyn WriteSeek + Send>>,
|
||||
frame: &crate::pes::PesFrame,
|
||||
additional: Option<&[u8]>,
|
||||
) -> io::Result<()> {
|
||||
m.write_frame(
|
||||
frame.track,
|
||||
frame.pts,
|
||||
frame.keyframe,
|
||||
&frame.data,
|
||||
frame.duration_ns,
|
||||
additional,
|
||||
)
|
||||
}
|
||||
|
||||
/// Scan a length-prefixed (4-byte big-endian) H.264 NAL stream for the first
|
||||
/// subset SPS (NAL type 15) and first PPS (NAL type 8) — the two parameter sets
|
||||
/// that populate the `mvcC` MVCDecoderConfigurationRecord. Returns
|
||||
/// `Some((subset_sps, pps))` only when BOTH are found; `None` otherwise (the
|
||||
/// serializer then emits no mvcC mapping and logs it).
|
||||
fn extract_mvc_params(data: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
let mut subset_sps: Option<Vec<u8>> = None;
|
||||
let mut pps: Option<Vec<u8>> = None;
|
||||
let mut i = 0usize;
|
||||
while i + 4 <= data.len() {
|
||||
let len = u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
|
||||
i += 4;
|
||||
// A zero-length NAL (a stray length prefix) is skipped, not fatal — the
|
||||
// subset SPS / PPS may still follow. A length that runs past the buffer
|
||||
// end IS unrecoverable (the NAL can't be read), so stop there.
|
||||
if len == 0 {
|
||||
continue;
|
||||
}
|
||||
if i + len > data.len() {
|
||||
break;
|
||||
}
|
||||
let nal = &data[i..i + len];
|
||||
i += len;
|
||||
match nal[0] & 0x1F {
|
||||
15 if subset_sps.is_none() => subset_sps = Some(nal.to_vec()),
|
||||
8 if pps.is_none() => pps = Some(nal.to_vec()),
|
||||
_ => {}
|
||||
}
|
||||
if subset_sps.is_some() && pps.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some((subset_sps?, pps?))
|
||||
}
|
||||
|
||||
impl MkvStream {
|
||||
/// Create for writing PES frames → MKV container.
|
||||
/// Codec privates come from title.codec_privates (populated by input stream).
|
||||
pub fn create(writer: Box<dyn WriteSeek + Send>, title: &DiscTitle) -> io::Result<Self> {
|
||||
Self::create_at(writer, title, None)
|
||||
}
|
||||
|
||||
/// As [`create`](Self::create), but `output_path` (when known) enables the
|
||||
/// `--log-level 3` opening-frame capture to `<output>.opening.bin`. A `None`
|
||||
/// path (e.g. an in-memory / stdio sink) silently skips the side-file
|
||||
/// capture; the per-track TrackEntry dump still fires.
|
||||
pub fn create_at(
|
||||
/// Create for writing PES frames → MKV container. Codec privates come from
|
||||
/// `title.codec_privates` (populated by the input stream).
|
||||
///
|
||||
/// `output_path` (when known) enables the `--log-level 3` opening-frame
|
||||
/// capture to `<output>.opening.bin`; `None` (e.g. an in-memory / stdio sink)
|
||||
/// silently skips the side-file capture — the per-track TrackEntry dump still
|
||||
/// fires either way.
|
||||
pub fn create(
|
||||
writer: Box<dyn WriteSeek + Send>,
|
||||
title: &DiscTitle,
|
||||
output_path: Option<&std::path::Path>,
|
||||
) -> io::Result<Self> {
|
||||
// Blu-ray 3D (MVC): a dependent (right-eye) view stream is NOT emitted as
|
||||
// its own track — it is folded into the base track as per-frame
|
||||
// BlockAdditional. Detect it so we skip building a track for it and set up
|
||||
// the merge. `base_stream_idx` is the first video stream.
|
||||
let dep_stream_idx = title
|
||||
.streams
|
||||
.iter()
|
||||
.position(|s| matches!(s, crate::disc::Stream::Video(v) if v.is_mvc_dependent()));
|
||||
// The base is the first NON-dependent video. Excluding the dependent here
|
||||
// means a (malformed / hand-built) title whose only video IS the dependent
|
||||
// yields `base_stream_idx == None` → no merge (the dependent is muxed as an
|
||||
// ordinary track) instead of `base == dep` and a panic on the skipped slot.
|
||||
let base_stream_idx = title
|
||||
.streams
|
||||
.iter()
|
||||
.position(|s| matches!(s, crate::disc::Stream::Video(v) if !v.is_mvc_dependent()));
|
||||
// The merge is only active when BOTH a dependent and a distinct base exist;
|
||||
// only then is the dependent's track skipped/folded.
|
||||
let mvc_active = dep_stream_idx.is_some() && base_stream_idx.is_some();
|
||||
let skip_stream_idx = if mvc_active { dep_stream_idx } else { None };
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
let mut has_default_video = false;
|
||||
let mut has_default_audio = false;
|
||||
// `title.streams` index → muxer track index (`None` = the dependent view,
|
||||
// which has no track). Streams after the dependent shift down by one.
|
||||
let mut stream_to_track: Vec<Option<usize>> = Vec::with_capacity(title.streams.len());
|
||||
for (idx, s) in title.streams.iter().enumerate() {
|
||||
if Some(idx) == skip_stream_idx {
|
||||
stream_to_track.push(None);
|
||||
continue;
|
||||
}
|
||||
let mut track = match s {
|
||||
crate::disc::Stream::Video(v) => MkvTrack::video(v),
|
||||
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
|
||||
@@ -166,9 +383,31 @@ impl MkvStream {
|
||||
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
|
||||
track.codec_private = Some(cp.clone());
|
||||
}
|
||||
stream_to_track.push(Some(tracks.len()));
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
// Assemble the MVC merge only when active — i.e. a dependent AND a
|
||||
// distinct base video both exist (established above). `base_stream_idx`
|
||||
// then always has a built track, so its remap is `Some` (no panic path).
|
||||
let mvc = match (mvc_active, dep_stream_idx, base_stream_idx) {
|
||||
(true, Some(dep_stream_idx), Some(base_stream_idx)) => stream_to_track
|
||||
.get(base_stream_idx)
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(|base_track_idx| MvcMerge {
|
||||
base_stream_idx,
|
||||
dep_stream_idx,
|
||||
base_track_idx,
|
||||
stream_to_track,
|
||||
pending_base: std::collections::VecDeque::new(),
|
||||
dep_by_pts: std::collections::HashMap::new(),
|
||||
captured_params: None,
|
||||
orphan_deps: 0,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Defer muxer construction (and the TrackEntry dump) until the first
|
||||
// coded picture arrives, so the primary video track's FieldOrder is set
|
||||
// from the parser's MEASURED value before the header is written — never
|
||||
@@ -177,6 +416,7 @@ impl MkvStream {
|
||||
|
||||
Ok(Self {
|
||||
disc_title: title.clone(),
|
||||
mvc,
|
||||
mode: Mode::Write(WriteMode::Pending(Box::new(PendingMux {
|
||||
writer,
|
||||
tracks,
|
||||
@@ -209,6 +449,24 @@ impl MkvStream {
|
||||
if let Some(vt) = pending.video_track {
|
||||
apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen);
|
||||
}
|
||||
// Blu-ray 3D: set the base video track's `mvc_params` from the dependent
|
||||
// view's captured subset-SPS/PPS BEFORE the header is written, so the
|
||||
// TrackEntry carries the `mvcC` BlockAdditionMapping. Captured from the
|
||||
// first dependent AU (which arrives right after the first base AU in the
|
||||
// SSIF), so it is available by the time the first base frame activates.
|
||||
if let Some(mvc) = &self.mvc {
|
||||
if let Some(params) = &mvc.captured_params {
|
||||
if let Some(t) = pending.tracks.get_mut(mvc.base_track_idx) {
|
||||
t.mvc_params = Some(params.clone());
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"MVC: no dependent-view subset-SPS/PPS captured before activation; \
|
||||
the base track will carry no mvcC mapping (3D not signalled)."
|
||||
);
|
||||
}
|
||||
}
|
||||
// --log-level 3: dump the FINAL TrackEntry metadata (field order set).
|
||||
for (i, track) in pending.tracks.iter().enumerate() {
|
||||
crate::diag::dump_mkv_track((i + 1) as u64, track);
|
||||
@@ -223,18 +481,72 @@ impl MkvStream {
|
||||
if let Some(path) = &pending.opening_capture_path {
|
||||
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
|
||||
}
|
||||
for f in pending.buffered.drain(..) {
|
||||
muxer.write_frame(f.track, f.pts, f.keyframe, &f.data, f.duration_ns)?;
|
||||
for (f, additional) in pending.buffered.drain(..) {
|
||||
muxer.write_frame(
|
||||
f.track,
|
||||
f.pts,
|
||||
f.keyframe,
|
||||
&f.data,
|
||||
f.duration_ns,
|
||||
additional.as_deref(),
|
||||
)?;
|
||||
}
|
||||
self.mode = Mode::Write(WriteMode::Active(Box::new(muxer)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit one frame (track index already muxer-relative) with an optional MVC
|
||||
/// dependent-view `BlockAdditional`, honouring the deferred-activation
|
||||
/// machinery: the first video frame triggers muxer construction (its coding
|
||||
/// sets FieldOrder); earlier frames buffer. `additional` is `None` for every
|
||||
/// non-3D frame and for the 3D base frames that had no paired dependent.
|
||||
fn emit(&mut self, frame: &crate::pes::PesFrame, additional: Option<&[u8]>) -> io::Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
|
||||
Mode::Write(WriteMode::Active(m)) => {
|
||||
return emit_to_muxer(m, frame, additional);
|
||||
}
|
||||
Mode::Write(WriteMode::Building) => return Ok(()),
|
||||
Mode::Write(WriteMode::Pending(_)) => {}
|
||||
}
|
||||
// Pending: the first video frame (or the safety cap) triggers muxer
|
||||
// construction; that frame's coding sets the field order. Other frames
|
||||
// buffer until then.
|
||||
let (activate_now, use_coding) = match &self.mode {
|
||||
Mode::Write(WriteMode::Pending(p)) => {
|
||||
let is_video = match p.video_track {
|
||||
Some(vt) => frame.track == vt,
|
||||
// No video track: nothing to wait for — build on frame one.
|
||||
None => true,
|
||||
};
|
||||
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
|
||||
}
|
||||
_ => unreachable!("guarded above"),
|
||||
};
|
||||
if activate_now {
|
||||
// Pass the trigger frame's coding only when it IS the video frame; a
|
||||
// cap-triggered build never saw the video frame, so nothing measured
|
||||
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
|
||||
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
|
||||
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
|
||||
return emit_to_muxer(m, frame, additional);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
|
||||
p.buffered
|
||||
.push((frame.clone(), additional.map(|a| a.to_vec())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an MKV file for reading → PES frames.
|
||||
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
|
||||
let (disc_title, codec_privates, ts_scale_ns) = parse_mkv_header(&mut reader)?;
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
mvc: None,
|
||||
mode: Mode::Read(ReadState {
|
||||
reader: Box::new(reader),
|
||||
cluster_ts_ticks: 0,
|
||||
@@ -425,59 +737,40 @@ impl crate::pes::Stream for MkvStream {
|
||||
}
|
||||
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
// Fast paths.
|
||||
match &mut self.mode {
|
||||
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
|
||||
Mode::Write(WriteMode::Active(m)) => {
|
||||
return m.write_frame(
|
||||
frame.track,
|
||||
frame.pts,
|
||||
frame.keyframe,
|
||||
&frame.data,
|
||||
frame.duration_ns,
|
||||
);
|
||||
if matches!(self.mode, Mode::Read(_)) {
|
||||
return Err(crate::error::Error::StreamReadOnly.into());
|
||||
}
|
||||
Mode::Write(WriteMode::Building) => return Ok(()),
|
||||
Mode::Write(WriteMode::Pending(_)) => {}
|
||||
// Non-3D fast path: emit the frame directly, no clone, no buffering.
|
||||
if self.mvc.is_none() {
|
||||
return self.emit(frame, None);
|
||||
}
|
||||
// Pending: the first video frame (or the safety cap) triggers muxer
|
||||
// construction; that frame's coding sets the field order. Other frames
|
||||
// buffer until then.
|
||||
let (activate_now, use_coding) = match &self.mode {
|
||||
Mode::Write(WriteMode::Pending(p)) => {
|
||||
let is_video = match p.video_track {
|
||||
Some(vt) => frame.track == vt,
|
||||
// No video track: nothing to wait for — build on frame one.
|
||||
None => true,
|
||||
};
|
||||
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
|
||||
}
|
||||
_ => unreachable!("guarded above"),
|
||||
};
|
||||
if activate_now {
|
||||
// Pass the trigger frame's coding only when it IS the video frame; a
|
||||
// cap-triggered build never saw the video frame, so nothing measured
|
||||
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
|
||||
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
|
||||
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
|
||||
return m.write_frame(
|
||||
frame.track,
|
||||
frame.pts,
|
||||
frame.keyframe,
|
||||
&frame.data,
|
||||
frame.duration_ns,
|
||||
);
|
||||
// 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(())
|
||||
} else {
|
||||
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
|
||||
p.buffered.push(frame.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
// Blu-ray 3D: flush any base frames still awaiting a dependent (emitted
|
||||
// unpaired as plain Blocks) before finalizing.
|
||||
if let Some(mvc) = self.mvc.as_mut() {
|
||||
let tail = mvc.flush();
|
||||
let orphans = mvc.orphan_deps;
|
||||
if orphans > 0 {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"MVC: {orphans} dependent-view access units had no matching base frame (dropped)"
|
||||
);
|
||||
}
|
||||
for (f, additional) in tail {
|
||||
self.emit(&f, additional.as_deref())?;
|
||||
}
|
||||
}
|
||||
// A title that produced no frames (or only buffered ones) is still
|
||||
// finalized into a valid MKV: activate now with no measured coding.
|
||||
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
|
||||
@@ -720,7 +1013,11 @@ fn parse_track(
|
||||
arem = arem.saturating_sub(ahlen as u64 + as_);
|
||||
match aid {
|
||||
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
|
||||
ebml::CHANNELS => ch = read_uint_bounded(r, as_)? as u8,
|
||||
// Clamp instead of `as u8`: a foreign/corrupt MKV with a
|
||||
// CHANNELS value that is a multiple of 256 would truncate to
|
||||
// 0 (an invalid channel count) on a bare cast. Saturate to
|
||||
// u8::MAX so an absurd count degrades to "many", never to 0.
|
||||
ebml::CHANNELS => ch = read_uint_bounded(r, as_)?.min(u8::MAX as u64) as u8,
|
||||
_ => {
|
||||
skip_bytes(r, as_)?;
|
||||
}
|
||||
@@ -916,6 +1213,207 @@ mod tests {
|
||||
use crate::pes::Stream as _;
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Length-prefix (4-byte big-endian) each NAL, as the H.264 parser emits.
|
||||
fn lp(nals: &[&[u8]]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
for n in nals {
|
||||
v.extend_from_slice(&(n.len() as u32).to_be_bytes());
|
||||
v.extend_from_slice(n);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn mvc_frame(track: usize, pts: i64, keyframe: bool, data: Vec<u8>) -> crate::pes::PesFrame {
|
||||
crate::pes::PesFrame {
|
||||
track,
|
||||
pts,
|
||||
keyframe,
|
||||
data,
|
||||
duration_ns: None,
|
||||
source: None,
|
||||
coding: None,
|
||||
}
|
||||
}
|
||||
|
||||
// A subset SPS (NAL type 15), a PPS (type 8), and a coded-slice-extension
|
||||
// (type 20) — the shape of a dependent-view access unit.
|
||||
const SUBSET_SPS: [u8; 5] = [0x6F, 0x80, 0x00, 0x33, 0xAA]; // 0x6F & 0x1F = 15
|
||||
const DEP_PPS: [u8; 3] = [0x68, 0xEE, 0x3C]; // 0x68 & 0x1F = 8
|
||||
const DEP_SLICE: [u8; 3] = [0x74, 0x11, 0x22]; // 0x74 & 0x1F = 20
|
||||
|
||||
#[test]
|
||||
fn extract_mvc_params_finds_subset_sps_and_pps() {
|
||||
let data = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
|
||||
let (s, p) = extract_mvc_params(&data).expect("both param sets present");
|
||||
assert_eq!(s, SUBSET_SPS, "subset SPS (NAL 15) captured verbatim");
|
||||
assert_eq!(p, DEP_PPS, "PPS (NAL 8) captured verbatim");
|
||||
// Missing PPS → None (the serializer then emits no mvcC mapping).
|
||||
assert!(extract_mvc_params(&lp(&[&SUBSET_SPS, &DEP_SLICE])).is_none());
|
||||
// Missing subset SPS → None.
|
||||
assert!(extract_mvc_params(&lp(&[&DEP_PPS, &DEP_SLICE])).is_none());
|
||||
}
|
||||
|
||||
fn empty_merge() -> MvcMerge {
|
||||
MvcMerge {
|
||||
base_stream_idx: 0,
|
||||
dep_stream_idx: 2,
|
||||
base_track_idx: 0,
|
||||
stream_to_track: vec![Some(0), Some(1), None],
|
||||
pending_base: std::collections::VecDeque::new(),
|
||||
dep_by_pts: std::collections::HashMap::new(),
|
||||
captured_params: None,
|
||||
orphan_deps: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_pairs_base_and_dependent_by_pts() {
|
||||
let mut m = empty_merge();
|
||||
let dep = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
|
||||
|
||||
// Base arrives first (SSIF order): buffered, nothing emitted yet.
|
||||
let e = m.ingest(&mvc_frame(0, 100, true, lp(&[&[0x65, 1, 2]])));
|
||||
assert!(e.is_empty(), "base held until its dependent arrives");
|
||||
|
||||
// Dependent arrives → base is emitted, remapped to the base track, with
|
||||
// the dependent AU as its BlockAdditional; params are captured.
|
||||
let e = m.ingest(&mvc_frame(2, 100, false, dep.clone()));
|
||||
assert_eq!(e.len(), 1, "the paired base frame is emitted");
|
||||
assert_eq!(e[0].0.track, 0, "remapped to the base muxer track");
|
||||
assert_eq!(
|
||||
e[0].1.as_deref(),
|
||||
Some(dep.as_slice()),
|
||||
"dependent attached"
|
||||
);
|
||||
assert!(m.captured_params.is_some(), "mvcC params captured");
|
||||
|
||||
// Audio passes straight through (remapped, no additional).
|
||||
let e = m.ingest(&mvc_frame(1, 100, true, vec![0xAA]));
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].0.track, 1);
|
||||
assert!(e[0].1.is_none());
|
||||
|
||||
// Dependent-before-base (reordered) also pairs.
|
||||
let dep2 = lp(&[&DEP_SLICE]);
|
||||
assert!(m.ingest(&mvc_frame(2, 200, false, dep2.clone())).is_empty());
|
||||
let e = m.ingest(&mvc_frame(0, 200, false, lp(&[&[0x61, 3, 4]])));
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].1.as_deref(), Some(dep2.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_flushes_unpaired_base_at_eof() {
|
||||
let mut m = empty_merge();
|
||||
// Base with no dependent ever → held, then flushed unpaired at EOF.
|
||||
assert!(
|
||||
m.ingest(&mvc_frame(0, 10, true, vec![0, 0, 0, 1]))
|
||||
.is_empty()
|
||||
);
|
||||
let tail = m.flush();
|
||||
assert_eq!(tail.len(), 1, "unpaired base still emitted");
|
||||
assert!(tail[0].1.is_none(), "no BlockAdditional when unpaired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_mvc_params_no_panic_on_truncated_or_empty() {
|
||||
// Empty, sub-header, zero-length NAL, and a length prefix claiming more
|
||||
// than is present must all return None without panicking (untrusted AU).
|
||||
assert!(extract_mvc_params(&[]).is_none());
|
||||
assert!(extract_mvc_params(&[0, 0, 0]).is_none());
|
||||
assert!(
|
||||
extract_mvc_params(&[0, 0, 0, 0]).is_none(),
|
||||
"lone zero-length NAL yields no params"
|
||||
);
|
||||
assert!(
|
||||
extract_mvc_params(&[0, 0, 0, 10, 0x6F]).is_none(),
|
||||
"length prefix past end breaks, no slice panic"
|
||||
);
|
||||
// A zero-length NAL is SKIPPED, not fatal: valid param sets that follow
|
||||
// are still found (a stray length prefix must not abandon the whole AU).
|
||||
let mut d = vec![0, 0, 0, 0];
|
||||
d.extend_from_slice(&lp(&[&SUBSET_SPS, &DEP_PPS]));
|
||||
let (s, p) = extract_mvc_params(&d).expect("params found past the zero-length NAL");
|
||||
assert_eq!(s, SUBSET_SPS);
|
||||
assert_eq!(p, DEP_PPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_flushes_oldest_base_once_past_window() {
|
||||
let mut m = empty_merge();
|
||||
// Push more unpaired base frames than the window; the excess flush as
|
||||
// plain (unpaired) blocks in FIFO order once len exceeds MVC_PAIR_WINDOW.
|
||||
let n = MVC_PAIR_WINDOW + 8;
|
||||
let mut emitted = 0usize;
|
||||
for pts in 0..n {
|
||||
emitted += m
|
||||
.ingest(&mvc_frame(0, pts as i64, false, vec![0, 0, 0, 1]))
|
||||
.len();
|
||||
}
|
||||
assert_eq!(emitted, 8, "the {n} bases beyond the window flush unpaired");
|
||||
assert_eq!(m.pending_base.len(), MVC_PAIR_WINDOW, "window still held");
|
||||
assert!(m.flush().iter().all(|(_, add)| add.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_dep_overflow_drops_old_keeps_newest() {
|
||||
let mut m = empty_merge();
|
||||
// Fill dep_by_pts to the bound with unpaired dependents (unique PTS).
|
||||
for pts in 0..(MVC_PAIR_WINDOW * 4) {
|
||||
assert!(
|
||||
m.ingest(&mvc_frame(2, pts as i64, false, lp(&[&DEP_SLICE])))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
assert_eq!(m.dep_by_pts.len(), MVC_PAIR_WINDOW * 4);
|
||||
// One more overflows: the drifted buffer is cleared BUT the newest survives
|
||||
// so its (soon-to-arrive) base can still pair.
|
||||
let dep_new = lp(&[&DEP_SLICE]);
|
||||
m.ingest(&mvc_frame(2, 9_999, false, dep_new.clone()));
|
||||
assert_eq!(m.dep_by_pts.len(), 1, "old cleared, newest kept");
|
||||
assert!(m.dep_by_pts.contains_key(&9_999));
|
||||
assert_eq!(
|
||||
m.orphan_deps,
|
||||
(MVC_PAIR_WINDOW * 4) as u64,
|
||||
"old buffer counted once"
|
||||
);
|
||||
// The surviving dependent pairs with its base.
|
||||
let e = m.ingest(&mvc_frame(0, 9_999, false, vec![0x61, 1]));
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].1.as_deref(), Some(dep_new.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_does_not_panic_when_only_video_is_mvc_dependent() {
|
||||
// A (malformed / hand-built) title whose single video IS the dependent
|
||||
// must NOT panic: base_stream_idx is None, so no merge is set up and the
|
||||
// dependent is muxed as an ordinary track.
|
||||
use crate::disc::{
|
||||
Codec, ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream, VideoStream,
|
||||
};
|
||||
let dep = VideoStream {
|
||||
pid: 0x1012,
|
||||
codec: Codec::H264,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F24,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
display_aspect: None,
|
||||
secondary: true,
|
||||
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
|
||||
measured_cicp: None,
|
||||
};
|
||||
let title = DiscTitle {
|
||||
streams: vec![Stream::Video(dep)],
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &title, None)
|
||||
.expect("create must succeed, not panic");
|
||||
assert!(
|
||||
s.mvc.is_none(),
|
||||
"no merge when there is no distinct base view"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_coding_to_track_sets_measured_field_order_never_guesses() {
|
||||
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
|
||||
|
||||
+17
-2
@@ -25,8 +25,10 @@
|
||||
|
||||
// Public modules — types here are intentionally part of the consumable API.
|
||||
pub mod disc;
|
||||
pub mod driver;
|
||||
pub mod pipelined_stream;
|
||||
pub mod resolve;
|
||||
pub mod select;
|
||||
|
||||
// Internal-only modules. Every reference is via `crate::mux::…` /
|
||||
// `super::…` from inside the crate; nothing in the downstream crates or
|
||||
@@ -39,6 +41,7 @@ pub mod resolve;
|
||||
// accessors and an alternate `DemuxThread` spawn path. They are kept as
|
||||
// part of the parser/demux surface and covered by unit tests; allow the
|
||||
// dead-code lint rather than delete still-relevant scaffolding.
|
||||
pub(crate) mod au_assembly;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod codec;
|
||||
pub(crate) mod demux_sink;
|
||||
@@ -61,6 +64,7 @@ pub(crate) mod m2ts;
|
||||
/// to round-trip codec_privates that don't fit inside the underlying format).
|
||||
/// Exposed for integration tests that exercise the wire format directly.
|
||||
pub mod meta;
|
||||
pub(crate) mod meta_sink;
|
||||
|
||||
// ── Sequential-sink muxers ──────────────────────────────────────────────────
|
||||
//
|
||||
@@ -88,6 +92,7 @@ pub(crate) mod hevc;
|
||||
pub(crate) mod m2ts_mux;
|
||||
pub(crate) mod mkv;
|
||||
pub(crate) mod mkvstream;
|
||||
pub(crate) mod mp4;
|
||||
pub(crate) mod network;
|
||||
pub(crate) mod null;
|
||||
pub(crate) mod ps;
|
||||
@@ -107,15 +112,19 @@ pub(crate) mod tsmux;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod videomap;
|
||||
|
||||
pub use demux_sink::{ChaptersFmt, DelayMode, DemuxOptions, DemuxSink, Naming};
|
||||
// `demux://` and `fvi://` sinks are constructed internally by `output()` via the
|
||||
// direct `super::demux_sink::` / `super::fvi_sink::` paths — no re-export needed,
|
||||
// and no consumer names these types, so they are not public API.
|
||||
pub use disc::DiscStream;
|
||||
pub use fvi_sink::FviSink;
|
||||
pub use driver::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream};
|
||||
pub use m2ts::M2tsStream;
|
||||
pub use mkvstream::MkvStream;
|
||||
pub use mp4::{Mp4FitReport, Mp4SkipReason, fit_report as mp4_fit_report};
|
||||
pub use network::NetworkStream;
|
||||
pub use null::NullStream;
|
||||
pub use pipelined_stream::PipelinedPesStream;
|
||||
pub use resolve::build_iso_pipeline;
|
||||
pub use resolve::resolve_mux_key_map;
|
||||
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
pub use stdio::StdioStream;
|
||||
|
||||
@@ -150,11 +159,17 @@ mod tests {
|
||||
assert_eq!(parse_url("disc://").scheme(), "disc");
|
||||
assert_eq!(parse_url("m2ts://f").scheme(), "m2ts");
|
||||
assert_eq!(parse_url("mkv://f").scheme(), "mkv");
|
||||
assert_eq!(parse_url("mp4://f").scheme(), "mp4");
|
||||
assert_eq!(parse_url("network://h:1").scheme(), "network");
|
||||
assert_eq!(parse_url("stdio://").scheme(), "stdio");
|
||||
assert_eq!(parse_url("iso://f").scheme(), "iso");
|
||||
assert_eq!(parse_url("null://").scheme(), "null");
|
||||
assert_eq!(parse_url("demux://out/").scheme(), "demux");
|
||||
assert_eq!(parse_url("video://out/").scheme(), "video");
|
||||
assert_eq!(parse_url("audio://out/").scheme(), "audio");
|
||||
assert_eq!(parse_url("sub://out/").scheme(), "sub");
|
||||
assert_eq!(parse_url("chapters://c.xml").scheme(), "chapters");
|
||||
assert_eq!(parse_url("json://t.json").scheme(), "json");
|
||||
assert_eq!(parse_url("bogus://x").scheme(), "unknown");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
//! MP4 audio sample entries and codec-config boxes for the `mp4://` muxer.
|
||||
//!
|
||||
//! Covers the codecs that map cleanly into MP4 and play widely: **AC-3**
|
||||
//! (`ac-3` + `dac3`), **E-AC-3 / Dolby Digital Plus** (`ec-3` + `dec3`, incl.
|
||||
//! Atmos-in-DD+ JOC), and **DTS / DTS-HD** (`dtsc`/`dtsh` + `ddts`, describing
|
||||
//! the core with whole access units passed through so an HD decoder finds the
|
||||
//! extension). Config boxes are derived from the first audio frame's bitstream
|
||||
//! (ISO/IEC 14496-12 amendments; ETSI TS 102 366 / 102 114). Codecs with no
|
||||
//! clean MP4 mapping (TrueHD, LPCM, bitmap subtitles) are excluded by the fit
|
||||
//! oracle in the sink.
|
||||
|
||||
use super::boxes::bx;
|
||||
use crate::disc::Codec;
|
||||
|
||||
/// AC-3 / E-AC-3 sample rates indexed by `fscod` (byte-4 bits 7-6).
|
||||
const FSCOD_RATES: [u32; 3] = [48_000, 44_100, 32_000];
|
||||
/// E-AC-3 reduced rates indexed by `fscod2` (byte-4 bits 5-4) when `fscod == 3`.
|
||||
const EAC3_REDUCED_RATES: [u32; 4] = [24_000, 22_050, 16_000, 48_000];
|
||||
/// Base channel count per `acmod` (A/52 Table 5.8), before the LFE.
|
||||
const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 4, 4, 5];
|
||||
|
||||
/// A big-endian MSB-first bit reader over a byte slice.
|
||||
struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
bit: usize,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, bit: 0 }
|
||||
}
|
||||
fn skip(&mut self, n: usize) {
|
||||
self.bit += n;
|
||||
}
|
||||
/// Read `n` bits (n ≤ 32). Returns 0 past end of data (callers pre-check len).
|
||||
fn read(&mut self, n: usize) -> u32 {
|
||||
let mut v = 0u32;
|
||||
for _ in 0..n {
|
||||
let byte = self.data.get(self.bit / 8).copied().unwrap_or(0);
|
||||
let shift = 7 - (self.bit % 8);
|
||||
v = (v << 1) | ((byte >> shift) & 1) as u32;
|
||||
self.bit += 1;
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded (E-)AC-3 stream parameters needed for the `dac3`/`dec3` config box
|
||||
/// and the audio sample entry.
|
||||
pub(super) struct DolbyConfig {
|
||||
pub fscod: u8,
|
||||
pub bsid: u8,
|
||||
pub bsmod: u8,
|
||||
pub acmod: u8,
|
||||
pub lfeon: bool,
|
||||
/// AC-3 only: `bit_rate_code` (= `frmsizecod >> 1`). Unused for E-AC-3.
|
||||
pub bit_rate_code: u8,
|
||||
/// E-AC-3 only: nominal data rate in kbps (for `dec3`). 0 for AC-3.
|
||||
pub data_rate_kbps: u16,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
}
|
||||
|
||||
impl DolbyConfig {
|
||||
fn channel_count(acmod: u8, lfeon: bool) -> u16 {
|
||||
ACMOD_CHANNELS[acmod as usize] as u16 + lfeon as u16
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the first (E-)AC-3 frame starting at the 0x0B77 syncword. Returns
|
||||
/// `None` if the frame is too short or the syncword is absent.
|
||||
pub(super) fn parse_dolby(frame: &[u8]) -> Option<DolbyConfig> {
|
||||
let start =
|
||||
(0..frame.len().saturating_sub(1)).find(|&i| frame[i] == 0x0B && frame[i + 1] == 0x77)?;
|
||||
let f = &frame[start..];
|
||||
if f.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
// bsid lives in byte 5 bits 7-3 for both AC-3 and E-AC-3.
|
||||
let bsid = (f[5] >> 3) & 0x1F;
|
||||
if bsid >= 11 {
|
||||
parse_eac3(f)
|
||||
} else {
|
||||
parse_ac3(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy AC-3 (A/52 §5.3.2): syncword | crc(16) | fscod(2) frmsizecod(6) |
|
||||
/// bsid(5) bsmod(3) | acmod(3) …optional… lfeon.
|
||||
fn parse_ac3(f: &[u8]) -> Option<DolbyConfig> {
|
||||
if f.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let fscod = (f[4] >> 6) & 0x03;
|
||||
let frmsizecod = f[4] & 0x3F;
|
||||
let bsid = (f[5] >> 3) & 0x1F;
|
||||
let bsmod = f[5] & 0x07;
|
||||
|
||||
// acmod + trailing optional 2-bit fields, then lfeon (byte 6 onward).
|
||||
let mut r = BitReader::new(f);
|
||||
r.bit = 6 * 8;
|
||||
let acmod = r.read(3) as u8;
|
||||
if (acmod & 0x1) != 0 && acmod != 0x1 {
|
||||
r.skip(2); // cmixlev
|
||||
}
|
||||
if (acmod & 0x4) != 0 {
|
||||
r.skip(2); // surmixlev
|
||||
}
|
||||
if acmod == 0x2 {
|
||||
r.skip(2); // dsurmod
|
||||
}
|
||||
let lfeon = r.read(1) == 1;
|
||||
|
||||
Some(DolbyConfig {
|
||||
fscod,
|
||||
bsid,
|
||||
bsmod,
|
||||
acmod,
|
||||
lfeon,
|
||||
bit_rate_code: frmsizecod >> 1,
|
||||
data_rate_kbps: 0,
|
||||
sample_rate: FSCOD_RATES.get(fscod as usize).copied().unwrap_or(48_000),
|
||||
channels: DolbyConfig::channel_count(acmod, lfeon),
|
||||
})
|
||||
}
|
||||
|
||||
/// E-AC-3 (A/52 Annex E BSI): syncword | strmtyp(2) substreamid(3) frmsiz(11) |
|
||||
/// fscod(2) numblkscod(2) acmod(3) lfeon(1) | bsid(5) …
|
||||
fn parse_eac3(f: &[u8]) -> Option<DolbyConfig> {
|
||||
if f.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
let frmsiz = (((f[2] & 0x07) as u32) << 8) | f[3] as u32; // words minus one
|
||||
let fscod = (f[4] >> 6) & 0x03;
|
||||
let numblkscod = (f[4] >> 4) & 0x03;
|
||||
let acmod = (f[4] >> 1) & 0x07;
|
||||
let lfeon = (f[4] & 0x01) == 1;
|
||||
let bsid = (f[5] >> 3) & 0x1F;
|
||||
|
||||
let (sample_rate, blocks) = if fscod == 0x03 {
|
||||
let fscod2 = (f[4] >> 4) & 0x03; // shares bits with numblkscod when fscod==3
|
||||
(EAC3_REDUCED_RATES[fscod2 as usize], 6u32)
|
||||
} else {
|
||||
let blocks = [1u32, 2, 3, 6][numblkscod as usize];
|
||||
(FSCOD_RATES[fscod as usize], blocks)
|
||||
};
|
||||
// Nominal data rate (kbps): frame is (frmsiz+1) 16-bit words per (blocks·256)
|
||||
// samples at sample_rate. rate = bytes·8·sr / samples / 1000.
|
||||
let frame_bytes = (frmsiz as u64 + 1) * 2;
|
||||
let samples = blocks as u64 * 256;
|
||||
let data_rate_kbps = if samples > 0 {
|
||||
((frame_bytes * 8 * sample_rate as u64) / samples / 1000) as u16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Some(DolbyConfig {
|
||||
fscod,
|
||||
bsid,
|
||||
bsmod: 0, // not in the E-AC-3 main header; dec3 default
|
||||
acmod,
|
||||
lfeon,
|
||||
bit_rate_code: 0,
|
||||
data_rate_kbps,
|
||||
sample_rate,
|
||||
channels: DolbyConfig::channel_count(acmod, lfeon),
|
||||
})
|
||||
}
|
||||
|
||||
/// The `dac3` config box (ETSI TS 102 366 Annex F.4): 24 bits —
|
||||
/// fscod(2) bsid(5) bsmod(3) acmod(3) lfeon(1) bit_rate_code(5) reserved(5).
|
||||
pub(super) fn dac3_box(c: &DolbyConfig) -> Vec<u8> {
|
||||
let mut v: u32 = 0;
|
||||
let mut push = |val: u32, bits: u32| v = (v << bits) | (val & ((1 << bits) - 1));
|
||||
push(c.fscod as u32, 2);
|
||||
push(c.bsid as u32, 5);
|
||||
push(c.bsmod as u32, 3);
|
||||
push(c.acmod as u32, 3);
|
||||
push(c.lfeon as u32, 1);
|
||||
push(c.bit_rate_code as u32, 5);
|
||||
push(0, 5); // reserved
|
||||
// 24 bits → the top 3 bytes of the big-endian u32.
|
||||
let b = v.to_be_bytes();
|
||||
bx(b"dac3", &[b[1], b[2], b[3]])
|
||||
}
|
||||
|
||||
/// The `dec3` config box (ETSI TS 102 366 Annex G.3) for a single independent
|
||||
/// substream, no dependent substreams: data_rate(13) num_ind_sub(3) then
|
||||
/// fscod(2) bsid(5) reserved(1) asvc(1) bsmod(3) acmod(3) lfeon(1) reserved(3)
|
||||
/// num_dep_sub(4) reserved(1).
|
||||
pub(super) fn dec3_box(c: &DolbyConfig) -> Vec<u8> {
|
||||
let mut v: u64 = 0;
|
||||
let mut push = |val: u64, bits: u32| v = (v << bits) | (val & ((1u64 << bits) - 1));
|
||||
push(c.data_rate_kbps as u64, 13);
|
||||
push(0, 3); // num_ind_sub - 1 = 0 (one substream)
|
||||
push(c.fscod as u64, 2);
|
||||
push(c.bsid as u64, 5);
|
||||
push(0, 1); // reserved
|
||||
push(0, 1); // asvc
|
||||
push(c.bsmod as u64, 3);
|
||||
push(c.acmod as u64, 3);
|
||||
push(c.lfeon as u64, 1);
|
||||
push(0, 3); // reserved
|
||||
push(0, 4); // num_dep_sub = 0
|
||||
push(0, 1); // reserved (chan_loc absent when num_dep_sub == 0)
|
||||
// 40 bits → the low 5 bytes of the big-endian u64.
|
||||
let b = v.to_be_bytes();
|
||||
bx(b"dec3", &[b[3], b[4], b[5], b[6], b[7]])
|
||||
}
|
||||
|
||||
/// Build an audio sample entry (`ac-3` / `ec-3`) with the given config box.
|
||||
/// `AudioSampleEntry` per ISO/IEC 14496-12 §12.2.3.
|
||||
pub(super) fn audio_sample_entry(
|
||||
fourcc: &[u8; 4],
|
||||
channels: u16,
|
||||
sample_rate: u32,
|
||||
config: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut e = Vec::new();
|
||||
e.extend_from_slice(&[0u8; 6]); // reserved
|
||||
e.extend_from_slice(&1u16.to_be_bytes()); // data_reference_index
|
||||
e.extend_from_slice(&[0u8; 8]); // reserved (version 0)
|
||||
e.extend_from_slice(&channels.to_be_bytes());
|
||||
e.extend_from_slice(&16u16.to_be_bytes()); // samplesize
|
||||
e.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
|
||||
e.extend_from_slice(&0u16.to_be_bytes()); // reserved
|
||||
// samplerate is 16.16 fixed point; the integer rate in the high 16 bits. The
|
||||
// integer part is only 16 bits, so cap at 65535 — 96/192 kHz (DTS-HD) would
|
||||
// otherwise overflow u32 and write a garbage rate (the true rate is in ddts).
|
||||
e.extend_from_slice(&(sample_rate.min(0xFFFF) << 16).to_be_bytes());
|
||||
e.extend_from_slice(config);
|
||||
bx(fourcc, &e)
|
||||
}
|
||||
|
||||
// ── DTS (dtsc/dtsh + ddts) ───────────────────────────────────────────────────
|
||||
|
||||
/// DTS core `SFREQ` (4-bit) → sample rate (Hz). Reserved indices → 48 kHz.
|
||||
const DTS_SFREQ: [u32; 16] = [
|
||||
48_000, 8_000, 16_000, 32_000, 48_000, 48_000, 11_025, 22_050, 44_100, 48_000, 48_000, 12_000,
|
||||
24_000, 48_000, 96_000, 192_000,
|
||||
];
|
||||
/// DTS core base channel count per `AMODE` (all 16 defined values). Matches the
|
||||
/// reference `ff_dca_channels[16]` table (ETSI TS 102 114) that the decodability
|
||||
/// gate in `dts.rs` (`DTS_AMODE_COUNT`) also uses, so a spec-legal DTS-ES / 6.1 /
|
||||
/// 7.1 core (AMODE 13→7, 14/15→8) is DECLARED with its true channel count in the
|
||||
/// mp4 AudioSampleEntry / `ddts` box rather than a truncated 6.
|
||||
const DTS_AMODE_CH: [u8; 16] = [1, 2, 2, 2, 2, 3, 3, 4, 4, 5, 6, 6, 6, 7, 8, 8];
|
||||
|
||||
/// Decoded DTS core parameters needed for the `ddts` box.
|
||||
struct DtsConfig {
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
amode: u8,
|
||||
lfe: bool,
|
||||
core_size: u32,
|
||||
/// Samples per frame ((NBLKS+1)·32).
|
||||
frame_samples: u32,
|
||||
/// Whether a DTS-HD extension substream follows the core.
|
||||
has_extension: bool,
|
||||
channel_layout: u16,
|
||||
}
|
||||
|
||||
/// Parse the DTS core header (ETSI TS 102 114 §5.3.1), starting at the
|
||||
/// 0x7FFE8001 big-endian core sync. Returns `None` if too short / no sync.
|
||||
fn parse_dts(frame: &[u8]) -> Option<DtsConfig> {
|
||||
let start = (0..frame.len().saturating_sub(3)).find(|&i| {
|
||||
frame[i] == 0x7F && frame[i + 1] == 0xFE && frame[i + 2] == 0x80 && frame[i + 3] == 0x01
|
||||
})?;
|
||||
let f = &frame[start..];
|
||||
if f.len() < 11 {
|
||||
return None;
|
||||
}
|
||||
// Bit fields after the 32-bit sync (MSB-first):
|
||||
// FTYPE1 SHORT5 CPF1 NBLKS7 FSIZE14 AMODE6 SFREQ4 RATE5 ...
|
||||
let nblks = (((f[4] & 0x01) as u32) << 6) | ((f[5] >> 2) as u32 & 0x3F);
|
||||
let fsize = (((f[5] & 0x03) as u32) << 12) | ((f[6] as u32) << 4) | ((f[7] >> 4) as u32 & 0x0F);
|
||||
let amode = (((f[7] & 0x0F) << 2) | ((f[8] >> 6) & 0x03)) as usize;
|
||||
let sfreq = ((f[8] >> 2) & 0x0F) as usize;
|
||||
// LFF is 2 bits at bit offset 85 → byte10 bits 2-1.
|
||||
let lff = (f[10] >> 1) & 0x03;
|
||||
let lfe = lff == 1 || lff == 2;
|
||||
|
||||
let sample_rate = DTS_SFREQ[sfreq];
|
||||
let base_ch = DTS_AMODE_CH.get(amode).copied().unwrap_or(6);
|
||||
let channels = base_ch as u16 + lfe as u16;
|
||||
let channel_layout = dts_channel_layout(amode, lfe);
|
||||
// DTS-HD extension substream sync (0x64582025) after the core frame. Search
|
||||
// ONLY the region at/after the core end (core_size = fsize+1): scanning the
|
||||
// whole frame would false-positive on the same 4 bytes occurring inside the
|
||||
// compressed core payload, mislabeling a plain DTS core as DTS-HD (dtsh).
|
||||
let ext_sync = [0x64, 0x58, 0x20, 0x25];
|
||||
// The EXSS begins at byte core_size (= fsize + 1); start the window search
|
||||
// exactly there so no 4-byte window inside the compressed core is ever tested.
|
||||
let ext_sync_start = (fsize as usize + 1).min(f.len());
|
||||
let has_extension = f.windows(4).skip(ext_sync_start).any(|w| w == ext_sync);
|
||||
|
||||
Some(DtsConfig {
|
||||
sample_rate,
|
||||
channels,
|
||||
amode: amode as u8,
|
||||
lfe,
|
||||
core_size: fsize + 1,
|
||||
frame_samples: (nblks + 1) * 32,
|
||||
has_extension,
|
||||
channel_layout,
|
||||
})
|
||||
}
|
||||
|
||||
/// `ddts` ChannelLayout (16-bit speaker mask) for the common core layouts.
|
||||
/// bit0=C, bit1=L/R, bit2=Ls/Rs, bit3=LFE.
|
||||
fn dts_channel_layout(amode: usize, lfe: bool) -> u16 {
|
||||
let mut m = match amode {
|
||||
0 => 0x0001, // C (mono)
|
||||
1..=4 => 0x0002, // L/R
|
||||
5 => 0x0003, // C + L/R
|
||||
6 | 8 => 0x0006, // L/R + Ls/Rs (no centre)
|
||||
_ => 0x0007, // C + L/R + surround (amode 7, 9, …)
|
||||
};
|
||||
if lfe {
|
||||
m |= 0x0008;
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The `ddts` config box (ETSI TS 102 114 Annex; DTS-in-ISO registration).
|
||||
/// Describes the DTS core; whole access units (core + any extension) are passed
|
||||
/// through as samples, so a DTS-HD-aware decoder still finds the extension.
|
||||
fn ddts_box(c: &DtsConfig) -> Vec<u8> {
|
||||
// avg/max bitrate: computed from the core frame size × frame rate (the core
|
||||
// RATE field reads "open/variable" for lossless, so it's not usable directly).
|
||||
let frames_per_sec = if c.frame_samples > 0 {
|
||||
c.sample_rate as u64 / c.frame_samples as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let bitrate = (c.core_size as u64 * 8 * frames_per_sec) as u32;
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&c.sample_rate.to_be_bytes()); // DTSSamplingFrequency
|
||||
out.extend_from_slice(&bitrate.to_be_bytes()); // maxBitrate
|
||||
out.extend_from_slice(&bitrate.to_be_bytes()); // avgBitrate
|
||||
out.push(if c.has_extension { 24 } else { 16 }); // pcmSampleDepth
|
||||
// Bit-packed tail (56 bits):
|
||||
// FrameDuration2 StreamConstruction5 CoreLFEPresent1 CoreLayout6 CoreSize14
|
||||
// StereoDownmix1 RepresentationType3 ChannelLayout16 MultiAssetFlag1
|
||||
// LBRDurationMod1 ReservedBoxPresent1 Reserved5
|
||||
let frame_duration = match c.frame_samples {
|
||||
0..=512 => 0,
|
||||
513..=1024 => 1,
|
||||
1025..=2048 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
// StreamConstruction: 1 = DTS core present. Whole-AU passthrough means an
|
||||
// HD decoder still parses the extension substreams from the stream itself.
|
||||
let stream_construction = 1u128;
|
||||
let mut v: u128 = 0;
|
||||
let mut push = |val: u128, bits: u32| v = (v << bits) | (val & ((1u128 << bits) - 1));
|
||||
push(frame_duration as u128, 2);
|
||||
push(stream_construction, 5);
|
||||
push(c.lfe as u128, 1);
|
||||
push(c.amode as u128, 6);
|
||||
push(c.core_size as u128, 14);
|
||||
push(0, 1); // StereoDownmix
|
||||
push(0, 3); // RepresentationType
|
||||
push(c.channel_layout as u128, 16);
|
||||
push(c.has_extension as u128, 1); // MultiAssetFlag
|
||||
push(0, 1); // LBRDurationMod
|
||||
push(0, 1); // ReservedBoxPresent
|
||||
push(0, 5); // Reserved
|
||||
// 56 bits → the low 7 bytes of the big-endian u128.
|
||||
let b = v.to_be_bytes();
|
||||
out.extend_from_slice(&b[9..16]);
|
||||
bx(b"ddts", &out)
|
||||
}
|
||||
|
||||
/// The MP4 fourcc + config box for an audio frame, or `None` if the codec has no
|
||||
/// MP4 mapping here. Together with [`audio_fits`] this is the fit oracle for
|
||||
/// audio: only what returns `Some` is muxable.
|
||||
pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option<Vec<u8>> {
|
||||
match codec {
|
||||
Codec::Ac3 => {
|
||||
let c = parse_dolby(first_frame)?;
|
||||
Some(audio_sample_entry(
|
||||
b"ac-3",
|
||||
c.channels,
|
||||
c.sample_rate,
|
||||
&dac3_box(&c),
|
||||
))
|
||||
}
|
||||
Codec::Ac3Plus => {
|
||||
let c = parse_dolby(first_frame)?;
|
||||
Some(audio_sample_entry(
|
||||
b"ec-3",
|
||||
c.channels,
|
||||
c.sample_rate,
|
||||
&dec3_box(&c),
|
||||
))
|
||||
}
|
||||
Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr => {
|
||||
let c = parse_dts(first_frame)?;
|
||||
// `dtsc` = DTS core; `dtsh` = DTS-HD (core + extension substreams).
|
||||
let fourcc: &[u8; 4] = if c.has_extension { b"dtsh" } else { b"dtsc" };
|
||||
Some(audio_sample_entry(
|
||||
fourcc,
|
||||
c.channels,
|
||||
c.sample_rate,
|
||||
&ddts_box(&c),
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit oracle for an audio codec: does `mp4://` currently carry it? Covers the
|
||||
/// Dolby family (AC-3 / E-AC-3) and DTS (core / DTS-HD HRA / DTS-HD MA — the core
|
||||
/// is described, whole access units pass through). TrueHD, LPCM, AAC are not yet
|
||||
/// mapped and are skipped with a loud report (never silently dropped).
|
||||
pub(super) fn audio_fits(codec: Codec) -> bool {
|
||||
matches!(
|
||||
codec,
|
||||
Codec::Ac3 | Codec::Ac3Plus | Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A synthetic legacy AC-3 header: syncword, crc, fscod=0 (48k),
|
||||
/// frmsizecod, bsid=8, bsmod=0, acmod=7 (3/2), lfeon=1 → 5.1.
|
||||
fn ac3_frame_5_1() -> Vec<u8> {
|
||||
let mut f = vec![0x0B, 0x77, 0x00, 0x00];
|
||||
// byte4: fscod(2)=0 | frmsizecod(6)=0b010110 (22)
|
||||
f.push(0b00_010110);
|
||||
// byte5: bsid(5)=8 (0b01000) | bsmod(3)=0
|
||||
f.push(0b01000_000);
|
||||
// byte6: acmod(3)=7 (0b111) | cmixlev(2) | surmixlev(2) | lfeon(1)...
|
||||
// acmod=7 has centre (needs cmixlev) and surround (needs surmixlev):
|
||||
// 111 | 00 | 00 | 1(lfeon) = 0b111_00_00_1
|
||||
f.push(0b111_00_00_1);
|
||||
f.push(0x00);
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac3_bsi_and_dac3() {
|
||||
let c = parse_dolby(&ac3_frame_5_1()).expect("parsed");
|
||||
assert!(c.bsid < 11, "legacy AC-3");
|
||||
assert_eq!(c.fscod, 0);
|
||||
assert_eq!(c.sample_rate, 48_000);
|
||||
assert_eq!(c.bsid, 8);
|
||||
assert_eq!(c.acmod, 7);
|
||||
assert!(c.lfeon);
|
||||
assert_eq!(c.channels, 6, "3/2 + LFE = 5.1");
|
||||
assert_eq!(c.bit_rate_code, 22 >> 1);
|
||||
|
||||
let dac3 = dac3_box(&c);
|
||||
// [size:4]["dac3"][3-byte payload] = 11 bytes.
|
||||
assert_eq!(dac3.len(), 11);
|
||||
assert_eq!(&dac3[4..8], b"dac3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eac3_bsi_and_dec3() {
|
||||
// E-AC-3: syncword | strmtyp/substreamid/frmsiz | fscod/numblks/acmod/lfeon | bsid
|
||||
let mut f = vec![0x0B, 0x77];
|
||||
f.push(0x00); // strmtyp=0, substreamid=0, frmsiz high=0
|
||||
f.push(0x3F); // frmsiz low = 63 → frame 128 bytes
|
||||
// byte4: fscod(2)=0 | numblkscod(2)=3 (6 blocks) | acmod(3)=7 | lfeon(1)=1
|
||||
f.push(0b00_11_111_1);
|
||||
// byte5: bsid(5)=16 (E-AC-3) | dialnorm high(3)
|
||||
f.push(0b10000_000);
|
||||
f.push(0x00);
|
||||
let c = parse_dolby(&f).expect("parsed");
|
||||
assert!(c.bsid >= 11, "E-AC-3");
|
||||
assert_eq!(c.bsid, 16);
|
||||
assert_eq!(c.fscod, 0);
|
||||
assert_eq!(c.sample_rate, 48_000);
|
||||
assert_eq!(c.acmod, 7);
|
||||
assert!(c.lfeon);
|
||||
assert_eq!(c.channels, 6);
|
||||
let dec3 = dec3_box(&c);
|
||||
// [size:4]["dec3"][5-byte payload] = 13 bytes.
|
||||
assert_eq!(dec3.len(), 13);
|
||||
assert_eq!(&dec3[4..8], b"dec3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_entry_shape() {
|
||||
let c = parse_dolby(&ac3_frame_5_1()).unwrap();
|
||||
let e = audio_sample_entry(b"ac-3", c.channels, c.sample_rate, &dac3_box(&c));
|
||||
assert_eq!(&e[4..8], b"ac-3");
|
||||
// channelcount at entry-body offset 16 (after 6 reserved + 2 dri + 8 reserved).
|
||||
let ch = u16::from_be_bytes([e[8 + 16], e[8 + 17]]);
|
||||
assert_eq!(ch, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_oracle_covers_dolby_and_dts() {
|
||||
assert!(audio_fits(Codec::Ac3));
|
||||
assert!(audio_fits(Codec::Ac3Plus));
|
||||
assert!(audio_fits(Codec::Dts));
|
||||
assert!(audio_fits(Codec::DtsHdMa));
|
||||
assert!(!audio_fits(Codec::TrueHd));
|
||||
assert!(!audio_fits(Codec::Lpcm));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dts_core_5_1_and_ddts() {
|
||||
// Synthetic DTS core: SFREQ=13 (48k), AMODE=9 (5ch), LFF=1 (LFE) → 5.1.
|
||||
let f = vec![
|
||||
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x3C, 0x05, 0xF2, 0x77, 0x00, 0x02, 0x00,
|
||||
];
|
||||
let c = parse_dts(&f).expect("dts core parsed");
|
||||
assert_eq!(c.sample_rate, 48_000);
|
||||
assert_eq!(c.amode, 9);
|
||||
assert!(c.lfe);
|
||||
assert_eq!(c.channels, 6, "5 core + LFE = 5.1");
|
||||
assert_eq!(c.channel_layout, 0x000F, "C + L/R + Ls/Rs + LFE");
|
||||
assert_eq!(c.core_size, 96);
|
||||
assert_eq!(c.frame_samples, 512);
|
||||
|
||||
let ddts = ddts_box(&c);
|
||||
assert_eq!(&ddts[4..8], b"ddts");
|
||||
// DTSSamplingFrequency (first field) = 48000.
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([ddts[8], ddts[9], ddts[10], ddts[11]]),
|
||||
48_000
|
||||
);
|
||||
// Sample entry uses dtsc (no extension in this synthetic frame).
|
||||
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
||||
assert_eq!(&e[4..8], b"dtsc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dts_ext_sync_inside_core_is_not_a_false_positive() {
|
||||
// The 4-byte ext-sync pattern occurring INSIDE the compressed core payload
|
||||
// (before core_size) must NOT be read as a DTS-HD extension → stays dtsc.
|
||||
// f[4..8] = ext_sync makes core_size huge (>> frame len), so the search
|
||||
// region is only after the core (skipped past this frame) → no extension.
|
||||
let f = vec![
|
||||
0x7F, 0xFE, 0x80, 0x01, 0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x02, 0x00,
|
||||
];
|
||||
let c = parse_dts(&f).expect("parses");
|
||||
assert!(!c.has_extension, "ext-sync inside core is not an extension");
|
||||
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
||||
assert_eq!(&e[4..8], b"dtsc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dts_ext_sync_at_core_end_is_detected() {
|
||||
// fsize=8 → core_size=9; the EXSS sync sits exactly at byte 9 (right after
|
||||
// the core) and MUST be detected → dtsh. Guards the off-by-4 boundary.
|
||||
let f = vec![
|
||||
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00, 0x00, 0x80, 0x00, 0x64, 0x58, 0x20, 0x25,
|
||||
];
|
||||
let c = parse_dts(&f).expect("parses");
|
||||
assert_eq!(c.core_size, 9);
|
||||
assert!(c.has_extension, "EXSS sync at core end is a real extension");
|
||||
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
||||
assert_eq!(&e[4..8], b"dtsh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dts_high_amode_channel_counts_are_declared() {
|
||||
// The 16-entry DTS_AMODE_CH must declare the true core channel count for the
|
||||
// spec-legal high AMODEs that now pass the decodability gate: AMODE 13→7,
|
||||
// 14→8, 15→8 (ETSI TS 102 114 / ff_dca_channels). The old 10-entry table
|
||||
// fell through `unwrap_or(6)` → every one of these was declared as 6.
|
||||
//
|
||||
// Frame layout (mirrors dts_core_5_1_and_ddts): SFREQ=13 (48k), LFF=0 (no
|
||||
// LFE) so `channels` is the bare base count. AMODE is split across
|
||||
// f[7] low nibble (amode>>2) and f[8] top 2 bits (amode&3).
|
||||
// f[8] = (amode&3)<<6 | 13<<2 = ... (keeps SFREQ=13)
|
||||
let frame = |f7: u8, f8: u8| {
|
||||
vec![
|
||||
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x05, 0xF2, f7, f8, 0x00, 0x00, 0x00,
|
||||
]
|
||||
};
|
||||
// AMODE 13 → base 7 channels.
|
||||
let c = parse_dts(&frame(0xF3, 0x74)).expect("amode 13 parses");
|
||||
assert_eq!(c.amode, 13);
|
||||
assert!(!c.lfe);
|
||||
assert_eq!(c.channels, 7, "AMODE 13 core is 7 channels, not 6");
|
||||
// AMODE 14 → base 8 channels.
|
||||
let c = parse_dts(&frame(0xF3, 0xB4)).expect("amode 14 parses");
|
||||
assert_eq!(c.amode, 14);
|
||||
assert_eq!(c.channels, 8, "AMODE 14 core is 8 channels, not 6");
|
||||
// AMODE 15 → base 8 channels.
|
||||
let c = parse_dts(&frame(0xF3, 0xF4)).expect("amode 15 parses");
|
||||
assert_eq!(c.amode, 15);
|
||||
assert_eq!(c.channels, 8, "AMODE 15 core is 8 channels, not 6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_entry_samplerate_does_not_overflow_at_96k() {
|
||||
// 96 kHz > 65535: the 16.16 integer part must saturate, not wrap to garbage.
|
||||
let e = audio_sample_entry(b"ac-3", 6, 96_000, &[]);
|
||||
// 8-byte box header + body offset 24 (6+2+8+2+2+2+2) → samplerate at 32;
|
||||
// high 16 bits = the integer rate.
|
||||
assert_eq!(&e[32..34], &[0xFF, 0xFF], "capped to 65535, not wrapped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! ISO-BMFF box primitives: `[size:u32-BE][type:4][body]` (ISO/IEC 14496-12
|
||||
//! §4.2), and the FullBox variant that prefixes a 1-byte version + 3-byte flags.
|
||||
|
||||
/// Wrap a body in a plain box `[size][type][body]`. `size` counts the 8-byte
|
||||
/// header. All `moov`-tree boxes are small (the large `mdat` is written directly
|
||||
/// with a 64-bit size, not through here), so a `u32` size never overflows.
|
||||
pub(super) fn bx(box_type: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
let total = body.len() + 8;
|
||||
debug_assert!(
|
||||
total <= u32::MAX as usize,
|
||||
"mp4 box {box_type:?} exceeds u32"
|
||||
);
|
||||
let mut out = Vec::with_capacity(total);
|
||||
out.extend_from_slice(&(total as u32).to_be_bytes());
|
||||
out.extend_from_slice(box_type);
|
||||
out.extend_from_slice(body);
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap a body in a FullBox: `[size][type][version:1][flags:3][body]`.
|
||||
pub(super) fn fullbox(box_type: &[u8; 4], version: u8, flags: u32, body: &[u8]) -> Vec<u8> {
|
||||
let mut full = Vec::with_capacity(body.len() + 4);
|
||||
full.push(version);
|
||||
full.extend_from_slice(&flags.to_be_bytes()[1..]); // low 3 bytes
|
||||
full.extend_from_slice(body);
|
||||
bx(box_type, &full)
|
||||
}
|
||||
+1134
File diff suppressed because it is too large
Load Diff
+1532
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user