Compare commits
164
Commits
3b06a4c844
..
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 |
@@ -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
|
||||
|
||||
+469
-5
@@ -1,5 +1,469 @@
|
||||
# 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
|
||||
@@ -39,7 +503,7 @@
|
||||
|
||||
- **Processing-Key resolution is ~15× faster on UHD.** A Processing Key is the
|
||||
key at its subset-difference node (one AES-G from the Media Key), so it is now
|
||||
tried directly against the MKB cvalue tables (matching libaacs `_calc_mk_pks`)
|
||||
tried directly against the MKB cvalue tables (direct PK × cvalue iteration)
|
||||
instead of BFS-walking the SD tree at unknown depth — which was both wrong for
|
||||
terminal PKs and slow on a large UHD MKB (~181k cvalues). PK derivation on UHD
|
||||
drops from ~37 s to ~2.4 s; the SD tree walk now lives solely in the device-key
|
||||
@@ -272,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
|
||||
@@ -293,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).
|
||||
@@ -301,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
|
||||
@@ -601,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.2"
|
||||
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,501 +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,
|
||||
//! both via the MKB's Subset-Difference cvalue tables: the device-key path
|
||||
//! recovers its Processing Key at the matching SD node and walks on to the MK;
|
||||
//! the processing-key path starts from a precomputed PK. Neither needs a VID
|
||||
//! (the VID enters at `vuk_from_mk`).
|
||||
|
||||
use super::derive::{decrypt_unit_key, derive_vuk};
|
||||
use super::derive::{derive_media_key_and_pk_from_dk, derive_media_key_from_pk};
|
||||
use super::types::DeviceKey;
|
||||
|
||||
/// 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]);
|
||||
|
||||
/// 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(Debug, 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 [`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 positioned device keys via the MKB's
|
||||
/// Subset-Difference tables.
|
||||
///
|
||||
/// Wraps [`derive_media_key_and_pk_from_dk`] — the real SD walk the resolver
|
||||
/// runs: each positioned device key is placed against the MKB's subset-diff /
|
||||
/// cvalue records, recovering its Processing Key at the matching node and
|
||||
/// continuing to the Media Key. Reachable for real discs whenever a device key
|
||||
/// applies to the MKB. No VID is involved here — it enters at [`vuk_from_mk`].
|
||||
///
|
||||
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no supplied device key
|
||||
/// resolves the MKB — the same terminal error as [`mk_from_pk`]; no numeric
|
||||
/// distinction is load-bearing at this boundary.
|
||||
///
|
||||
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
|
||||
pub fn mk_from_dk(device_keys: &[DeviceKey], mkb: &[u8]) -> Result<MediaKey, crate::error::Error> {
|
||||
// Positioned device keys drive the real Subset-Difference MKB walk
|
||||
// ([`derive_media_key_and_pk_from_dk`], the same walk the resolver runs). The
|
||||
// old Media-Key-Variant path needed integrator Key Correction Data absent
|
||||
// in-tree, so it Err'd for EVERY real disc (dead for both consumers —
|
||||
// freemkv-keysources' DK fallback and the kdb harvester). No VID is needed
|
||||
// for the Media Key; it enters only at [`vuk_from_mk`].
|
||||
match derive_media_key_and_pk_from_dk(mkb, device_keys) {
|
||||
Some((km, _pk)) => Ok(MediaKey(km)),
|
||||
None => 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),
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate key at any rung of the AACS ladder, handed to [`resolve_candidate`].
|
||||
///
|
||||
/// Each variant carries the module's existing newtype for that rung (a `Dk` is a
|
||||
/// POSITIONED [`DeviceKey`] — recover an unpositioned one with
|
||||
/// [`super::keys::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 (positional
|
||||
/// order); the caller runs [`super::content::unit_key_validates`] to find which
|
||||
/// one actually opens the disc. Rungs above the candidate are `None` (a `Vuk`
|
||||
/// candidate has no `mk`/`pk`/`dk`; a `Uk` candidate has only `unit_keys`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedChain {
|
||||
/// Every unit key derived from the VUK, as `(cps_unit_number, key)` — the
|
||||
/// CPS-unit numbers come from `Unit_Key_RO.inf` (via `parse_unit_key_ro`), so
|
||||
/// a consumer maps `UK → CPS unit` directly. Same shape as
|
||||
/// [`super::keys::ResolvedKeys::unit_keys`]. A `Uk` candidate yields exactly
|
||||
/// itself, keyed by its own `idx`.
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 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 module's own boil steps
|
||||
/// ([`mk_from_pk`], [`vuk_from_mk`], [`uk_from_vuk`]) and parses
|
||||
/// `Unit_Key_RO.inf` at the version the disc's MKB declares (48-byte stride for
|
||||
/// AACS-1.0, 64 for AACS-2.x), so a multi-CPS disc yields all its unit keys from
|
||||
/// the one candidate.
|
||||
///
|
||||
/// PURE DERIVATION: no sampling, no validation, no position recovery. Every step
|
||||
/// is deterministic AES, so the returned keys are only as sound as the input
|
||||
/// candidate — validate `unit_keys` against a real encrypted unit with
|
||||
/// [`super::content::unit_key_validates`] 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> {
|
||||
use super::derive::derive_media_key_and_pk_from_dk;
|
||||
use super::inf::parse_unit_key_ro;
|
||||
use super::mkb::{AacsVersion, mkb_type};
|
||||
|
||||
// Boil a VUK → all unit keys, each paired with its declared CPS-unit number.
|
||||
// `.inf` parsing lives here: derive the stride version from the disc's own
|
||||
// MKB, then defer the VUK→unit-keys step to the shared `derive_unit_keys`
|
||||
// (the one place both resolvers and this path decrypt the title keys).
|
||||
let boil = |vuk: Vuk| -> Option<Vec<(u32, [u8; 16])>> {
|
||||
let version = mkb_type(mkb)
|
||||
.map(|t| t.generation())
|
||||
.unwrap_or(AacsVersion::V10);
|
||||
let ukf = parse_unit_key_ro(unit_key_ro, version)?;
|
||||
if ukf.encrypted_keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(super::derive::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_from_mk(*mk, vid?);
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(*mk),
|
||||
pk: None,
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Pk(pk) => {
|
||||
let mk = mk_from_pk(std::slice::from_ref(&pk.0), mkb).ok()?;
|
||||
let vuk = vuk_from_mk(mk, vid?);
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(mk),
|
||||
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 mk = MediaKey(km);
|
||||
let vuk = vuk_from_mk(mk, vid?);
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(mk),
|
||||
pk: Some(ProcessingKey(pk)),
|
||||
dk: Some(dk.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||
use crate::aacs::derive::{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)` when the MKB has no
|
||||
/// processable Subset-Difference tables (empty MKB, or one with no
|
||||
/// mk_dv/cvalues/subdiff records) — never a wrong key, never a panic.
|
||||
#[test]
|
||||
fn mk_from_dk_errors_on_unprocessable_mkb() {
|
||||
let dk = DeviceKey {
|
||||
key: [0x11; 16],
|
||||
node: 1,
|
||||
uv: 1,
|
||||
u_mask_shift: 0,
|
||||
};
|
||||
// Empty MKB → no SD records to walk → Err.
|
||||
let e = mk_from_dk(std::slice::from_ref(&dk), &[]);
|
||||
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
|
||||
// An MKB with no complete Subset-Difference tables (mk_dv / cvalues /
|
||||
// subdiff) cannot yield a Media Key, so the real walk also errors —
|
||||
// never silently yields a key.
|
||||
let mut mkb: Vec<u8> = Vec::new();
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // stray data record only
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
let e2 = mk_from_dk(&[dk], &mkb);
|
||||
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)
|
||||
));
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
idx: 2,
|
||||
key: [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");
|
||||
}
|
||||
}
|
||||
+648
-739
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -69,7 +69,7 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
///
|
||||
/// The Media Key Variant chain uses AES-G to derive both the variant
|
||||
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
|
||||
/// (`Kvu = AES-G(Km, VID)`). See [`super::keys::derive_vuk`] for the
|
||||
/// (`Kvu = AES-G(Km, VID)`). See [`super::derive::derive_vuk`] for the
|
||||
/// classical VUK form — the math is identical, this exposes it as a
|
||||
/// neutral primitive for the variant chain.
|
||||
pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
||||
@@ -89,7 +89,7 @@ pub(crate) const AESG3_SEED: [u8; 16] = [
|
||||
/// left=`D(k,s0)⊕s0` inc 0, pk=`D(k,s0+1)⊕(s0+1)` inc 1, right=`D(k,s0+2)⊕(s0+2)` inc 2).
|
||||
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
|
||||
///
|
||||
/// Shared with [`super::variants`] (its variant chain runs the same SD
|
||||
/// Shared with [`super::variant`] (its variant chain runs the same SD
|
||||
/// tree); a single definition keeps the two walks byte-identical.
|
||||
pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
let mut seed = AESG3_SEED;
|
||||
|
||||
+294
-12
@@ -10,8 +10,8 @@ use super::types::*;
|
||||
///
|
||||
/// A Processing Key is **terminal**: it is the key at its Subset-Difference
|
||||
/// node, one `AES-G` from the Media Key. So this is the fast path — each PK is
|
||||
/// tried *directly* against the MKB cvalue tables (no tree descent), matching
|
||||
/// libaacs `_calc_mk_pks` (iterate PKs × cvalues). On a large AACS 2.x UHD MKB
|
||||
/// tried *directly* against the MKB cvalue tables (no tree descent) — the
|
||||
/// direct PK × cvalue iteration. On a large AACS 2.x UHD MKB
|
||||
/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node
|
||||
/// label and walking the tree.
|
||||
///
|
||||
@@ -105,7 +105,7 @@ pub(crate) fn validate_processing_key(
|
||||
None
|
||||
}
|
||||
|
||||
/// Compute v_mask from a UV value. [C] §3.2.3. Shared with [`super::variants`].
|
||||
/// Compute v_mask from a UV value. [C] §3.2.3. Shared with [`super::variant`].
|
||||
pub(super) fn calc_v_mask(uv: u32) -> u32 {
|
||||
let mut v_mask: u32 = 0xFFFF_FFFF;
|
||||
while (uv & !v_mask) == 0 && v_mask != 0 {
|
||||
@@ -115,7 +115,7 @@ pub(super) fn calc_v_mask(uv: u32) -> u32 {
|
||||
}
|
||||
|
||||
/// Derive processing key from device key using subset-difference tree traversal.
|
||||
/// [C] §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variants`].
|
||||
/// [C] §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variant`].
|
||||
pub(super) fn calc_pk_from_dk(
|
||||
dk: &[u8; 16],
|
||||
uv: u32,
|
||||
@@ -203,13 +203,14 @@ pub fn derive_media_key_and_pk_from_dk(
|
||||
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
||||
let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value
|
||||
|
||||
if u_mask_shift & 0xC0 != 0 {
|
||||
break; // device revoked
|
||||
}
|
||||
// Shifts of 32..=63 (0x20..=0x3F pass the 0xC0 mask above) would
|
||||
// panic in debug / wrap to a wrong mask in release. The MKB byte
|
||||
// is disc-controlled, so a crafted/corrupt MKB must not crash the
|
||||
// ripper: skip an out-of-range slot rather than `<<` it.
|
||||
// `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;
|
||||
}
|
||||
@@ -366,6 +367,7 @@ pub(crate) fn resolve_dk_node(
|
||||
/// 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;
|
||||
|
||||
@@ -432,7 +434,7 @@ pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] {
|
||||
|
||||
/// 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 `boil::resolve_candidate` call this, so the
|
||||
/// 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
|
||||
@@ -441,3 +443,283 @@ pub(crate) fn derive_unit_keys(uk_file: &UnitKeyFile, vuk: &[u8; 16]) -> Vec<(u3
|
||||
.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
+219
-4
@@ -121,7 +121,7 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
return None;
|
||||
}
|
||||
|
||||
// Title → CPS unit mapping. libaacs (unit_key.c) validates each on-disc CPS
|
||||
// Title → CPS unit mapping (AACS Unit_Key_RO format): each on-disc CPS
|
||||
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
|
||||
// index to a 0-based key index. We mirror that so the stored value is a safe,
|
||||
// ready-to-use key index rather than a raw 1-based number.
|
||||
@@ -161,6 +161,105 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
})
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -261,10 +360,10 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Content Certificate layout (matches libaacs content_cert.c):
|
||||
// Content Certificate layout (per the AACS content-cert format):
|
||||
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||||
// [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (libaacs: `p + 14`)
|
||||
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (`p + 14`)
|
||||
let version = if data[0] == 0x00 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
@@ -283,3 +382,119 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+124
-14
@@ -2,10 +2,36 @@
|
||||
//!
|
||||
//! 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 from the
|
||||
//! former `keys.rs` / `variant.rs` so the one place that understands MKB bytes
|
||||
//! is `mkb`. A follow-up collapses the remaining duplicate finders (see the
|
||||
//! private refactor notes) — for now both dialects live here side by side.
|
||||
//! 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)]
|
||||
@@ -41,7 +67,7 @@ pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
|
||||
/// 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::keys` are built on this, so the framing rules — and
|
||||
/// MKB record walk in `aacs::resolve`/`aacs::derive` are built on this, so the framing rules — and
|
||||
/// any future fix to them — live in exactly one place (they had drifted across
|
||||
/// six hand-rolled copies).
|
||||
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
|
||||
@@ -131,11 +157,13 @@ impl AacsVersion {
|
||||
}
|
||||
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [libaacs] `mkb.c` — not in the public spec.
|
||||
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [RE] — not in the public spec (from real 2.x MKBs).
|
||||
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
|
||||
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
|
||||
let found = mkb_records(mkb).find(|&(_, rt, len)| (rt == 0x81 || rt == 0x86) && len >= 20);
|
||||
let found = mkb_records(mkb).find(|&(_, rt, len)| {
|
||||
(rt == REC_VERIFY_MEDIA_KEY_V1 || rt == REC_VERIFY_MEDIA_KEY_V2) && len >= 20
|
||||
});
|
||||
match found {
|
||||
Some((o, rec_type, rec_len)) => {
|
||||
let mut dv = [0u8; 16];
|
||||
@@ -170,9 +198,8 @@ pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
///
|
||||
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
|
||||
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
|
||||
/// 5-byte Subset-Difference index entries in record `0x04`. This matches
|
||||
/// libaacs, whose `mkb_cvalues()` reads `0x05` and `mkb_subdiff_records()`
|
||||
/// reads `0x04`.
|
||||
/// 5-byte Subset-Difference index entries in record `0x04` — the standard AACS
|
||||
/// MKB layout (`0x05` cvalues 1:1 with the `0x04` subset-difference index).
|
||||
///
|
||||
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
|
||||
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
|
||||
@@ -240,7 +267,7 @@ pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): version is the BE u32 at body offset 4
|
||||
// (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version).
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == 0x10 && len >= 12)
|
||||
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 12)
|
||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
|
||||
}
|
||||
|
||||
@@ -253,10 +280,10 @@ pub const MKB_TYPE_4_PRERECORDED: u32 = 0x0004_1003;
|
||||
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
|
||||
pub const MKB_TYPE_10_CLASS_II: u32 = 0x000A_1003;
|
||||
|
||||
/// `0x48141003` — AACS 2.0 Category C (UHD content). libaacs `MKB_20_CATEGORY_C`.
|
||||
/// `0x48141003` — AACS 2.0 Category C (UHD content) MKB type value.
|
||||
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
|
||||
|
||||
/// `0x48151003` — AACS 2.1 Category C (UHD content). libaacs `MKB_21_CATEGORY_C`.
|
||||
/// `0x48151003` — AACS 2.1 Category C (UHD content) MKB type value.
|
||||
pub const MKB_21_CATEGORY_C: u32 = 0x4815_1003;
|
||||
|
||||
/// The AACS MKB Type field, decoded.
|
||||
@@ -309,7 +336,7 @@ pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
|
||||
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == 0x10 && len >= 8)
|
||||
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 8)
|
||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]]))
|
||||
}
|
||||
|
||||
@@ -323,3 +350,86 @@ pub fn mkb_type(mkb: &[u8]) -> Option<MkbType> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+266
-13
@@ -22,26 +22,43 @@
|
||||
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
|
||||
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
|
||||
//! - `[BD]` — AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
|
||||
//! - `[libaacs]` — the libaacs reference implementation, cited only where the spec
|
||||
//! is silent (the `0x86` verify record and the Category-C MKBType names).
|
||||
//! - `[RE]` — reverse-engineered from real discs, cited only where the public
|
||||
//! spec is silent (the `0x86` verify record and the Category-C MKB type values).
|
||||
|
||||
pub mod boil;
|
||||
pub mod content;
|
||||
pub mod crypto;
|
||||
pub mod derive;
|
||||
pub mod host_certs;
|
||||
pub mod index_select;
|
||||
pub mod inf;
|
||||
pub mod mkb;
|
||||
pub mod provider;
|
||||
pub mod resolve;
|
||||
pub mod segment;
|
||||
pub mod segment_key;
|
||||
pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variant;
|
||||
|
||||
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
|
||||
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
|
||||
/// or changing a fallback in one place can then never silently diverge the
|
||||
/// On-disc UDF paths to the AACS key-input files, 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";
|
||||
@@ -50,10 +67,124 @@ 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";
|
||||
|
||||
// No facade: 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`, `aacs::boil::mk_from_dk`, `aacs::resolve::resolve_keys_v2`.
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -61,7 +192,7 @@ mod tests {
|
||||
//! Touching one representative item per module keeps these as a
|
||||
//! compile-time contract that the module paths stay stable.
|
||||
|
||||
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
use super::content::ALIGNED_UNIT_LEN;
|
||||
use super::inf::{disc_hash, disc_hash_hex};
|
||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||
use super::variant::is_variant_mkb;
|
||||
@@ -89,10 +220,132 @@ mod tests {
|
||||
fn public_helpers_are_callable_by_module_path() {
|
||||
// Touch a representative function from each module so a dropped/renamed
|
||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
|
||||
let _ = !crate::aacs::content::is_clean(
|
||||
&[0u8; ALIGNED_UNIT_LEN],
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
let _ = super::boil::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).
|
||||
|
||||
+59
-17
@@ -8,12 +8,11 @@ use super::mkb::*;
|
||||
//
|
||||
// Canonical form is `<category>1003` (low 16 bits `0x1003` is a fixed marker).
|
||||
// Types 3/4/10 are from the AACS Common Cryptographic Elements spec (0.953,
|
||||
// §3.2.5.1.1); the Category-C 2.0/2.1 values match libaacs `mkb.h` constants.
|
||||
// §3.2.5.1.1); the Category-C 2.0/2.1 values are the standard MKB type constants.
|
||||
|
||||
// ── Full VUK resolution chain ───────────────────────────────────────────────
|
||||
|
||||
/// Result of resolving a disc's VUK.
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedKeys {
|
||||
/// Disc hash (SHA1 of Unit_Key_RO.inf)
|
||||
pub disc_hash: [u8; 20],
|
||||
@@ -34,6 +33,23 @@ pub struct ResolvedKeys {
|
||||
pub key_source: u8,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `vuk` and `unit_keys` are raw key bytes, never printed.
|
||||
// `disc_hash` is the public per-disc identifier (SHA-1 of the .inf), not secret.
|
||||
// Guarded by `resolved_keys_debug_is_redacted`.
|
||||
impl std::fmt::Debug for ResolvedKeys {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ResolvedKeys")
|
||||
.field("disc_hash", &self.disc_hash)
|
||||
.field("vuk", &self.vuk.map(|_| "<redacted>"))
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.field("title_cps_unit", &self.title_cps_unit)
|
||||
.field("version", &self.version)
|
||||
.field("bus_encryption", &self.bus_encryption)
|
||||
.field("key_source", &self.key_source)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs shared by every classical-path resolver. References only —
|
||||
/// callers retain ownership of all buffers.
|
||||
pub struct ResolveContext<'a> {
|
||||
@@ -106,8 +122,12 @@ pub fn resolve_keys_with_reason(
|
||||
pub(crate) fn classify_resolve_failure(ctx: &ResolveContext<'_>) -> ResolveFailure {
|
||||
let has_vid = *ctx.volume_id != [0u8; 16];
|
||||
let providers = super::provider::Providers(ctx.providers);
|
||||
let has_derivation_material =
|
||||
!providers.device_keys().is_empty() || !providers.processing_keys().is_empty();
|
||||
// Media keys are also derivation material: with an MK you can derive the VUK
|
||||
// once you have the VID, so a media-keys-only provider that is merely missing
|
||||
// the VID is VidUnavailable, not NoMaterial.
|
||||
let has_derivation_material = !providers.device_keys().is_empty()
|
||||
|| !providers.processing_keys().is_empty()
|
||||
|| !providers.media_keys().is_empty();
|
||||
if !has_vid && has_derivation_material {
|
||||
ResolveFailure::VidUnavailable
|
||||
} else {
|
||||
@@ -152,7 +172,7 @@ pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
/// equivalent of path 2 — there's no host-side PK derivation against a
|
||||
/// Variant MKB.)
|
||||
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?;
|
||||
let uk_file = parse_title_keys(ctx.unit_key_ro, AacsVersion::V20)?;
|
||||
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||
let bus_encryption = ctx
|
||||
.content_cert
|
||||
@@ -276,8 +296,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
.map(|cc| cc.bus_encryption)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Parse Unit_Key_RO.inf at the version-appropriate stride.
|
||||
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
|
||||
// Parse the disc's title-key file (BD/UHD Unit_Key_RO.inf at the
|
||||
// version-appropriate stride, or HD DVD VTKF000.AACS) → common UnitKeyFile.
|
||||
let uk_file = parse_title_keys(ctx.unit_key_ro, version)?;
|
||||
|
||||
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||
let has_vid = *ctx.volume_id != [0u8; 16];
|
||||
@@ -462,6 +483,27 @@ mod tests {
|
||||
use super::super::types::*;
|
||||
use super::*;
|
||||
|
||||
/// `ResolvedKeys` carries the disc's VUK and unit keys raw; `Debug` must not
|
||||
/// leak them. Sentinel 213 (0xD5); non-secret fields are not 213.
|
||||
#[test]
|
||||
fn resolved_keys_debug_is_redacted() {
|
||||
let rk = ResolvedKeys {
|
||||
disc_hash: [0u8; 20],
|
||||
vuk: Some([0xD5; 16]),
|
||||
unit_keys: vec![(1, [0xD5; 16])],
|
||||
title_cps_unit: vec![0],
|
||||
version: AacsVersion::V21,
|
||||
bus_encryption: true,
|
||||
key_source: 1,
|
||||
};
|
||||
let dbg = format!("{rk:?}");
|
||||
assert!(!dbg.contains("213"), "ResolvedKeys leaked keys: {dbg}");
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"ResolvedKeys missing marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Audit #5: the `major` / `from_major` mapping is load-bearing for the
|
||||
/// Unit_Key_RO stride, so pin it as a table. V10 ↔ BD; V20/V21 → UHD; any
|
||||
/// non-BD major selects the V20/V21 64-byte stride (V10 is the only 48-byte).
|
||||
@@ -786,7 +828,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validate_processing_key_round_trip_with_nonzero_uv() {
|
||||
// Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the
|
||||
// libaacs _validate_pk relation, then confirm validate_processing_key
|
||||
// AACS PK-validation relation, then confirm validate_processing_key
|
||||
// recovers mk. Catches the bugs that landed pre-fix:
|
||||
// * uv XOR step was missing → mk wrong whenever uv != 0
|
||||
// * AES-128E + 12-zero check instead of AES-128D + magic
|
||||
@@ -840,8 +882,8 @@ mod tests {
|
||||
// ── MKB cvalue-record selection (issue #259 / #281) ─────────────────
|
||||
//
|
||||
// The cvalue (Media Key Data) table is record 0x05; the
|
||||
// Subset-Difference index is record 0x04. This matches libaacs
|
||||
// (`mkb_cvalues` → 0x05, `mkb_subdiff_records` → 0x04). Record 0x07
|
||||
// Subset-Difference index is record 0x04 (the standard AACS MKB layout:
|
||||
// 0x05 = cvalues, 0x04 = subset-difference index). Record 0x07
|
||||
// (Explicit Subset-Difference Record) is NOT the cvalue table. On real
|
||||
// in-drive AACS 2.x UHD MKBs 0x07 is small (~96 entries) while the 0x05
|
||||
// table is large (181270 entries, 1:1 with 0x04). An earlier
|
||||
@@ -1260,7 +1302,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn test_content_cert_parse() {
|
||||
// AACS 1.0 cert, bus encryption OFF. Layout matches libaacs: flag in
|
||||
// AACS 1.0 cert, bus encryption OFF. Content-cert layout: flag in
|
||||
// BIT 7 of byte 1, cc_id at bytes 14..20.
|
||||
let mut data = vec![0u8; 20];
|
||||
data[0] = 0x00; // AACS 1.0
|
||||
@@ -1341,7 +1383,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn stride_v10_is_48_v20_is_64_and_picks_distinct_keys() {
|
||||
// AACS 1.0 stride = 48, AACS 2.0/2.1 stride = 64 (keys.rs:30-35).
|
||||
// AACS 1.0 stride = 48, AACS 2.0/2.1 stride = 64 (aacs/inf.rs).
|
||||
// Lay keys at 64-byte stride. Parsing at V20 stride must pick exactly
|
||||
// those keys; parsing the SAME bytes at V10 (48) stride would read the
|
||||
// wrong (intermediate) bytes for key 2 onward — proving the stride
|
||||
@@ -1443,7 +1485,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn parse_unit_key_ro_cps_unit_numbers_are_1_based() {
|
||||
// The disc's CPS unit numbers are emitted as (i+1) — keys.rs:162.
|
||||
// The disc's CPS unit numbers are emitted as (i+1) — aacs/inf.rs.
|
||||
let data = build_unit_key_ro(3, 48);
|
||||
let p = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
|
||||
assert_eq!(
|
||||
@@ -1456,7 +1498,7 @@ mod tests {
|
||||
// [20..22] first_play, [22..24] top_menu, [24..26] num_titles, then
|
||||
// per-title 2-byte pad + 2-byte CPS unit at 26 + i*4 + 2. Each on-disc
|
||||
// 1-based CPS number in `1..=num_uk` is validated and converted to a
|
||||
// 0-based key index (libaacs unit_key.c); an out-of-range number → 0.
|
||||
// 0-based key index (per the AACS Unit_Key_RO format); an out-of-range number → 0.
|
||||
let mut data = build_unit_key_ro(4, 64); // num_uk = 4 → CPS 1..=4 valid
|
||||
data[20..22].copy_from_slice(&1u16.to_be_bytes()); // first_play CPS 1
|
||||
data[22..24].copy_from_slice(&2u16.to_be_bytes()); // top_menu CPS 2
|
||||
@@ -1576,7 +1618,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn parse_content_cert_extracts_cc_id_and_nonzero_type_is_v20() {
|
||||
// libaacs layout: [0]=type, [1] bit7=bus-enc, [14..20]=cc_id. Any
|
||||
// Content-cert layout: [0]=type, [1] bit7=bus-enc, [14..20]=cc_id. Any
|
||||
// non-0x00 type → V20.
|
||||
let mut data = vec![0u8; 20];
|
||||
data[0] = 0x10; // AACS2 type marker → V20
|
||||
@@ -1589,7 +1631,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn parse_content_cert_bus_encryption_reads_bit7() {
|
||||
// bus_encryption = (data[1] >> 7) & 1 (libaacs). Low bits set with bit7
|
||||
// bus_encryption = (data[1] >> 7) & 1. Low bits set with bit7
|
||||
// clear → false; bit7 set → true. Pins the bit, not a truthiness of the byte.
|
||||
let mut data = vec![0u8; 20];
|
||||
data[1] = 0x7F; // bits 0..6 set, bit 7 clear
|
||||
@@ -1932,7 +1974,7 @@ mod tests {
|
||||
//
|
||||
// The rc.6 E7017/E7022 split is also exercised end-to-end through the
|
||||
// `ensure_decryptable` gate in `disc/mod.rs`. These tests pin the
|
||||
// *classifier* directly at the keys.rs seam and cover the branches the
|
||||
// *classifier* directly at the aacs::resolve seam and cover the branches the
|
||||
// gate test does not: VID-present (must never be VidUnavailable), the
|
||||
// processing-keys-only material path, and the version dispatch / Ok path.
|
||||
|
||||
|
||||
@@ -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:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
+258
-76
@@ -3,8 +3,9 @@
|
||||
//! On AACS 2.1 the Media Key derivation gains a second stage on top of
|
||||
//! the classical subset-difference walk. The classical walk yields a
|
||||
//! Media Key Precursor (Kmp) rather than the final Media Key; the
|
||||
//! Precursor combines with disc-supplied Variant Key Data (VKD) and the
|
||||
//! fixed Key Correction Data (KCD) constant to produce the Media Key.
|
||||
//! Precursor combines with disc-supplied Variant Key Data (VKD) and a
|
||||
//! per-licensee Key Correction Data (KCD) constant to produce the Media
|
||||
//! Key.
|
||||
//!
|
||||
//! The entry point is [`derive_media_key_variant`] — a `Kp -> Km`
|
||||
//! derivation. Deriving `Kp` itself from device keys (DK -> PK) is the
|
||||
@@ -27,14 +28,16 @@
|
||||
//! ```
|
||||
//!
|
||||
//! **Status.** The record layout is pinned against real variant MKBs:
|
||||
//! `variants_for_uv` reads the `VARIANTS[uv]` table from `0x2d`, `C` from
|
||||
//! the `0x2d` head, `VKD` from `0x2f`, and the Nonce from the `0x2d`
|
||||
//! tail. The one input still missing is a covering 2.1 Processing Key to
|
||||
//! run the chain end-to-end against the `0x86` Verify-Media-Key record —
|
||||
//! which would also confirm the last layout picks (the 16-bit `Kvn` width
|
||||
//! vs. a narrower spec value, and Nonce head-vs-tail). Until then the
|
||||
//! final verify gate rejects any wrong pick, so a bad key is never
|
||||
//! emitted — only an error.
|
||||
//! `C` is the per-slot block of the `0x0c` cvalue table (indexed by the
|
||||
//! matched subset-difference — NOT the `0x2d` head), `VARIANTS[uv]` is the
|
||||
//! `0x2d` VARIANTS table (leading `body-16` bytes, Nonce at the `0x2d`
|
||||
//! tail), and `VKD` is `0x2f`. Two inputs still block an end-to-end run
|
||||
//! against the `0x86` Verify-Media-Key record: the real per-licensee KCD
|
||||
//! (see [`KEY_CORRECTION_DATA`] — not coded, per-manufacturer), and a
|
||||
//! covering 2.1 Processing Key. Either one missing yields a wrong `Km`
|
||||
//! that the final verify gate rejects, so a bad key is never emitted —
|
||||
//! only an error. (A covering key would also confirm the last layout
|
||||
//! picks: the 16-bit `Kvn` width and Nonce head-vs-tail.)
|
||||
//!
|
||||
//! Two condition bits on `Kmp[15]` route off the default KCD path (Soft
|
||||
//! Correction and Online Challenge); the chain does not model those modes
|
||||
@@ -51,12 +54,25 @@ use super::crypto::{aes_ecb_decrypt, aes_g};
|
||||
use super::mkb::*;
|
||||
use super::types::DeviceKey;
|
||||
|
||||
// The MKB record types this chain selects — `REC_MEDIA_KEY_VARIANT_DATA`
|
||||
// (`0x0c`, the per-slot C table), `REC_VARIANT_DATA_AND_NONCE` (`0x2d`, VARIANTS
|
||||
// + tail Nonce), `REC_VKD_TABLE` (`0x2f`), the subset-difference / cvalue records
|
||||
// (`0x04` / `0x05` / `0x07`), and the verify records (`0x81` / `0x86`) — are the
|
||||
// canonical set in [`super::mkb`], in scope here via the `use super::mkb::*` glob.
|
||||
|
||||
// ── Public constants ──────────────────────────────────────────────────────
|
||||
|
||||
/// AACS 2.1 Key Correction Data — a fixed algorithm constant.
|
||||
const KEY_CORRECTION_DATA: [u8; 16] = [
|
||||
0x3b, 0x62, 0x8a, 0x78, 0x29, 0x00, 0xca, 0x2f, 0xdb, 0xe7, 0x7a, 0x49, 0xfe, 0x22, 0xd6, 0x6e,
|
||||
];
|
||||
/// AACS 2.1 Key Correction Data — a zero placeholder, NOT real key material.
|
||||
///
|
||||
/// **KCD is PER-LICENSEE** (per player manufacturer) — there is no single
|
||||
/// universal value. libfreemkv compiles in no AACS key material (keydb.cfg is
|
||||
/// the single source of truth), so this stays all-zero: the chain's SHAPE still
|
||||
/// runs, but on a real variant disc the derivation yields a wrong Media Key that
|
||||
/// the final Verify-Media-Key gate rejects. The variant chain therefore cannot
|
||||
/// complete on a real disc today — a key-acquisition gap, not a code gap. If a
|
||||
/// real per-licensee KCD is ever available it must come from keydb.cfg, never a
|
||||
/// compiled constant.
|
||||
const KEY_CORRECTION_DATA: [u8; 16] = [0u8; 16];
|
||||
|
||||
// ── MKB record walking ────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,38 +85,41 @@ const KEY_CORRECTION_DATA: [u8; 16] = [
|
||||
/// The earlier `0x82`/`0x83` guess was speculative and never appeared in any
|
||||
/// real MKB.
|
||||
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
|
||||
records.iter().any(|r| matches!(r.rec_type, 0x2d | 0x2f))
|
||||
records
|
||||
.iter()
|
||||
.any(|r| matches!(r.rec_type, REC_VARIANT_DATA_AND_NONCE | REC_VKD_TABLE))
|
||||
}
|
||||
|
||||
/// Body of the Encrypted Media Key Variant Data record (type `0x2d`).
|
||||
///
|
||||
/// Confirmed against a live variant MKB as the `0x2d` record (92,220 bytes on
|
||||
/// the reference disc — not a clean multiple of 16, so it is a structured /
|
||||
/// count-prefixed record, not a flat C-block table). The exact per-uv C
|
||||
/// selection is the one sub-field still unconfirmed without a real disc+key to
|
||||
/// test against; the chain currently consumes the leading 16-byte block.
|
||||
/// `pub(crate)` until that offset is pinned.
|
||||
/// Body of the `0x2d` record: the `VARIANTS` table followed by the trailing
|
||||
/// 16-byte `Kvn` Nonce. Measured `46_100*2 + 16 = 92_216` on Zombieland v70 and
|
||||
/// `92_220` on Stand By Me v70 — in both, the leading `body.len() - 16` bytes are
|
||||
/// the big-endian `u16` `VARIANTS` table (one per subset-difference) and the last
|
||||
/// 16 bytes are the Nonce, with NO leading header. This does NOT hold the C used
|
||||
/// for `Kmp` — that is the per-slot block in `0x0c`
|
||||
/// ([`REC_MEDIA_KEY_VARIANT_DATA`]). Both [`variant_nonce`] and
|
||||
/// [`variants_for_uv`] read this body.
|
||||
pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == 0x2d)
|
||||
.find(|r| r.rec_type == REC_VARIANT_DATA_AND_NONCE)
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)`.
|
||||
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)` — the trailing 16 bytes of the
|
||||
/// `0x2d` record ([`variant_data_record`]).
|
||||
///
|
||||
/// **UNCONFIRMED source.** The `0x2d` Encrypted-Media-Key-Variant-Data record is
|
||||
/// the most likely home for a per-disc nonce, so this reads its trailing 16
|
||||
/// bytes. Confirming this (vs. a fixed slice elsewhere in `0x2d`) needs a
|
||||
/// covering key to run the whole chain against the `0x86` verify; until then a
|
||||
/// wrong nonce can only fail that final gate, never emit a bad key.
|
||||
/// The Nonce-at-tail placement is consistent across both reference MKBs (the
|
||||
/// leading `body-16` bytes form the `VARIANTS` table exactly), but head-vs-tail
|
||||
/// is only truly pinned by running the full chain against the `0x86` verify with
|
||||
/// a covering key. Until then a wrong nonce can only fail that final gate, never
|
||||
/// emit a bad key.
|
||||
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
let r = records.iter().find(|r| r.rec_type == 0x2d)?;
|
||||
if r.body.len() < 16 {
|
||||
let body = variant_data_record(records)?;
|
||||
if body.len() < 16 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&r.body[r.body.len() - 16..]);
|
||||
out.copy_from_slice(&body[body.len() - 16..]);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -112,14 +131,14 @@ pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == 0x2f && !r.body.is_empty() && r.body.len() % 16 == 0)
|
||||
.find(|r| r.rec_type == REC_VKD_TABLE && !r.body.is_empty() && r.body.len() % 16 == 0)
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
|
||||
|
||||
// `calc_v_mask` and `calc_pk_from_dk` (and the AES-G3 seed step they ride
|
||||
// on) are shared with the classical walk in [`super::keys`] — a single
|
||||
// on) are shared with the classical walk in [`super::derive`] — a single
|
||||
// definition keeps the variant SD tree byte-identical to the classical one.
|
||||
// (`aesg3` itself is imported separately in the test module.)
|
||||
use super::derive::{calc_pk_from_dk, calc_v_mask};
|
||||
@@ -127,7 +146,7 @@ use super::derive::{calc_pk_from_dk, calc_v_mask};
|
||||
/// Outcome of a subset-difference walk against an MKB. Carries the
|
||||
/// processing key and the matching `uv` slot — both needed as inputs
|
||||
/// to the variant chain.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ProcessingKeyMatch {
|
||||
/// Processing Key.
|
||||
pub kp: [u8; 16],
|
||||
@@ -139,10 +158,25 @@ pub struct ProcessingKeyMatch {
|
||||
pub cvalue_index: usize,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `kp` (a Processing Key) and `cvalue` are secret, never
|
||||
// printed. `uv` / `cvalue_index` are non-secret coordinates. Guarded by
|
||||
// `processing_key_match_debug_is_redacted`.
|
||||
impl std::fmt::Debug for ProcessingKeyMatch {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ProcessingKeyMatch")
|
||||
.field("kp", &"<redacted>")
|
||||
.field("uv", &self.uv)
|
||||
.field("cvalue", &"<redacted>")
|
||||
.field("cvalue_index", &self.cvalue_index)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
let r = records
|
||||
.iter()
|
||||
.find(|r| (r.rec_type == 0x81 || r.rec_type == 0x86) && r.body.len() >= 16)?;
|
||||
let r = records.iter().find(|r| {
|
||||
(r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2)
|
||||
&& r.body.len() >= 16
|
||||
})?;
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&r.body[..16]);
|
||||
Some(out)
|
||||
@@ -152,7 +186,7 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
/// `device_keys` covers. Returns `None` if no DK walks any uv.
|
||||
///
|
||||
/// This is the AACS-2.1 **variant** walk; the classical walk lives in
|
||||
/// [`super::keys::derive_media_key_and_pk_from_dk`]. The two are kept
|
||||
/// [`super::derive::derive_media_key_and_pk_from_dk`]. The two are kept
|
||||
/// separate on purpose and select MKB records in DELIBERATELY different
|
||||
/// order:
|
||||
///
|
||||
@@ -161,7 +195,7 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
/// small `0x07` Explicit-Subset-Difference record carries the
|
||||
/// cvalue the Precursor chain consumes, whereas a classical UHD MKB
|
||||
/// keeps its 1:1 cvalue table in the large `0x05` record (see the
|
||||
/// note on [`super::keys::probe::mkb_cvalues`]). They must NOT be
|
||||
/// note on [`super::derive::probe::mkb_cvalues`]). They must NOT be
|
||||
/// unified to one order — each is correct for its own MKB shape.
|
||||
/// - finders: this walk operates on parsed [`MkbRecord`]s (needed
|
||||
/// because the variant chain also reads `0x2d`/`0x2f`); the
|
||||
@@ -176,14 +210,14 @@ pub fn walk_processing_key(
|
||||
device_keys: &[DeviceKey],
|
||||
) -> Option<ProcessingKeyMatch> {
|
||||
let mk_dv = mkb_find_mk_dv(records)?;
|
||||
let uvs = mkb_find_body(records, 0x04)?;
|
||||
let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
|
||||
// Variant cvalue source: a real variant MKB carries its per-uv cvalue table
|
||||
// in record `0x0c` (confirmed 46,101×16, one per `0x04` subset-difference
|
||||
// slot). Fall back to `0x07`/`0x05` for the synthetic fixtures and any MKB
|
||||
// shape that keeps its cvalues there.
|
||||
let cvalues = mkb_find_body(records, 0x0c)
|
||||
.or_else(|| mkb_find_body(records, 0x07))
|
||||
.or_else(|| mkb_find_body(records, 0x05))?;
|
||||
let cvalues = mkb_find_body(records, REC_MEDIA_KEY_VARIANT_DATA)
|
||||
.or_else(|| mkb_find_body(records, REC_EXPLICIT_SUBSET_DIFF))
|
||||
.or_else(|| mkb_find_body(records, REC_MEDIA_KEY_DATA))?;
|
||||
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
@@ -201,14 +235,11 @@ pub fn walk_processing_key(
|
||||
// parse stops, no inner re-check needed.
|
||||
let u_mask_shift = uvs[5 * uvs_idx];
|
||||
|
||||
if u_mask_shift & 0xC0 != 0 {
|
||||
break;
|
||||
}
|
||||
// 0x20..=0x3F (32..=63) pass the 0xC0 revoked-marker check but are
|
||||
// out of range for a u32 shift. `wrapping_shl` would silently
|
||||
// compute shift % 32 (e.g. 32 → no shift → 0xFFFF_FFFF), matching a
|
||||
// wrong uv slot and deriving a wrong key. Disc-controlled byte:
|
||||
// skip the slot instead.
|
||||
// 0x20..=0x3F (32..=63) have their revoked-marker bits clear (so they
|
||||
// pass the take_while above) but are out of range for a u32 shift.
|
||||
// `wrapping_shl` would silently compute shift % 32 (e.g. 32 → no shift
|
||||
// → 0xFFFF_FFFF), matching a wrong uv slot and deriving a wrong key.
|
||||
// Disc-controlled byte: skip the slot instead.
|
||||
if u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
@@ -343,10 +374,18 @@ impl std::error::Error for MediaKeyVariantError {}
|
||||
/// yield a wrong `Km`, which the final Verify-Media-Key gate rejects (never a
|
||||
/// silent bad key).
|
||||
fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
|
||||
let body = records.iter().find(|r| r.rec_type == 0x2d)?.body.as_slice();
|
||||
let body = variant_data_record(records)?;
|
||||
// The VARIANTS table is the leading bytes; the 16-byte Kvn Nonce is packed at
|
||||
// the TAIL (see [`variant_nonce`]). Bound the read to the table region so a
|
||||
// near-end slot can never read Nonce bytes as a VARIANTS entry. NO leading
|
||||
// header (measured: Zombieland v70 `0x2d` body = 46_100*2 + 16 = 92_216).
|
||||
const NONCE: usize = 16;
|
||||
let table_len = body.len().checked_sub(NONCE)?;
|
||||
let off = sd_slot_index.checked_mul(2)?;
|
||||
let bytes = body.get(off..off + 2)?;
|
||||
Some(u16::from_be_bytes([bytes[0], bytes[1]]))
|
||||
if off + 2 > table_len {
|
||||
return None;
|
||||
}
|
||||
Some(u16::from_be_bytes([body[off], body[off + 1]]))
|
||||
}
|
||||
|
||||
/// Enumerate the `(uv, slot_index)` pairs of a variant MKB's subset-difference
|
||||
@@ -354,7 +393,7 @@ fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
|
||||
/// to index cvalues. Factored out so a bare Processing Key (which arrives without
|
||||
/// its slot) can be tried against each slot.
|
||||
fn variant_uv_slots(records: &[MkbRecord]) -> Option<Vec<(u32, usize)>> {
|
||||
let uvs = mkb_find_body(records, 0x04)?;
|
||||
let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
|
||||
let mut out = Vec::new();
|
||||
let mut idx = 0usize;
|
||||
while (idx + 1) * 5 <= uvs.len() {
|
||||
@@ -381,7 +420,11 @@ struct VariantMkb<'a> {
|
||||
records: &'a [MkbRecord],
|
||||
nonce: [u8; 16],
|
||||
vkd_table: &'a [u8],
|
||||
c_block: [u8; 16],
|
||||
/// The per-subset-difference Encrypted-Media-Key-Variant-Data (C) table from
|
||||
/// record `0x0c` — one 16-byte C per slot. C for slot `i` is
|
||||
/// `cvalues[i*16..][..16]`, the SAME source/index [`walk_processing_key`]
|
||||
/// uses. (NOT `0x2d`, which is VARIANTS + Nonce.)
|
||||
cvalues: &'a [u8],
|
||||
mk_dv: [u8; 16],
|
||||
}
|
||||
|
||||
@@ -395,8 +438,21 @@ fn variant_km_for_slot(
|
||||
uv: u32,
|
||||
slot_index: usize,
|
||||
) -> Result<[u8; 16], MediaKeyVariantError> {
|
||||
// C for THIS subset-difference: the slot's 16-byte block in the `0x0c`
|
||||
// Encrypted-Media-Key-Variant-Data table (same index that selected the
|
||||
// cvalue in `walk_processing_key`). `0x2d` is VARIANTS + Nonce, not C.
|
||||
let cv_off = slot_index
|
||||
.checked_mul(16)
|
||||
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let c_slice = m
|
||||
.cvalues
|
||||
.get(cv_off..cv_off + 16)
|
||||
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let mut c_block = [0u8; 16];
|
||||
c_block.copy_from_slice(c_slice);
|
||||
|
||||
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
|
||||
let mut kmp = aes_ecb_decrypt(kp, &m.c_block);
|
||||
let mut kmp = aes_ecb_decrypt(kp, &c_block);
|
||||
let uv_bytes = uv.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
kmp[12 + i] ^= uv_bytes[i];
|
||||
@@ -476,19 +532,21 @@ pub fn derive_media_key_variant(
|
||||
}
|
||||
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let c_value = variant_data_record(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
if c_value.len() < 16 {
|
||||
return Err(MediaKeyVariantError::MkbIncomplete);
|
||||
}
|
||||
let mut c_block = [0u8; 16];
|
||||
c_block.copy_from_slice(&c_value[..16]);
|
||||
// C for the Kmp step is the per-subset-difference `0x0c` table (one 16-byte
|
||||
// C per slot) — the SAME source and index `walk_processing_key` uses. `0x2d`
|
||||
// holds VARIANTS + Nonce, NOT C. Fall back to `0x07`/`0x05` for the synthetic
|
||||
// fixtures that keep a single cvalue there.
|
||||
let cvalues = mkb_find_body(mkb_records, REC_MEDIA_KEY_VARIANT_DATA)
|
||||
.or_else(|| mkb_find_body(mkb_records, REC_EXPLICIT_SUBSET_DIFF))
|
||||
.or_else(|| mkb_find_body(mkb_records, REC_MEDIA_KEY_DATA))
|
||||
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let m = VariantMkb {
|
||||
records: mkb_records,
|
||||
nonce,
|
||||
vkd_table,
|
||||
c_block,
|
||||
cvalues,
|
||||
mk_dv,
|
||||
};
|
||||
|
||||
@@ -509,15 +567,109 @@ pub fn derive_media_key_variant(
|
||||
Err(correction.unwrap_or(MediaKeyVariantError::ProcessingKeyUnavailable))
|
||||
}
|
||||
|
||||
/// Run the variant chain from a caller-supplied Processing Key and EXPLICIT
|
||||
/// per-slot inputs — the harness entry that tries a captured `Kp` against known
|
||||
/// slot material, bypassing both the device-key walk and the on-MKB
|
||||
/// `VARIANTS[uv]` lookup. The caller supplies the `0x0c` C block, the slot's
|
||||
/// subset-difference number `uv`, and its `VARIANTS[uv]`; the MKB supplies the
|
||||
/// Nonce, the VKD table, and the Verify-Media-Key value.
|
||||
///
|
||||
/// Returns `(Km, Kvu)`. The terminal Verify-Media-Key gate is identical to
|
||||
/// [`derive_media_key_variant`], so a wrong `c_block` / `uv` / `variants_uv`
|
||||
/// returns [`MediaKeyVariantError::MediaKeyVerifyFailed`] rather than a bogus
|
||||
/// key. The soft-correction / online-challenge bits on `Kmp[15]` are classified
|
||||
/// the same way, so a slot needing an out-of-band correction path is
|
||||
/// distinguishable from a non-matching input.
|
||||
///
|
||||
/// (Note the KCD caveat on [`KEY_CORRECTION_DATA`]: without the real per-licensee
|
||||
/// KCD this fails the verify gate on a real disc — a key-acquisition gap.)
|
||||
pub fn media_key_variant_from_kp(
|
||||
kp: &[u8; 16],
|
||||
c_block: &[u8; 16],
|
||||
uv: u32,
|
||||
variants_uv: u16,
|
||||
mkb_records: &[MkbRecord],
|
||||
vid: &[u8; 16],
|
||||
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
|
||||
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
|
||||
// Kmp = AES-128D(Kp, C) XOR uv.
|
||||
let mut kmp = aes_ecb_decrypt(kp, c_block);
|
||||
let uv_bytes = uv.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
kmp[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
if kmp[15] & 0b0000_0010 != 0 {
|
||||
return Err(MediaKeyVariantError::SoftCorrectionRequired);
|
||||
}
|
||||
if kmp[15] & 0b0000_0100 != 0 {
|
||||
return Err(MediaKeyVariantError::OnlineChallengeRequired);
|
||||
}
|
||||
|
||||
// Kpnew = Kmp XOR KCD.
|
||||
let mut kpnew = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
|
||||
}
|
||||
|
||||
// Kvn = AES-G(Kp, Nonce) & 0xFFFF; VKD_idx = Kvn XOR VARIANTS[uv].
|
||||
let kvn_block = aes_g(kp, &nonce);
|
||||
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
|
||||
let vkd_idx = kvn ^ variants_uv;
|
||||
let off = (vkd_idx as usize) * 16;
|
||||
if off + 16 > vkd_table.len() {
|
||||
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
|
||||
}
|
||||
let mut vkd = [0u8; 16];
|
||||
vkd.copy_from_slice(&vkd_table[off..off + 16]);
|
||||
|
||||
// Km = AES-128D(Kpnew, VKD) XOR uv, then the authoritative Verify-Media-Key gate.
|
||||
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
|
||||
for i in 0..4 {
|
||||
km[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
if aes_ecb_decrypt(&km, &mk_dv)[..8] != VERIFY_MAGIC {
|
||||
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
|
||||
}
|
||||
|
||||
// Kvu = AES-G(Km, VID).
|
||||
let kvu = aes_g(&km, vid);
|
||||
Ok((km, kvu))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// These three live in `super::keys` now (consolidated SD-walk helpers);
|
||||
// These three live in `super::derive` now (consolidated SD-walk helpers);
|
||||
// `use super::*` does not re-export the parent module's private `use`
|
||||
// imports, so pull them in directly for the tests below.
|
||||
use super::super::crypto::aesg3;
|
||||
use super::super::derive::calc_pk_from_dk;
|
||||
|
||||
/// `ProcessingKeyMatch` carries the Processing Key (`kp`) and `cvalue` raw;
|
||||
/// `Debug` must redact both. Non-secret `uv`/`cvalue_index` are not 213.
|
||||
#[test]
|
||||
fn processing_key_match_debug_is_redacted() {
|
||||
let m = ProcessingKeyMatch {
|
||||
kp: [0xD5; 16],
|
||||
uv: 1,
|
||||
cvalue: [0xD5; 16],
|
||||
cvalue_index: 2,
|
||||
};
|
||||
let dbg = format!("{m:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"ProcessingKeyMatch leaked kp/cvalue: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"ProcessingKeyMatch missing marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
|
||||
// Regression for the unbounded-loop hang: pick a (dev_key_v_mask,
|
||||
@@ -710,21 +862,24 @@ mod tests {
|
||||
aes_d_result[15] ^= 0x02;
|
||||
let c_block = aes_ecb_encrypt(&kp, &aes_d_result);
|
||||
|
||||
// cvalues record (0x07): one 16-byte cvalue. The walker
|
||||
// indexes it for the magic-check step; on a variant MKB the
|
||||
// magic check fails but `variant_present` is true so the
|
||||
// walker still returns the match. Content is don't-care.
|
||||
// cvalues record (0x07): the per-SD C the chain reads for `Kmp`. This
|
||||
// fixture has no `0x0c`, so both the walk and the chain fall back to
|
||||
// `0x07` — plant the computed `c_block` HERE so `AES-D(Kp, C) XOR uv ==
|
||||
// Kmp` and the chosen `kmp15` bit lands. On a variant MKB the per-match
|
||||
// magic check fails, but `variant_present` is true, so the walk still
|
||||
// returns the match.
|
||||
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
mkb.extend_from_slice(&c_block);
|
||||
|
||||
// Verify Media Key (0x86): body content is don't-care.
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xCD; 16]);
|
||||
|
||||
// 0x2d record: Encrypted Media Key Variant Data — C (head 16) then a
|
||||
// trailing 16-byte Nonce (variant_nonce reads the tail), 32-byte body.
|
||||
// 0x2d record: VARIANTS table (head, don't-care for these tests) then the
|
||||
// trailing 16-byte Nonce (`variant_nonce` reads the tail), 32-byte body.
|
||||
// (C is NOT here — it is the `0x07`/`0x0c` cvalue above.)
|
||||
mkb.extend_from_slice(&[0x2d, 0x00, 0x00, 0x24]);
|
||||
mkb.extend_from_slice(&c_block);
|
||||
mkb.extend_from_slice(&[0x11; 16]);
|
||||
mkb.extend_from_slice(&[0x77; 16]);
|
||||
|
||||
// 0x2f record: Variant Key Data table — one 16-byte entry.
|
||||
@@ -769,7 +924,7 @@ mod tests {
|
||||
fn mkb_records_matches_walk_mkb_framing() {
|
||||
// The lazy `mkb_records` iterator and the owning `walk_mkb` must agree on
|
||||
// (offset, type, len) for every record — they share the one framing
|
||||
// walker, and every keys.rs MKB walk now relies on this equivalence.
|
||||
// walker, and every aacs::resolve/derive MKB walk now relies on this equivalence.
|
||||
let mut mkb = vec![0x10, 0x00, 0x00, 0x06, 0xAA, 0xBB];
|
||||
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 1, 2, 3, 4]);
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0xFF]); // terminator + trailing
|
||||
@@ -1023,4 +1178,31 @@ mod tests {
|
||||
let codes: HashSet<String> = cases.iter().map(|e| e.to_string()).collect();
|
||||
assert_eq!(codes.len(), cases.len(), "all error codes must be unique");
|
||||
}
|
||||
|
||||
/// `media_key_variant_from_kp` runs the full chain from explicit inputs and
|
||||
/// classifies the `Kmp[15]` soft-correction bit. A `c_block` chosen so
|
||||
/// `AES-D(Kp, C) == Kmp` with bit `0x02` set (uv=0) must surface
|
||||
/// `SoftCorrectionRequired` before it touches the VKD / verify steps —
|
||||
/// proving the explicit-input entry runs the same chain and gates.
|
||||
#[test]
|
||||
fn media_key_variant_from_kp_classifies_soft_correction() {
|
||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||
let kp = [0x11u8; 16];
|
||||
// Plant Kmp[15]=0x02 (soft-correction) with uv=0 so Kmp == AES-D(kp, C).
|
||||
let mut target_kmp = [0x00u8; 16];
|
||||
target_kmp[15] = 0x02;
|
||||
let c_block = aes_ecb_encrypt(&kp, &target_kmp);
|
||||
// Minimal variant MKB: 0x2d (16-byte body = tail Nonce), 0x2f (one VKD
|
||||
// entry), 0x86 (Verify-Media-Key).
|
||||
let mut mkb = vec![0x2d, 0x00, 0x00, 0x14];
|
||||
mkb.extend_from_slice(&[0x99; 16]);
|
||||
mkb.extend_from_slice(&[0x2f, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xBB; 16]);
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xCD; 16]);
|
||||
let recs = walk_mkb(&mkb);
|
||||
let err = media_key_variant_from_kp(&kp, &c_block, 0, 0, &recs, &[0u8; 16])
|
||||
.expect_err("soft-correction bit → classified, not a key");
|
||||
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -236,7 +236,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
}
|
||||
|
||||
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
|
||||
/// 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.
|
||||
|
||||
+727
-781
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
|
||||
|
||||
+52
-18
@@ -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:
|
||||
@@ -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));
|
||||
@@ -311,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 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))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::inf::parse_content_cert);
|
||||
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::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
|
||||
@@ -432,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
|
||||
@@ -572,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];
|
||||
@@ -584,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).
|
||||
@@ -980,10 +1014,10 @@ mod tests {
|
||||
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
||||
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::boil::UnitKey>> {
|
||||
) -> Result<Vec<crate::aacs::types::UnitKey>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::types::HostCert> {
|
||||
|
||||
+118
-94
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
+1397
File diff suppressed because it is too large
Load Diff
-1670
File diff suppressed because it is too large
Load Diff
+1092
-2826
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
-1022
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",
|
||||
|
||||
+617
-72
@@ -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::boil::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::boil::mk_from_dk`] / [`crate::aacs::boil::vuk_from_mk`] /
|
||||
//! [`crate::aacs::boil::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,11 +18,69 @@
|
||||
//! keeping key *policy* (which store, which order, online vs local) out of the
|
||||
//! library.
|
||||
|
||||
use crate::aacs::boil::{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.
|
||||
@@ -56,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
|
||||
@@ -76,7 +135,7 @@ pub trait ResolveCtx {
|
||||
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::inf::parse_unit_key_ro`]),
|
||||
/// in on-disc order. Feed straight into [`crate::aacs::boil::uk_from_vuk`].
|
||||
/// in on-disc order. Feed straight into [`crate::aacs::derive::decrypt_unit_key`].
|
||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error>;
|
||||
/// Up to `n` encrypted on-disc content sample units, for a source that
|
||||
/// validates a candidate server-side against real ciphertext.
|
||||
@@ -166,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::boil::uk_from_vuk`]; one that holds device keys calls
|
||||
/// [`crate::aacs::boil::mk_from_dk`] → [`crate::aacs::boil::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
|
||||
@@ -214,14 +297,14 @@ pub fn resolve_and_apply(
|
||||
/// [`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::boil::uk_from_vuk`]; the library's canonical CPS-unit number is
|
||||
/// from [`crate::aacs::derive::decrypt_unit_key`]; the library's canonical CPS-unit number is
|
||||
/// `position + 1` (matching [`crate::aacs::inf::parse_unit_key_ro`]'s `(i + 1)`), so
|
||||
/// 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
|
||||
@@ -242,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
|
||||
@@ -292,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 {
|
||||
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
|
||||
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.
|
||||
let ctx = DiscInputsCtx::new(&di);
|
||||
fetch_unit_keys(&sources, &ctx)
|
||||
.into_iter()
|
||||
.map(|u| u.key)
|
||||
.collect()
|
||||
})
|
||||
// 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.
|
||||
let ctx = DiscInputsCtx::new(&di);
|
||||
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
|
||||
@@ -343,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::content::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::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
|
||||
// Probe several evenly-spaced points across EACH extent rather than only the
|
||||
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
|
||||
@@ -400,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;
|
||||
@@ -415,9 +615,49 @@ pub fn read_encrypted_units(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::boil::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.
|
||||
@@ -429,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())
|
||||
}
|
||||
}
|
||||
@@ -486,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 {
|
||||
@@ -543,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)])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,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)])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,7 +858,7 @@ mod tests {
|
||||
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0xEEu8; crate::aacs::content::ALIGNED_UNIT_LEN]];
|
||||
let got = cb(&samples);
|
||||
let got = cb.unit_keys(&samples);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![key],
|
||||
@@ -638,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.
|
||||
@@ -645,7 +1098,7 @@ mod tests {
|
||||
/// finds the early scrambled band.
|
||||
#[test]
|
||||
fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() {
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
|
||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||
use crate::error::Result;
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
@@ -715,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+249
-56
@@ -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);
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: frame_pts_ns,
|
||||
keyframe: true,
|
||||
data: data[start..start + frame_size].to_vec(),
|
||||
duration_ns: Some(duration_ns),
|
||||
});
|
||||
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: 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");
|
||||
}
|
||||
}
|
||||
+765
-55
@@ -37,7 +37,7 @@ pub struct DtsParser {
|
||||
/// timestamp instead of the later PES's. Offsets are kept relative to the
|
||||
/// current `buf` start and rebased whenever bytes are drained from the
|
||||
/// front.
|
||||
pts_marks: Vec<(usize, i64)>,
|
||||
pts_marks: std::collections::VecDeque<(usize, i64)>,
|
||||
/// The `front_pts` of the PREVIOUS emitted access unit. When the current
|
||||
/// AU's `front_pts` differs, it began a new PES → re-base to it. When it is
|
||||
/// unchanged, this AU shares the previous AU's PES → advance one frame
|
||||
@@ -49,6 +49,12 @@ pub struct DtsParser {
|
||||
/// running cursor: previous emit + its duration). Only consulted when
|
||||
/// `front_pts` is unchanged from `last_front_pts`. `PTS_UNSET` = no base yet.
|
||||
next_pts_ns: i64,
|
||||
/// Keep/drop bookkeeping for the decodability gate: counts, per-drop and
|
||||
/// aggregate logging, and the whole-track poison fallback. A dropped AU is
|
||||
/// NEVER emitted, but the PTS clock is still advanced across it (see
|
||||
/// [`stamp_pts`] usage) so every SURVIVING AU keeps the exact timestamp it
|
||||
/// would have had — a drop becomes a silence gap, never a shift.
|
||||
tally: super::dropgate::DropTally,
|
||||
}
|
||||
|
||||
impl Default for DtsParser {
|
||||
@@ -62,9 +68,53 @@ impl DtsParser {
|
||||
Self {
|
||||
buf: Vec::with_capacity(32768),
|
||||
pending_pts: 0,
|
||||
pts_marks: Vec::new(),
|
||||
pts_marks: std::collections::VecDeque::new(),
|
||||
last_front_pts: PTS_UNSET,
|
||||
next_pts_ns: PTS_UNSET,
|
||||
tally: super::dropgate::DropTally::new("dts"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of access units dropped as undecodable so far. The mux/CLI reads
|
||||
/// this to surface the count ("dropped N damaged DTS frames").
|
||||
pub fn dropped_frames(&self) -> u64 {
|
||||
self.tally.dropped_frames()
|
||||
}
|
||||
|
||||
/// Total decoded duration (ns) of all dropped access units — the length of
|
||||
/// audio silence introduced by dropping undecodable frames.
|
||||
pub fn dropped_duration_ns(&self) -> u64 {
|
||||
self.tally.dropped_duration_ns()
|
||||
}
|
||||
|
||||
/// Gate an assembled access unit through the decodability check and either
|
||||
/// push it or drop it. `au_pts`/`dur_ns` are already stamped on the shared
|
||||
/// PTS clock (which the caller advances whether or not the AU survives), so
|
||||
/// a drop leaves the following audio on its true timeline — a gap, not a
|
||||
/// shift. Every drop is logged (fail-loud, never silent).
|
||||
fn emit_or_drop(&mut self, au: Vec<u8>, au_pts: i64, dur_ns: i64, out: &mut Vec<Frame>) {
|
||||
let verdict = if self.tally.is_poisoned() {
|
||||
Err(DropReason::TrackPoisoned)
|
||||
} else {
|
||||
core_header_drop_reason(&au).map_or(Ok(()), Err)
|
||||
};
|
||||
match verdict {
|
||||
Ok(()) => {
|
||||
self.tally.record_kept();
|
||||
out.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: au_pts,
|
||||
keyframe: true,
|
||||
data: au,
|
||||
duration_ns: Some(dur_ns as u64),
|
||||
});
|
||||
}
|
||||
Err(reason) => {
|
||||
self.tally
|
||||
.record_drop(au_pts, dur_ns, au.len(), reason.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,13 +238,13 @@ impl CodecParser for DtsParser {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// A PES with no PTS (rare for audio, but legal — the case OSS demuxers
|
||||
// A PES with no PTS (rare for audio, but legal — the case demuxers
|
||||
// guard at a post-gap continuation) must NOT reset the timeline to 0;
|
||||
// continue from the most recent known base. Defense-in-depth: the
|
||||
// discontinuity-carrying PES is a PUSI with a PTS in practice.
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or_else(|| {
|
||||
self.pts_marks
|
||||
.last()
|
||||
.back()
|
||||
.map(|&(_, p)| p)
|
||||
.filter(|&p| p >= 0)
|
||||
.unwrap_or(if self.pending_pts >= 0 {
|
||||
@@ -230,7 +280,10 @@ impl CodecParser for DtsParser {
|
||||
// (see `front_pts`), so an AU whose core arrived in an earlier PES keeps
|
||||
// that core's timestamp even when its extensions / the following core
|
||||
// arrive (with a later PTS) in this same parse() call.
|
||||
self.pts_marks.push((self.buf.len(), pts_ns));
|
||||
// (pts_marks is bounded implicitly: an empty PES returns above without
|
||||
// pushing a mark, and a non-empty run grows `buf`, which is cleared —
|
||||
// along with pts_marks — once it exceeds MAX_AU_BYTES.)
|
||||
self.pts_marks.push_back((self.buf.len(), pts_ns));
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
|
||||
let mut frames = Vec::new();
|
||||
@@ -296,9 +349,18 @@ impl CodecParser for DtsParser {
|
||||
// flush is an extension-substream PES, carrying its own later
|
||||
// timestamp) must NOT become the next unit's PTS base.
|
||||
let mut forced = false;
|
||||
let au_end = match next_core_boundary(&self.buf, core_size) {
|
||||
NextCore::Found(end) => end,
|
||||
NextCore::NeedMore => break, // candidate sync needs more header
|
||||
let (au_end, ext_clean) = match next_core_boundary(&self.buf, core_size) {
|
||||
NextCore::Found { end, ext_clean } => (end, ext_clean),
|
||||
NextCore::NeedMore if self.buf.len() <= MAX_AU_BYTES => break,
|
||||
NextCore::NeedMore => {
|
||||
// A candidate boundary exists but is not fully buffered. Normally
|
||||
// we wait for more PES; but once the buffer exceeds the AU cap,
|
||||
// apply the same force-flush safety valve as `None` so a crafted
|
||||
// stream that keeps a boundary perpetually incomplete can't grow
|
||||
// `buf` without bound (the `break` above never reaches it).
|
||||
forced = true;
|
||||
(self.buf.len(), true)
|
||||
}
|
||||
NextCore::None => {
|
||||
// No next core sync buffered yet. The trailing extension
|
||||
// substream PES packets may still be arriving, so WAIT for
|
||||
@@ -309,27 +371,32 @@ impl CodecParser for DtsParser {
|
||||
break;
|
||||
}
|
||||
forced = true;
|
||||
self.buf.len()
|
||||
(self.buf.len(), true)
|
||||
}
|
||||
};
|
||||
|
||||
let au: Vec<u8> = self.buf[..au_end].to_vec();
|
||||
// Damaged source encoding: when the extension boundary was GARBAGE
|
||||
// (not any DTS sync — `ext_clean == false`), the extension bytes for
|
||||
// this AU are corrupt and would make the decoder cascade "DSYNC check
|
||||
// failed" / "Read past end of XLL band data". Emit the clean DTS core
|
||||
// ALONE (a decodable, lossy frame) and still drain past the garbage to
|
||||
// the next core — a perfect mux drops the bad frame's corrupt part
|
||||
// rather than shipping it. A recognized-but-unsizeable extension
|
||||
// (`ext_clean == true`) is preserved in full (lossless).
|
||||
let emit_end = if ext_clean { au_end } else { core_size };
|
||||
let au: Vec<u8> = self.buf[..emit_end].to_vec();
|
||||
// The AU's own core PES PTS (the PES covering its first byte, even if
|
||||
// that PES preceded the one(s) carrying its extensions or the next
|
||||
// core), stamped monotonically: honored when it advances past the
|
||||
// running clock (UHD one-AU-per-PES), but never allowed to collide
|
||||
// with the previous AU when several cores share ONE PES (DVD).
|
||||
let dur_ns = dts_core_duration_ns(&au) as i64;
|
||||
// Advance the PTS clock for this AU BEFORE the decodability gate, so
|
||||
// a dropped AU still advances the timeline exactly as an emitted one
|
||||
// would: the following AU keeps its true PTS and the drop is a gap,
|
||||
// never a shift. `emit_or_drop` decides whether to actually push it.
|
||||
let au_pts = self.stamp_pts(self.front_pts(), dur_ns);
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: au_pts,
|
||||
keyframe: true,
|
||||
data: au,
|
||||
duration_ns: Some(dur_ns as u64),
|
||||
});
|
||||
self.emit_or_drop(au, au_pts, dur_ns, &mut frames);
|
||||
self.drain_front(au_end);
|
||||
// After draining, the marker covering the new front (if any) carries
|
||||
// the next AU's PTS; `pending_pts` is only the fallback when no
|
||||
@@ -356,10 +423,23 @@ impl CodecParser for DtsParser {
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
// End of stream: emit the final access unit still buffered (the last
|
||||
// core + its extension substreams, which had no following core sync to
|
||||
// close it during streaming). Require a complete core frame; drop a
|
||||
// bare partial sync tail.
|
||||
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>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl DtsParser {
|
||||
/// Emit the final buffered access unit (the last core + its extension
|
||||
/// substreams, which had no following core sync to close it during
|
||||
/// streaming), gated through the decodability check. Require a complete core
|
||||
/// frame; drop a bare partial sync tail.
|
||||
fn flush_tail(&mut self) -> Vec<Frame> {
|
||||
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) || self.buf.len() < CORE_HEADER_MIN_BYTES
|
||||
{
|
||||
self.buf.clear();
|
||||
@@ -378,19 +458,9 @@ impl CodecParser for DtsParser {
|
||||
let dur_ns = dts_core_duration_ns(&au) as i64;
|
||||
let pts_ns = self.stamp_pts(self.front_pts(), dur_ns);
|
||||
self.pts_marks.clear();
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: au,
|
||||
duration_ns: Some(dur_ns as u64),
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
let mut out = Vec::new();
|
||||
self.emit_or_drop(au, pts_ns, dur_ns, &mut out);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +474,14 @@ fn find_sync(data: &[u8], pattern: &[u8; 4]) -> Option<usize> {
|
||||
/// Result of scanning for the next valid core sync that closes an access unit.
|
||||
enum NextCore {
|
||||
/// A valid next core sync was found; the access unit ends at this offset.
|
||||
Found(usize),
|
||||
/// `ext_clean` is `false` only when the byte at the extension boundary was
|
||||
/// GARBAGE — neither a core sync nor a DTS-HD extension sync — meaning the
|
||||
/// extension region is corrupt (damaged source encoding). The caller then
|
||||
/// emits the clean DTS core alone and drops the garbage, instead of shipping
|
||||
/// a corrupt AU that makes the decoder cascade DSYNC / "Read past end of XLL".
|
||||
/// It stays `true` when the region is a real (if unsizeable) extension sync —
|
||||
/// that path is load-bearing for valid streams and must NOT be dropped.
|
||||
Found { end: usize, ext_clean: bool },
|
||||
/// A candidate core sync was found but its header isn't fully buffered yet,
|
||||
/// so its validity can't be decided — wait for more data.
|
||||
NeedMore,
|
||||
@@ -489,8 +566,11 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
|
||||
}
|
||||
pos += sz; // skip the whole extension substream precisely
|
||||
}
|
||||
// Couldn't size it (truncated/garbage header) — heuristic fallback.
|
||||
_ => return scan_for_next_core(buf, pos),
|
||||
// A real extension sync we couldn't size (truncated header /
|
||||
// unsupported sub-form) — heuristic fallback, but the region IS a
|
||||
// recognized extension, so keep it (ext_clean = true). This path
|
||||
// is load-bearing for valid streams.
|
||||
_ => return scan_for_next_core(buf, pos, true),
|
||||
}
|
||||
} else if buf[pos..].starts_with(&DTS_CORE_SYNC) {
|
||||
// The bytes right after the precisely-skipped extensions are the next
|
||||
@@ -500,13 +580,18 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
|
||||
}
|
||||
let sz = dts_core_frame_size(&buf[pos..]);
|
||||
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
|
||||
return NextCore::Found(pos);
|
||||
return NextCore::Found {
|
||||
end: pos,
|
||||
ext_clean: true,
|
||||
};
|
||||
}
|
||||
return scan_for_next_core(buf, pos); // implausible core here — fall back
|
||||
return scan_for_next_core(buf, pos, true); // implausible core — recognized sync, keep
|
||||
} else {
|
||||
// Neither a known extension nor a core sync at the precise boundary
|
||||
// (padding / junk) — fall back to the heuristic scan.
|
||||
return scan_for_next_core(buf, pos);
|
||||
// GARBAGE at the extension boundary — neither a core sync nor a
|
||||
// DTS-HD extension sync. This is damaged source encoding: the
|
||||
// extension region is corrupt. Mark ext_clean = false so the caller
|
||||
// emits the clean core alone and drops the garbage.
|
||||
return scan_for_next_core(buf, pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,7 +600,7 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
|
||||
/// syncword whose decoded size is plausible. Used only when precise extension
|
||||
/// skipping can't proceed; a chance core syncword in extension payload usually
|
||||
/// decodes to an implausible size and is skipped.
|
||||
fn scan_for_next_core(buf: &[u8], from: usize) -> NextCore {
|
||||
fn scan_for_next_core(buf: &[u8], from: usize, ext_clean: bool) -> NextCore {
|
||||
let mut from = from;
|
||||
while let Some(rel) = find_sync(&buf[from..], &DTS_CORE_SYNC) {
|
||||
let pos = from + rel;
|
||||
@@ -524,7 +609,10 @@ fn scan_for_next_core(buf: &[u8], from: usize) -> NextCore {
|
||||
}
|
||||
let sz = dts_core_frame_size(&buf[pos..]);
|
||||
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
|
||||
return NextCore::Found(pos);
|
||||
return NextCore::Found {
|
||||
end: pos,
|
||||
ext_clean,
|
||||
};
|
||||
}
|
||||
from = pos + SYNCWORD_BYTES;
|
||||
}
|
||||
@@ -564,8 +652,8 @@ const DTS_CORE_SAMPLE_RATES: [u32; 16] = [
|
||||
];
|
||||
|
||||
/// Samples in one DTS core frame: `(NBLKS + 1) * 32`. `NBLKS` (7 bits) is the
|
||||
/// core-header PCM-sample-block count — the same field ffmpeg's `dca` decoder
|
||||
/// uses to timestamp frames. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5)
|
||||
/// core-header PCM-sample-block count (ETSI TS 102 114) that fixes the frame's
|
||||
/// decoded sample count. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5)
|
||||
/// CPF(1) **NBLKS(7)** FSIZE(14) …, so NBLKS = byte4 bit0 + byte5 bits7-2.
|
||||
fn dts_core_samples(data: &[u8]) -> u32 {
|
||||
if data.len() < CORE_HEADER_MIN_BYTES {
|
||||
@@ -594,6 +682,145 @@ fn dts_core_duration_ns(data: &[u8]) -> u64 {
|
||||
(samples * 1_000_000_000 + rate / 2) / rate
|
||||
}
|
||||
|
||||
/// DTS core-header validity constants (ETSI TS 102 114).
|
||||
/// For a NORMAL frame `deficit_samples` must equal this (`DTS_PCMBLOCK_SAMPLES`)
|
||||
/// — a termination frame may carry fewer; `npcmblocks` must be a multiple of
|
||||
/// `DTS_SUBBAND_SAMPLES`; `audio_mode` must be below `DTS_AMODE_COUNT`;
|
||||
/// `lfe_present == DTS_LFE_FLAG_INVALID` is rejected.
|
||||
const DTS_PCMBLOCK_SAMPLES: u32 = 32;
|
||||
const DTS_SUBBAND_SAMPLES: u32 = 8;
|
||||
/// Number of LEGAL `AMODE` (channel-arrangement) codes. The 6-bit AMODE field
|
||||
/// (ETSI TS 102 114 §5.3.1) has 16 defined channel arrangements, codes 0-15;
|
||||
/// only 16-63 are reserved/user-defined and undecodable. ffmpeg's
|
||||
/// `ff_dca_channels[16] = {1,2,2,2,2,3,3,4,4,5,6,6,6,7,8,8}` confirms all 16 are
|
||||
/// decodable — codes 10-15 are the 6/7/8-channel layouts. A frame is dropped
|
||||
/// only when `audio_mode >= DTS_AMODE_COUNT` (i.e. a truly reserved 16-63 code);
|
||||
/// dropping a legal 10-15 multichannel core would silence recoverable audio.
|
||||
const DTS_AMODE_COUNT: u32 = 16;
|
||||
const DTS_LFE_FLAG_INVALID: u32 = 3;
|
||||
|
||||
/// Sample rate (Hz) per core `SFREQ` code (ETSI TS 102 114 Table 6-4); a `0`
|
||||
/// entry marks a reserved code that fails header validation as an invalid
|
||||
/// sample rate. Valid entries are locked to the spec by
|
||||
/// `dts_core_sfreq_table_matches_the_dca_spec`; the reserved codes are
|
||||
/// {0, 4, 5, 9, 10}.
|
||||
const DTS_CORE_SR_VALID: [u32; 16] = [
|
||||
0, 8_000, 16_000, 32_000, 0, 0, 11_025, 22_050, 44_100, 0, 0, 12_000, 24_000, 48_000, 96_000,
|
||||
192_000,
|
||||
];
|
||||
|
||||
/// Bits per sample per core `PCMR` code (ETSI TS 102 114); a `0` entry marks a
|
||||
/// reserved `PCMR` code that fails header validation as an invalid PCM
|
||||
/// resolution; reserved codes are {4, 7}.
|
||||
const DTS_CORE_PCMR_BITS: [u8; 8] = [16, 16, 20, 20, 0, 24, 24, 0];
|
||||
|
||||
/// Why an access unit was judged undecodable. Each core-header variant is a
|
||||
/// condition under which the DTS core-frame header (ETSI TS 102 114) is invalid
|
||||
/// and a decoder would reject the frame; `TrackPoisoned` is our whole-track drop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DropReason {
|
||||
DeficitSamples,
|
||||
PcmBlocks,
|
||||
FrameSize,
|
||||
Amode,
|
||||
SampleRate,
|
||||
LfeFlag,
|
||||
PcmRes,
|
||||
TrackPoisoned,
|
||||
}
|
||||
|
||||
impl DropReason {
|
||||
/// Short static label for the drop log (the shared tally logs `&str`).
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DropReason::DeficitSamples => "deficit-samples",
|
||||
DropReason::PcmBlocks => "pcm-blocks",
|
||||
DropReason::FrameSize => "frame-size",
|
||||
DropReason::Amode => "audio-mode",
|
||||
DropReason::SampleRate => "sample-rate",
|
||||
DropReason::LfeFlag => "lfe-flag",
|
||||
DropReason::PcmRes => "pcm-resolution",
|
||||
DropReason::TrackPoisoned => "track-poisoned",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodability gate: the core-frame header validity checks from ETSI TS 102
|
||||
/// 114. Returns `Some(reason)` when the DTS core-frame header is invalid — in
|
||||
/// which case the packet is undecodable ("Invalid data found") and dropping it
|
||||
/// loses nothing a decoder could have used. Returns `None` (keep) for a
|
||||
/// decodable header OR if the header can't be fully read (never false-drop on
|
||||
/// our own buffer underrun; the framer only emits AUs whose core is fully
|
||||
/// buffered and ≥ 96 bytes).
|
||||
///
|
||||
/// The 4-byte core sync is already validated by the framer, so this reads the
|
||||
/// header fields that follow it. The 16-bit CPF header CRC is not verified (we
|
||||
/// skip past it) and the audio-header/side-info CRCs are likewise not checked,
|
||||
/// because decoders treat those bytes as optional/ignored — verifying them
|
||||
/// would drop frames that decode fine (false positives).
|
||||
fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> {
|
||||
let mut r = BitReader::new(au.get(SYNCWORD_BYTES..)?);
|
||||
|
||||
// FTYPE: 1 = NORMAL frame, 0 = TERMINATION frame (the last frame of the
|
||||
// stream). Per ETSI TS 102 114 and both reference decoders — ffmpeg's
|
||||
// `ff_dca_parse_core_frame_header` (`normal_frame && deficit_samples !=
|
||||
// DCA_PCMBLOCK_SAMPLES`) and dcadec's `parse_frame_header` (which branches
|
||||
// on `normal_frame`) — the deficit-sample field must equal 32 ONLY for a
|
||||
// normal frame. A termination frame legitimately carries fewer samples and
|
||||
// is fully decodable; dropping it would silence the last frame of every
|
||||
// stream that ends on one (a guaranteed per-track loss on real discs).
|
||||
let normal_frame = r.read_bit()? == 1;
|
||||
let deficit_samples = r.read_bits(5)? + 1;
|
||||
if normal_frame && deficit_samples != DTS_PCMBLOCK_SAMPLES {
|
||||
return Some(DropReason::DeficitSamples);
|
||||
}
|
||||
let crc_present = r.read_bit()? == 1;
|
||||
let npcmblocks = r.read_bits(7)? + 1;
|
||||
if npcmblocks & (DTS_SUBBAND_SAMPLES - 1) != 0 {
|
||||
return Some(DropReason::PcmBlocks);
|
||||
}
|
||||
let frame_size = r.read_bits(14)? + 1;
|
||||
if frame_size < MIN_CORE_FRAME_BYTES as u32 {
|
||||
return Some(DropReason::FrameSize);
|
||||
}
|
||||
let audio_mode = r.read_bits(6)?;
|
||||
if audio_mode >= DTS_AMODE_COUNT {
|
||||
return Some(DropReason::Amode);
|
||||
}
|
||||
let sr_code = r.read_bits(4)? as usize;
|
||||
if DTS_CORE_SR_VALID[sr_code] == 0 {
|
||||
return Some(DropReason::SampleRate);
|
||||
}
|
||||
let _br_code = r.read_bits(5)?;
|
||||
// Reserved bit. Both reference decoders SKIP this field rather than reject
|
||||
// on it — ffmpeg (`skip_bits1`) and dcadec (`bits_skip1`, comment "Reserved
|
||||
// field"). A frame that sets it is still fully decodable, so rejecting it
|
||||
// was a false-drop that silenced any real stream whose encoder set the bit.
|
||||
// Read past it without gating (never reject a decodable frame).
|
||||
let _reserved = r.read_bit()?;
|
||||
// drc, ts, aux, hdcd (1 each) → ext_audio_type (3) → ext_present, aspf (1 each).
|
||||
r.skip_bits(4)?;
|
||||
r.skip_bits(3)?;
|
||||
r.skip_bits(2)?;
|
||||
let lfe_present = r.read_bits(2)?;
|
||||
if lfe_present == DTS_LFE_FLAG_INVALID {
|
||||
return Some(DropReason::LfeFlag);
|
||||
}
|
||||
let _predictor_history = r.read_bit()?;
|
||||
if crc_present {
|
||||
// Skip past the 16-bit header CRC here — it is not verified.
|
||||
r.skip_bits(16)?;
|
||||
}
|
||||
let _filter_perfect = r.read_bit()?;
|
||||
let _encoder_rev = r.read_bits(4)?;
|
||||
let _copy_hist = r.read_bits(2)?;
|
||||
let pcmr_code = r.read_bits(3)? as usize;
|
||||
if DTS_CORE_PCMR_BITS[pcmr_code] == 0 {
|
||||
return Some(DropReason::PcmRes);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -614,6 +841,12 @@ mod tests {
|
||||
let fsize = size - 1;
|
||||
let mut data = vec![0u8; size];
|
||||
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
|
||||
// byte4: FTYPE(1) SHORT(5) CPF(0) NBLKS-high(0). FTYPE = 1 = a NORMAL
|
||||
// frame (the common real-stream case); SHORT = 31 makes deficit_samples
|
||||
// = 32 = DTS_PCMBLOCK_SAMPLES, which the decodability gate (per ETSI TS
|
||||
// 102 114) requires of a normal frame. NBLKS high bit (byte4 bit0) stays
|
||||
// 0 for NBLKS = 15. (0x80 | (31 << 2) = 0xFC.)
|
||||
data[4] = 0x80 | (31u8 << 2);
|
||||
// NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm).
|
||||
// NBLKS is byte4 bit0 + byte5 bits7-2; here byte4 bit0 = 0, byte5 = 15<<2.
|
||||
data[5] = (15u8 << 2) | ((fsize >> 12) & 0x03) as u8;
|
||||
@@ -661,8 +894,8 @@ mod tests {
|
||||
// AU = core(512) + a REAL EXSS substream whose XLL payload embeds a DTS
|
||||
// core syncword decoding to a plausible size (512). The heuristic-only
|
||||
// framer would split here and truncate the lossless extension (the
|
||||
// Dunkirk `dca` "Failed to decode block code(s)" class). Precise EXSS
|
||||
// sizing spans the whole extension to the REAL next core.
|
||||
// Dunkirk "Failed to decode block code(s)" decoder-failure class).
|
||||
// Precise EXSS sizing spans the whole extension to the REAL next core.
|
||||
let core = make_dts_core(512);
|
||||
let exss = make_exss(600, Some(40));
|
||||
let next = make_dts_core(512);
|
||||
@@ -673,12 +906,60 @@ mod tests {
|
||||
assert!(
|
||||
matches!(
|
||||
next_core_boundary(&buf, core.len()),
|
||||
NextCore::Found(end) if end == core.len() + exss.len()
|
||||
NextCore::Found { end, .. } if end == core.len() + exss.len()
|
||||
),
|
||||
"AU must end at the REAL next core (after the full EXSS), not the false sync inside it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_extension_emits_core_only_but_valid_ext_is_kept() {
|
||||
// Damaged source: a valid core, then GARBAGE (no core sync, no extension
|
||||
// sync) where the extension belongs, then the next core. The framer must
|
||||
// mark this boundary ext_clean=false and the parser must emit the clean
|
||||
// 512-byte CORE alone (dropping the garbage), draining to the next core.
|
||||
let core = make_dts_core(512);
|
||||
let garbage = vec![0xE4, 0x3F, 0xE3, 0x90, 0xCC, 0x6C]; // real Bourne head bytes
|
||||
let mut garbage = garbage;
|
||||
garbage.extend(std::iter::repeat(0xAB).take(300));
|
||||
let next = make_dts_core(512);
|
||||
let mut buf = core.clone();
|
||||
buf.extend_from_slice(&garbage);
|
||||
buf.extend_from_slice(&next);
|
||||
assert!(
|
||||
matches!(
|
||||
next_core_boundary(&buf, core.len()),
|
||||
NextCore::Found { end, ext_clean: false } if end == core.len() + garbage.len()
|
||||
),
|
||||
"garbage boundary must be flagged unclean"
|
||||
);
|
||||
|
||||
let mut parser = DtsParser::new();
|
||||
let mut frames = parser.parse(&make_pes(buf, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
assert!(!frames.is_empty());
|
||||
for f in &frames {
|
||||
assert_eq!(f.data.len(), 512, "garbage-extension AU emits core only");
|
||||
assert_eq!(&f.data[0..4], &DTS_CORE_SYNC);
|
||||
}
|
||||
|
||||
// Contrast: a REAL extension sync (even if unsizeable) must be KEPT in
|
||||
// full — ext_clean stays true, never downgraded to core-only.
|
||||
let mut buf2 = make_dts_core(512);
|
||||
buf2.extend_from_slice(&make_dts_ext(256));
|
||||
buf2.extend_from_slice(&make_dts_core(512));
|
||||
assert!(
|
||||
matches!(
|
||||
next_core_boundary(&buf2, 512),
|
||||
NextCore::Found {
|
||||
ext_clean: true,
|
||||
..
|
||||
}
|
||||
),
|
||||
"a recognized extension sync is preserved, not dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = DtsParser::new();
|
||||
@@ -721,9 +1002,9 @@ mod tests {
|
||||
// B1: a partial DTS core is buffered, then a concealed gap (PES marked
|
||||
// discontinuity) carries a fresh core. The truncated partial must be
|
||||
// DROPPED — splicing it makes the framer emit a corrupt sub-core-length
|
||||
// AU (the Dunkirk `dca` "Failed to decode block code(s)" class) and
|
||||
// strands the rest. With the fix the post-gap core is the only AU, and it
|
||||
// carries the post-gap PTS (not the stale pre-gap one).
|
||||
// AU (the Dunkirk "Failed to decode block code(s)" decoder-failure
|
||||
// class) and strands the rest. With the fix the post-gap core is the
|
||||
// only AU, and it carries the post-gap PTS (not the stale pre-gap one).
|
||||
let mut parser = DtsParser::new();
|
||||
|
||||
// PES 1: first half of a 512-byte core (no boundary marker).
|
||||
@@ -870,7 +1151,7 @@ mod tests {
|
||||
fn dvd_many_cores_one_pes_are_strictly_monotonic() {
|
||||
// Punisher-DVD reproduction: a single PES carrying SEVERAL DTS core
|
||||
// frames (the DVD packing) must emit STRICTLY-increasing PTSs. The old
|
||||
// code stamped every AU with the one PES PTS, which ffmpeg rejected as
|
||||
// code stamped every AU with the one PES PTS, which a muxer rejects as
|
||||
// "non monotonically increasing dts to muxer: X >= X".
|
||||
let mut parser = DtsParser::new();
|
||||
let mut stream = Vec::new();
|
||||
@@ -915,6 +1196,37 @@ mod tests {
|
||||
assert_eq!(dts_core_sample_rate(&core), 48_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dts_core_sfreq_table_matches_the_dca_spec() {
|
||||
// Lock the SFREQ → sample-rate table to the authoritative values in
|
||||
// ETSI TS 102 114 Table 6-4. The high-rate triad in particular —
|
||||
// 48 k / 96 k / 192 k at indices 13/14/15 — must not
|
||||
// be shifted; a wrong entry would compute an N× frame duration and
|
||||
// reintroduce PTS drift on a 96/192 kHz DTS stream.
|
||||
let mut core = make_dts_core(512);
|
||||
let set_sfreq = |c: &mut [u8], idx: u8| c[8] = (c[8] & !0x3C) | ((idx & 0x0F) << 2);
|
||||
for (idx, want) in [
|
||||
(1u8, 8_000u32),
|
||||
(2, 16_000),
|
||||
(3, 32_000),
|
||||
(6, 11_025),
|
||||
(7, 22_050),
|
||||
(8, 44_100),
|
||||
(11, 12_000),
|
||||
(12, 24_000),
|
||||
(13, 48_000),
|
||||
(14, 96_000),
|
||||
(15, 192_000),
|
||||
] {
|
||||
set_sfreq(&mut core, idx);
|
||||
assert_eq!(
|
||||
dts_core_sample_rate(&core),
|
||||
want,
|
||||
"SFREQ {idx} must be {want} Hz"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_pes_rebases_to_its_own_pts_no_drift() {
|
||||
// Regression for the drift bug: a global running clock overshot a
|
||||
@@ -922,6 +1234,14 @@ mod tests {
|
||||
// PES arrives whose PTS is BEHIND where accumulated frame durations
|
||||
// would put a running clock, the AU must re-base to that PES's OWN
|
||||
// timestamp — tracking the container, not drifting ahead of it.
|
||||
//
|
||||
// The re-base can make one emitted PTS sit just below the previous AU's
|
||||
// (a fresh PES whose PTS lands under the within-PES cursor). That is
|
||||
// CORRECT here and is NOT a muxer defect: the parser reports the true
|
||||
// container timestamps, and the mkv muxer applies the strictly-monotonic
|
||||
// per-track nudge to AUDIO at emit time (`mkv::block_ts` / `monotonic_ts`,
|
||||
// tested in `mkv.rs`), so the written block DTS is always monotonic. The
|
||||
// alternative — clamping in the parser — is what reintroduced the drift.
|
||||
let mut parser = DtsParser::new();
|
||||
// PES A: core1 + core2 (2 frames), pts 90000.
|
||||
let mut pes_a = make_dts_core(512);
|
||||
@@ -1171,6 +1491,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needmore_past_cap_force_flushes_to_bound_buffer() {
|
||||
// A crafted DTS-HD stream whose extension substream declares a size
|
||||
// larger than what is (ever) buffered keeps `next_core_boundary` in a
|
||||
// sustained NeedMore state (a candidate boundary that is never fully
|
||||
// buffered). Once `buf` exceeds MAX_AU_BYTES the NeedMore force-flush
|
||||
// safety valve must fire — mirroring the None arm — so the buffer can't
|
||||
// grow without bound. WITHOUT the guard the parser would `break` and
|
||||
// retain everything, emitting nothing.
|
||||
let mut parser = DtsParser::new();
|
||||
|
||||
let core = make_dts_core(512);
|
||||
// Short-form EXSS header declaring the maximum 16-bit size (65536 bytes);
|
||||
// we buffer only a truncated prefix of it, so the extension is never
|
||||
// "fully buffered" and the candidate boundary stays NeedMore.
|
||||
let full_ext = make_exss(65536, None);
|
||||
assert_eq!(exss_frame_size(&full_ext), Some(65536));
|
||||
|
||||
// Land the total buffer in (MAX_AU_BYTES, core_size + declared_ext_size):
|
||||
// 65600 > 65536 fires the cap; 65600 < 512 + 65536 = 66048 keeps NeedMore.
|
||||
let total = 65600usize;
|
||||
let mut data = core.clone();
|
||||
data.extend_from_slice(&full_ext[..total - core.len()]);
|
||||
assert!(data.len() > MAX_AU_BYTES, "buffer must exceed the AU cap");
|
||||
assert!(
|
||||
data.len() < core.len() + 65536,
|
||||
"extension must not be fully buffered (sustained NeedMore)"
|
||||
);
|
||||
assert!(
|
||||
matches!(next_core_boundary(&data, core.len()), NextCore::NeedMore),
|
||||
"the framing decision at this buffer size is NeedMore past the cap"
|
||||
);
|
||||
|
||||
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
1,
|
||||
"NeedMore past the AU cap must force-emit, not stall and balloon the buffer"
|
||||
);
|
||||
assert!(
|
||||
parser.buf.is_empty(),
|
||||
"the forced flush drains the buffer instead of growing it unbounded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = DtsParser::new();
|
||||
@@ -1436,4 +1801,349 @@ mod tests {
|
||||
"sub-floor sync skipped, real 512 core is AU1"
|
||||
);
|
||||
}
|
||||
|
||||
/// A structurally-framed but UNDECODABLE core: a valid `make_dts_core` whose
|
||||
/// LFE flag is set to the reserved value 3 (`DTS_LFE_FLAG_INVALID`). It still
|
||||
/// sizes and syncs correctly (so the framer delimits it normally), but the
|
||||
/// core-frame header validity check rejects it as an invalid LFE flag (ETSI
|
||||
/// TS 102 114; dcadec `LFE_FLAG_INVALID`). LFE is byte10 bits2-1, and does
|
||||
/// NOT feed the frame duration (NBLKS + SFREQ only), so a dropped bad core
|
||||
/// still carries the same `DTS_CORE_DUR_NS` as its good peers.
|
||||
fn make_bad_dts_core(size: usize) -> Vec<u8> {
|
||||
let mut d = make_dts_core(size);
|
||||
assert!(
|
||||
core_header_drop_reason(&d).is_none(),
|
||||
"base core is decodable"
|
||||
);
|
||||
d[10] |= 0x06; // LFE flag = 3 (invalid)
|
||||
assert_eq!(
|
||||
core_header_drop_reason(&d),
|
||||
Some(DropReason::LfeFlag),
|
||||
"invalid-LFE core must be judged undecodable"
|
||||
);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_stream_drops_nothing() {
|
||||
// A clean stream of decodable cores must pass the gate untouched — the
|
||||
// detector follows the spec's validity rules exactly, so zero false
|
||||
// positives.
|
||||
let mut parser = DtsParser::new();
|
||||
let mut stream = Vec::new();
|
||||
for _ in 0..5 {
|
||||
stream.extend_from_slice(&make_dts_core(512));
|
||||
}
|
||||
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(frames.len(), 5, "all five cores emitted");
|
||||
assert_eq!(parser.dropped_frames(), 0, "nothing dropped");
|
||||
assert_eq!(parser.dropped_duration_ns(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecodable_core_is_dropped_and_counted() {
|
||||
// A single undecodable core between good ones is dropped; the survivors
|
||||
// are emitted and the drop is counted.
|
||||
let mut parser = DtsParser::new();
|
||||
let mut stream = make_dts_core(512);
|
||||
stream.extend_from_slice(&make_bad_dts_core(512));
|
||||
stream.extend_from_slice(&make_dts_core(640));
|
||||
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(frames.len(), 2, "the bad core is dropped, two survive");
|
||||
assert_eq!(frames[0].data.len(), 512);
|
||||
assert_eq!(frames[1].data.len(), 640);
|
||||
assert_eq!(parser.dropped_frames(), 1);
|
||||
assert_eq!(
|
||||
parser.dropped_duration_ns(),
|
||||
DTS_CORE_DUR_NS as u64,
|
||||
"one frame's worth of audio silence introduced"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_preserves_av_sync_no_shift() {
|
||||
// THE INVARIANT: dropping an undecodable AU must never shift the audio
|
||||
// that follows. A good/bad/good run in ONE PES — the bad middle core is
|
||||
// dropped, but the trailing good core must keep the EXACT PTS it would
|
||||
// have had with no drop (base + 2 frame durations), so the drop is a
|
||||
// silence gap, not a shift.
|
||||
let mut parser = DtsParser::new();
|
||||
let mut stream = make_dts_core(512); // c1: good
|
||||
stream.extend_from_slice(&make_bad_dts_core(512)); // c2: undecodable
|
||||
stream.extend_from_slice(&make_dts_core(640)); // c3: good
|
||||
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
|
||||
assert_eq!(frames.len(), 2, "c2 dropped; c1 and c3 survive");
|
||||
let base = pts_to_ns(90000);
|
||||
assert_eq!(frames[0].pts_ns, base, "c1 keeps the PES base PTS");
|
||||
assert_eq!(
|
||||
frames[1].pts_ns,
|
||||
base + 2 * DTS_CORE_DUR_NS,
|
||||
"c3 keeps its TRUE timeline (base + 2 frames) — the drop is a gap, not a shift"
|
||||
);
|
||||
// The gap between the survivors is exactly the dropped frame's duration
|
||||
// beyond the normal one-frame spacing.
|
||||
assert_eq!(
|
||||
frames[1].pts_ns - frames[0].pts_ns,
|
||||
2 * DTS_CORE_DUR_NS,
|
||||
"surviving AUs are spaced by the real timeline including the dropped frame's slot"
|
||||
);
|
||||
assert_eq!(parser.dropped_frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_track_poison_drops_remainder() {
|
||||
// A track dominated by undecodable frames is judged too damaged to mux:
|
||||
// once the >50% verdict fires (after the minimum sample count), the
|
||||
// whole track — including any later good frames — is dropped.
|
||||
let mut parser = DtsParser::new();
|
||||
let mut stream = Vec::new();
|
||||
// 300 AUs, ~2/3 undecodable → well over the 50% threshold and the
|
||||
// 200-AU minimum.
|
||||
for i in 0..300 {
|
||||
if i % 3 == 0 {
|
||||
stream.extend_from_slice(&make_dts_core(512));
|
||||
} else {
|
||||
stream.extend_from_slice(&make_bad_dts_core(512));
|
||||
}
|
||||
}
|
||||
// A trailing burst of GOOD cores that must be dropped once poisoned.
|
||||
for _ in 0..20 {
|
||||
stream.extend_from_slice(&make_dts_core(512));
|
||||
}
|
||||
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
assert!(
|
||||
parser.tally.is_poisoned(),
|
||||
"track poisoned by >50% drop rate"
|
||||
);
|
||||
// Once poisoned, later good cores are dropped too, so the kept count
|
||||
// (kept = emitted survivors) is far below the ~120 good cores present.
|
||||
let kept = frames.len() as u64;
|
||||
assert!(
|
||||
kept < 120,
|
||||
"post-poison good frames also dropped (kept={kept})"
|
||||
);
|
||||
assert!(parser.dropped_frames() > 150, "majority dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sr_validity_table_marks_reserved_codes() {
|
||||
// The core-header sample-rate validity table must have ZERO (reject) at
|
||||
// exactly the reserved SFREQ codes {0,4,5,9,10} and a real rate
|
||||
// elsewhere — this is what drives the invalid-sample-rate rejection.
|
||||
for code in 0..16usize {
|
||||
let reserved = matches!(code, 0 | 4 | 5 | 9 | 10);
|
||||
assert_eq!(
|
||||
DTS_CORE_SR_VALID[code] == 0,
|
||||
reserved,
|
||||
"SFREQ code {code} reserved={reserved}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_core_header_error_class_is_detected() {
|
||||
// Exercise each header-validity rejection so the gate stays faithful to
|
||||
// the spec. Start from a decodable core and corrupt one field at a time.
|
||||
let good = make_dts_core(512);
|
||||
assert_eq!(core_header_drop_reason(&good), None);
|
||||
|
||||
// deficit_samples != 32: clear SHORT (byte4 bits6-2) → deficit = 1.
|
||||
let mut d = good.clone();
|
||||
d[4] &= !0x7C;
|
||||
assert_eq!(
|
||||
core_header_drop_reason(&d),
|
||||
Some(DropReason::DeficitSamples)
|
||||
);
|
||||
|
||||
// npcmblocks not a multiple of 8: NBLKS low bits (byte5 bits7-2) → 14
|
||||
// (npcmblocks=15, 15 & 7 = 7 ≠ 0).
|
||||
let mut d = good.clone();
|
||||
d[5] = (d[5] & 0x03) | (14u8 << 2);
|
||||
assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmBlocks));
|
||||
|
||||
// audio_mode reserved (>= 16): AMODE = byte7 bits3-0 (high 4) + byte8
|
||||
// bits7-6. Set AMODE high nibble to 0xF → audio_mode = 60, a genuinely
|
||||
// RESERVED code (16-63) a decoder rejects. (Codes 10-15 are LEGAL
|
||||
// multichannel layouts and must NOT be dropped — see
|
||||
// legal_multichannel_amode_is_not_dropped.)
|
||||
let mut d = good.clone();
|
||||
d[7] |= 0x0F;
|
||||
assert_eq!(core_header_drop_reason(&d), Some(DropReason::Amode));
|
||||
|
||||
// sample_rate reserved: SFREQ (byte8 bits5-2) = 0.
|
||||
let mut d = good.clone();
|
||||
d[8] &= !0x3C;
|
||||
assert_eq!(core_header_drop_reason(&d), Some(DropReason::SampleRate));
|
||||
|
||||
// lfe_present == 3: LFE is byte10 bits2-1.
|
||||
let mut d = good.clone();
|
||||
d[10] |= 0x06;
|
||||
assert_eq!(core_header_drop_reason(&d), Some(DropReason::LfeFlag));
|
||||
|
||||
// pcmr_code reserved (7): pcmr is byte11 bit0 + byte12 bits7-6 → set all
|
||||
// three to 1 (code 7 → DTS_CORE_PCMR_BITS[7] = 0, a reserved PCMR code).
|
||||
let mut d = good.clone();
|
||||
d[11] |= 0x01;
|
||||
d[12] |= 0xC0;
|
||||
assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmRes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legal_multichannel_amode_is_not_dropped() {
|
||||
// ETSI TS 102 114 §5.3.1: AMODE is a 6-bit field with 16 LEGAL
|
||||
// channel-arrangement codes (0-15); only 16-63 are reserved. ffmpeg's
|
||||
// ff_dca_channels[16] = {1,2,2,2,2,3,3,4,4,5,6,6,6,7,8,8} confirms codes
|
||||
// 10-15 are decodable 6/7/8-channel layouts. The decodability gate must
|
||||
// KEEP them — dropping a spec-legal multichannel core silences audio the
|
||||
// recover-100% goal must preserve.
|
||||
fn set_amode(core: &mut [u8], amode: u32) {
|
||||
// audio_mode = (byte7 & 0x0F) << 2 | (byte8 >> 6).
|
||||
core[7] = (core[7] & 0xF0) | ((amode >> 2) & 0x0F) as u8;
|
||||
core[8] = (core[8] & 0x3F) | (((amode & 0x03) << 6) as u8);
|
||||
}
|
||||
|
||||
// Every legal code 0-15 is kept — the range is a literal (NOT
|
||||
// DTS_AMODE_COUNT) so reverting the bound to 10 makes 10-15 fail here.
|
||||
for amode in 0u32..16 {
|
||||
let mut core = make_dts_core(512);
|
||||
set_amode(&mut core, amode);
|
||||
assert_eq!(
|
||||
core_header_drop_reason(&core),
|
||||
None,
|
||||
"legal AMODE {amode} must not be dropped"
|
||||
);
|
||||
}
|
||||
// The first reserved code (16) and above are still rejected.
|
||||
for amode in [16u32, 40, 63] {
|
||||
let mut core = make_dts_core(512);
|
||||
set_amode(&mut core, amode);
|
||||
assert_eq!(
|
||||
core_header_drop_reason(&core),
|
||||
Some(DropReason::Amode),
|
||||
"reserved AMODE {amode} must be dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set FTYPE (byte4 bit7: 1 = normal, 0 = termination) and the 5-bit SHORT
|
||||
/// field (byte4 bits6-2), leaving CPF and the NBLKS high bit (bits1-0) intact.
|
||||
/// `deficit_samples = short_field + 1`.
|
||||
fn set_ftype_short(core: &mut [u8], normal: bool, short_field: u8) {
|
||||
core[4] = (core[4] & 0x03) | ((normal as u8) << 7) | ((short_field & 0x1F) << 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn termination_frame_with_small_deficit_is_kept() {
|
||||
// ETSI TS 102 114 / ffmpeg (`normal_frame && deficit != 32`) / dcadec:
|
||||
// a TERMINATION frame (FTYPE=0) may legally carry fewer than 32 deficit
|
||||
// samples and is fully decodable. It must NOT be dropped — dropping the
|
||||
// last frame of a stream silences real audio (recover-100% violation).
|
||||
let mut core = make_dts_core(512);
|
||||
set_ftype_short(&mut core, false, 10); // termination, deficit = 11 (< 32)
|
||||
assert_eq!(
|
||||
core_header_drop_reason(&core),
|
||||
None,
|
||||
"a termination frame with a small deficit is decodable and must be kept"
|
||||
);
|
||||
// End-to-end: a termination frame closed by a following core survives.
|
||||
let mut term = make_dts_core(512);
|
||||
set_ftype_short(&mut term, false, 5); // deficit = 6
|
||||
let mut stream = term;
|
||||
stream.extend_from_slice(&make_dts_core(640));
|
||||
let mut parser = DtsParser::new();
|
||||
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(frames.len(), 2, "termination frame is emitted, not dropped");
|
||||
assert_eq!(frames[0].data.len(), 512);
|
||||
assert_eq!(parser.dropped_frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_frame_with_wrong_deficit_is_dropped() {
|
||||
// The other side of the FTYPE gate: a NORMAL frame (FTYPE=1) whose
|
||||
// deficit-sample field is not 32 is genuinely undecodable and must be
|
||||
// dropped. Guards against the fix over-relaxing into "never check deficit".
|
||||
let mut core = make_dts_core(512);
|
||||
set_ftype_short(&mut core, true, 10); // normal, deficit = 11 (!= 32)
|
||||
assert_eq!(
|
||||
core_header_drop_reason(&core),
|
||||
Some(DropReason::DeficitSamples),
|
||||
"a normal frame with deficit != 32 is undecodable and must be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_bit_set_is_not_dropped() {
|
||||
// The bit after RATE is a RESERVED field that both reference decoders
|
||||
// SKIP (ffmpeg `skip_bits1`, dcadec `bits_skip1` "Reserved field") — they
|
||||
// never reject a frame that sets it. Rejecting was a false-drop that
|
||||
// silenced any real stream whose encoder set the bit. Setting it (byte9
|
||||
// bit4) on an otherwise-valid core must leave it KEPT.
|
||||
let mut core = make_dts_core(512);
|
||||
assert_eq!(core_header_drop_reason(&core), None, "baseline decodable");
|
||||
core[9] |= 0x10; // set the reserved bit
|
||||
assert_eq!(
|
||||
core_header_drop_reason(&core),
|
||||
None,
|
||||
"a set reserved bit must NOT drop a decodable frame"
|
||||
);
|
||||
}
|
||||
|
||||
/// Real-data fixture (ignored). Re-parses a raw `.dts` elementary stream
|
||||
/// through `DtsParser` and writes the emitted access units back out, so the
|
||||
/// garbage-extension → core-only drop can be validated against an actual
|
||||
/// damaged stream (e.g. the extracted Bourne DTS-HD MA track) end-to-end
|
||||
/// with an external DTS decoder. Env: `DTS_IN` (input), `DTS_OUT` (output).
|
||||
/// cargo test --lib dts::tests::reparse_real_dts_file -- --ignored --nocapture
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn reparse_real_dts_file() {
|
||||
use std::io::Write;
|
||||
let inp = std::env::var("DTS_IN").expect("DTS_IN");
|
||||
let outp = std::env::var("DTS_OUT").expect("DTS_OUT");
|
||||
let bytes = std::fs::read(&inp).expect("read DTS_IN");
|
||||
let mut parser = DtsParser::new();
|
||||
let mut out =
|
||||
std::io::BufWriter::new(std::fs::File::create(&outp).expect("create DTS_OUT"));
|
||||
let mut au_count = 0usize;
|
||||
let mut out_bytes = 0usize;
|
||||
// 90 kHz PTS advancing per chunk; arbitrary chunking is faithful because
|
||||
// the framer resyncs on core sync and buffers across PES boundaries.
|
||||
let mut pts: i64 = 90_000;
|
||||
const CHUNK: usize = 64 * 1024;
|
||||
for chunk in bytes.chunks(CHUNK) {
|
||||
let pes = PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts: Some(pts),
|
||||
dts: None,
|
||||
data: chunk.to_vec(),
|
||||
discontinuity: false,
|
||||
};
|
||||
pts += 2_100; // ~one AU worth; value irrelevant to AU framing/bytes
|
||||
for f in parser.parse(&pes) {
|
||||
au_count += 1;
|
||||
out_bytes += f.data.len();
|
||||
out.write_all(&f.data).expect("write AU");
|
||||
}
|
||||
}
|
||||
for f in parser.flush() {
|
||||
au_count += 1;
|
||||
out_bytes += f.data.len();
|
||||
out.write_all(&f.data).expect("write AU");
|
||||
}
|
||||
out.flush().expect("flush");
|
||||
eprintln!(
|
||||
"REPARSE in={} bytes -> out={} bytes across {} AUs ({} bytes dropped)",
|
||||
bytes.len(),
|
||||
out_bytes,
|
||||
au_count,
|
||||
bytes.len().saturating_sub(out_bytes)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
|
||||
+134
-268
@@ -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,195 +179,98 @@ 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;
|
||||
};
|
||||
// The current AU ends where the next one begins: the first
|
||||
// picture / sequence / GOP start code after this picture.
|
||||
let end = match find_au_start(&self.buf, pic + 4) {
|
||||
Some(b) => b,
|
||||
None if force => self.buf.len(),
|
||||
None if self.buf.len() > MAX_AU_BUFFER => self.buf.len(),
|
||||
None => break, // AU not yet complete — await the next boundary
|
||||
};
|
||||
if end == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
// Phase 1 — read everything from `buf` before any mutation of self
|
||||
// (the slice borrow must end before we touch self fields).
|
||||
let hdr = extract_seq_header(&self.buf[..end]);
|
||||
// A GOP header (0xB8) or a fresh sequence header (0xB3) starts a new
|
||||
// GOP, resetting temporal_reference to 0.
|
||||
let gop_boundary = find_code(&self.buf[..end], 0, GOP_CODE).is_some()
|
||||
|| find_code(&self.buf[..end], 0, SEQ_HEADER_CODE).is_some();
|
||||
// picture_coding_type: the full 3-bit value (bits 5-3 of buf[pic+5]).
|
||||
// 0 when the picture header is truncated (no coding type available).
|
||||
let raw_coding_type = if pic + 5 < end {
|
||||
(self.buf[pic + 5] >> 3) & 0x07
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// temporal_reference: the 10 bits immediately after the picture
|
||||
// start code = display order within the GOP.
|
||||
let tr = if pic + 5 < end {
|
||||
(((self.buf[pic + 4] as u64) << 2) | ((self.buf[pic + 5] as u64) >> 6)) & 0x3FF
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end_abs = self.base_offset + end as u64;
|
||||
let data = self.buf[..end].to_vec();
|
||||
|
||||
// Phase 2 — mutate self.
|
||||
if let Some(h) = hdr {
|
||||
self.progressive_sequence = parse_progressive_sequence(&h);
|
||||
self.seq_header = Some(h);
|
||||
if let Some((num, den)) = self.frame_rate() {
|
||||
if num > 0 {
|
||||
self.frame_duration_ns = 1_000_000_000i64 * den as i64 / num as i64;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Decode the picture coding extension ONCE here and fold every
|
||||
// per-picture datum (coding type + tff/rff/progressive_frame/
|
||||
// frame_picture, plus the sequence's progressive flag) into one
|
||||
// codec-agnostic `PictureInfo`. `nb_fields()`, `keyframe()`, and
|
||||
// `field_order()` all derive from it; nothing downstream re-parses
|
||||
// the elementary stream.
|
||||
let (tff, rff, progressive_frame, frame_picture) = picture_coding_flags(&data);
|
||||
let info = PictureInfo::mpeg2(
|
||||
coding_type_from_raw(raw_coding_type),
|
||||
Mpeg2Coding {
|
||||
top_field_first: tff,
|
||||
repeat_first_field: rff,
|
||||
progressive_frame,
|
||||
progressive_sequence: self.progressive_sequence,
|
||||
frame_picture,
|
||||
},
|
||||
);
|
||||
let keyframe = info.keyframe();
|
||||
|
||||
// An explicit PES PTS for this access unit, if any. By the mark-drain
|
||||
// invariant the front mark's offset is >= this AU's start, so a front
|
||||
// mark inside [start, end) is this AU's own timestamp.
|
||||
let explicit = self
|
||||
.pts_marks
|
||||
.front()
|
||||
.filter(|&&(off, _)| off < end_abs)
|
||||
.map(|&(_, p)| p);
|
||||
|
||||
// Byte-exact source provenance for this AU, by the same mark-drain
|
||||
// invariant as the PTS: the front source mark inside [start, end)
|
||||
// belongs to this access unit.
|
||||
let src = self
|
||||
.source_marks
|
||||
.front()
|
||||
.filter(|&&(off, _)| off < end_abs)
|
||||
.map(|&(_, s)| s);
|
||||
|
||||
// A GOP boundary means the buffered run is a COMPLETE GOP (all its
|
||||
// pictures display before the next GOP's), so flush it before
|
||||
// starting the new one. `temporal_reference` resets to 0 at the
|
||||
// boundary, keeping each GOP's display order self-contained.
|
||||
if gop_boundary && !self.gop_buf.is_empty() {
|
||||
self.flush_gop(&mut out);
|
||||
}
|
||||
// A concealed-gap mark inside this AU's range [start, end_abs) means
|
||||
// this picture's own bytes begin after the gap — the first post-gap
|
||||
// AU. Same front-mark invariant as PTS/source. Carries through GOP
|
||||
// buffering/reorder to the ResyncGate (which arms at this picture).
|
||||
let discontinuity = self.disc_marks.front().is_some_and(|&off| off < end_abs);
|
||||
self.gop_buf.push(BufferedPicture {
|
||||
tr,
|
||||
info,
|
||||
explicit_pts: explicit,
|
||||
frame: Frame {
|
||||
pts_ns: 0,
|
||||
keyframe,
|
||||
discontinuity,
|
||||
data,
|
||||
duration_ns: None,
|
||||
coding: Some(info),
|
||||
source: src,
|
||||
},
|
||||
});
|
||||
// Safety cap: a stream with no GOP/sequence boundaries would buffer
|
||||
// unbounded. Force-flush a pathologically long run as its own GOP.
|
||||
if self.gop_buf.len() >= MAX_PENDING_FRAMES {
|
||||
self.flush_gop(&mut out);
|
||||
}
|
||||
self.buf.drain(..end);
|
||||
self.base_offset = end_abs;
|
||||
// Drop PTS marks fully consumed by the emitted AU; keep the mark at
|
||||
// the boundary (it belongs to the next AU).
|
||||
while let Some(&(off, _)) = self.pts_marks.front() {
|
||||
if off < end_abs {
|
||||
self.pts_marks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(&(off, _)) = self.source_marks.front() {
|
||||
if off < end_abs {
|
||||
self.source_marks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(&off) = self.disc_marks.front() {
|
||||
if off < end_abs {
|
||||
self.disc_marks.pop_front();
|
||||
} else {
|
||||
break;
|
||||
/// 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;
|
||||
};
|
||||
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() {
|
||||
if num > 0 {
|
||||
self.frame_duration_ns = 1_000_000_000i64 * den as i64 / num as i64;
|
||||
}
|
||||
}
|
||||
}
|
||||
// EOF: emit the final (possibly incomplete) GOP so nothing is dropped.
|
||||
if force {
|
||||
self.flush_gop(&mut out);
|
||||
// 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.
|
||||
let (tff, rff, progressive_frame, frame_picture) = picture_coding_flags(&data);
|
||||
let info = PictureInfo::mpeg2(
|
||||
coding_type_from_raw(raw_coding_type),
|
||||
Mpeg2Coding {
|
||||
top_field_first: tff,
|
||||
repeat_first_field: rff,
|
||||
progressive_frame,
|
||||
progressive_sequence: self.progressive_sequence,
|
||||
frame_picture,
|
||||
},
|
||||
);
|
||||
let keyframe = info.keyframe();
|
||||
|
||||
// A GOP boundary means the buffered run is a COMPLETE GOP (all its
|
||||
// pictures display before the next GOP's), so flush it before starting
|
||||
// the new one. `temporal_reference` resets to 0 at the boundary, keeping
|
||||
// each GOP's display order self-contained.
|
||||
if gop_boundary && !self.gop_buf.is_empty() {
|
||||
self.flush_gop(out);
|
||||
}
|
||||
self.gop_bytes += data.len();
|
||||
self.gop_buf.push(BufferedPicture {
|
||||
tr,
|
||||
info,
|
||||
explicit_pts: au.pts,
|
||||
frame: Frame {
|
||||
pts_ns: 0,
|
||||
keyframe,
|
||||
// The assembler attributes the concealed-gap flag to the AU whose
|
||||
// own bytes begin after the gap — the first post-gap picture — so
|
||||
// it rides through GOP buffering/reorder to the ResyncGate.
|
||||
discontinuity: au.discontinuity,
|
||||
data,
|
||||
duration_ns: None,
|
||||
coding: Some(info),
|
||||
source: au.source,
|
||||
},
|
||||
});
|
||||
// Safety cap: a stream with no GOP/sequence boundaries would buffer
|
||||
// unbounded. Force-flush a pathologically long run as its own GOP —
|
||||
// bounded by BOTH the frame count and the total buffered bytes, so a
|
||||
// crafted stream of few-but-huge pictures cannot over-allocate either.
|
||||
if self.gop_buf.len() >= MAX_PENDING_FRAMES || self.gop_bytes >= MAX_PENDING_BYTES {
|
||||
self.flush_gop(out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit the buffered GOP. Each frame's PTS is the display-order prefix-sum of
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+661
-22
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
duration_ns: None,
|
||||
});
|
||||
if let Some(keyframe) = emit_keyframe {
|
||||
self.tally.record_kept();
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
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.
|
||||
|
||||
+7
-19
@@ -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 {
|
||||
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 source = SourceInfo {
|
||||
path: source_path,
|
||||
title: source_title,
|
||||
..SourceInfo::default()
|
||||
};
|
||||
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");
|
||||
}
|
||||
}
|
||||
+842
-68
File diff suppressed because it is too large
Load Diff
+562
-64
@@ -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,
|
||||
);
|
||||
}
|
||||
Mode::Write(WriteMode::Building) => return Ok(()),
|
||||
Mode::Write(WriteMode::Pending(_)) => {}
|
||||
if matches!(self.mode, Mode::Read(_)) {
|
||||
return Err(crate::error::Error::StreamReadOnly.into());
|
||||
}
|
||||
// Pending: the first video frame (or the safety cap) triggers muxer
|
||||
// construction; that frame's coding sets the field order. Other frames
|
||||
// buffer until then.
|
||||
let (activate_now, use_coding) = match &self.mode {
|
||||
Mode::Write(WriteMode::Pending(p)) => {
|
||||
let is_video = match p.video_track {
|
||||
Some(vt) => frame.track == vt,
|
||||
// No video track: nothing to wait for — build on frame one.
|
||||
None => true,
|
||||
};
|
||||
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
|
||||
}
|
||||
_ => unreachable!("guarded above"),
|
||||
};
|
||||
if activate_now {
|
||||
// Pass the trigger frame's coding only when it IS the video frame; a
|
||||
// cap-triggered build never saw the video frame, so nothing measured
|
||||
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
|
||||
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
|
||||
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
|
||||
return m.write_frame(
|
||||
frame.track,
|
||||
frame.pts,
|
||||
frame.keyframe,
|
||||
&frame.data,
|
||||
frame.duration_ns,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
|
||||
p.buffered.push(frame.clone());
|
||||
}
|
||||
Ok(())
|
||||
// Non-3D fast path: emit the frame directly, no clone, no buffering.
|
||||
if self.mvc.is_none() {
|
||||
return self.emit(frame, None);
|
||||
}
|
||||
// Blu-ray 3D: run the frame through the MVC merge, which remaps track
|
||||
// indices, folds the dependent view into the base as BlockAdditional
|
||||
// (paired by PTS), and yields 0+ frames ready to emit. `ingest` returns
|
||||
// owned pairs so the `self.mvc` borrow is released before `emit`.
|
||||
let emits = self.mvc.as_mut().unwrap().ingest(frame);
|
||||
for (f, additional) in emits {
|
||||
self.emit(&f, additional.as_deref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
// Blu-ray 3D: flush any base frames still awaiting a dependent (emitted
|
||||
// unpaired as plain Blocks) before finalizing.
|
||||
if let Some(mvc) = self.mvc.as_mut() {
|
||||
let tail = mvc.flush();
|
||||
let orphans = mvc.orphan_deps;
|
||||
if orphans > 0 {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"MVC: {orphans} dependent-view access units had no matching base frame (dropped)"
|
||||
);
|
||||
}
|
||||
for (f, additional) in tail {
|
||||
self.emit(&f, additional.as_deref())?;
|
||||
}
|
||||
}
|
||||
// A title that produced no frames (or only buffered ones) is still
|
||||
// finalized into a valid MKV: activate now with no measured coding.
|
||||
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
|
||||
@@ -720,7 +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
+195
-53
@@ -56,14 +56,6 @@ pub struct PipelinedPesStream {
|
||||
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
|
||||
/// per-poll reads it replaces were needless hot-path overhead.
|
||||
skip_parse: bool,
|
||||
/// Cumulative bytes of scrambled AACS units the producer's decrypt step
|
||||
/// could not decrypt — silent decrypt loss the demux drops without a sync.
|
||||
/// Shared with the producer thread's [`DecryptingSectorSource`]
|
||||
/// (`crate::sector::DecryptingSectorSource::decrypt_loss`). Surfaced through
|
||||
/// [`Stream::lost_bytes`] so the file-backed mux abort gate sees a partial
|
||||
/// decrypt failure instead of reporting a perfect rip. `None` for pipelines
|
||||
/// with no AACS decrypt step (e.g. the M2TS byte-stream path).
|
||||
decrypt_loss: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
|
||||
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These
|
||||
/// are expected on every disc; instead of a per-packet WARN they're tallied
|
||||
/// and summarised once at EOF.
|
||||
@@ -75,6 +67,24 @@ pub struct PipelinedPesStream {
|
||||
/// Per-track "is inter-coded video" flag (only video has cross-frame
|
||||
/// references the gate must protect). Indexed by stream index.
|
||||
is_video: Vec<bool>,
|
||||
/// Per-track access-unit assembler. On the PS path a program-stream video AU
|
||||
/// is split across many fixed-size PES fragments; this reassembles them to the
|
||||
/// codec's AU boundary so the parser sees AU-complete PES — the same shape the
|
||||
/// TS demuxer already delivers via PUSI. Self-framing codecs (MPEG-2, audio)
|
||||
/// use passthrough, so every track runs through it uniformly. Indexed by
|
||||
/// stream index. (TS titles are AU-complete already, so this is a passthrough
|
||||
/// there too — `consume_ts` does not use it.)
|
||||
au_asm: Vec<super::au_assembly::AuAssembler>,
|
||||
}
|
||||
|
||||
/// The `Codec` of a stream, for configuring its [`AuAssembler`].
|
||||
fn stream_codec(s: &crate::disc::Stream) -> crate::disc::Codec {
|
||||
use crate::disc::Stream;
|
||||
match s {
|
||||
Stream::Video(v) => v.codec,
|
||||
Stream::Audio(a) => a.codec,
|
||||
Stream::Subtitle(sub) => sub.codec,
|
||||
}
|
||||
}
|
||||
|
||||
impl PipelinedPesStream {
|
||||
@@ -102,6 +112,11 @@ impl PipelinedPesStream {
|
||||
let resync = (0..title.streams.len())
|
||||
.map(|_| super::resync::ResyncGate::new())
|
||||
.collect();
|
||||
let au_asm = title
|
||||
.streams
|
||||
.iter()
|
||||
.map(|s| super::au_assembly::AuAssembler::for_codec(stream_codec(s)))
|
||||
.collect();
|
||||
Self {
|
||||
title,
|
||||
parsers,
|
||||
@@ -111,27 +126,13 @@ impl PipelinedPesStream {
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
eof: false,
|
||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||
decrypt_loss: None,
|
||||
dropped_nav_packets: 0,
|
||||
resync,
|
||||
is_video,
|
||||
au_asm,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the producer's decrypt-loss counter so [`Stream::lost_bytes`]
|
||||
/// reports bytes of scrambled AACS units that could not be decrypted (and
|
||||
/// were therefore silently dropped downstream). Obtained from the
|
||||
/// producer's `DecryptingSectorSource::decrypt_loss()` before it is moved
|
||||
/// into the prefetch thread. The M2TS / no-decrypt pipelines leave this
|
||||
/// unset.
|
||||
pub(crate) fn with_decrypt_loss(
|
||||
mut self,
|
||||
loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
) -> Self {
|
||||
self.decrypt_loss = Some(loss);
|
||||
self
|
||||
}
|
||||
|
||||
/// Pull one batch of `PesPacket`s from the demux thread, run
|
||||
/// codec parse on each, enqueue resulting `PesFrame`s on
|
||||
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
|
||||
@@ -254,23 +255,50 @@ impl PipelinedPesStream {
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let pes = PesPacket {
|
||||
// Carry the PS demuxer's byte-exact source stamp through to the
|
||||
// codec parser, exactly as the TS path does — provenance must
|
||||
// survive the PsPacket → PesPacket seam so the frame's `source`
|
||||
// reaches the mux/index (FVI `src`), never reconstructed.
|
||||
source: ps.source,
|
||||
pid,
|
||||
pts: ps.pts.map(|p| p as i64),
|
||||
dts: ps.dts.map(|d| d as i64),
|
||||
data: ps.data,
|
||||
// PS (DVD/CSS) path: no AACS conceal → no continuity-gap flag.
|
||||
discontinuity: false,
|
||||
// Carry the PS demuxer's byte-exact source stamp through to the codec
|
||||
// parser, exactly as the TS path does — provenance must survive the
|
||||
// PsPacket → PesPacket seam so the frame's `source` reaches the
|
||||
// mux/index (FVI `src`), never reconstructed.
|
||||
let (pts_i64, dts_i64, src) = (
|
||||
ps.pts.map(|p| p as i64),
|
||||
ps.dts.map(|d| d as i64),
|
||||
ps.source,
|
||||
);
|
||||
// Reassemble the PS fragments into AU-complete PES for this track
|
||||
// (passthrough for self-framing codecs — MPEG-2/audio), so the parser
|
||||
// sees exactly the AU-complete shape a transport stream delivers. The
|
||||
// AU-start PTS/source survive the reassembly. A track with no assembler
|
||||
// (only reachable via a hand-built `pid_to_track` outrunning the stream
|
||||
// list) passes the fragment straight through. (PS path: no AACS conceal
|
||||
// → no continuity-gap flag.)
|
||||
let pkts: Vec<PesPacket> = match self.au_asm.get_mut(track) {
|
||||
Some(asm) => asm
|
||||
.push_owned(ps.data, pts_i64, dts_i64, src, false)
|
||||
.into_iter()
|
||||
.map(|au| PesPacket {
|
||||
source: au.source,
|
||||
pid,
|
||||
pts: au.pts,
|
||||
dts: au.dts,
|
||||
data: au.data,
|
||||
discontinuity: au.discontinuity,
|
||||
})
|
||||
.collect(),
|
||||
None => vec![PesPacket {
|
||||
source: src,
|
||||
pid,
|
||||
pts: pts_i64,
|
||||
dts: dts_i64,
|
||||
data: ps.data,
|
||||
discontinuity: false,
|
||||
}],
|
||||
};
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||
for frame in parser.parse(&pes) {
|
||||
self.pending_frames
|
||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
for pes in &pkts {
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames
|
||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,11 +343,30 @@ impl Stream for PipelinedPesStream {
|
||||
let pending = &mut self.pending_frames;
|
||||
let resync = &mut self.resync;
|
||||
let is_video = &self.is_video;
|
||||
let au_asm = &mut self.au_asm;
|
||||
for (pid, parser) in self.parsers.iter_mut() {
|
||||
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
|
||||
continue;
|
||||
};
|
||||
for frame in parser.flush() {
|
||||
// First: the trailing access unit(s) the PS assembler
|
||||
// buffered past the final fragment (the last AU has no
|
||||
// following boundary). Parse them, THEN drain the parser's
|
||||
// own internal buffer (MPEG-2 final GOP, DTS-HD tail).
|
||||
let mut frames = Vec::new();
|
||||
let tail = au_asm.get_mut(track).map(|a| a.flush()).unwrap_or_default();
|
||||
for au in tail {
|
||||
let pes = PesPacket {
|
||||
source: au.source,
|
||||
pid: *pid,
|
||||
pts: au.pts,
|
||||
dts: au.dts,
|
||||
data: au.data,
|
||||
discontinuity: au.discontinuity,
|
||||
};
|
||||
frames.extend(parser.parse(&pes));
|
||||
}
|
||||
frames.extend(parser.flush());
|
||||
for frame in frames {
|
||||
let emit = match resync.get_mut(track) {
|
||||
Some(gate) => gate.admit(
|
||||
is_video.get(track).copied().unwrap_or(false),
|
||||
@@ -394,17 +441,9 @@ impl Stream for PipelinedPesStream {
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
|
||||
fn lost_bytes(&self) -> u64 {
|
||||
// The file-backed highway has no read-error zero-fill term (resolve
|
||||
// tracks read loss separately), but the producer's decrypt step can
|
||||
// pass scrambled units through undecrypted — silent loss the demux
|
||||
// drops. Surface that so the mux abort gate sees a partial AACS/CSS
|
||||
// decrypt failure rather than reporting a perfect rip.
|
||||
self.decrypt_loss
|
||||
.as_ref()
|
||||
.map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
// `lost_bytes` uses the trait default (0): the file-backed highway has no
|
||||
// read-error zero-fill term (resolve/mapfile tracks physical read loss
|
||||
// separately) and the decrypt path no longer reports a decrypt-loss term.
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -620,8 +659,8 @@ mod tests {
|
||||
|
||||
/// B1 end-to-end: after a TS discontinuity on a VIDEO track the consumer
|
||||
/// must DROP every inter-coded frame until the next keyframe, so no frame
|
||||
/// with a dangling reference reaches the muxer (an ffmpeg deep-scan would
|
||||
/// otherwise report a missing reference). The frame carrying the
|
||||
/// with a dangling reference reaches the muxer (a strict decode-order
|
||||
/// deep-scan would otherwise report a missing reference). The frame carrying the
|
||||
/// discontinuity and the inter frames behind it are dropped; the stream
|
||||
/// resumes cleanly at the next keyframe.
|
||||
#[test]
|
||||
@@ -801,6 +840,109 @@ mod tests {
|
||||
assert!(stream.read().unwrap().is_none(), "unmappable PS dropped");
|
||||
}
|
||||
|
||||
/// Build a single-video-stream title on `codec`, a [`CountingParser`] (1 frame
|
||||
/// per PES it is handed), and feed three 0xE0 program-stream fragments that
|
||||
/// together form TWO H.264 access units (AUD-delimited); only AU-start
|
||||
/// fragments carry a PTS. Returns every emitted frame.
|
||||
fn run_ps_fragments(codec: Codec) -> Vec<crate::pes::PesFrame> {
|
||||
let mut title = DiscTitle::empty();
|
||||
title.streams.push(crate::disc::Stream::Video(VideoStream {
|
||||
pid: crate::mux::ps::DVD_VIDEO_PID,
|
||||
codec,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
}));
|
||||
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
|
||||
crate::mux::ps::DVD_VIDEO_PID,
|
||||
Box::new(CountingParser {
|
||||
per_pes: 1,
|
||||
flush_n: 0,
|
||||
cp: None,
|
||||
}),
|
||||
)];
|
||||
let pid_to_track = vec![(crate::mux::ps::DVD_VIDEO_PID, 0usize)];
|
||||
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
|
||||
|
||||
let frag = |pts, data: &[u8]| PsPacket {
|
||||
source: None,
|
||||
stream_id: 0xE0,
|
||||
sub_stream_id: None,
|
||||
pts,
|
||||
dts: None,
|
||||
data: data.to_vec(),
|
||||
};
|
||||
tx.send(DemuxBatch::Ps(vec![
|
||||
frag(Some(9_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA]), // AU1: AUD + slice head
|
||||
frag(None, &[0xBB, 0xCC]), // AU1: slice tail (no PTS)
|
||||
frag(Some(18_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD]), // AU2 opener (AUD closes AU1)
|
||||
]))
|
||||
.unwrap();
|
||||
tx.send(DemuxBatch::Eof).unwrap();
|
||||
|
||||
let mut out = Vec::new();
|
||||
while let Some(f) = stream.read().unwrap() {
|
||||
out.push(f);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// PS-path integration: an H.264 access unit split across several fixed-size
|
||||
/// PES fragments (only the first with a PTS) must be REJOINED so the parser
|
||||
/// sees one AU-complete PES with the AU-START pts — not one bogus per-fragment
|
||||
/// frame each with pts 0 (the HD-DVD truncation/corruption bug). The
|
||||
/// `CountingParser` makes it observable: 3 fragments forming 2 AUs → 2 frames.
|
||||
#[test]
|
||||
fn ps_h264_au_split_across_fragments_reassembles_to_one_frame() {
|
||||
let frames = run_ps_fragments(Codec::H264);
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
2,
|
||||
"3 fragments → 2 access units, not 3 frames"
|
||||
);
|
||||
assert_eq!(frames[0].track, 0);
|
||||
assert_eq!(
|
||||
frames[0].data,
|
||||
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA, 0xBB, 0xCC],
|
||||
"AU1 = fragment1 + fragment2 rejoined"
|
||||
);
|
||||
assert_eq!(
|
||||
frames[0].pts, 9_000,
|
||||
"AU carries its START pts, not the mid-fragment None→0"
|
||||
);
|
||||
assert_eq!(
|
||||
frames[1].data,
|
||||
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD],
|
||||
"AU2 flushed at EOF (no following boundary)"
|
||||
);
|
||||
assert_eq!(frames[1].pts, 18_000);
|
||||
}
|
||||
|
||||
/// Contrast: a self-framing codec (MPEG-2 reassembles in its own parser) uses
|
||||
/// a Passthrough assembler — the SAME three fragments pass straight through as
|
||||
/// three frames, byte-identical to the pre-assembler behaviour. This proves the
|
||||
/// reassembly is gated by codec and does not disturb the DVD/MPEG-2 path.
|
||||
#[test]
|
||||
fn ps_self_framing_codec_is_not_reassembled() {
|
||||
let frames = run_ps_fragments(Codec::Mpeg2);
|
||||
assert_eq!(
|
||||
frames.len(),
|
||||
3,
|
||||
"MPEG-2 passthrough: one frame per fragment"
|
||||
);
|
||||
assert_eq!(frames[0].pts, 9_000);
|
||||
assert_eq!(
|
||||
frames[1].pts, 0,
|
||||
"mid-fragment has no PTS under passthrough"
|
||||
);
|
||||
assert_eq!(frames[2].pts, 18_000);
|
||||
}
|
||||
|
||||
/// A batch with no trackable packets must NOT terminate the stream early:
|
||||
/// pump_one_batch loops to the next batch. Here an empty-but-untracked
|
||||
/// batch is followed by a real frame batch — the consumer must skip the
|
||||
|
||||
+249
-5
@@ -27,6 +27,12 @@ const PRIVATE_STREAM_1: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_1;
|
||||
/// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable
|
||||
/// elementary stream; expected to be dropped on every disc.
|
||||
const PRIVATE_STREAM_2: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_2;
|
||||
/// Extended stream id (0xFD) — the H.222.0 escape whereby the real stream id is
|
||||
/// the `stream_id_extension` carried in the PES extension. HD-DVD `.evo` puts its
|
||||
/// VC-1 video (and HD audio) here (Shaun of the Dead: VC-1 on `0xFD` ext `0x55`);
|
||||
/// a transport stream never uses it. The elementary-stream bytes follow the PES
|
||||
/// header exactly like any other PES — only the routing key differs.
|
||||
const EXTENDED_STREAM_ID: u8 = 0xFD;
|
||||
|
||||
/// Hard cap on the demuxer's reassembly buffer. A length-0 (unbounded) video
|
||||
/// PES is delimited by the next PS-layer boundary; if a corrupt stream declares
|
||||
@@ -73,9 +79,16 @@ pub const DVD_VIDEO_PID: u16 = 0xE0;
|
||||
/// source of truth shared with `Disc::scan_dvd_titles`
|
||||
/// (`src/disc/dvd.rs`), which sets each `AudioStream.pid` from the same
|
||||
/// function so demuxer output routes through the title's `pid_to_track`.
|
||||
///
|
||||
/// HD-DVD (`.evo` Enhanced VOB) carries Dolby Digital Plus (E-AC-3) on
|
||||
/// `private_stream_1` sub-stream ids `0xC0..=0xC7` — a range DVD never uses
|
||||
/// (DVD audio is `0x80..=0x8F` / `0xA0..=0xA7`), so admitting it here is purely
|
||||
/// additive and cannot change any DVD mapping. The PID is `0xBD00 | sub` just
|
||||
/// like the DVD audio ranges, so a mixed HD-DVD title (four DD+ tracks
|
||||
/// `0xC0..0xC3`) routes each track to its own distinct PID.
|
||||
pub fn dvd_audio_pid(sub_stream_id: u8) -> Option<u16> {
|
||||
match sub_stream_id {
|
||||
0x80..=0x8F | 0xA0..=0xA7 => Some(0xBD00 | sub_stream_id as u16),
|
||||
0x80..=0x8F | 0xA0..=0xA7 | 0xC0..=0xC7 => Some(0xBD00 | sub_stream_id as u16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -90,6 +103,17 @@ pub fn dvd_subtitle_pid(sub_stream_id: u8) -> Option<u16> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical PID for an HD-DVD extended-stream-id (`0xFD`) stream, keyed by its
|
||||
/// `stream_id_extension`: `0xFD00 | ext`. Disjoint from the DVD video (`0xE0`) and
|
||||
/// `private_stream_1` (`0xBD00..`) PID spaces, so several elementary streams
|
||||
/// multiplexed on `0xFD` (VC-1 video, MLP/TrueHD audio) never collide. The
|
||||
/// scanner's head probe and `PsPacket::dvd_pid` derive the same PID from the same
|
||||
/// `stream_id_extension`, so demux output routes through the title's
|
||||
/// `pid_to_track`.
|
||||
pub fn hddvd_extended_pid(stream_id_extension: u8) -> u16 {
|
||||
0xFD00 | stream_id_extension as u16
|
||||
}
|
||||
|
||||
impl PsPacket {
|
||||
/// Map this packet to the canonical DVD PID assigned by
|
||||
/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can
|
||||
@@ -112,6 +136,12 @@ impl PsPacket {
|
||||
let sub = self.sub_stream_id?;
|
||||
dvd_audio_pid(sub).or_else(|| dvd_subtitle_pid(sub))
|
||||
}
|
||||
// HD-DVD extended-stream-id (0xFD): route by the stream_id_extension
|
||||
// (carried in `sub_stream_id`) to a distinct `0xFD00 | ext` PID, so a
|
||||
// disc that puts several elementary streams on 0xFD keeps them apart.
|
||||
// The codec (VC-1 etc.) is decided by the scanner's head probe, not
|
||||
// here — this only assigns a stable routing key.
|
||||
EXTENDED_STREAM_ID => self.sub_stream_id.map(hddvd_extended_pid),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -347,9 +377,74 @@ fn find_ps_boundary(data: &[u8], from: usize) -> Option<usize> {
|
||||
/// Check whether a start code byte is a valid PES stream ID that carries payload.
|
||||
fn is_pes_stream_id(id: u8) -> bool {
|
||||
// Video: 0xE0-0xEF, MPEG audio: 0xC0-0xDF, private stream 1: 0xBD,
|
||||
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc.
|
||||
// We parse anything in the payload-bearing PES range.
|
||||
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id)
|
||||
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc. — plus the HD-DVD
|
||||
// extended-stream-id (0xFD), which carries VC-1 video / HD audio.
|
||||
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id) || id == EXTENDED_STREAM_ID
|
||||
}
|
||||
|
||||
/// For an extended-stream-id (`0xFD`) PES, walk the optional PES-header fields to
|
||||
/// the PES extension and read the 7-bit `stream_id_extension` — the real stream
|
||||
/// id. `data` starts at the PES start code; the optional fields live in
|
||||
/// `data[9..header_end]` (all bounds-checked against `header_end`). Returns `None`
|
||||
/// if the extension is absent or malformed.
|
||||
fn parse_stream_id_extension(data: &[u8], flags2: u8, header_end: usize) -> Option<u8> {
|
||||
let get = |p: usize| -> Option<u8> {
|
||||
if p < header_end {
|
||||
data.get(p).copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let mut pos = 9usize;
|
||||
let pts_dts = (flags2 >> 6) & 0x03;
|
||||
if pts_dts & 0x02 != 0 {
|
||||
pos += 5; // PTS
|
||||
}
|
||||
if pts_dts == 0x03 {
|
||||
pos += 5; // DTS
|
||||
}
|
||||
if flags2 & 0x20 != 0 {
|
||||
pos += 6; // ESCR
|
||||
}
|
||||
if flags2 & 0x10 != 0 {
|
||||
pos += 3; // ES_rate
|
||||
}
|
||||
if flags2 & 0x08 != 0 {
|
||||
pos += 1; // DSM_trick_mode
|
||||
}
|
||||
if flags2 & 0x04 != 0 {
|
||||
pos += 1; // additional_copy_info
|
||||
}
|
||||
if flags2 & 0x02 != 0 {
|
||||
pos += 2; // PES_CRC
|
||||
}
|
||||
if flags2 & 0x01 == 0 {
|
||||
return None; // no PES_extension
|
||||
}
|
||||
let ext_flags = get(pos)?;
|
||||
pos += 1;
|
||||
if ext_flags & 0x80 != 0 {
|
||||
pos += 16; // PES_private_data
|
||||
}
|
||||
if ext_flags & 0x40 != 0 {
|
||||
// pack_header_field: 1-byte length + that many bytes.
|
||||
pos += 1 + get(pos)? as usize;
|
||||
}
|
||||
if ext_flags & 0x20 != 0 {
|
||||
pos += 2; // program_packet_sequence_counter
|
||||
}
|
||||
if ext_flags & 0x10 != 0 {
|
||||
pos += 2; // P-STD_buffer
|
||||
}
|
||||
if ext_flags & 0x01 == 0 {
|
||||
return None; // no PES_extension_flag_2
|
||||
}
|
||||
// PES_extension_field_length (7 bits, marker in the top bit), then the
|
||||
// stream_id_extension byte: present when its top bit (the extension flag) is 0.
|
||||
let _field_len = get(pos)? & 0x7F;
|
||||
pos += 1;
|
||||
let b = get(pos)?;
|
||||
(b & 0x80 == 0).then_some(b & 0x7F)
|
||||
}
|
||||
|
||||
/// Parse a single PES packet from a byte slice that starts at the start code.
|
||||
@@ -416,10 +511,30 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> {
|
||||
|
||||
// For private stream 1, the first payload byte is the sub-stream ID,
|
||||
// followed by a sub-header whose length depends on the sub-stream type.
|
||||
let (sub_stream_id, es_data) = if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() {
|
||||
let (sub_stream_id, es_data) = if stream_id == EXTENDED_STREAM_ID {
|
||||
// HD-DVD extended-stream-id: the real stream id lives in the
|
||||
// stream_id_extension inside the PES extension. There is no leading
|
||||
// sub-header byte on the payload (unlike private_stream_1), so the ES
|
||||
// is the payload verbatim.
|
||||
(
|
||||
parse_stream_id_extension(data, data[7], header_end),
|
||||
payload.to_vec(),
|
||||
)
|
||||
} else if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() {
|
||||
let sub_id = payload[0];
|
||||
let skip = match sub_id {
|
||||
0x80..=0x8F => 4, // AC3/DTS: sub_id + frame_count + access_unit_ptr(2)
|
||||
// HD-DVD Dolby Digital Plus (E-AC-3): the sub-header is the same
|
||||
// 4-byte shape as DVD AC-3 — sub_id + number_of_frames(1) +
|
||||
// first_access_unit_pointer(2). Verified empirically on ANCHORMAN
|
||||
// EVO: across every 0xC0..=0xC7 packet the 0x0B77 E-AC-3 syncword
|
||||
// sits `first_access_unit_pointer` bytes past this 4-byte header
|
||||
// (the leading bytes are the tail of the previous frame). Stripping
|
||||
// exactly these 4 bytes on EVERY packet yields a clean, continuous
|
||||
// E-AC-3 elementary stream that the ac3 parser reassembles across
|
||||
// PES boundaries; a shorter skip would splice the sub-header bytes
|
||||
// into a straddling frame and corrupt it.
|
||||
0xC0..=0xC7 => 4,
|
||||
0xA0..=0xA7 => 7, // LPCM: sub_id + frames + ptr(2) + emphasis + quant_freq + channels
|
||||
_ => 1,
|
||||
};
|
||||
@@ -836,6 +951,90 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_extended_stream_id_extracts_stream_id_extension() {
|
||||
// SHAUN's VC-1 video PES: stream_id 0xFD, flags2=0x01 (PES_extension
|
||||
// only), header_data_length=3, optional bytes 0x0F/... — build the
|
||||
// minimal well-formed variant: ext_flags=0x01 (PES_extension_flag_2),
|
||||
// field_len=0x81, stream_id_extension=0x55. Payload is the ES.
|
||||
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
||||
let opt = [0x01u8, 0x81, 0x55];
|
||||
let es = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||
let len = (3 + opt.len() + es.len()) as u16;
|
||||
pkt.extend_from_slice(&len.to_be_bytes());
|
||||
pkt.extend_from_slice(&[0x80, 0x01, opt.len() as u8]);
|
||||
pkt.extend_from_slice(&opt);
|
||||
pkt.extend_from_slice(&es);
|
||||
|
||||
let parsed = parse_pes_packet(&pkt).expect("parses");
|
||||
assert_eq!(parsed.stream_id, EXTENDED_STREAM_ID);
|
||||
assert_eq!(
|
||||
parsed.sub_stream_id,
|
||||
Some(0x55),
|
||||
"stream_id_extension extracted from PES extension"
|
||||
);
|
||||
// ES is the payload verbatim — no leading sub-header byte stripped.
|
||||
assert_eq!(parsed.data, es);
|
||||
// Routes to the extended-stream-id PID space.
|
||||
assert_eq!(parsed.dvd_pid(), Some(hddvd_extended_pid(0x55)));
|
||||
assert_eq!(parsed.dvd_pid(), Some(0xFD55));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_extended_stream_id_skips_pts_and_dts_before_the_extension() {
|
||||
// The common real case: an AU-opening 0xFD VC-1 video PES carries a PTS
|
||||
// (and often DTS) in the optional-header region, which the parser must
|
||||
// SKIP (PTS +5, DTS +5) to reach the PES_extension → stream_id_extension.
|
||||
// Both branches were previously untested (flags2 there was 0x01, skipping
|
||||
// everything), so an off-by-one in the skip would silently misroute video.
|
||||
let build = |flags2: u8, skip: usize| {
|
||||
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
||||
// optional region: `skip` bytes (PTS/DTS placeholders) then
|
||||
// ext_flags=0x01, field_len=0x81, stream_id_extension=0x55.
|
||||
let mut opt = vec![0xFFu8; skip];
|
||||
opt.extend_from_slice(&[0x01, 0x81, 0x55]);
|
||||
let es = [0xDEu8, 0xAD];
|
||||
let len = (3 + opt.len() + es.len()) as u16;
|
||||
pkt.extend_from_slice(&len.to_be_bytes());
|
||||
// flags1=0x80, flags2, header_data_length = optional region length.
|
||||
pkt.extend_from_slice(&[0x80, flags2, opt.len() as u8]);
|
||||
pkt.extend_from_slice(&opt);
|
||||
pkt.extend_from_slice(&es);
|
||||
pkt
|
||||
};
|
||||
// PTS present (pts_dts bits = 10 → flags2 0x80) + PES_extension (0x01).
|
||||
let pts_only = parse_pes_packet(&build(0x81, 5)).expect("parses");
|
||||
assert_eq!(
|
||||
pts_only.sub_stream_id,
|
||||
Some(0x55),
|
||||
"extension found after skipping a 5-byte PTS"
|
||||
);
|
||||
// PTS+DTS present (pts_dts bits = 11 → flags2 0xC0) + PES_extension.
|
||||
let pts_dts = parse_pes_packet(&build(0xC1, 10)).expect("parses");
|
||||
assert_eq!(
|
||||
pts_dts.sub_stream_id,
|
||||
Some(0x55),
|
||||
"extension found after skipping a 10-byte PTS+DTS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_extended_stream_id_without_extension_yields_no_sub_id() {
|
||||
// A 0xFD PES that declares no PES_extension (flags2=0x00) can't carry a
|
||||
// stream_id_extension → sub_stream_id None, and dvd_pid falls through.
|
||||
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
||||
let es = [0x11u8, 0x22];
|
||||
let len = (3 + es.len()) as u16;
|
||||
pkt.extend_from_slice(&len.to_be_bytes());
|
||||
pkt.extend_from_slice(&[0x80, 0x00, 0x00]);
|
||||
pkt.extend_from_slice(&es);
|
||||
|
||||
let parsed = parse_pes_packet(&pkt).expect("parses");
|
||||
assert_eq!(parsed.sub_stream_id, None);
|
||||
assert_eq!(parsed.dvd_pid(), None);
|
||||
assert_eq!(parsed.data, es);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_pid_matches_scanner_assignment() {
|
||||
// Video → 0xE0 (matches dvd.rs VideoStream pid).
|
||||
@@ -1175,6 +1374,51 @@ mod tests {
|
||||
assert_eq!(dvd_audio_pid(0xA8), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hddvd_ddplus_substream_maps_to_bd_pid() {
|
||||
// HD-DVD Dolby Digital Plus sub-ids 0xC0..=0xC7 map to 0xBD00|sub,
|
||||
// distinct per track and disjoint from the DVD audio space. A DVD never
|
||||
// emits these, so the range is purely additive.
|
||||
assert_eq!(dvd_audio_pid(0xC0), Some(0xBDC0));
|
||||
assert_eq!(dvd_audio_pid(0xC3), Some(0xBDC3));
|
||||
assert_eq!(dvd_audio_pid(0xC7), Some(0xBDC7));
|
||||
// Just outside the range.
|
||||
assert_eq!(dvd_audio_pid(0xBF), None);
|
||||
assert_eq!(dvd_audio_pid(0xC8), None);
|
||||
// Four DD+ tracks (ANCHORMAN) get four distinct PIDs.
|
||||
let pids: Vec<u16> = (0xC0u8..=0xC3).map(|s| dvd_audio_pid(s).unwrap()).collect();
|
||||
assert_eq!(pids, vec![0xBDC0, 0xBDC1, 0xBDC2, 0xBDC3]);
|
||||
// And route through dvd_pid on a private_stream_1 packet.
|
||||
assert_eq!(mk(0xBD, Some(0xC0)).dvd_pid(), Some(0xBDC0));
|
||||
assert_eq!(mk(0xBD, Some(0xC3)).dvd_pid(), Some(0xBDC3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hddvd_ddplus_pes_strips_4byte_subheader_to_syncword() {
|
||||
// A private_stream_1 PES carrying DD+ (sub-id 0xC0) has a 4-byte
|
||||
// sub-header (sub_id + num_frames(1) + access_unit_ptr(2)); the demuxer
|
||||
// must strip exactly those 4 bytes so es_data begins at the E-AC-3
|
||||
// payload — here the 0x0B77 syncword sits right after the sub-header.
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
let mut data = vec![
|
||||
0x00, 0x00, 0x01, 0xBD, // private stream 1
|
||||
0x00, 0x0B, // PES_packet_length = 11 (flags2 + hdl1 + 8 payload)
|
||||
0x80, 0x00, 0x00, // no PTS, header_data_len = 0
|
||||
0xC0, // sub-stream id: DD+ track 0
|
||||
0x01, 0x00, 0x00, // num_frames(1) + access_unit_ptr(2)
|
||||
0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + payload
|
||||
];
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
||||
let p = demuxer.feed(&data);
|
||||
assert_eq!(p.len(), 1);
|
||||
assert_eq!(p[0].sub_stream_id, Some(0xC0));
|
||||
assert_eq!(
|
||||
p[0].data,
|
||||
vec![0x0B, 0x77, 0xDE, 0xAD],
|
||||
"4-byte DD+ sub-header stripped; es_data starts at the syncword"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_subtitle_pid_range_boundaries() {
|
||||
// VobSub subtitle sub-ids 0x20..=0x3F map to the identity PID.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user