Compare commits

...
37 Commits
Author SHA1 Message Date
MattJackson 5b6ea8f5c4 v0.30.7: mux correctness pass
- hevc: real chroma/bit-depth in hvcC (10-bit Main10), emit redefined
  param sets in-band; clamp oversized param sets
- h264: emit redefined SPS/PPS in-band, clamp oversized avcC
- ac3: flush final frame at EOS, per-frame PTS
- dts: attribute each access unit's PTS to its own core PES
- truehd: capture PTS only at access-unit boundary
- meta: preserve color_space round-trip
- dvd: fix subtitle/audio track PID collision
- pgs/vobsub: flush trailing subtitle at EOF; multi-PES SPU reassembly
- ts: unify codec table via from_coding_type (+LPCM), warn on unknown
2026-06-06 21:46:49 -07:00
MattJackson eeba94b21d Merge branch 'worktree-agent-a184f5a97a37e2c1a' 2026-06-06 21:40:42 -07:00
MattJackson cfc12774f2 mux: fix DVD subtitle/audio track collision, PGS/VobSub flush, unify TS codec table
Subtitle/DVD output-corruption + stream-mapping coverage fixes.

1. DVD subtitle/audio track-mapping collision (CRITICAL). The PS path
   routed 0xBD private-stream packets to a track via (sub_id & 0x1F)+1,
   so VobSub subtitle sub-id 0x20+j aliased audio track j+1: subtitle
   PES was fed to the AC-3 parser and the real subtitle track got
   nothing. Route by the canonical DVD PID instead via a new
   PsPacket::dvd_pid() that mirrors scan_dvd_titles' PID assignment
   (video 0xE0, audio 0xBD00+i, subtitle 0x20+j), then look up the
   track in pid_to_track. Fixed identically at all three sites
   (pipelined_stream consume_ps, disc.rs live feed, disc.rs EOF flush).
   Unmappable/unmapped packets now WARN instead of silently dropping.

2. PGS flush() missing. PgsParser inherited the no-op default flush, so
   the last subtitle of every PGS track (emitted only when a following
   PCS arrives) was dropped at EOF. Implemented flush() to drain the
   pending display set (duration_ns: None for the trailing block).

3. DVD VobSub multi-PES SPU not reassembled. A subpicture unit larger
   than one PES spans multiple PES (only the head carries a PTS).
   DvdSubParser is now stateful: it buffers per sub-stream until the
   leading 2-byte SPU_size is satisfied, inherits the head PTS, and
   emits one Frame. flush() drains a truncated trailing SPU at EOF.

4. One-table hygiene. scan_streams had a duplicate stream_type->Codec
   table that had drifted from Codec::from_coding_type (missing 0x80
   LPCM, 0x85 mapped to DTS-HD MA vs HR, etc.). scan_streams now uses
   from_coding_type plus a new Codec::kind()/CodecKind category split,
   so the two mappings can never diverge. Silent drops in
   scan_streams and bluray STN parsing now WARN with PID + type.

Tests: dvd_pid mapping + subtitle/audio collision regression, PGS
final-subtitle flush, VobSub multi-PES reassembly + EOF flush,
scan_streams 0x80 LPCM via from_coding_type.
2026-06-06 21:33:11 -07:00
MattJackson fd543c058b mux: rustfmt (Rust 1.86) on touched codec/meta files 2026-06-06 21:32:04 -07:00
MattJackson cd8ce708ac mux/h264: emit redefined SPS/PPS in-band + clamp oversized avcC
H.264 stored SPS/PPS in single Option slots and always stripped them
from frame data, so a mid-title parameter-set redefinition (same id,
different body) was lost — those frames decoded against the stale avcC
copy the player re-applies at each keyframe. Same defect class as the
HEVC PPS-redefinition bug. Mirror that fix: emit a changed SPS/PPS
in-band at every occurrence; strip only the first-seen / identical ones.

Also guard avcC's 16-bit NAL length fields: a param set > 65535 bytes
truncated the length while appending all bytes. Return None instead.
2026-06-06 21:24:16 -07:00
MattJackson 2eee777b8f mux/hevc: real chroma/bit-depth in hvcC + clamp oversized param sets
codec_private() hardcoded 8-bit 4:2:0 in the hvcC fixed header, wrong for
10-bit Main 10 UHD (essentially all UHD). Parse chroma_format_idc and
bit_depth_luma/chroma_minus8 from the SPS RBSP (with emulation-prevention
removal and sub-layer profile_tier_level handling) and emit the real
values; fall back to 8-bit 4:2:0 only if the SPS can't be parsed.

Also guard the 16-bit NAL length fields: a param set larger than 65535
bytes would truncate the length while appending all bytes, mis-framing
the record. Return None instead of emitting a corrupt hvcC.
2026-06-06 21:22:44 -07:00
MattJackson a5962be86c mux/truehd: capture PTS only at an access-unit boundary
A continuation PES (one that merely extends a TrueHD AU spanning PES
packets) carries its own later PTS, which the parser was adopting
mid-assembly, snapping the AU's timestamp and breaking the monotonic
per-AU cadence (A/V drift). Capture the PTS base only when the
reassembly buffer is empty, i.e. when a PES actually begins a new AU.
2026-06-06 21:19:53 -07:00
MattJackson 7306f661b9 mux/dts: attribute each access unit's PTS to its own core PES
When two access units flushed in one parse() call (a core arriving in an
earlier PES than the PES that closes the unit, the standard DTS-HD MA
core+extension-as-separate-PES layout), the second AU inherited the
latest PES's PTS instead of its own core's.

Track per-PES PTS markers keyed by buffer offset and stamp each emitted
AU with the PTS of the PES covering its first byte, rebasing markers on
every front drain. Each AU now keeps its own core's timestamp.

Extends two_cores_back_to_back to assert PTS; adds cross-PES coverage.
2026-06-06 21:19:07 -07:00
MattJackson 77f7aced83 mux/ac3: flush final frame at EOS and stamp per-frame PTS
Two defects:
- Ac3Parser inherited the no-op default flush(), so a complete final
  frame still buffered at end-of-stream was dropped (~32 ms of audio
  lost). Add a flush() that drains a complete buffered frame, mirroring
  dts.rs.
- Every frame in one parse() call was stamped with the single PES PTS,
  collapsing their timecodes and drifting A/V. Compute a base PTS once
  per call, then advance per frame by the frame's own duration (AC-3 =
  1536 samples; E-AC-3 from numblkscod), converting samples->ns at the
  stream sample rate (fscod). Each Frame now carries duration_ns.
2026-06-06 21:16:17 -07:00
MattJackson 97ae452e40 mux/meta: preserve color_space round-trip in m2ts metadata
MetaStream::Video dropped color_space on from_title/to_title, hardcoding
BT.709 on the way back. HDR titles (BT.2020) lost their color metadata.

Add a color_space field, populate it in from_title, and use it in
to_title. For pre-0.30.7 metadata that has no color_space, derive it from
the preserved hdr field (all HDR formats are BT.2020, SDR is BT.709).

Adds ColorSpace::id() + FromStr for serialization round-trip.
2026-06-06 21:13:29 -07:00
MattJackson eec0594a30 Merge branch 'main' into worktree-agent-a91cd2cf29779b84e 2026-06-06 21:11:05 -07:00
MattJackson dab6ea4359 mux/hevc: emit redefined param sets in-band at every keyframe
Fight Club redefines PPS id 0 mid-title; the parser froze the first PPS into
codecPrivate and stripped the rest, so the redefined segment decoded against
the wrong PPS (CABAC/cu_qp_delta desync, intact framing). Now any VPS/SPS/PPS
whose body differs from the codecPrivate copy is emitted in-band at every
occurrence, overriding the hvcC copy a player re-applies per keyframe. Proven:
Fight Club re-mux decode errors 320+ -> 0 across all corrupt regions.

Also adds aacs::unit_key_validates (1-block early-reject UK validation) and
ts_sync_count/ts_packet_total helpers.
2026-06-06 21:09:04 -07:00
MattJackson 9a5ed57044 mux: enforce strictly-monotonic per-track block timestamps
Fixes the non-monotonic DTS ffmpeg/players reject (observed on Fight Club audio
streams 16/18: "14061 >= 14060"). Some audio PES PTS truncate to the same
millisecond as the prior frame, or tick back 1ms from rounding. The MKV writer
now tracks the last block timecode per track and nudges a non-increasing one to
prev+1ms (sub-frame, inaudible, A/V sync unaffected at ms granularity), recorded
only for frames actually written. New helper monotonic_ts() + unit test.

Independent of the separate HEVC slice-payload corruption investigation.
2026-06-06 15:57:00 -07:00
MattJackson 7d58ba7b08 v0.30.0: DiscInputs carries the disc's volume label (title)
Add `DiscInputs.volume_label: Option<String>` — the disc's human title (UDF/ISO
volume identifier, else BDMV <di:name>), populated by Disc::inputs() from the
scan. Identity only, no secret, not used in any AACS derivation; lets a key
source forward the title so a key service can catalog disc_hash → title.
2026-06-06 09:05:15 -07:00
MattJackson 4f1dbfd042 v0.29.0: bump version 2026-06-05 20:36:19 -07:00
MattJackson 5b702a76a7 mux: write PTS (presentation), not DTS, as the MKV block timecode
The video codec parsers (HEVC, H.264, VC-1, MPEG-2) used
pes.dts.or(pes.pts) as each frame's timestamp. MKV block timecodes
are presentation timestamps; frames are stored in decode order and
the player reorders for display by timecode. Using DTS makes the
timecode monotonic in storage order, presenting B-frames in decode
order — visible motion judder / wrong frames on playback, and
PTS-based seeking lands on the wrong frame.

The compressed video was always byte-correct (verified by NAL-level
diff against a known-good demux); this was purely a timestamp defect
affecting every B-frame title. Fix: prefer PTS (pes.pts.or(pes.dts)).
Verified on a real UHD iso->mkv: emitted PTS now reorders for
B-frames identically to a reference muxer.

Update the two tests that asserted the old DTS-preferred behavior and
add an HEVC regression test pinning PTS as the block timecode.
2026-06-05 20:01:13 -07:00
MattJackson 6be5198886 libfreemkv: v1.0 hardening — codec/EBML/TS robustness + DTS parser fixes
Audit-driven fixes (rounds 1–3):
- hevc: correct hvcC profile/level SPS offsets (HEVC has a 2-byte NAL header)
- mkv: map all DTS variants to the registered A_DTS codec id; force a new
  cluster before the i16 cluster-relative timestamp can overflow
- ebml/mkvstream: bound untrusted EBML sizes (no multi-GB allocs); reject
  uint>8 (was an OOB panic) and non-{0,4,8} float widths (were a desync)
- ts: skip PES-header bytes that span a TS packet boundary; add the PMT
  section_len/prog_info_len bounds the PAT parser already had
- ac3: preserve a 0x0B77 syncword split across a PES boundary; cap buffer
- dts: validate each next-core boundary by decoded core size (a 0x7FFE8001
  pattern inside XLL payload no longer false-splits/drops the lossless
  extension); reject sub-minimum core frames; fix forced-emit PTS base
- lpcm: DVD program-stream PCM no longer double-strips the BD LPCM header
- vc1/mpeg2: do not emit a parameter-set-only PES as a standalone frame
- pgs/truehd: cap the pending reassembly buffer (parity with ac3/dts)
- aacs: ts_syncs_intact uses the exact packet count
- prefetched: capacity-guard the recycled-buffer set_len
- Cargo.toml: exclude project docs from the published crate

Convergence: a third independent audit pass found no remaining material
(CRITICAL/HIGH/MEDIUM) issues. Full precommit (fmt + clippy -D + tests,
Rust 1.86) green.
2026-06-05 16:23:39 -07:00
MattJackson e2aa9abd6d aacs: validate a resolved key against content before applying it
decrypt_with now takes the disc's encrypted content samples and, after
deriving the candidate unit keys, confirms at least one de-scrambles a real
aligned unit before committing them. A wrong key (a keydb VK that doesn't
match the disc, a stale UK) is rejected with AacsKeyRejected instead of
silently applying garbage unit keys. Conservative by design: with no samples
(resume / mapfile cache) it accepts as before, leaving those paths unchanged.

The KeySource trait becomes a stateful provider — next_key hands one candidate
at a time (the source owns the order) and reports exhaustion, replacing the
all-at-once resolve; errored() distinguishes a failed source from a clean
no-key.
2026-06-05 09:23:08 -07:00
MattJackson 3b7bee9ed4 Fix DTS-HD MA muxing as lossy core only (#10)
DTS-HD MA/HRA access units on Blu-ray are a DTS core frame (sync
0x7FFE8001) followed by one or more DTS extension substreams (sync
0x64582025) carrying the lossless audio. Ground-truthing the Dunkirk
ISO showed the m2ts demuxer hands these out as SEPARATE PES packets on
the same PID: one core PES (exactly core-sized, nothing trailing), then
the extension substreams in following PES packets with their own later
PTS.

The old DtsParser emitted one frame per PES the moment a core frame was
complete, and dropped any PES with no core sync. So every core became a
core-only (lossy) frame and the extension PES packets were discarded as
junk -- silently downgrading the track to lossy DTS core (1557 kb/s CBR,
16-bit) instead of DTS-HD MA (VBR, 24-bit lossless).

Rewrite the parser to assemble across PES boundaries: an access unit
runs from its core sync up to (but not including) the NEXT core sync, so
the core plus every following extension substream stays together. Add a
CodecParser::flush() (default empty) called at end-of-stream by both the
pipelined and inline DiscStream mux paths to drain the final buffered
unit. A 64 KiB cap guarantees forward progress and never stalls if a
boundary can't be found.

Validated on the rip1 testbed: Dunkirk eng+ger and Fight Club eng main
audio now ffprobe as profile=DTS-HD MA (Fight Club eng at 24-bit), with
VBR packet sizes (~2716-2788 B) well above the old fixed 2012 B lossy
core. Genuinely-lossy DTS dub tracks are left untouched.
2026-06-05 06:40:27 -07:00
MattJackson b7405e2d27 mux: fail iso:// with no usable AACS key instead of muxing garbage
When an AACS-encrypted ISO is muxed with decryption requested (not --raw)
but key resolution yielded no usable key, input() proceeded to mux the
still-encrypted stream — emitting ~100 MB of garbage (no TS syncs, demuxer
emits nothing) and sometimes spinning for tens of minutes.

Add a cheap result-check in resolve::input()'s Iso branch via the pure
predicate aacs_key_missing(raw, has_aacs, keys): when decryption is
requested AND the disc carries AACS state AND decrypt_keys() is None,
return new Error::NoDiscKey { disc_hash } (E7022) before muxing. The
40-hex disc hash is sourced from AacsState::disc_hash. --raw and
non-AACS (unencrypted / CSS) discs are unaffected. Unit-tested.
2026-06-05 05:06:15 -07:00
MattJackson e04d79c593 v0.28.1: bump version 2026-06-04 21:46:41 -07:00
MattJackson b34af1fa74 mux: wire TrueHD channel probe into iso:// mux setup
correct_truehd_channels() probes the first DECRYPTED access units of the
chosen title (TrueHD PIDs, bounded 8 MiB read of the first extent) and
sets AudioStream.channels from the MLP major sync — fixing the MPLS
audio_format understatement (5.1 declared on a 7.1/Atmos TrueHD track).
Regenerates the basic codec label for the corrected count; richer
editorial labels are left untouched. Wired in resolve.rs input() for
iso:// after decrypt_with (the m2ts is only decryptable post-key), using
a fresh reader so the mux reader is undisturbed. generate_audio_label
made pub(crate).
2026-06-04 20:28:37 -07:00
MattJackson a7317f8885 mux/truehd: decode true channel count from MLP major-sync format_info
Adds truehd_channels(format_info) + truehd_channels_from_stream(): the
real presentation channel count (8ch/7.1 when present, else 6ch/5.1)
from the TrueHD major sync, which the MPLS audio_format base field
understates (declares 5.1 even on a 7.1/Atmos track — the Dune case).
Per the MLP spec presentation channel-assignment masks. 3 unit tests.

This is the decoder; wiring it to correct AudioStream.channels requires
a mux-time probe of the DECRYPTED audio payload (the channel truth isn't
readable at scan — the m2ts is AACS-encrypted until the key is applied
at mux), to be added + validated on a real rip.
2026-06-04 19:51:46 -07:00
MattJackson 474273afc0 mux/mkv: signal Dolby Vision via dvcC BlockAdditionMapping
With the EL now carried (mpls PID fix), the MKV writer tags the Dolby
Vision layer so players/mediainfo recognise it: a BlockAdditionMapping
(0x41E4) with BlockAddIDType 'dvcC' and a DOVIDecoderConfigurationRecord
(profile 7, bl+el+rpu present) on the DV-layer video track. Disc
Profile 7 dual-layer is preserved losslessly as a second video track
(archival-correct, MakeMKV-equivalent); M2TS carries the EL as a native
second video PID with no extra signaling needed.

Adds dolby_vision_config() + a profile-7 dvcC unit test. End-to-end DV
recognition (mediainfo / playback) to be confirmed on a full rip.
2026-06-04 19:42:14 -07:00
MattJackson 0a2bab5789 mpls: resolve PID for sub-path / DV-EL stream entries (types 2/3/4)
parse_stream_entry only read the PID for stream-entry type 1 (a stream
in the PlayItem's own clip), returning PID 0 for every other type. The
Dolby Vision enhancement layer uses type 4 (verified on Wicked + Dune:
se_len=9 type=0x04 [00 10 15] -> PID 0x1015), so the EL fell through to
PID 0x0000 and was silently dropped by the demux/mux — Dolby Vision lost.

Now the PID offset is keyed off the entry type per the BD stream_entry()
layout: type 1 -> +2, type 2 -> +4, type 3/4 -> +3. The DV EL now
resolves to its real PID (0x1015) so it is demuxed and carried as a
stream through the PES layer to every writer (M2TS carries dual-PID DV
natively; the MKV writer's DV signaling is the format-specific piece).
2026-06-04 19:28:34 -07:00
MattJackson fa4d7ef871 mux/dts: keep DTS-HD extension substream across PES boundaries
The DTS parser emitted a core-only frame and discarded the trailing
DTS-HD extension substream whenever the extension straddled a PES
boundary (it advanced past only the core, then re-synced on the core
syncword, skipping the leftover extension bytes). That silently
downgrades DTS-HD MA / HRA to lossy DTS core.

Now: when an extension sync is visible after the core — full, or a
partial prefix at the buffer edge — wait for the full extension instead
of splitting it off; only 'nothing after the core' (final unit / EOF,
no parser flush) is taken as a genuine lossy core-only unit. Adds a
boundary-split regression test asserting core+extension is preserved.
2026-06-04 19:16:26 -07:00
MattJackson 36d1af1b7f labels+disc: codec from stream, audio-richness title tiebreak
Two validated audio-correctness fixes (proven on real discs Wicked/
Paddington/Dune/Fight Club via head-captures):

labels: apply_labels now derives the codec/channel descriptor from the
stream's OWN codec/channels unless the parser's codec_hint is BOTH
consistent with it AND richer (e.g. "Dolby Atmos" on a TrueHD stream).
A mis-bound hint ("AC-3 2.0" on a TrueHD track) is rejected and the
stream's own codec used — killing the cross-labeled shuffle (Wicked) and
the compat-core mislabel (Paddington), while keeping rich hints and
normalizing plain ones to uniform marketing names. (codec_hint_consistent
+ codec_hint_adds_detail, 5 tests.)

disc: canonical_title_order gains an audio-richness tiebreak
(lossless > channels > track-count) for titles that tie on
duration+clips — so a movie authored as a full-audio playlist plus a
stereo-only twin (Fight Club 00800 vs 00004) picks the full-audio one
instead of falling to array order.
2026-06-04 19:10:11 -07:00
MattJackson b96f6206fe 0.28.0: version bump (keydb decoupling) 2026-06-04 16:41:04 -07:00
MattJackson bb32fb993b 0.28.0 review fixes: propagate mux key-apply error; scrub key-shaped fixtures
- mux input(): propagate a failed decrypt_with rather than muxing an
  undecryptable stream silently (review M6).
- Scrub synthetic but key-SHAPED hex (HOST_PRIV_KEY/DEVICE_KEY/disc-entry) from
  the keydb parser tests to obvious zero/repeated-byte placeholders. No real or
  real-looking key material in code.
2026-06-04 16:38:58 -07:00
MattJackson c1eb74dfa5 AACS: cutover — lookup-free lib; DriveCredentials for the handshake
The library no longer loads a keydb anywhere. The scan path always captures the
disc's AACS inputs (MKB, VID, Unit_Key_RO.inf) and resolves NO key; a caller
resolves a Key from a key source and applies it via Disc::decrypt_with.

- ScanOptions loses keydb_path / unit_key / disable_keydb (and the path search);
  it now carries only optional DriveCredentials (host certs) for the live-drive
  AACS handshake. do_handshake_cert uses those instead of loading the keydb.
  An unlocked / LibreDrive drive takes the OEM Volume-ID path and needs none.
- The mux input() path takes caller-resolved unit_keys instead of a keydb_path,
  and applies them via decrypt_with.
- Deleted the now-dead inline resolve_encryption / resolve_encryption_static.

All 700+ lib tests pass.
2026-06-04 16:04:06 -07:00
MattJackson 46838c63ca AACS: trim the MKB captured at keyless scan to its record length
The VID-only scan path stashed MKB_RO/RW raw — those files are allocated to a
fixed ~128 MiB and zero-padded, so the MKB on AacsState (consumed by
Disc::inputs() and the device/processing-key decrypt_with derivation) was the
full pad, not the ~few-MB record stream. Trim to mkb_content_len, matching
read_aacs_inputs.
2026-06-04 15:32:12 -07:00
MattJackson 1e60220ff6 AACS: KeySource::needs_samples() gates the content-sample read
A source that validates server-side against ciphertext (an online key service)
needs encrypted content samples; one that keys on disc identity (keydb, mapfile)
does not. needs_samples() lets the caller skip the extra disc read unless a
configured source actually needs it. Defaults false.
2026-06-04 14:45:40 -07:00
MattJackson 080f03e8ed AACS: DiscInputs gains app-populated samples for ciphertext-validating sources
An online key service validates a candidate against real ciphertext, so it
needs a few encrypted content sample units. Add a samples field to DiscInputs;
Disc::inputs() leaves it empty (reading content needs the disc reader, which
scan does not retain) for the application to fill.
2026-06-04 14:42:11 -07:00
MattJackson 35de6101d4 AACS: add KeySource trait + DiscInputs; Media key carries a candidate pool
The library now describes the key-source abstraction it consumes, while the
concrete sources live in a companion crate. A KeySource looks a disc up from
its DiscInputs (disc hash, volume id, MKB, Unit_Key_RO.inf) and returns a Key;
the library derives down and decrypts. Sources do no derivation.

Disc::inputs() exposes those inputs from a scanned disc.

Key::Media now carries a Vec of candidate media keys rather than one: a media
key is MKB-scoped, so a source hands its whole pool and the library picks the
one that verifies against the disc's MKB. That mirrors Device/Processing and
keeps key selection (a derivation step) inside the library.
2026-06-04 14:29:32 -07:00
MattJackson 9012101573 AACS: decrypt_with accepts higher-level keys and derives down
Disc::decrypt_with now takes Device / Processing / Media / Volume keys in
addition to Unit. A caller hands in whatever level it resolved and the
library derives down the AACS chain to the per-CPS-unit keys, then
decrypts:

  Device   -> MKB walk         -> media key -> VUK -> per-CPS unit keys
  Processing -> MKB            -> media key -> VUK -> per-CPS unit keys
  Media      -> Volume ID      -> VUK              -> per-CPS unit keys
  Volume     -> Unit_Key_RO.inf, one unit key per CPS unit
  Unit       -> used directly (terminal)

Derivation stays centralized in the version-dispatched resolver
(1.0 / 2.0 / 2.1), fed by a single-key provider built from the supplied
key — no new crypto. Volume notably does NOT stop at the volume key: it
decrypts every CPS unit's key.

Scan stashes the AACS inputs (Unit_Key_RO.inf and MKB) on AacsState so an
out-of-band decrypt_with can derive without re-reading the disc.

Non-breaking: Key is #[non_exhaustive] and the existing Unit path is
unchanged. New tests cover the Volume -> per-CPS derive-down, the
missing-inputs error, and the no-units rejection.
2026-06-04 14:20:16 -07:00
MattJackson 8bc1de6c9b 0.27.5 (step 2, Phase 1): expose AACS inputs (uk_ro, mkb) on AacsState
scan now stashes the raw Unit_Key_RO.inf + MKB bytes on AacsState (via
resolve_vid_only, the disable_keydb path), so an external key-resolver can
derive unit keys from a resolved VUK without re-reading the disc — the
foundation for moving lookup/derivation out of libfreemkv. Additive: the keydb
path is untouched, all existing constructors default the new fields empty.
584 lib tests green. Builds on the KeyOrigin rename + the Key/decrypt_with API.
2026-06-04 13:20:27 -07:00
MattJackson d94a4d3444 0.27.5 (step 2, item 2): rename provenance enum KeySource -> KeyOrigin
Disambiguates the key vocabulary: Key (the input handed to decrypt_with),
key sources (the resolver's lookup list), and KeyOrigin (how a key was
resolved). Internal-only rename — no dependents import it.
2026-06-04 11:42:21 -07:00
36 changed files with 4574 additions and 869 deletions
+26
View File
@@ -1,5 +1,31 @@
# Changelog
## 0.29.0 (2026-06-06)
### Fixed
- **Video frames now carry their presentation timestamp (PTS), not the decode
timestamp (DTS), as the MKV block timecode.** The HEVC, H.264, VC-1 and
MPEG-2 parsers stamped each frame with `dts.or(pts)`, so B-frame titles were
muxed with monotonic decode-order timecodes: players presented frames in
decode order (visible judder / apparent "corruption") and PTS-based seeking
landed on the wrong frame. The compressed video was always byte-correct —
this was purely a timestamp defect, and it affected every B-frame title. The
parsers now use `pts.or(dts)`. Verified against a reference muxer on a real
UHD title: the emitted frame sequence is now identical.
- **DTS-HD MA tracks no longer downgrade to the lossy DTS core** — the access
unit is delimited by the next *validated* core sync, so a `0x7FFE8001` byte
pattern inside the XLL extension can no longer truncate the unit. (#10)
- **A resolved AACS key is verified against the content before it is applied**,
so a stale or wrong VUK can no longer silently produce garbage output.
- **`iso://` mux fails fast when no usable AACS key is available** instead of
writing a garbage MKV.
### Changed
- Codec / EBML / TS hardening: bounds-checked PMT and PES-header parsing, EBML
length validation, and bounded codec buffers (DTS, TrueHD, PGS).
## 0.27.3 (2026-06-04)
### Added
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.27.4"
version = "0.30.7"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
@@ -8,6 +8,8 @@ description = "Open source raw disc access library for optical drives"
repository = "https://github.com/freemkv/libfreemkv"
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
categories = ["hardware-support", "multimedia"]
# Keep internal AI-instruction / private notes out of the published crate.
exclude = []
[dependencies]
serde = { version = "1", features = ["derive"] }
+76 -3
View File
@@ -91,7 +91,11 @@ pub fn is_aacs_scrambled(unit: &[u8]) -> bool {
/// unit looks like clear MPEG-TS. Syncs sit at offset 4 and every 192 bytes
/// after (4-byte TP_extra_header + 188-byte TS packet). An encrypted body
/// scrambles all but the first (which lives in the clear 16-byte seed).
fn ts_syncs_intact(unit: &[u8]) -> bool {
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
/// (offset 4 and every 192 bytes after). A clear or correctly-decrypted m2ts
/// unit shows ~one per packet; an encrypted unit, or a non-content unit
/// decrypted under a key that doesn't apply, shows ~none.
pub fn ts_sync_count(unit: &[u8]) -> usize {
let mut count = 0;
let mut offset = 4;
while offset < unit.len() {
@@ -100,8 +104,19 @@ fn ts_syncs_intact(unit: &[u8]) -> bool {
}
offset += TS_PACKET_LEN;
}
let total = (unit.len() - 4) / TS_PACKET_LEN + 1;
count > total / 2
count
}
/// Number of BD-TS packets in the unit — the maximum possible sync count.
pub fn ts_packet_total(unit: &[u8]) -> usize {
// One sync byte per 192-byte BD-TS packet (at offset 4 of each). The old
// `(len - 4) / TS_PACKET_LEN + 1` over-counted by one for lengths of the
// form `4 + k·192`.
unit.len() / TS_PACKET_LEN
}
fn ts_syncs_intact(unit: &[u8]) -> bool {
ts_sync_count(unit) > ts_packet_total(unit) / 2
}
/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact).
@@ -147,6 +162,64 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
verify_ts(unit)
}
/// Fast, NON-MUTATING unit-key validation for the brute-force key search.
///
/// `decrypt_unit` pays a full 6128-byte (383-block) CBC decrypt before
/// `verify_ts` can reject a wrong key — but in a brute scan ~every candidate is
/// wrong. In CBC the plaintext of block *i* is `AES_dec(C_i) XOR C_{i-1}`, so
/// the FIRST restored TS sync byte (payload offset 196, which lands in CBC
/// block 11 of the `unit[16..]` region) can be recovered with a SINGLE block
/// decrypt instead of 383. A wrong key fails this 1-byte gate ~255/256 of the
/// time for the cost of one AES block; the rare survivor is then confirmed with
/// the full [`decrypt_unit`], so the set of accepted keys is bit-for-bit
/// identical to the slow path.
///
/// The caller MUST pass an aligned, already-[`is_aacs_scrambled`] unit
/// (`unit.len() >= ALIGNED_UNIT_LEN`). The brute pre-filters its units, so the
/// per-candidate scramble re-scan is intentionally skipped here.
///
/// NOTE: this is a search accelerator — it never writes the input and never
/// participates in the content decrypt path. Aggregate correctness (does a key
/// validate against *any* of the disc's units) is preserved because a true key
/// restores offset-196 on every standard BD-TS unit.
pub fn unit_key_validates(unit: &[u8], unit_key: &[u8; 16]) -> bool {
if unit.len() < ALIGNED_UNIT_LEN {
return false;
}
// Per-unit decrypt key: AES-ECB-encrypt the 16-byte plaintext header with
// the unit key, XOR with the header (same derivation as `decrypt_unit`).
let mut header = [0u8; 16];
header.copy_from_slice(&unit[..16]);
let derived = aes_ecb_encrypt(unit_key, &header);
let mut decrypt_key = [0u8; 16];
for i in 0..16 {
decrypt_key[i] = derived[i] ^ header[i];
}
// Cheap gate: recover ONLY payload byte 196 (the 2nd BD-TS packet's sync).
// The CBC region is `unit[16..]`; payload offset 196 → region offset 180 =
// block 11, byte 4. P[11] = AES_dec(C[11]) XOR C[10]; C[10] is raw
// ciphertext (no decrypt needed). Constant offsets for the fixed 6144 unit.
const SYNC_PAYLOAD_OFF: usize = 196;
let region_off = SYNC_PAYLOAD_OFF - 16; // 180
let blk = region_off / 16; // 11
let byte = region_off % 16; // 4
let c11 = 16 + blk * 16; // absolute offset of C[11] in `unit` (=192)
let cipher = Aes128::new(GenericArray::from_slice(&decrypt_key));
let mut b = GenericArray::clone_from_slice(&unit[c11..c11 + 16]);
cipher.decrypt_block(&mut b);
let prev = unit[c11 - 16 + byte]; // C[10] byte (region block 10)
if b[byte] ^ prev != TS_SYNC {
return false;
}
// Survivor (~1/256 of candidates): confirm with the authoritative full
// decrypt + verify, so the verdict matches `decrypt_unit` exactly.
let mut full = [0u8; ALIGNED_UNIT_LEN];
full.copy_from_slice(&unit[..ALIGNED_UNIT_LEN]);
decrypt_unit(&mut full, unit_key)
}
/// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked.
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<usize> {
if !is_aacs_scrambled(unit) {
+29 -14
View File
@@ -455,8 +455,13 @@ mod tests {
#[test]
fn test_parse_disc_entry() {
let line = r#"0x000102030405060708090A0B0C0D0E0F10111213 = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x000102030405060708090A0B0C0D0E0F | I | 0x101112131415161718191A1B1C1D1E1F | V | 0x202122232425262728292A2B2C2D2E2F | U | 1-0x303132333435363738393A3B3C3D3E3F ; MKBv77"#;
let entry = KeyDb::parse_disc_entry(line).unwrap();
// All-zero placeholders — synthetic; no real key material in code.
let z40 = "00".repeat(20);
let z32 = "00".repeat(16);
let line = format!(
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} | U | 1-0x{z32} ; MKBv77"
);
let entry = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(entry.title, "Sample Film");
assert!(entry.media_key.is_some());
assert!(entry.vuk.is_some());
@@ -464,9 +469,14 @@ mod tests {
assert_eq!(entry.unit_keys[0].0, 1);
}
// NOTE: key fields below use obvious repeated-byte / zero placeholders
// (0x01.., 0x02.., 0x03.., 0x00..). NEVER put real — or real-looking — host,
// device, or processing key material in code; these tests exercise the
// parser's field-splitting only, not any genuine key.
#[test]
fn test_parse_device_key() {
let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
let dk = KeyDb::parse_device_key(line).unwrap();
assert_eq!(dk.node, 0x0800);
assert_eq!(dk.u_mask_shift, 0x17);
@@ -479,9 +489,9 @@ mod tests {
// candidate: it lands in `processing_keys` and the brute walker
// handles it.
let cfg = r#"
| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA ; orphan, no position fields
| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48
| PK | 0xCAFEBABE0001020304050607080900BB ; legacy PK row still works
| DK | DEVICE_KEY 0x01010101010101010101010101010101 ; orphan, no position fields
| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48
| PK | 0x03030303030303030303030303030303 ; legacy PK row still works
"#;
let db = KeyDb::parse(cfg);
assert_eq!(
@@ -495,29 +505,34 @@ mod tests {
2,
"orphan DK row + legacy PK row both belong in processing_keys"
);
assert_eq!(db.processing_keys[0][..4], [0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(db.processing_keys[1][..4], [0xCA, 0xFE, 0xBA, 0xBE]);
assert_eq!(db.processing_keys[0][..4], [0x01, 0x01, 0x01, 0x01]);
assert_eq!(db.processing_keys[1][..4], [0x03, 0x03, 0x03, 0x03]);
}
#[test]
fn test_parse_orphan_dk_rejects_lines_with_position_fields() {
// The parser must NOT pick up a positioned DK row as an orphan
// (that would double-count). parse_orphan_dk explicitly checks.
let positioned = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17";
let positioned = "| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17";
assert!(
KeyDb::parse_orphan_dk(positioned).is_none(),
"positioned DK must not match orphan parser"
);
let orphan = "| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA";
let orphan = "| DK | DEVICE_KEY 0x01010101010101010101010101010101";
let key = KeyDb::parse_orphan_dk(orphan).expect("orphan should parse");
assert_eq!(key[..4], [0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(key[..4], [0x01, 0x01, 0x01, 0x01]);
}
#[test]
fn test_parse_host_cert() {
let line = "| HC | HOST_PRIV_KEY 0xDEADBEEF000102030405060708090A0B0C0D0E0F | HOST_CERT 0x000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B ; Revoked";
let hc = KeyDb::parse_host_cert(line).unwrap();
assert_eq!(hc.private_key[0], 0xDE);
// 20-byte priv + 92-byte cert, all zeros — placeholders, not a key.
let line = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked",
"00".repeat(20),
"00".repeat(92)
);
let hc = KeyDb::parse_host_cert(&line).unwrap();
assert_eq!(hc.private_key, [0u8; 20]);
assert_eq!(hc.certificate.len(), 92);
}
+1 -1
View File
@@ -24,7 +24,7 @@ pub mod variants;
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
pub use decrypt::{
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
is_aacs_scrambled,
is_aacs_scrambled, unit_key_validates,
};
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::probe;
+39
View File
@@ -120,3 +120,42 @@ impl Providers<'_> {
self.0.iter().find_map(|p| p.lookup_disc_by_vid(volume_id))
}
}
/// A [`KeyProvider`] backed by a single caller-supplied key's raw material —
/// the bridge for [`crate::disc::Disc::decrypt_with`].
///
/// The application's key source did the lookup and handed in material at one
/// level (DK / PK / MK / VUK). This exposes exactly that material to the
/// version-dispatched resolver, which owns ALL derivation — so a source never
/// derives, and the lib remains the single home for the AACS chain across
/// 1.0 / 2.0 / 2.1 / 2.x.
///
/// Each level fills only its own field; the rest stay empty, so the resolver
/// naturally runs the matching path (DK→…, PK→…, MK-pool brute, or a
/// disc-keyed VUK hit). `decrypt_with` already knows the disc, so the
/// `lookup_disc_by_*` hash/VID arguments are irrelevant — a present
/// `disc_entry` is returned for any query.
pub(crate) struct SuppliedKey {
pub device_keys: Vec<DeviceKey>,
pub processing_keys: Vec<[u8; 16]>,
pub media_keys: Vec<[u8; 16]>,
pub disc_entry: Option<DiscEntry>,
}
impl KeyProvider for SuppliedKey {
fn device_keys(&self) -> Vec<DeviceKey> {
self.device_keys.clone()
}
fn processing_keys(&self) -> Vec<[u8; 16]> {
self.processing_keys.clone()
}
fn media_keys(&self) -> Vec<[u8; 16]> {
self.media_keys.clone()
}
fn lookup_disc_by_hash(&self, _disc_hash: &[u8; 20]) -> Option<DiscEntry> {
self.disc_entry.clone()
}
fn lookup_disc_by_vid(&self, _volume_id: &[u8; 16]) -> Option<DiscEntry> {
self.disc_entry.clone()
}
}
+10 -2
View File
@@ -156,8 +156,16 @@ impl Disc {
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
})),
// Stream type 4 = IG, unknown types -- skip
_ => None,
// Stream type 4 = IG, unknown types -- skip.
other => {
tracing::warn!(
"dropping STN stream entry: unhandled stream_type {} (PID {:#06x}, coding_type {:#04x})",
other,
s.pid,
s.coding_type,
);
None
}
}
})
.collect();
+29 -252
View File
@@ -179,54 +179,33 @@ impl Disc {
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> (Option<HandshakeResult>, Option<Error>) {
use crate::aacs::{self, KeyDb};
use crate::aacs;
let keydb_path = match opts.resolve_keydb() {
Some(p) => p,
None => {
// Host certs come from the caller's DriveCredentials (e.g. the keydb's
// host_certs(), sourced app-side) — the library does not load a keydb.
// Absent ⇒ no cert auth: an unlocked / LibreDrive drive already returned
// a Volume ID via the OEM path before reaching here, so this is the
// locked-drive-without-credentials case.
let host_certs: &[aacs::HostCert] = match &opts.credentials {
Some(c) if !c.host_certs.is_empty() => &c.host_certs,
_ => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_no_keydb",
"no KEYDB found in search paths; handshake skipped"
phase = "handshake_no_credentials",
"no drive credentials supplied; cert handshake skipped"
);
return (None, None);
}
};
let keydb = match KeyDb::load(&keydb_path) {
Ok(db) => db,
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_keydb_load_failed",
io_error_kind = ?e.kind(),
keydb = %keydb_path.display(),
"KEYDB load failed; handshake skipped"
);
return (
None,
Some(Error::KeydbLoad {
path: keydb_path.display().to_string(),
}),
);
}
};
let host_cert_count = keydb.host_certs.len();
let host_cert_count = host_certs.len();
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_start",
host_cert_count,
keydb = %keydb_path.display(),
"handshake starting"
);
if host_cert_count == 0 {
// No host certs in keydb -> cert auth cannot proceed.
// Surface as RawReadUnsupported so the caller knows
// neither path is available on this configuration.
return (None, Some(Error::AacsRawReadUnsupported));
}
// v0.25.7 wedge fix. Pre-0.25.7 this loop fired up to 16 AACS
// authenticate attempts back-to-back with no pause. Each attempt
// is 5-10 SCSI REPORT_KEY/SEND_KEY exchanges. On a disc whose
@@ -246,7 +225,7 @@ impl Disc {
const MAX_CERT_ATTEMPTS: usize = 3;
const PER_CERT_BACKOFF_MS: u64 = 1000;
let mut last_err_code: Option<u16> = None;
for (idx, hc) in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
for (idx, hc) in host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
if idx > 0 {
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
}
@@ -317,219 +296,6 @@ impl Disc {
(None, Some(Error::AacsHostCertRejected))
}
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
///
/// Reads AACS files from UDF (via SectorSource), resolves keys through
/// whatever path works: KEYDB VUK lookup, media key derivation, processing
/// keys, device keys. Uses handshake result (volume ID, bus key) if available.
pub(super) fn resolve_encryption(
udf_fs: &udf::UdfFs,
reader: &mut dyn SectorSource,
keydb_path: &std::path::Path,
handshake: Option<&HandshakeResult>,
) -> Result<AacsState> {
use crate::aacs::{self, KeyDb};
use crate::drm::{DrmContext, DrmProbe, DrmScheme, ResolvedScheme};
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad {
path: keydb_path.display().to_string(),
})?;
// Read AACS files from disc/image via UDF
let uk_ro_data = udf_fs
.read_file(reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
// Log the disc hash so we can confirm whether it's present in KEYDB
// when key resolution fails. The disc hash is SHA-1 of the full
// Unit_Key_RO.inf file bytes — same value KEYDB.cfg keys VUK entries by.
let dh = crate::aacs::disc_hash(&uk_ro_data);
let dh_hex = crate::aacs::disc_hash_hex(&dh);
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_disc_hash",
disc_hash = %dh_hex,
uk_ro_len = uk_ro_data.len(),
"disc hash computed (compare with keydb.cfg entries)"
);
let cc_data = udf_fs
.read_file(reader, "/AACS/Content000.cer")
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
.ok();
let mkb_data = udf_fs
.read_file(reader, "/AACS/MKB_RW.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
.ok();
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
let mkb_first_64_hex = mkb_data
.as_deref()
.map(|m| {
m.iter()
.take(64)
.map(|b| format!("{b:02x}"))
.collect::<String>()
})
.unwrap_or_default();
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_mkb_info",
mkb_present = mkb_data.is_some(),
mkb_len = mkb_data.as_deref().map(|m| m.len()).unwrap_or(0),
mkb_version = ?mkb_ver,
mkb_first_64 = %mkb_first_64_hex,
keydb_disc_count = keydb.disc_entries.len(),
keydb_dk_count = keydb.device_keys.len(),
keydb_pk_count = keydb.processing_keys.len(),
"AACS resolution inputs"
);
// Use handshake volume ID if available, otherwise zeros
// (KEYDB VUK lookup by disc hash works without volume ID;
// paths 2/3/4 in `resolve_keys` short-circuit on the zero
// sentinel and don't waste cycles trying to derive against
// garbage input).
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
let vid_available = volume_id != [0u8; 16];
let read_data_key = handshake.and_then(|h| h.read_data_key);
// Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key.
//
// Distinguish "we had every input and still missed" from "we
// never had VID so the derivation paths couldn't run." The
// former points at a stale keydb / unsupported MKB; the
// latter points at a failed handshake upstream. Path 1
// (disc-hash lookup) ran without VID and missed -> disc isn't
// in the keydb. If the caller has a handshake-failure reason
// it overrides this in `scan_with`.
let miss_error = if vid_available {
Error::AacsMkUnavailable
} else {
Error::AacsVukNotInKeydb
};
// Build a probe + context and let the dispatcher pick V10 / V20
// / V21. CSS is impossible here (this function is only called
// when /AACS exists), so we don't populate the DVD probe sector
// or a CSS context.
let probe = DrmProbe {
dvd_sample_sector: None,
content_cert: cc_data.as_deref(),
mkb: mkb_data.as_deref(),
};
let scheme = match DrmScheme::detect(&probe) {
Some(s) => s,
None => return Err(miss_error),
};
let providers: &[&dyn aacs::KeyProvider] = &[&keydb];
let aacs_ctx = aacs::ResolveContext {
unit_key_ro: &uk_ro_data,
content_cert: cc_data.as_deref(),
volume_id: &volume_id,
providers,
mkb: mkb_data.as_deref(),
};
let mut ctx = DrmContext {
aacs: Some(aacs_ctx),
css: None,
};
let resolved = match scheme.load(&mut ctx) {
Some(ResolvedScheme::Aacs(r)) => r,
// Resolution against /AACS inputs can only produce AACS
// keys. Either the dispatcher returned None (load failed)
// or — structurally impossible here — a CSS state. Both
// surface as the upstream miss-error.
_ => return Err(miss_error),
};
Ok(AacsState {
version: match resolved.version {
aacs::AacsVersion::V10 => 1,
aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => 2,
},
bus_encryption: resolved.bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
key_source: match resolved.key_source {
1 => KeySource::DeviceKey,
2 => KeySource::ProcessingKey,
3 => KeySource::KeyDbDerived,
4 => KeySource::KeyDb,
5 => KeySource::KeyDbUnitKeys,
_ => KeySource::KeyDb,
},
vuk: resolved.vuk,
unit_keys: resolved.unit_keys,
read_data_key,
volume_id,
})
}
/// Resolve encryption from a caller-supplied Unit Key (the external key service
/// path). No keydb, no derivation: read `Unit_Key_RO.inf` for the disc
/// hash + version/bus-encryption flags, then use `unit_key` directly as
/// CPS unit 1's decryption key. The handshake (if any) still supplies the
/// volume ID and AACS 2.0 read-data key for bus decryption.
pub(super) fn resolve_encryption_static(
udf_fs: &udf::UdfFs,
reader: &mut dyn SectorSource,
unit_key: [u8; 16],
handshake: Option<&HandshakeResult>,
) -> Result<AacsState> {
use crate::aacs;
let uk_ro_data = udf_fs
.read_file(reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
let dh = aacs::disc_hash(&uk_ro_data);
let cc = udf_fs
.read_file(reader, "/AACS/Content000.cer")
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
.ok()
.as_deref()
.and_then(aacs::parse_content_cert);
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
let version = match cc.as_ref().map(|c| c.version) {
Some(aacs::AacsVersion::V10) => 1,
Some(_) => 2,
None if bus_encryption => 2,
None => 1,
};
let mkb_ver = udf_fs
.read_file(reader, "/AACS/MKB_RW.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
.ok()
.as_deref()
.and_then(aacs::mkb_version);
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_external_uk",
disc_hash = %aacs::disc_hash_hex(&dh),
version,
bus_encryption,
"using caller-supplied unit key"
);
Ok(AacsState {
version,
bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&dh),
key_source: KeySource::ExternalUk,
vuk: None,
unit_keys: vec![(1, unit_key)],
read_data_key: handshake.and_then(|h| h.read_data_key),
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
})
}
/// Build a keys-free AACS state that carries only the Volume ID (+ version
/// metadata), for callers that resolve Unit Keys out-of-band and have
/// disabled the local keydb. The VID is on-disc content read during the
@@ -562,13 +328,22 @@ impl Disc {
None if bus_encryption => 2,
None => 1,
};
// MKB_RO is the correctly-sized copy; avoid reading the padded RW region.
let mkb_ver = udf_fs
// MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to
// the real record length (same as `read_aacs_inputs`). Without this the
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
// processing-key `decrypt_with` derivation consume, and which a key
// source ships to an online service — is the full 128 MiB pad, not the
// ~few-MB record stream.
let mut mkb_bytes = udf_fs
.read_file(reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
.ok()
.as_deref()
.and_then(aacs::mkb_version);
.unwrap_or_default();
let n = aacs::mkb_content_len(&mkb_bytes);
if n > 0 && n < mkb_bytes.len() {
mkb_bytes.truncate(n);
}
let mkb_ver = aacs::mkb_version(&mkb_bytes);
tracing::warn!(
target: "freemkv::disc",
@@ -585,11 +360,13 @@ impl Disc {
bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&dh),
key_source: KeySource::ExternalUk,
key_source: KeyOrigin::ExternalUk,
vuk: None,
unit_keys: vec![],
read_data_key: handshake.and_then(|h| h.read_data_key),
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
uk_ro: uk_ro_data,
mkb: mkb_bytes,
})
}
}
+621 -142
View File
@@ -375,6 +375,79 @@ pub struct Extent {
pub sector_count: u32,
}
/// Correct the channel count of a title's TrueHD audio streams by probing the
/// first decrypted access units. The MPLS `audio_format` field declares the
/// BASE layout (often 5.1) even for a 7.1/Atmos TrueHD track; the real count is
/// in the MLP major sync. `reader` must yield DECRYPTED sectors (the m2ts is
/// AACS-encrypted, so this can only run at mux time, not scan). Reads a bounded
/// window of the title's first extent. Also regenerates the stream's codec
/// label when it was the basic descriptor for the (now corrected) count —
/// richer editorial labels (e.g. "Dolby Atmos") are left untouched.
pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) {
use crate::mux::codec::truehd::truehd_channels_from_stream;
let pids: Vec<u16> = title
.streams
.iter()
.filter_map(|s| match s {
Stream::Audio(a) if matches!(a.codec, Codec::TrueHd) => Some(a.pid),
_ => None,
})
.collect();
if pids.is_empty() {
return;
}
let Some(ext) = title.extents.first() else {
return;
};
// Bounded probe: up to 8 MiB from the start of the title — enough for the
// first interleaved TrueHD major sync of each stream.
const PROBE_SECTORS: u32 = 4096;
let n = ext.sector_count.min(PROBE_SECTORS) as u16;
if n == 0 {
return;
}
let mut buf = vec![0u8; n as usize * 2048];
if reader
.read_sectors(ext.start_lba, n, &mut buf, true)
.is_err()
{
return;
}
let mut demux = crate::mux::ts::TsDemuxer::new(&pids);
let mut payloads: std::collections::HashMap<u16, Vec<u8>> = std::collections::HashMap::new();
for pes in demux.feed(&buf).into_iter().chain(demux.flush()) {
payloads
.entry(pes.pid)
.or_default()
.extend_from_slice(&pes.data);
}
for s in title.streams.iter_mut() {
let Stream::Audio(a) = s else { continue };
if !matches!(a.codec, Codec::TrueHd) {
continue;
}
let Some(payload) = payloads.get(&a.pid) else {
continue;
};
let Some(count) = truehd_channels_from_stream(payload) else {
continue;
};
let new_ch = AudioChannels::from_count(count);
if new_ch == AudioChannels::Unknown || new_ch == a.channels {
continue;
}
let was_basic =
a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary);
a.channels = new_ch;
if was_basic {
a.label = crate::labels::generate_audio_label(&a.codec, &new_ch, a.secondary);
}
}
}
/// Calculate how many bytes of bad/unreadable data fall within a title's extents.
/// `pub(crate)` so autorip can use it for main-movie lost_ms computation.
pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 {
@@ -449,7 +522,7 @@ impl Codec {
("ssa", "SSA", Codec::Ssa),
];
fn from_coding_type(ct: u8) -> Self {
pub(crate) fn from_coding_type(ct: u8) -> Self {
match ct {
0x24 => Codec::Hevc,
0x1B => Codec::H264,
@@ -467,6 +540,40 @@ impl Codec {
ct => Codec::Unknown(ct),
}
}
/// Broad stream category for a codec. Used by demuxers to decide
/// whether a PMT/STN entry becomes a video, audio, or subtitle
/// `Stream` without duplicating per-codec knowledge.
pub fn kind(&self) -> CodecKind {
match self {
Codec::Hevc | Codec::H264 | Codec::Vc1 | Codec::Mpeg2 | Codec::Mpeg1 | Codec::Av1 => {
CodecKind::Video
}
Codec::TrueHd
| Codec::DtsHdMa
| Codec::DtsHdHr
| Codec::Dts
| Codec::Ac3
| Codec::Ac3Plus
| Codec::Lpcm
| Codec::Aac
| Codec::Mp2
| Codec::Mp3
| Codec::Flac
| Codec::Opus => CodecKind::Audio,
Codec::Pgs | Codec::DvdSub | Codec::Srt | Codec::Ssa => CodecKind::Subtitle,
Codec::Unknown(_) => CodecKind::Unknown,
}
}
}
/// Broad category of a [`Codec`] — video / audio / subtitle / unknown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodecKind {
Video,
Audio,
Subtitle,
Unknown,
}
impl std::fmt::Display for Codec {
@@ -706,6 +813,22 @@ impl ColorSpace {
ColorSpace::Unknown => "",
}
}
const ALL_CS: &[(&'static str, ColorSpace)] = &[
("bt709", ColorSpace::Bt709),
("bt2020", ColorSpace::Bt2020),
("unknown", ColorSpace::Unknown),
];
/// Compact identifier for serialization (round-trips via `FromStr`).
pub fn id(&self) -> &'static str {
for (id, v) in Self::ALL_CS {
if v == self {
return id;
}
}
"unknown"
}
}
impl std::fmt::Display for ColorSpace {
@@ -714,6 +837,24 @@ impl std::fmt::Display for ColorSpace {
}
}
impl std::str::FromStr for ColorSpace {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, ()> {
for (id, v) in ColorSpace::ALL_CS {
if *id == s {
return Ok(*v);
}
}
// Also accept display names (e.g. "BT.2020").
for (_id, v) in ColorSpace::ALL_CS {
if ColorSpace::name(v) == s {
return Ok(*v);
}
}
Ok(ColorSpace::Unknown)
}
}
// ─── FromStr impls — single source of truth via ALL_* arrays ───────────────
//
// Each enum defines a const array of (str, variant) pairs. Display, FromStr,
@@ -882,9 +1023,9 @@ pub struct AacsState {
/// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix
pub disc_hash: String,
/// How keys were resolved
pub key_source: KeySource,
pub key_source: KeyOrigin,
/// Volume Unique Key (16 bytes). `None` when keys were resolved
/// via the [`KeySource::KeyDbUnitKeys`] path — that source delivers
/// via the [`KeyOrigin::KeyDbUnitKeys`] path — that source delivers
/// pre-decrypted unit keys without a VUK to derive them from.
pub vuk: Option<[u8; 16]>,
/// Decrypted unit keys (CPS unit number, key)
@@ -893,6 +1034,13 @@ pub struct AacsState {
pub read_data_key: Option<[u8; 16]>,
/// Volume ID (16 bytes) -- from SCSI handshake
pub volume_id: [u8; 16],
/// Raw `Unit_Key_RO.inf` bytes (encrypted unit keys + CPS map). Stashed at
/// scan so an external resolver (key-resolver) can derive the unit keys
/// from a VUK without re-reading the disc. Empty when not captured.
pub uk_ro: Vec<u8>,
/// Raw MKB bytes (`MKB_RO.inf`). Stashed at scan so an external resolver can
/// walk it (device/processing key → media key). Empty when not captured.
pub mkb: Vec<u8>,
}
/// How AACS keys were resolved. Variants are ordered root-of-trust →
@@ -900,7 +1048,7 @@ pub struct AacsState {
/// attempts derivation from the strongest input it has first and falls
/// back toward pre-computed per-disc material.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KeySource {
pub enum KeyOrigin {
/// MKB + device keys → subset-difference tree → VUK
DeviceKey,
/// MKB + processing keys → media key → VUK
@@ -917,74 +1065,46 @@ pub enum KeySource {
ExternalUk,
}
impl KeySource {
impl KeyOrigin {
pub fn name(&self) -> &'static str {
match self {
KeySource::DeviceKey => "MKB + device key",
KeySource::ProcessingKey => "MKB + processing key",
KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::KeyDb => "KEYDB",
KeySource::KeyDbUnitKeys => "KEYDB (unit keys)",
KeySource::ExternalUk => "external UK",
KeyOrigin::DeviceKey => "MKB + device key",
KeyOrigin::ProcessingKey => "MKB + processing key",
KeyOrigin::KeyDbDerived => "KEYDB (derived)",
KeyOrigin::KeyDb => "KEYDB",
KeyOrigin::KeyDbUnitKeys => "KEYDB (unit keys)",
KeyOrigin::ExternalUk => "external UK",
}
}
}
// ─── Disc scanning ──────────────────────────────────────────────────────────
/// Standard KEYDB.cfg search locations (compatible with libaacs).
const KEYDB_SEARCH_PATHS: &[&str] = &[
".config/aacs/KEYDB.cfg", // libaacs standard path
".config/freemkv/keydb.cfg", // freemkv download path
];
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
/// Options for disc scanning.
#[derive(Default)]
pub struct ScanOptions {
/// Path to KEYDB.cfg for AACS key lookup.
/// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/).
pub keydb_path: Option<std::path::PathBuf>,
/// Caller-supplied Unit Key — an alternative to keydb lookup. When set,
/// libfreemkv skips keydb lookup and all derivation and uses this key
/// directly to decrypt; it takes precedence over `keydb_path`. The caller
/// obtains the key however it likes; libfreemkv stays free of any network
/// dependency.
pub unit_key: Option<[u8; 16]>,
/// Disable KEYDB entirely: skip both the explicit `keydb_path` and the
/// standard-location search, so no keydb is loaded for this scan. A caller
/// that resolves keys out-of-band (e.g. a remote key service) sets this so
/// a keydb that merely happens to sit in a default location does not shadow
/// the out-of-band path. `unit_key` still takes precedence over everything.
pub disable_keydb: bool,
/// AACS host credentials for the live-drive authenticated handshake.
///
/// Optional and source-agnostic: an unlocked / LibreDrive drive uses the OEM
/// Volume-ID path and needs none, and an ISO scan has no handshake at all. The
/// caller supplies the host cert(s) from wherever it likes — today the keydb's
/// `host_certs()`, tomorrow a cert file or built-in. Decoupled from the key
/// source: a locked drive needs the cert to unlock even when the decryption key
/// comes from an online service.
#[derive(Default, Clone)]
pub struct DriveCredentials {
/// Host certificate(s) + private key(s) for the SCSI AACS handshake.
pub host_certs: Vec<crate::aacs::HostCert>,
}
impl ScanOptions {
/// Resolve KEYDB path: explicit path first, then standard locations.
/// Returns `None` when `disable_keydb` is set — no keydb is consulted.
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
if self.disable_keydb {
return None;
}
if let Some(p) = &self.keydb_path {
if p.exists() {
return Some(p.clone());
}
}
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
for relative in KEYDB_SEARCH_PATHS {
let p = std::path::PathBuf::from(&home).join(relative);
if p.exists() {
return Some(p);
}
}
}
let p = std::path::PathBuf::from(KEYDB_SYSTEM_PATH);
if p.exists() {
return Some(p);
}
None
}
/// Options for disc scanning.
///
/// libfreemkv is lookup-free — it resolves no keys. The caller resolves a key
/// out-of-band (a key source) and applies it via [`Disc::decrypt_with`]. The
/// only scan input is the optional drive credentials for the live-drive
/// authenticated handshake.
#[derive(Default)]
pub struct ScanOptions {
/// Host credentials for the live-drive AACS handshake. `None` for an
/// unlocked / LibreDrive drive (OEM Volume-ID path) and for ISO scans.
pub credentials: Option<DriveCredentials>,
}
/// Quick disc identification — name, format, capacity. No title/stream parsing.
@@ -1226,7 +1346,7 @@ impl Disc {
capacity: u32,
handshake: Option<HandshakeResult>,
handshake_error: Option<Error>,
opts: &ScanOptions,
_opts: &ScanOptions,
udf_fs: udf::UdfFs,
) -> Result<Self> {
// 2. Resolve encryption (AACS, CSS, or none)
@@ -1235,69 +1355,16 @@ impl Disc {
let (aacs, aacs_error) = if !encrypted {
(None, None)
} else if let Some(unit_key) = opts.unit_key {
// Second key source: caller supplied the Unit Key directly
// (external Unit Key). Skip keydb entirely.
match Self::resolve_encryption_static(&udf_fs, reader, unit_key, handshake.as_ref()) {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(e)),
}
} else if opts.disable_keydb {
// Keydb disabled: keys are resolved out-of-band. Still capture the
// VID (read during the handshake) so the out-of-band path has it;
// carry no keys (disc reports "encrypted, no keys" until re-scanned
// with a resolved Unit Key).
} else {
// Lookup-free: capture the disc's AACS inputs (MKB, VID,
// Unit_Key_RO.inf) but resolve NO key. The caller resolves a Key
// from a key source and applies it via `Disc::decrypt_with`. The
// disc reports "encrypted, no keys" until then.
match Self::resolve_vid_only(&udf_fs, reader, handshake.as_ref()) {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(e)),
}
} else {
match opts.resolve_keydb() {
Some(keydb_path) => {
match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref())
{
Ok(state) => (Some(state), None),
Err(e) => {
// When the handshake itself failed AND resolution
// bottomed out at "no keys", surface the upstream
// handshake failure — it's more actionable than
// the generic AacsNoKeys.
let final_err = match (&e, handshake_error.as_ref()) {
(
Error::AacsNoKeys
| Error::AacsVukNotInKeydb
| Error::AacsVidUnavailable,
Some(_),
) => handshake_error.unwrap(),
_ => e,
};
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_resolve_failed",
error_code = final_err.code(),
keydb = %keydb_path.display(),
handshake_ok = handshake.is_some(),
"AACS key resolution failed"
);
(None, Some(final_err))
}
}
}
None => {
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_no_keydb",
"encrypted disc but no KEYDB found in search paths"
);
// Sentinel path string lets autorip's message switch
// distinguish "no keydb found anywhere" from "keydb at
// <path> failed to parse".
let final_err =
handshake_error.unwrap_or_else(|| crate::error::Error::KeydbLoad {
path: String::from("<no keydb in search paths>"),
});
(None, Some(final_err))
}
// A handshake failure (no VID) is more actionable than the
// generic capture error, so surface it when present.
Err(e) => (None, Some(handshake_error.unwrap_or(e))),
}
};
@@ -1430,6 +1497,44 @@ impl Disc {
.cmp(&b_oversize)
.then_with(|| a.clips.len().cmp(&b.clips.len()))
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
// Same length + clip count = the same feature authored as multiple
// playlists (a full-audio main vs an audio-reduced twin, e.g. Fight
// Club's 00800 [DTS-HD MA + 13 tracks] vs 00004 [stereo AC-3 only]).
// Prefer the richer audio so we never rip a stereo-only variant over
// the lossless-multichannel main feature.
.then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a)))
}
/// Audio-richness rank for `canonical_title_order`'s same-length tiebreak.
/// Higher is better: `(any lossless track, best channel count, audio count)`.
fn audio_richness(t: &DiscTitle) -> (u8, u8, usize) {
let mut lossless = 0u8;
let mut max_ch = 0u8;
let mut count = 0usize;
for s in &t.streams {
if let Stream::Audio(a) = s {
count += 1;
if matches!(
a.codec,
Codec::TrueHd | Codec::DtsHdMa | Codec::DtsHdHr | Codec::Lpcm | Codec::Flac
) {
lossless = 1;
}
let ch = match a.channels {
AudioChannels::Surround71 => 8,
AudioChannels::Surround61 => 7,
AudioChannels::Surround51 => 6,
AudioChannels::Surround50 => 5,
AudioChannels::Quad => 4,
AudioChannels::Stereo21 => 3,
AudioChannels::Stereo => 2,
AudioChannels::Mono => 1,
AudioChannels::Unknown => 0,
};
max_ch = max_ch.max(ch);
}
}
(lossless, max_ch, count)
}
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
@@ -1485,19 +1590,76 @@ impl Disc {
/// AACS-chain steps it can from disc-read inputs (MKB / VID / `Unit_Key_RO.inf`).
///
/// `#[non_exhaustive]`: AACS is a derivation chain
/// (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ UK`). Today only the final
/// [`Key::Unit`] entry point is wired; the higher entry points
/// (device / media / volume key) land as the derivation is lifted out of the
/// keydb-coupled scan path. Adding them is non-breaking thanks to
/// `#[non_exhaustive]`.
/// (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ UK`). Each variant is an entry
/// point at one level of that chain; [`Disc::decrypt_with`] derives down from
/// it to the per-CPS unit keys. New levels can be added without breaking
/// callers.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Key {
/// Device key(s) (AACS DK, positioned). libfreemkv walks the MKB
/// (subset-difference tree) to find the one that applies → media key →
/// VUK → unit keys. A source hands in its FULL device-key set, because
/// choosing which one applies *is* the MKB walk (derivation), and all
/// derivation lives here — never in a source.
Device(Vec<crate::aacs::keydb::DeviceKey>),
/// Processing key(s) (AACS PK). libfreemkv applies each against the MKB
/// → media key → VUK → unit keys.
Processing(Vec<[u8; 16]>),
/// Media key candidate(s) (Km). A source hands its full pool because an MK
/// is MKB-scoped (shared across a pressing/MKB family) — picking the one
/// that applies is `km_verifies` against this disc's MKB, which is
/// derivation, so it lives here. libfreemkv verifies, then derives the VUK
/// via the Volume ID and the per-CPS-unit keys.
Media(Vec<[u8; 16]>),
/// Volume Unique Key (VK / VUK). libfreemkv decrypts `Unit_Key_RO.inf`
/// into the per-CPS-unit keys. NOT terminal — the chain continues to the
/// unit keys.
Volume([u8; 16]),
/// Final per-CPS-unit AACS keys (`(cps_unit, 16-byte key)`). A key source
/// (keydb / key server) resolved these, or they were cached in the mapfile
/// at sweep; libfreemkv decrypts directly with no further derivation.
/// at sweep; libfreemkv decrypts directly with no further derivation. This
/// is the terminal level every other variant derives down into.
Unit(Vec<(u32, [u8; 16])>),
}
/// True if `unit_keys` can de-scramble at least one of the supplied content
/// `samples` — the validation gate for [`Disc::decrypt_with`]. Conservative: a
/// sample that is not AACS-scrambled proves nothing, and with no scrambled
/// sample at all there is nothing to disprove against, so it returns `true`
/// (accept). It only returns `false` when a scrambled sample exists and NO unit
/// key restores it to clear MPEG-TS — i.e. the key is provably wrong for this
/// disc. Reuses the ecosystem's single `is_aacs_scrambled` predicate and the
/// full (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt.
fn aligned_unit_keys_validate(
unit_keys: &[(u32, [u8; 16])],
read_data_key: Option<&[u8; 16]>,
samples: &[Vec<u8>],
) -> bool {
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, decrypt_unit_full, is_aacs_scrambled};
let scrambled: Vec<&[u8]> = samples
.iter()
.map(|s| s.as_slice())
.filter(|s| s.len() >= ALIGNED_UNIT_LEN && is_aacs_scrambled(s))
.collect();
if scrambled.is_empty() {
return true; // nothing to disprove against — accept
}
if unit_keys.is_empty() {
return false;
}
let mut probe = vec![0u8; ALIGNED_UNIT_LEN];
for sample in scrambled {
for (_, k) in unit_keys {
probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
if decrypt_unit_full(&mut probe, k, read_data_key) {
return true;
}
}
}
false
}
impl Disc {
/// Get the resolved decryption keys for this disc.
/// Used by disc-to-ISO and other full-disc operations.
@@ -1543,18 +1705,20 @@ impl Disc {
pub fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
if let Some(aacs) = self.aacs.as_mut() {
aacs.unit_keys = keys;
aacs.key_source = KeySource::ExternalUk;
aacs.key_source = KeyOrigin::ExternalUk;
} else if self.encrypted && self.css.is_none() {
self.aacs = Some(AacsState {
version: if self.format == DiscFormat::Uhd { 2 } else { 1 },
bus_encryption: self.format == DiscFormat::Uhd,
mkb_version: None,
disc_hash: String::new(),
key_source: KeySource::ExternalUk,
key_source: KeyOrigin::ExternalUk,
vuk: None,
unit_keys: keys,
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
});
// The prior resolution error (e.g. KeydbLoad) is now moot — we have
// the decryption key. Clear it so callers don't treat the disc as
@@ -1563,19 +1727,151 @@ impl Disc {
}
}
/// The public AACS inputs for this disc, for a [`crate::KeySource`] to look
/// a key up. `None` when the disc carries no AACS state (unencrypted, CSS,
/// or AACS inputs not captured at scan). Contains no secrets — just disc
/// identity plus the on-disc AACS structures.
pub fn inputs(&self) -> Option<crate::keysource::DiscInputs> {
self.aacs.as_ref().map(|a| crate::keysource::DiscInputs {
disc_hash: a.disc_hash.clone(),
volume_id: a.volume_id,
mkb: a.mkb.clone(),
unit_key_ro: a.uk_ro.clone(),
// Content samples need the disc reader, which scan does not retain;
// the caller fills these for sources that validate against ciphertext.
samples: Vec::new(),
// Human title: prefer the UDF/ISO volume identifier, fall back to the
// BDMV display name. Identity only — a key service may catalog it.
volume_label: {
let v = self.volume_id.trim();
if v.is_empty() {
self.meta_title.clone()
} else {
Some(v.to_string())
}
},
})
}
/// Apply a caller-resolved [`Key`] so [`Self::decrypt_keys`] yields usable
/// decryption state. **Lookup-free**: no keydb, no network — the caller
/// (an application, via a key source) does all resolution and hands the key
/// in here. For [`Key::Unit`] this is the deferred-mux / resume path: the
/// unit keys came from a key source or the mapfile cache, and libfreemkv
/// decrypts directly (see [`Self::inject_unit_keys`]).
pub fn decrypt_with(&mut self, key: Key) -> Result<()> {
match key {
Key::Unit(keys) => {
self.inject_unit_keys(keys);
Ok(())
/// `samples` are encrypted on-disc aligned units (each 6144 bytes), supplied
/// by the caller for content validation. A wrong key — a keydb VK that does
/// not match this disc, a stale UK — can still *derive* a non-empty (garbage)
/// unit-key set, so before the key touches disc state we confirm it actually
/// de-scrambles real ciphertext. This is the single home of key validation:
/// every caller loops a key source's candidates through `decrypt_with` and a
/// rejected key (`Err(AacsKeyRejected)`) transparently falls through to the
/// next. Pass `&[]` when no content sample is available (resume / mapfile
/// cache) — validation is then skipped and the key is applied as-is.
pub fn decrypt_with(&mut self, key: Key, samples: &[Vec<u8>]) -> Result<()> {
// The AACS 2.x bus key, needed to de-scramble a sample for validation;
// captured before any mutable borrow. None for AACS 1.0 and file-backed
// ISO units (bus encryption was already removed at read time).
let read_data_key = self.aacs.as_ref().and_then(|a| a.read_data_key);
// Resolve the supplied key DOWN to candidate unit keys WITHOUT
// committing them, so a wrong higher-level key can be rejected before it
// poisons disc state.
let (candidate_unit_keys, candidate_vuk) = if let Key::Unit(keys) = key {
// Terminal — the source / mapfile already holds the final UKs.
(keys, None)
} else {
// Every higher level derives DOWN to the unit keys, reusing the
// version-dispatched resolver — the single home for all AACS
// derivation (1.0 / 2.0 / 2.1 / 2.x). It needs the AACS inputs
// (Unit_Key_RO.inf, MKB, VID) stashed on the disc at scan time.
let aacs = self.aacs.as_ref().ok_or(crate::error::Error::AacsNoKeys)?;
if aacs.uk_ro.is_empty() {
// Scan captured no Unit_Key_RO.inf — nothing to derive into.
return Err(crate::error::Error::AacsNoKeys);
}
// Map the supplied Key -> raw material. The source handed in material
// at exactly one level; choosing/applying it is the resolver's job.
let mut supplied = crate::aacs::provider::SuppliedKey {
device_keys: Vec::new(),
processing_keys: Vec::new(),
media_keys: Vec::new(),
disc_entry: None,
};
match key {
Key::Device(dks) => supplied.device_keys = dks,
Key::Processing(pks) => supplied.processing_keys = pks,
Key::Media(mks) => supplied.media_keys = mks,
Key::Volume(vuk) => {
supplied.disc_entry = Some(crate::aacs::DiscEntry {
disc_hash: aacs.disc_hash.clone(),
title: String::new(),
media_key: None,
disc_id: None,
vuk: Some(vuk),
unit_keys: Vec::new(),
});
}
Key::Unit(_) => unreachable!("Key::Unit handled above"),
}
// Snapshot inputs (releases the &self borrow before the &mut below).
let volume_id = aacs.volume_id;
let mkb = aacs.mkb.clone();
let uk_ro = aacs.uk_ro.clone();
let version_u8 = aacs.version;
let provider_refs: [&dyn crate::aacs::KeyProvider; 1] = [&supplied];
let ctx = crate::aacs::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &volume_id,
providers: &provider_refs,
mkb: if mkb.is_empty() { None } else { Some(&mkb) },
};
// Version dispatch — V10 uses the classical resolver at 48-byte
// stride; V20/V21 share the 64-byte stride, so try the classical V20
// paths first and fall back to the 2.1 variant chain.
let resolved = match version_u8 {
1 => crate::aacs::resolve_keys_v1(&ctx),
_ => crate::aacs::resolve_keys_v2(&ctx)
.or_else(|| crate::aacs::resolve_keys_v21(&ctx)),
}
.ok_or(crate::error::Error::AacsKeyRejected)?;
if resolved.unit_keys.is_empty() {
return Err(crate::error::Error::AacsKeyRejected);
}
(resolved.unit_keys, resolved.vuk)
};
// VALIDATE against real ciphertext. Conservative: reject only when a
// supplied sample is AACS-scrambled and NO candidate unit key can
// de-scramble it. With no samples (or only clear ones) there is nothing
// to disprove against, so the key is accepted as-is — keeping the
// sample-less paths (resume / mapfile cache) byte-for-byte unchanged.
if !aligned_unit_keys_validate(&candidate_unit_keys, read_data_key.as_ref(), samples) {
return Err(crate::error::Error::AacsKeyRejected);
}
// Commit — only now does the key touch disc state.
match self.aacs.as_mut() {
Some(a) => {
a.unit_keys = candidate_unit_keys;
if candidate_vuk.is_some() {
a.vuk = candidate_vuk;
}
a.key_source = KeyOrigin::ExternalUk;
}
// A Unit key for an AACS disc whose scan built no state (keyless
// scan, no keydb): synthesize a minimal ExternalUk state.
None => self.inject_unit_keys(candidate_unit_keys),
}
// A prior scan-time resolution error (e.g. keyless scan) is now moot.
self.aacs_error = None;
Ok(())
}
/// Copy disc sectors to an ISO image file.
@@ -2816,7 +3112,7 @@ mod tests {
);
assert_eq!(
disc.aacs.as_ref().unwrap().key_source,
KeySource::ExternalUk
KeyOrigin::ExternalUk
);
}
@@ -2828,11 +3124,13 @@ mod tests {
bus_encryption: true,
mkb_version: None,
disc_hash: String::new(),
key_source: KeySource::DeviceKey,
key_source: KeyOrigin::DeviceKey,
vuk: None,
unit_keys,
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
}
}
@@ -2857,7 +3155,7 @@ mod tests {
disc.encrypted = true;
disc.aacs = Some(aacs_with(vec![(0, [0x01; 16])]));
let new = vec![(0u32, [0x77u8; 16]), (1, [0x88; 16])];
disc.decrypt_with(Key::Unit(new.clone())).unwrap();
disc.decrypt_with(Key::Unit(new.clone()), &[]).unwrap();
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
assert_eq!(unit_keys, new, "must replace, preserving every CPS unit");
@@ -2866,10 +3164,103 @@ mod tests {
}
assert_eq!(
disc.aacs.as_ref().unwrap().key_source,
KeySource::ExternalUk
KeyOrigin::ExternalUk
);
}
/// Build a minimal valid `Unit_Key_RO.inf` carrying the given encrypted
/// unit keys at the V20 (64-byte) stride. Header is inert (no titles); only
/// the key-storage area matters for `parse_unit_key_ro`.
fn uk_ro_v20(enc_keys: &[[u8; 16]]) -> Vec<u8> {
let uk_pos = 32usize;
let keys_start = uk_pos + 48;
let stride = 64usize;
let mut data = vec![0u8; keys_start + enc_keys.len().max(1) * stride];
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
data[uk_pos..uk_pos + 2].copy_from_slice(&(enc_keys.len() as u16).to_be_bytes());
for (i, k) in enc_keys.iter().enumerate() {
let off = keys_start + i * stride;
data[off..off + 16].copy_from_slice(k);
}
data
}
#[test]
fn decrypt_with_volume_derives_per_cps_unit_keys() {
// A Volume key (VUK) is NOT terminal — the lib must decrypt
// Unit_Key_RO.inf into ONE unit key per CPS unit. Oracle = the lib's
// own decrypt_unit_key, so this pins the derive-down WIRING (Volume →
// per-CPS Unit), not the cipher.
let vuk = [0x5au8; 16];
let enc0 = [0x12u8; 16];
let enc1 = [0x34u8; 16];
let exp0 = crate::aacs::decrypt_unit_key(&vuk, &enc0);
let exp1 = crate::aacs::decrypt_unit_key(&vuk, &enc1);
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
let mut a = aacs_with(Vec::new());
a.uk_ro = uk_ro_v20(&[enc0, enc1]);
disc.aacs = Some(a);
disc.decrypt_with(Key::Volume(vuk), &[]).unwrap();
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
assert_eq!(
unit_keys,
vec![(1u32, exp0), (2u32, exp1)],
"VUK must decrypt EACH CPS unit's encrypted key (does not stop at VK)"
);
}
_ => panic!("expected Aacs decrypt keys after Volume-key derive-down"),
}
assert_eq!(
disc.aacs.as_ref().unwrap().key_source,
KeyOrigin::ExternalUk
);
}
#[test]
fn decrypt_with_higher_key_without_inputs_errors() {
// A non-Unit key needs the AACS inputs (Unit_Key_RO.inf) stashed at
// scan. Without them the lib cannot derive — surfaces AacsNoKeys, not a
// panic and not a silent keyless "success".
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(Vec::new())); // uk_ro empty
assert!(matches!(
disc.decrypt_with(Key::Volume([0x11u8; 16]), &[])
.unwrap_err(),
crate::error::Error::AacsNoKeys
));
// No AACS state at all → same.
let mut disc2 = make_test_disc(1000, "UHD");
disc2.encrypted = true;
assert!(matches!(
disc2
.decrypt_with(Key::Media(vec![[0x22u8; 16]]), &[])
.unwrap_err(),
crate::error::Error::AacsNoKeys
));
}
#[test]
fn decrypt_with_volume_yielding_no_units_is_rejected() {
// A key that produces zero unit keys (here: an empty key-storage area)
// is a rejection, not a silent empty success.
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
let mut a = aacs_with(Vec::new());
a.uk_ro = uk_ro_v20(&[]); // num_uk = 0
disc.aacs = Some(a);
assert!(matches!(
disc.decrypt_with(Key::Volume([0x11u8; 16]), &[])
.unwrap_err(),
crate::error::Error::AacsKeyRejected
));
}
#[test]
fn decrypt_with_unit_key_yields_decrypt_keys() {
// The public lookup-free entry point: hand libfreemkv a Key::Unit and
@@ -2878,7 +3269,7 @@ mod tests {
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
let uk = vec![(0u32, [0x44u8; 16])];
disc.decrypt_with(Key::Unit(uk.clone())).unwrap();
disc.decrypt_with(Key::Unit(uk.clone()), &[]).unwrap();
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
assert_eq!(unit_keys, uk);
@@ -2887,6 +3278,94 @@ mod tests {
}
}
#[test]
fn unit_key_validation_gates_on_real_ciphertext() {
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, is_aacs_scrambled};
// No samples -> nothing to disprove against -> accept (sample-less paths
// like resume / mapfile must be unaffected).
assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])],
None,
&[]
));
// A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
// accept even with an arbitrary key.
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
clear[off] = 0x47;
off += 192;
}
assert!(!is_aacs_scrambled(&clear));
assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])],
None,
&[clear.clone()]
));
// A genuinely scrambled unit the RIGHT key restores to clear TS.
let uk = [0x5au8; 16];
let enc = encrypt_unit_for_test(&clear, &uk);
assert!(
is_aacs_scrambled(&enc),
"encrypted unit must read scrambled"
);
// Right key -> de-scrambles -> accept (NO false reject of a good key).
assert!(super::aligned_unit_keys_validate(
&[(7, uk)],
None,
&[enc.clone()]
));
// Wrong key -> cannot de-scramble a scrambled sample -> reject.
assert!(!super::aligned_unit_keys_validate(
&[(7, [0x00u8; 16])],
None,
&[enc.clone()]
));
// Empty key set against a scrambled sample -> reject.
assert!(!super::aligned_unit_keys_validate(&[], None, &[enc]));
}
/// Inverse of `decrypt_unit` for one 6144-byte unit: produce on-disc
/// ciphertext that `decrypt_unit(uk)` restores to `clear`. Mirrors the AACS
/// unit algorithm — ECB-derive the per-unit key, then AES-CBC encrypt the
/// body with the fixed AACS IV.
fn encrypt_unit_for_test(clear: &[u8], uk: &[u8; 16]) -> Vec<u8> {
use crate::aacs::decrypt::{AACS_IV, ALIGNED_UNIT_LEN};
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
let mut header = [0u8; 16];
header.copy_from_slice(&unit[..16]);
let cipher = Aes128::new(GenericArray::from_slice(uk));
let mut blk = GenericArray::clone_from_slice(&header);
cipher.encrypt_block(&mut blk);
let mut dk = [0u8; 16];
for i in 0..16 {
dk[i] = blk[i] ^ header[i];
}
let bc = Aes128::new(GenericArray::from_slice(&dk));
let mut prev = AACS_IV;
let mut i = 16;
while i + 16 <= ALIGNED_UNIT_LEN {
let mut b = [0u8; 16];
for j in 0..16 {
b[j] = unit[i + j] ^ prev[j];
}
let mut g = GenericArray::clone_from_slice(&b);
bc.encrypt_block(&mut g);
for j in 0..16 {
unit[i + j] = g[j];
}
prev.copy_from_slice(&unit[i..i + 16]);
i += 16;
}
unit
}
#[test]
fn inject_unit_keys_is_noop_without_aacs_on_unencrypted_or_css() {
// Unencrypted disc: nothing to inject into, stays None.
+13
View File
@@ -78,6 +78,7 @@ pub const E_AACS_MK_UNAVAILABLE: u16 = 7018;
pub const E_AACS_VUK_NOT_IN_KEYDB: u16 = 7019;
pub const E_DRIVE_PROFILE_MISSING: u16 = 7020;
pub const E_VID_CDB_UNAVAILABLE: u16 = 7021;
pub const E_NO_DISC_KEY: u16 = 7022;
// Keydb (8xxx)
pub const E_KEYDB_CONNECT: u16 = 8000;
@@ -253,6 +254,16 @@ pub enum Error {
/// template (older profile blob, or a drive class without an OEM
/// VID path).
VidCdbUnavailable,
/// The disc is AACS-encrypted and decryption was requested, but key
/// resolution produced no usable key for it — so muxing would emit
/// undecryptable garbage. Distinct from [`Error::KeydbLoad`] (no keydb
/// file at all): a keydb may be present but lack an entry for this disc.
/// `disc_hash` is the 40-hex SHA1 of `Unit_Key_RO.inf` (no `0x` prefix)
/// so the application can name the disc; empty if the hash wasn't
/// captured at scan.
NoDiscKey {
disc_hash: String,
},
// Keydb (8xxx)
KeydbConnect {
@@ -345,6 +356,7 @@ impl Error {
Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB,
Error::DriveProfileMissing => E_DRIVE_PROFILE_MISSING,
Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE,
Error::NoDiscKey { .. } => E_NO_DISC_KEY,
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
Error::KeydbInvalid => E_KEYDB_INVALID,
@@ -471,6 +483,7 @@ impl std::fmt::Display for Error {
Error::StreamUrlMissingPort { addr } => write!(f, "E{}: {}", self.code(), addr),
Error::PesFrameTooLarge { size } => write!(f, "E{}: {}", self.code(), size),
Error::IsoTooLarge { path } => write!(f, "E{}: {}", self.code(), path),
Error::NoDiscKey { disc_hash } => write!(f, "E{}: {}", self.code(), disc_hash),
_ => write!(f, "E{}", self.code()),
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Key sources — the lookup layer that hands libfreemkv a [`Key`].
//!
//! libfreemkv performs NO key lookup. An application resolves a key for a disc
//! through one or more [`KeySource`]s, each a dumb adapter over a backing store
//! (a keydb file, a key server, the mapfile cache): given the disc's
//! [`DiscInputs`] it returns the raw [`Key`] at whatever level it holds. The
//! library then derives down and decrypts via `Disc::decrypt_with`.
//!
//! Source implementations are published in the companion `freemkv-keysources`
//! crate — keeping all key *policy* (which store, which order, online vs local)
//! out of the library while all key *mechanism* (the AACS derivation chain)
//! stays in it.
use crate::disc::Key;
/// 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.
#[derive(Debug, Clone)]
pub struct DiscInputs {
/// SHA-1 of `Unit_Key_RO.inf`, `0x`-prefixed hex. The value a keydb keys
/// its per-disc entries by, and a key server identifies the disc with.
pub disc_hash: String,
/// Volume ID (16 bytes). `[0u8; 16]` when no authenticated handshake ran
/// (e.g. an ISO/mapfile flow), which disables VID-keyed lookups.
pub volume_id: [u8; 16],
/// Raw MKB bytes. Empty when not captured.
pub mkb: Vec<u8>,
/// Raw `Unit_Key_RO.inf` bytes. Empty when not captured.
pub unit_key_ro: Vec<u8>,
/// Encrypted on-disc content sample units (each a 6144-byte aligned unit),
/// for sources that validate a key server-side against real ciphertext
/// (e.g. an online key service). Empty for sources that don't need them
/// (a local keydb). Populated by the application — reading content requires
/// the disc reader, which the library's scan does not retain — so
/// [`crate::Disc::inputs`] leaves it empty for the caller to fill.
pub samples: Vec<Vec<u8>>,
/// The disc's human title — the UDF/ISO volume identifier (e.g.
/// `WICKED_FOR_GOOD`), falling back to the BDMV `<di:name>` when present.
/// `None` when not captured. Identity only, no secret; a key service may
/// record it (keyed by `disc_hash`) to build a hash→title catalog. Not used
/// in any AACS derivation.
pub volume_label: Option<String>,
}
/// A key source: a stateful provider that hands a disc's candidate [`Key`]s out
/// **one at a time**, in whatever order it judges best for its backing store.
///
/// Dumb by contract — a source queries its store and yields the raw material it
/// holds at whatever level it has (device / processing / media / volume / unit).
/// It performs NO AACS derivation and NO validation: `Disc::decrypt_with`
/// derives down AND validates against real ciphertext, returning `Err` for a key
/// that does not decrypt this disc. That keeps every derivation step and the one
/// validation gate in the library, across AACS 1.0 / 2.0 / 2.1 / 2.x.
///
/// The source is the one that knows how many candidates it has and in what order
/// to try them — a keydb holds a per-disc UK *and* VUK *and* a device-key pool,
/// so it hands them out cheapest/most-specific first (UK ▸ VK ▸ MK ▸ DK) and
/// reports exhaustion when its list runs out; an online key service or a mapfile
/// cache hold exactly one. The caller drives the loop: `next_key` →
/// `Disc::decrypt_with` → on `Err`, ask again → until a key decrypts or the
/// source returns `None` (a genuine "no key for this disc"). Compose several
/// sources, in the caller's chosen order, with [`crate`]'s `MultiSource`.
pub trait KeySource {
/// Hand the NEXT candidate key for this disc, or `None` once this source is
/// exhausted. Stateful: the source tracks what it already handed out this
/// session, so asking again after a rejected key yields the next candidate
/// (or `None`) — it never re-offers a key or re-hits a one-shot backend (an
/// online service is asked at most once). A source failure (I/O, network,
/// parse) surfaces as `None` — there is simply nothing more to try.
fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key>;
/// Whether this source needs [`DiscInputs::samples`] populated (encrypted
/// content samples) — true for a source that validates against ciphertext
/// server-side, false for one that keys purely on disc identity. The caller
/// reads samples (an extra disc read) only when some source needs them.
fn needs_samples(&self) -> bool {
false
}
/// Whether this source FAILED (I/O, network, parse) rather than simply
/// having no key. Checked after exhaustion so the caller can tell a genuine
/// "no key for this disc" apart from "the key service was unreachable". A
/// store that treats absence as not-an-error (a missing keydb / mapfile)
/// leaves this `false`.
fn errored(&self) -> bool {
false
}
}
+185 -5
View File
@@ -207,14 +207,41 @@ pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) {
// Structured fields — callers translate purpose to UI text.
a.purpose = label.purpose;
// Codec descriptor: trust the parser's `codec_hint` ONLY
// when it's consistent with the stream's actual codec — it
// may legitimately be richer (e.g. "Dolby Atmos" on a TrueHD
// stream, which the raw spec codec can't express). If the
// hint CONTRADICTS the stream (a mis-bound / shuffled label,
// e.g. "AC-3 2.0" on a TrueHD track, or "TrueHD" on a DD+
// track), discard it and derive the descriptor from the
// stream itself — that's correct per-stream and can never be
// shuffled. An empty hint is left for `fill_defaults`.
let codec_desc = if label.codec_hint.is_empty() {
// No codec hint — leave for fill_defaults.
String::new()
} else if !codec_hint_consistent(&label.codec_hint, &a.codec) {
// Hint contradicts the stream (mis-bound / shuffled):
// derive from the stream itself.
generate_audio_label(&a.codec, &a.channels, a.secondary)
} else if codec_hint_adds_detail(&label.codec_hint) {
// Consistent AND richer than the spec codec can express
// (e.g. "Dolby Atmos", "DTS:X") — keep the parser's hint.
label.codec_hint.clone()
} else {
// Consistent but a plain codec/channel restatement —
// normalize to the stream's own marketing descriptor so
// styling is uniform across tracks.
generate_audio_label(&a.codec, &a.channels, a.secondary)
};
// a.label only carries codec/variant info. NEVER any
// English purpose text — the CLI handles that via i18n.
let mut parts = Vec::new();
if !label.variant.is_empty() {
parts.push(format!("({})", label.variant));
}
if !label.codec_hint.is_empty() {
parts.push(label.codec_hint.clone());
if !codec_desc.is_empty() {
parts.push(codec_desc);
}
if !parts.is_empty() {
a.label = parts.join(" ");
@@ -322,7 +349,70 @@ fn generate_video_label(
parts.join(" ")
}
fn generate_audio_label(
/// Does the parser's `codec_hint` name a codec consistent with the stream's
/// actual `codec`? [`apply_labels`] uses this to keep richer-but-consistent
/// hints (e.g. "Dolby Atmos" on a TrueHD stream — Atmos is a TrueHD extension
/// the raw spec codec can't express) while rejecting mis-bound ones (e.g.
/// "AC-3 2.0" on a TrueHD stream, the shuffled-label bug). Matching is by codec
/// FAMILY parsed out of the hint string. "Atmos" with no carrier named is
/// treated as compatible with its lossless carriers (TrueHD / E-AC-3). A hint
/// naming no recognizable codec family (pure editorial, e.g. "Commentary") is
/// consistent — it isn't asserting a codec.
fn codec_hint_consistent(hint: &str, codec: &crate::disc::Codec) -> bool {
use crate::disc::Codec;
let h = hint.to_ascii_lowercase();
let says_truehd = h.contains("truehd") || h.contains("true hd");
let says_ddp = h.contains("ac-3+")
|| h.contains("ac3+")
|| h.contains("e-ac-3")
|| h.contains("eac-3")
|| h.contains("eac3")
|| h.contains("digital plus")
|| h.contains("dd+");
let says_ac3 =
!says_ddp && (h.contains("ac-3") || h.contains("ac3") || h.contains("dolby digital"));
let says_dts_ma = h.contains("master audio") || h.contains("hd ma");
let says_dts_hr = h.contains("high resolution") || h.contains("hd hr");
let says_dts = !says_dts_ma && !says_dts_hr && h.contains("dts");
let says_lpcm = h.contains("lpcm") || h.contains("pcm");
let says_atmos = h.contains("atmos");
let names_family =
says_truehd || says_ddp || says_ac3 || says_dts_ma || says_dts_hr || says_dts || says_lpcm;
// Pure-editorial hint (no codec family named) isn't asserting a codec →
// consistent. "Atmos" alone implies a lossless carrier (TrueHD or DD+).
if !names_family {
return if says_atmos {
matches!(codec, Codec::TrueHd | Codec::Ac3Plus)
} else {
true
};
}
match codec {
Codec::TrueHd => says_truehd || says_atmos,
Codec::Ac3Plus => says_ddp || says_atmos,
Codec::Ac3 => says_ac3,
Codec::DtsHdMa => says_dts_ma,
Codec::DtsHdHr => says_dts_hr,
Codec::Dts => says_dts,
Codec::Lpcm => says_lpcm,
// Unknown / other stream codec — don't second-guess the parser's hint.
_ => true,
}
}
/// Does the hint carry object-audio detail the spec codec can't express
/// (Atmos / DTS:X)? Such hints are kept verbatim; plain codec/channel hints are
/// normalized to the stream's own descriptor for uniform styling across tracks.
fn codec_hint_adds_detail(hint: &str) -> bool {
let h = hint.to_ascii_lowercase();
h.contains("atmos") || h.contains("dts:x") || h.contains("dts-x") || h.contains("dtsx")
}
pub(crate) fn generate_audio_label(
codec: &crate::disc::Codec,
channels: &crate::disc::AudioChannels,
_secondary: bool,
@@ -1182,6 +1272,94 @@ mod apply_tests {
}
}
#[test]
fn apply_rejects_mismatched_codec_hint_and_uses_stream_codec() {
// Paddington case: a TrueHD+Atmos main track the parser mislabeled
// "AC-3 2.0" (a compat-core hint bound to the wrong stream). The hint
// contradicts the stream's real codec → discard it, use the stream's own.
let mut titles = vec![title_with(vec![audio(
0x1100,
Codec::TrueHd,
AudioChannels::Surround71,
"eng",
)])];
let labels = vec![audio_label(1, "eng", "AC-3 2.0", "")];
apply_labels(&labels, &mut titles);
if let Stream::Audio(a) = &titles[0].streams[0] {
assert_eq!(a.label, "Dolby TrueHD 7.1");
} else {
panic!("expected audio stream");
}
}
#[test]
fn apply_unshuffles_cross_labeled_streams() {
// Wicked case: hints fully cross-bound — a TrueHD stream wears "AC-3 5.1"
// and a DD+ stream wears "TrueHD 5.1". Each is corrected from its own
// stream codec, eliminating the shuffle.
let mut titles = vec![title_with(vec![
audio(0x1100, Codec::TrueHd, AudioChannels::Surround51, "eng"),
audio(0x1101, Codec::Ac3Plus, AudioChannels::Surround51, "spa"),
])];
let labels = vec![
audio_label(1, "eng", "AC-3 5.1", ""),
audio_label(2, "spa", "TrueHD 5.1", ""),
];
apply_labels(&labels, &mut titles);
let got: Vec<String> = titles[0]
.streams
.iter()
.filter_map(|s| {
if let Stream::Audio(a) = s {
Some(a.label.clone())
} else {
None
}
})
.collect();
assert_eq!(got, vec!["Dolby TrueHD 5.1", "Dolby Digital Plus 5.1"]);
}
#[test]
fn apply_keeps_consistent_richer_atmos_hint() {
// A DD+ Atmos stream legitimately labeled "Dolby Atmos" — the hint is
// richer than the spec codec yet consistent with it, so it's kept.
let mut titles = vec![title_with(vec![audio(
0x1100,
Codec::Ac3Plus,
AudioChannels::Surround51,
"eng",
)])];
let labels = vec![audio_label(1, "eng", "Dolby Atmos", "")];
apply_labels(&labels, &mut titles);
if let Stream::Audio(a) = &titles[0].streams[0] {
assert_eq!(a.label, "Dolby Atmos");
} else {
panic!("expected audio stream");
}
}
#[test]
fn apply_normalizes_plain_consistent_hint_to_marketing() {
// Wicked's French track: a DD+ stream whose hint "AC-3+ 5.1" is correct
// but short-form. A sibling DD+ track that fell back uses the marketing
// form — keeping the short form here would read inconsistently, so a
// plain (non-richer) consistent hint is normalized to the stream's own.
let mut titles = vec![title_with(vec![audio(
0x1100,
Codec::Ac3Plus,
AudioChannels::Surround51,
"fra",
)])];
let labels = vec![audio_label(1, "fra", "AC-3+ 5.1", "")];
apply_labels(&labels, &mut titles);
if let Stream::Audio(a) = &titles[0].streams[0] {
assert_eq!(a.label, "Dolby Digital Plus 5.1");
} else {
panic!("expected audio stream");
}
}
#[test]
fn apply_sets_purpose_on_audio_commentary() {
let mut titles = vec![title_with(vec![audio(
@@ -1342,9 +1520,11 @@ mod apply_tests {
if let Stream::Audio(a) = &titles[0].streams[1] {
assert_eq!(a.label, "Dolby Atmos");
}
// Audio #2 (4th stream overall)
// Audio #2 (4th stream overall). The plain "Dolby Digital" hint is
// consistent with the AC-3 stream but carries no channel info, so it's
// normalized to the stream's own uniform descriptor.
if let Stream::Audio(a) = &titles[0].streams[3] {
assert_eq!(a.label, "Dolby Digital");
assert_eq!(a.label, "Dolby Digital 2.0");
}
// Subtitle #1
if let Stream::Subtitle(s) = &titles[0].streams[2] {
+5 -3
View File
@@ -85,6 +85,7 @@ pub(crate) mod identity;
pub(crate) mod ifo;
pub mod io;
pub mod keydb;
pub mod keysource;
pub mod labels;
pub(crate) mod mpls;
pub mod mux;
@@ -170,10 +171,11 @@ pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_thr
// prefix at the crate root to keep both addressable.
pub use disc::{
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, Key, KeySource,
LabelPurpose, LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions,
Stream, SubtitleStream, SweepOptions, VideoStream, classify_damage,
Disc, DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, FrameRate, HdrFormat, Key,
KeyOrigin, LabelPurpose, LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate,
ScanOptions, Stream, SubtitleStream, SweepOptions, VideoStream, classify_damage,
};
pub use keysource::{DiscInputs, KeySource};
// ─── Streams ────────────────────────────────────────────────────────────────
//
+15 -3
View File
@@ -301,9 +301,21 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
return None;
}
// PID from stream entry (type 0x01 = PlayItem stream: PID at bytes 2-3)
let pid = if item[pos + 1] == 0x01 && pos + 4 <= item.len() {
u16::from_be_bytes([item[pos + 2], item[pos + 3]])
// PID location depends on the stream-entry type (BD spec stream_entry()):
// type 1 (stream in the PlayItem's Clip): PID at +2
// type 2 (stream in a SubPath SubClip): +subpath_id(1)+subclip_id(1) → PID at +4
// type 3 / 4 (SubPath clip; type 4 = Dolby Vision +subpath_id(1) → PID at +3
// enhancement layer, e.g. PID 0x1015):
// Previously only type 1 was handled, so the DV EL (type 4) and any
// sub-path stream fell through to PID 0 and were dropped by the mux.
let pid_off = match item[pos + 1] {
0x01 => 2,
0x02 => 4,
0x03 | 0x04 => 3,
_ => 0,
};
let pid = if pid_off != 0 && pos + pid_off + 2 <= item.len() {
u16::from_be_bytes([item[pos + pid_off], item[pos + pid_off + 1]])
} else {
0
};
+307 -10
View File
@@ -6,9 +6,29 @@
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
/// Sample rates indexed by fscod (0=48kHz, 1=44.1kHz, 2=32kHz). fscod=3 is
/// reserved in AC-3 and signals "fscod2" (reduced rates) in E-AC-3; we treat
/// the base rate as 48 kHz in that case for duration purposes.
const SAMPLE_RATES: [u32; 4] = [48_000, 44_100, 32_000, 48_000];
/// AC-3 (legacy) always carries 6 audio blocks × 256 samples = 1536 samples.
const AC3_SAMPLES_PER_FRAME: u32 = 1536;
/// Hard cap on the carry-over buffer. An AC-3/E-AC-3 frame is at most 8192
/// bytes (the `frame_size > 8192` reject below), so a single straddling frame
/// plus a little slack never needs more than this. If the buffer grows past
/// the cap without yielding a frame (pathological / never-syncing input) we
/// drop it and resync rather than accumulate one PES worth of data per call
/// for the whole title.
const MAX_AC3_BUF: usize = 64 * 1024;
pub struct Ac3Parser {
/// Leftover bytes from previous PES (incomplete frame at end).
buf: Vec<u8>,
/// PTS (ns) to stamp on the frame that begins the carry-over `buf` — i.e.
/// 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,
}
impl Default for Ac3Parser {
@@ -21,6 +41,7 @@ impl Ac3Parser {
pub fn new() -> Self {
Self {
buf: Vec::with_capacity(4096),
flush_pts_ns: 0,
}
}
}
@@ -31,7 +52,12 @@ impl CodecParser for Ac3Parser {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
// Base PTS for the FIRST frame emitted from this call. Each subsequent
// frame in the same call advances by the previous frame's duration, so a
// PES that carries several AC-3 frames stamps a monotonically increasing
// PTS per frame instead of the same PES timestamp on all of them (which
// collapses their timecodes and drifts A/V).
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
// Prepend leftover from previous PES
self.buf.extend_from_slice(&pes.data);
@@ -39,6 +65,8 @@ impl CodecParser for Ac3Parser {
let data = &self.buf;
let mut frames = Vec::new();
let mut pos = 0;
// Running PTS for the next frame to emit in this call.
let mut frame_pts_ns = base_pts_ns;
while pos < data.len() {
let sync = find_ac3_sync(&data[pos..]);
@@ -72,28 +100,53 @@ impl CodecParser for Ac3Parser {
break;
}
let duration_ns = frame_duration_ns(remaining, bsid);
frames.push(Frame {
pts_ns,
pts_ns: frame_pts_ns,
keyframe: true,
data: data[start..start + frame_size].to_vec(),
duration_ns: None,
duration_ns: Some(duration_ns),
});
frame_pts_ns += duration_ns as i64;
pos = start + frame_size;
}
// Keep unconsumed data for next call
// `pos` points to the start of unconsumed data (either a partial sync or leftover)
// Keep unconsumed data for the next call. `pos` is the start of the
// unconsumed region: either a partial frame that straddles this PES
// boundary — which, by construction, begins at a syncword (every byte
// before `pos` was emitted as a frame or skipped as pre-sync junk) — or
// trailing bytes too short to size/complete a frame. Carry from `pos`,
// NOT from the next syncword: discarding bytes between `pos` and the
// next sync would drop the partial frame we are deliberately keeping
// across the boundary.
let keep_from = if pos < data.len() {
// Find the last sync word position in the unconsumed region
find_ac3_sync(&data[pos..])
.map(|o| pos + o)
.unwrap_or(data.len())
// A syncword at/after `pos` marks the carry-over start (anything
// before it is junk with no sync). With no full sync, retain the
// whole tail — including a lone trailing 0x0B that may be the first
// half of a syncword split across the PES boundary.
match find_ac3_sync(&data[pos..]) {
Some(o) => pos + o,
None if data.last() == Some(&0x0B) => data.len() - 1,
None => data.len(),
}
} else {
data.len()
};
if keep_from < data.len() {
self.buf = data[keep_from..].to_vec();
let tail = &data[keep_from..];
if tail.len() > MAX_AC3_BUF {
// No frame could be parsed out of a buffer this large — this is
// not valid AC-3 here. Drop it and resync on the next PES rather
// than grow without bound on pathological input.
self.buf.clear();
} else {
self.buf = tail.to_vec();
// The carried partial frame, when later completed and emitted by
// flush() at EOS, is timed at the running per-frame PTS reached
// here (the PTS of the next frame in presentation order).
self.flush_pts_ns = frame_pts_ns;
}
} else {
self.buf.clear();
}
@@ -101,11 +154,85 @@ impl CodecParser for Ac3Parser {
frames
}
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 frame_size == 0 || frame_size > 8192 || off + frame_size > buf.len() {
return Vec::new();
}
let duration_ns = frame_duration_ns(frame, bsid);
vec![Frame {
pts_ns: self.flush_pts_ns,
keyframe: true,
data: buf[off..off + frame_size].to_vec(),
duration_ns: Some(duration_ns),
}]
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
}
}
/// Number of samples per E-AC-3 frame from numblkscod (audio blocks × 256).
fn eac3_samples_per_frame(data: &[u8]) -> u32 {
if data.len() < 5 {
return AC3_SAMPLES_PER_FRAME;
}
// E-AC-3 byte 4: fscod(2) | numblkscod(2) | ... — but only when fscod != 3.
// When fscod == 3 (fscod2 / reduced rate), numblks is fixed at 6.
let fscod = (data[4] >> 6) & 0x03;
if fscod == 0x03 {
return 6 * 256;
}
let numblkscod = (data[4] >> 4) & 0x03;
let numblks = match numblkscod {
0 => 1,
1 => 2,
2 => 3,
_ => 6,
};
numblks * 256
}
/// Sample rate (Hz) of an AC-3/E-AC-3 frame from its fscod field (byte 4 bits 7-6).
fn frame_sample_rate(data: &[u8]) -> u32 {
if data.len() < 5 {
return SAMPLE_RATES[0];
}
SAMPLE_RATES[((data[4] >> 6) & 0x03) as usize]
}
/// Duration of one AC-3/E-AC-3 frame in nanoseconds: samples_per_frame /
/// sample_rate. AC-3 is always 1536 samples; E-AC-3 derives from numblkscod.
fn frame_duration_ns(data: &[u8], bsid: u8) -> u64 {
let samples = if bsid >= 11 {
eac3_samples_per_frame(data)
} else {
AC3_SAMPLES_PER_FRAME
} as u64;
let rate = frame_sample_rate(data) as u64;
// samples / rate seconds → ns, rounded to nearest.
(samples * 1_000_000_000 + rate / 2) / rate
}
/// Find AC3/E-AC-3 syncword (0x0B77) in data.
fn find_ac3_sync(data: &[u8]) -> Option<usize> {
(0..data.len().saturating_sub(1)).find(|&i| data[i] == 0x0B && data[i + 1] == 0x77)
@@ -276,6 +403,176 @@ mod tests {
assert_eq!(frames[0].data.len(), 160);
}
#[test]
fn sync_word_split_across_pes_is_preserved() {
// A frame whose 0x0B77 syncword straddles the PES boundary (0x0B at the
// tail of PES 1, 0x77 at the head of PES 2) must still be emitted whole.
// Previously the lone trailing 0x0B was dropped and the frame lost.
let mut parser = Ac3Parser::new();
let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B 0x77
// PES 1: a complete frame, then a single 0x0B (first half of next sync).
let mut pes1_data = frame_data.clone();
pes1_data.push(0x0B);
let pes1 = PesPacket {
pid: 0,
pts: Some(90000),
dts: None,
data: pes1_data,
};
let frames1 = parser.parse(&pes1);
assert_eq!(frames1.len(), 1, "first complete frame emitted");
// PES 2: 0x77 (second half of sync) + rest of the second frame.
let mut pes2_data = vec![0x77];
pes2_data.extend_from_slice(&frame_data[2..]);
let pes2 = PesPacket {
pid: 0,
pts: Some(93000),
dts: None,
data: pes2_data,
};
let frames2 = parser.parse(&pes2);
assert_eq!(frames2.len(), 1, "split-sync frame must be recovered");
assert_eq!(frames2[0].data.len(), 160);
}
#[test]
fn buffer_stays_bounded_across_many_garbage_pes() {
// Finding 14: the carry-over buffer must never grow without bound. Feed
// many large PES packets that contain no usable frame and assert the
// retained buffer stays tiny — carry-from-`pos` drops all pre-sync junk,
// and a never-completing frame is bounded by the 8192-byte frame cap and
// the MAX_AC3_BUF resync guard.
let mut parser = Ac3Parser::new();
for i in 0..256 {
// Vary the trailing byte so we also exercise the lone-0x0B retain.
let mut data = vec![0x55u8; 8192];
if i % 3 == 0 {
*data.last_mut().unwrap() = 0x0B;
}
let pes = PesPacket {
pid: 0,
pts: None,
dts: None,
data,
};
let frames = parser.parse(&pes);
assert!(frames.is_empty());
assert!(
parser.buf.len() <= MAX_AC3_BUF,
"buffer grew to {} (cap {})",
parser.buf.len(),
MAX_AC3_BUF
);
}
// After all that garbage the retained tail is at most a single partial
// syncword byte — never an accumulation of whole PES packets.
assert!(parser.buf.len() <= 1, "retained {} bytes", parser.buf.len());
}
#[test]
fn split_sync_below_cap_is_still_retained() {
// The cap must not break the normal split-sync straddle: a short tail
// ending in 0x0B (well under the cap) is retained so the next PES can
// complete the syncword.
let mut parser = Ac3Parser::new();
let data = vec![0x00, 0x00, 0x0B];
let pes = PesPacket {
pid: 0,
pts: None,
dts: None,
data,
};
assert!(parser.parse(&pes).is_empty());
assert_eq!(parser.buf, vec![0x0B], "lone trailing 0x0B retained");
}
#[test]
fn flush_emits_complete_buffered_frame_at_eos() {
// A complete final frame sitting in the carry-over buffer with no
// following PES must be drained by flush() at EOS — the bug was that
// ac3 inherited the no-op default flush and dropped the last frame.
let mut parser = Ac3Parser::new();
let frame_data = make_ac3_frame(0, 2);
parser.buf = frame_data.clone();
parser.flush_pts_ns = pts_to_ns(99000);
let f = parser.flush();
assert_eq!(f.len(), 1, "complete buffered frame drained at EOS");
assert_eq!(f[0].data.len(), 160);
assert_eq!(f[0].pts_ns, pts_to_ns(99000), "flush uses carried PTS");
assert!(f[0].duration_ns.is_some(), "flush sets duration");
assert!(parser.buf.is_empty(), "buffer consumed by flush");
}
#[test]
fn flush_carries_running_pts_from_partial_tail() {
// After a full frame emits in parse, the partial next frame held in the
// buffer is timed at the running per-frame PTS; flush completing it must
// use that, not the original PES base.
let mut parser = Ac3Parser::new();
let frame_data = make_ac3_frame(0, 2);
let mut data = frame_data.clone();
data.extend_from_slice(&frame_data[..40]); // partial frame 2 held
let pes = PesPacket {
pid: 0,
pts: Some(90000),
dts: None,
data,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 1, "frame 1 emitted in parse");
let dur = f[0].duration_ns.unwrap() as i64;
// The held partial's flush PTS should be base + one frame duration.
assert_eq!(parser.flush_pts_ns, pts_to_ns(90000) + dur);
}
#[test]
fn flush_drops_partial_tail() {
// A partial frame (cannot be sized/completed) at EOS is dropped, not
// emitted truncated.
let mut parser = Ac3Parser::new();
let frame_data = make_ac3_frame(0, 2);
parser.buf = frame_data[..80].to_vec(); // half a frame
assert!(parser.flush().is_empty(), "partial tail dropped");
}
#[test]
fn per_frame_pts_increments_within_one_pes() {
// Two AC-3 frames in a single PES must get distinct, increasing PTS —
// one per frame, not the single PES timestamp on both.
let mut parser = Ac3Parser::new();
let frame_data = make_ac3_frame(0, 2); // 48kHz, 1536 samples
let mut data = frame_data.clone();
data.extend_from_slice(&frame_data);
let pes = PesPacket {
pid: 0,
pts: Some(90000),
dts: None,
data,
};
let f = parser.parse(&pes);
assert_eq!(f.len(), 2);
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "frame 0 uses PES base PTS");
// 1536 samples @ 48kHz = 32 ms = 32_000_000 ns.
let expect = 1536u64 * 1_000_000_000 / 48_000;
assert_eq!(f[0].duration_ns, Some(expect));
assert_eq!(
f[1].pts_ns - f[0].pts_ns,
expect as i64,
"frame 1 PTS advances by one frame duration, not equal to frame 0"
);
}
#[test]
fn frame_duration_ac3_48khz() {
// AC-3 @ 48kHz: 1536 / 48000 s = 32 ms.
let frame = make_ac3_frame(0, 2);
let bsid = get_bsid(&frame);
assert!(bsid < 11, "test frame is legacy AC-3");
assert_eq!(frame_duration_ns(&frame, bsid), 32_000_000);
}
#[test]
fn ac3_frame_size_table() {
// fscod=0 (48kHz), frmsizecod=0: 64 words = 128 bytes
+545 -75
View File
@@ -8,10 +8,31 @@
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
const DTS_CORE_SYNC: [u8; 4] = [0x7F, 0xFE, 0x80, 0x01];
/// DTS-HD extension substream syncword. The parser delimits an access unit by
/// the next CORE sync (so every extension between two cores is captured), and
/// never needs to locate or size the extension itself — so this is referenced
/// only by the tests that synthesize extension substreams.
#[cfg(test)]
const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25];
pub struct DtsParser {
buf: Vec<u8>,
/// PTS of the access unit currently being assembled in `buf` (the unit
/// starting at the first buffered core sync). Captured when that core
/// frame's PES first arrived; the trailing extension-substream PES
/// packets carry their own (later) PTS which must NOT override it.
pending_pts: i64,
/// PTS markers attributing buffer regions to their source PES. Each entry
/// is `(buffer_offset, pts_ns)` for the PES whose bytes begin at that
/// offset. When an access unit is emitted from the front of `buf`, its PTS
/// is the marker covering offset 0 — NOT the most recent PES's PTS. This is
/// what fixes multi-AU-per-call PTS attribution: if a PES carries the
/// extension substreams (and possibly the next core) for an AU whose own
/// core arrived in an earlier PES, the emitted AU keeps its own core PES's
/// 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)>,
}
impl Default for DtsParser {
@@ -24,10 +45,66 @@ impl DtsParser {
pub fn new() -> Self {
Self {
buf: Vec::with_capacity(32768),
pending_pts: 0,
pts_marks: Vec::new(),
}
}
/// Drop `n` bytes from the front of `buf` and rebase the PTS markers so
/// their offsets stay relative to the new buffer start. A marker that now
/// sits at or before offset 0 is clamped to 0 (it still covers the front).
/// Redundant markers all at offset 0 collapse to the last one.
fn drain_front(&mut self, n: usize) {
if n == 0 {
return;
}
self.buf.drain(..n);
for m in &mut self.pts_marks {
m.0 = m.0.saturating_sub(n);
}
// Collapse all leading markers that now sit at offset 0 to the last
// such marker — that is the PES whose data currently begins the buffer.
let last_zero = self
.pts_marks
.iter()
.rposition(|&(off, _)| off == 0)
.filter(|&i| i > 0);
if let Some(i) = last_zero {
self.pts_marks.drain(..i);
}
}
/// PTS that should be stamped on an access unit currently at the front of
/// `buf` (offset 0): the most recent marker at offset 0, falling back to
/// `pending_pts`.
fn front_pts(&self) -> i64 {
self.pts_marks
.iter()
.rev()
.find(|&&(off, _)| off == 0)
.map(|&(_, pts)| pts)
.unwrap_or(self.pending_pts)
}
}
/// Hard cap on a buffered access unit (core + all its extension substreams).
/// A DTS-HD MA frame is at most a few tens of KB; if the buffer grows past
/// this without a clean boundary we resync rather than stall or balloon.
const MAX_AU_BYTES: usize = 65536;
/// Minimum plausible DTS core frame size. The core header alone is ~10-14
/// bytes; a decoded `core_size` below this means we matched a false/corrupt
/// core sync (the 14-bit `fsize` field decoded to a tiny value) rather than a
/// real frame, so we resync instead of emitting a junk access unit.
const MIN_CORE_FRAME_BYTES: usize = 10;
/// Sentinel for "no valid PTS base captured yet". Real PTS-in-ns values are
/// non-negative (derived from the unsigned 90 kHz PES timestamp), so a negative
/// value can never collide with a genuine timestamp. Used to mark the PTS base
/// invalid after a forced flush so the next PES sets it regardless of buffer
/// state.
const PTS_UNSET: i64 = -1;
impl CodecParser for DtsParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
if pes.data.is_empty() {
@@ -35,80 +112,176 @@ impl CodecParser for DtsParser {
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
// On Blu-ray, a DTS-HD MA/HRA access unit is a DTS core frame
// (sync 0x7FFE8001) followed by one or more DTS extension substreams
// (sync 0x64582025). The m2ts demuxer hands those out as SEPARATE PES
// packets on the same PID — the core in one PES, then the extension
// substreams in following PES packets (with their own, later PTS). The
// lossless audio lives entirely in the extension substreams, so an
// access unit is only complete once all of its trailing extensions
// have been buffered. We assemble across PES boundaries here: an access
// unit runs from its core sync up to (but not including) the NEXT core
// sync. Emitting on the core boundary keeps the core + every following
// extension substream together (the lossless data), instead of the
// old per-PES emit that dropped the extension PES packets and
// downgraded the track to lossy DTS core (the Dunkirk / Fight Club
// bug). The PTS is the core frame's PTS, captured when the unit began.
// Capture the access unit's PTS base on a fresh buffer, or whenever a
// prior forced (safety-valve) flush left it invalidated — in the
// forced case the bytes still in `buf` are not a real core frame, so
// the first PES to arrive after the flush carries the correct base.
if self.buf.is_empty() || self.pending_pts == PTS_UNSET {
self.pending_pts = pts_ns;
}
// Mark where THIS PES's bytes begin in the buffer, with its PTS. The
// emitted access unit takes the PTS of the PES covering its first byte
// (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));
self.buf.extend_from_slice(&pes.data);
let data = &self.buf;
let mut frames = Vec::new();
let mut pos = 0;
while pos < data.len() {
// Find DTS core sync
let start = match find_sync(&data[pos..], &DTS_CORE_SYNC) {
Some(offset) => pos + offset,
None => break,
loop {
// Resync to the first core sync; drop any leading junk.
let Some(start) = find_sync(&self.buf, &DTS_CORE_SYNC) else {
// No core sync at all yet — keep at most a 3-byte tail so a
// sync split across PES packets can still be found next time.
if self.buf.len() > 3 {
let tail = self.buf.len() - 3;
self.drain_front(tail);
}
break;
};
// Need at least 10 bytes for core header to get frame size
if start + 10 > data.len() {
break;
}
let core_size = dts_core_frame_size(&data[start..]);
if core_size == 0 || core_size > 32768 {
pos = start + 4;
continue;
}
if start + core_size > data.len() {
// Incomplete core frame
break;
}
// Check for DTS-HD extension after core
let mut total_size = core_size;
if start + core_size + 4 <= data.len() {
if let Some(0) = find_sync(
&data[start + core_size..start + core_size + 4],
&DTS_HD_EXT_SYNC,
) {
let ext = &data[start + core_size..];
if ext.len() >= 9 {
let ext_size = dts_hd_ext_frame_size(ext);
if start + core_size + ext_size <= data.len() {
total_size = core_size + ext_size;
}
// If ext incomplete, just emit core
}
if start > 0 {
self.drain_front(start);
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) {
// Shouldn't happen, but never loop forever.
break;
}
}
// Need the core header to size the core frame.
if self.buf.len() < 10 {
break;
}
let core_size = dts_core_frame_size(&self.buf);
// `dts_core_frame_size` returns a 14-bit `fsize + 1`, so it is
// always in [1, 16384]; the bare `== 0` / `> MAX_AU_BYTES` checks
// can never fire. A real DTS core header is at least ~10-14 bytes,
// so any decoded size below that came from a false/corrupt sync.
// Reject it (drain the 4 syncword bytes and resync) instead of
// letting a tiny bogus size close the current access unit at a junk
// boundary and drop the trailing extension substreams. The
// `> MAX_AU_BYTES` upper bound is kept as a harmless guard.
if !(MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&core_size) {
// Bogus core sync — skip past it and resync.
self.drain_front(4);
continue;
}
if self.buf.len() < core_size {
break; // core frame not fully buffered yet — wait
}
// The access unit ends at the next *valid* core sync. The search
// begins after this core's syncword so we don't re-match it.
// Anything between the core and that next sync is this unit's
// extension substream(s) — which can themselves contain byte
// sequences matching the core syncword, so a raw `find_sync` match
// is not enough: a candidate is only a real boundary if its decoded
// core size is plausible. `next_core_boundary` skips bogus matches.
//
// `forced` distinguishes a real next-core boundary from a forced
// safety-valve flush. On a forced flush the access unit was NOT
// closed by a new core sync, so the bytes following it are not a
// fresh core frame and the current PES's PTS (which on a forced
// 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
NextCore::None => {
// No next core sync buffered yet. The trailing extension
// substream PES packets may still be arriving, so WAIT for
// them rather than emit a core-only (lossy) frame — unless
// the buffer has grown unreasonably large, in which case
// emit what we have to guarantee forward progress.
if self.buf.len() <= MAX_AU_BYTES {
break;
}
forced = true;
self.buf.len()
}
};
let au: Vec<u8> = self.buf[..au_end].to_vec();
// The AU takes the PTS of the PES covering its first byte — its own
// core's PES, even if that PES preceded the one(s) carrying its
// extensions or the next core.
let au_pts = self.front_pts();
frames.push(Frame {
pts_ns,
pts_ns: au_pts,
keyframe: true,
data: data[start..start + total_size].to_vec(),
data: au,
duration_ns: None,
});
pos = start + total_size;
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
// marker survives. Track it so the fallback stays sensible.
self.pending_pts = self.front_pts();
if forced {
// Safety-valve flush: the next access unit's real core PES has
// not arrived. Invalidate the PTS so the next PES sets it
// regardless of buffer state, rather than inheriting this
// (non-core) PES's timestamp.
self.pending_pts = PTS_UNSET;
self.pts_marks.clear();
}
}
// Keep unconsumed data
let keep_from = if pos < data.len() {
find_sync(&data[pos..], &DTS_CORE_SYNC)
.map(|o| pos + o)
.unwrap_or(data.len())
} else {
data.len()
};
if keep_from < data.len() {
self.buf = data[keep_from..].to_vec();
} else {
self.buf.clear();
// Discard markers that no longer reference live buffer bytes (everything
// past the buffer end can't happen, but collapse duplicates at offset 0
// and drop a stale empty-buffer marker set).
if self.buf.is_empty() {
self.pts_marks.clear();
}
frames
}
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.
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) || self.buf.len() < 10 {
self.buf.clear();
return Vec::new();
}
let core_size = dts_core_frame_size(&self.buf);
// `dts_core_frame_size` returns a 14-bit `fsize + 1` (never 0), so the
// old `== 0` check was dead; reject a sub-minimum core like `parse()`.
if core_size < MIN_CORE_FRAME_BYTES || self.buf.len() < core_size {
self.buf.clear();
return Vec::new();
}
// The final AU's PTS is the PES covering the buffer front (its core's
// PES). Fall back to pending_pts, clamping the sentinel to 0.
let front = self.front_pts();
let pts_ns = if front == PTS_UNSET { 0 } else { front };
let au = std::mem::take(&mut self.buf);
self.pts_marks.clear();
vec![Frame {
pts_ns,
keyframe: true,
data: au,
duration_ns: None,
}]
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
}
@@ -121,6 +294,40 @@ fn find_sync(data: &[u8], pattern: &[u8; 4]) -> Option<usize> {
(0..=data.len() - 4).find(|&i| data[i..i + 4] == *pattern)
}
/// 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),
/// 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,
/// No (further) core sync found in the buffer.
None,
}
/// Find the next *valid* core sync after the current core frame, to delimit the
/// access unit. Extension-substream payload can contain byte sequences that
/// match the core syncword, so each candidate is validated by decoding its
/// core size: a match whose decoded size is implausible (< MIN_CORE_FRAME_BYTES
/// or > MAX_AU_BYTES) is a false sync and is skipped, continuing the search.
fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
let mut from = core_size;
while let Some(rel) = find_sync(&buf[from..], &DTS_CORE_SYNC) {
let pos = from + rel;
// Need the candidate's core header to judge it.
if buf.len() - pos < 10 {
return NextCore::NeedMore;
}
let sz = dts_core_frame_size(&buf[pos..]);
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
return NextCore::Found(pos);
}
// False sync inside extension payload — skip it and keep searching.
from = pos + 4;
}
NextCore::None
}
/// DTS core frame size from header bits.
/// fsize is at bits 46-59 (14 bits) of the header: bytes 5-7.
fn dts_core_frame_size(data: &[u8]) -> usize {
@@ -134,20 +341,6 @@ fn dts_core_frame_size(data: &[u8]) -> usize {
fsize + 1
}
/// DTS-HD extension frame size from extension header.
pub fn dts_hd_ext_frame_size(ext: &[u8]) -> usize {
if ext.len() < 9 {
return 0;
}
let raw =
((ext[6] as usize & 0x1F) << 11) | ((ext[7] as usize) << 3) | ((ext[8] as usize) >> 5);
raw + 1
}
pub fn find_dts_hd_ext_sync(data: &[u8]) -> Option<usize> {
find_sync(data, &DTS_HD_EXT_SYNC)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -181,12 +374,16 @@ mod tests {
#[test]
fn parse_single_frame() {
// A single core frame with no following core sync is the LAST access
// unit — held during streaming (can't know an extension won't follow),
// then drained on flush() at EOF.
let mut parser = DtsParser::new();
let frame = make_dts_core(512);
let pes = make_pes(frame, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data.len(), 512);
assert!(parser.parse(&pes).is_empty());
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].data.len(), 512);
}
#[test]
@@ -199,9 +396,282 @@ mod tests {
assert!(parser.parse(&pes1).is_empty());
let pes2 = make_pes(frame[mid..].to_vec(), Some(93000));
let frames = parser.parse(&pes2);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data.len(), 512);
assert!(parser.parse(&pes2).is_empty());
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].data.len(), 512);
}
#[test]
fn two_cores_back_to_back_emit_first_on_boundary() {
// The first complete unit is emitted as soon as the next core sync is
// seen; the second is held until flush. Both cores arrived in ONE PES,
// so both legitimately carry that PES's PTS.
let mut parser = DtsParser::new();
let mut stream = make_dts_core(512);
stream.extend_from_slice(&make_dts_core(640));
let f = parser.parse(&make_pes(stream, Some(90000)));
assert_eq!(f.len(), 1);
assert_eq!(f[0].data.len(), 512);
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 keeps its PES PTS");
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].data.len(), 640);
assert_eq!(
tail[0].pts_ns,
pts_to_ns(90000),
"AU2 came in the same PES → same PTS"
);
}
#[test]
fn two_aus_flushed_in_one_call_keep_their_own_pts() {
// The real-stream trigger: core1 in PES A (pts 100), then core2 + core3
// arrive in a LATER PES B (pts 200). Processing PES B closes both AU1
// (core1) and AU2 (core2) in a single parse() call. AU2's core arrived
// in PES A region? No — here AU2 (core2) is in PES B, so it should be
// 200. The defect to guard is AU1 NOT being overwritten to 200, and AU2
// not inheriting an unrelated timestamp.
let mut parser = DtsParser::new();
// PES A: just core1 (held — no following core yet).
let f0 = parser.parse(&make_pes(make_dts_core(512), Some(100)));
assert!(f0.is_empty(), "core1 held awaiting next core");
// PES B: core2 + core3. Closes AU1 (core1) and AU2 (core2).
let mut pes_b = make_dts_core(600);
pes_b.extend_from_slice(&make_dts_core(640));
let f = parser.parse(&make_pes(pes_b, Some(200)));
assert_eq!(f.len(), 2, "AU1 and AU2 both close in this call");
assert_eq!(f[0].data.len(), 512, "AU1 = core1");
assert_eq!(
f[0].pts_ns,
pts_to_ns(100),
"AU1 keeps PES A's PTS, not the later PES B PTS"
);
assert_eq!(f[1].data.len(), 600, "AU2 = core2");
assert_eq!(
f[1].pts_ns,
pts_to_ns(200),
"AU2's core arrived in PES B → PES B PTS"
);
// AU3 (core3) drains on flush, also PES B PTS.
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].pts_ns, pts_to_ns(200));
}
#[test]
fn second_au_with_core_in_earlier_pes_keeps_that_pts() {
// core1 + core2 both arrive in PES A (pts 100); core3 arrives in PES B
// (pts 200). When PES B closes AU2 (core2, whose core was in PES A), AU2
// must keep PES A's 100 — the bug was AU2 inheriting the closing PES's
// 200.
let mut parser = DtsParser::new();
// PES A: core1 + core2. AU1 (core1) emits immediately (core2 boundary);
// AU2 (core2) held awaiting a third core.
let mut pes_a = make_dts_core(512);
pes_a.extend_from_slice(&make_dts_core(600));
let f = parser.parse(&make_pes(pes_a, Some(100)));
assert_eq!(f.len(), 1);
assert_eq!(f[0].pts_ns, pts_to_ns(100), "AU1 PES A PTS");
// PES B: core3 — closes AU2 (core2). AU2's core was in PES A.
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(200)));
assert_eq!(f2.len(), 1);
assert_eq!(f2[0].data.len(), 600, "AU2 = core2");
assert_eq!(
f2[0].pts_ns,
pts_to_ns(100),
"AU2's core arrived in PES A → must keep PES A's PTS, not PES B's"
);
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].pts_ns, pts_to_ns(200), "AU3 = core3, PES B PTS");
}
/// Build a minimal DTS-HD extension substream of `size` bytes (just the
/// sync + zero-padding). The parser delimits extensions by the next CORE
/// sync, not by the extension's own size header, so a valid header isn't
/// required — only that the bytes carry no spurious core sync.
fn make_dts_ext(size: usize) -> Vec<u8> {
let mut e = vec![0u8; size];
e[0..4].copy_from_slice(&DTS_HD_EXT_SYNC);
e
}
#[test]
fn keeps_dts_hd_extension_in_separate_pes_packets() {
// The real Blu-ray layout (ground-truthed on Dunkirk): the DTS core
// arrives in one PES, then its DTS-HD MA extension substreams arrive
// in SEPARATE following PES packets on the same PID. The parser must
// stitch core + all trailing extensions into one access unit — not
// emit a core-only (lossy) frame and drop the extension PES packets
// (the Dunkirk / Fight Club lossy-core bug).
let mut parser = DtsParser::new();
// Frame 1: core (512) + two extension substreams (256 + 200).
assert!(
parser
.parse(&make_pes(make_dts_core(512), Some(90000)))
.is_empty(),
"core alone: must wait for any following extension"
);
assert!(
parser
.parse(&make_pes(make_dts_ext(256), Some(91000)))
.is_empty(),
"first extension PES: still waiting for the unit to close"
);
assert!(
parser
.parse(&make_pes(make_dts_ext(200), Some(91500)))
.is_empty(),
"second extension PES: unit still not closed (no next core yet)"
);
// Frame 2's core PES arrives — that closes frame 1. The emitted unit
// must be core + BOTH extensions (lossless preserved), and keep the
// core's PTS, not the extension PES timestamps.
let f = parser.parse(&make_pes(make_dts_core(512), Some(93000)));
assert_eq!(f.len(), 1);
assert_eq!(
f[0].data.len(),
512 + 256 + 200,
"frame must include core + every extension substream"
);
// EOF drains frame 2.
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].data.len(), 512);
}
#[test]
fn extension_split_across_pes_is_preserved() {
// An extension substream straddling a PES boundary must still be fully
// attached to its core.
let mut parser = DtsParser::new();
let ext = make_dts_ext(300);
assert!(
parser
.parse(&make_pes(make_dts_core(512), Some(90000)))
.is_empty()
);
assert!(
parser
.parse(&make_pes(ext[..150].to_vec(), Some(91000)))
.is_empty()
);
assert!(
parser
.parse(&make_pes(ext[150..].to_vec(), Some(91000)))
.is_empty()
);
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].data.len(), 512 + 300);
}
/// Build 4 bytes that look like a DTS core sync but whose `fsize` field
/// decodes to a tiny `core_size` (< MIN_CORE_FRAME_BYTES). With the
/// dead-code guards this passed validation and could close an access unit
/// at a junk boundary; with the fix it must be drained and resynced past.
fn bogus_tiny_core_sync() -> Vec<u8> {
// Core sync + zero header bytes. fsize = 0 → core_size = 1 (< 10).
let mut v = vec![0u8; 10];
v[0..4].copy_from_slice(&DTS_CORE_SYNC);
// bytes 5,6,7 left zero → fsize = 0 → dts_core_frame_size = 1.
assert_eq!(dts_core_frame_size(&v), 1);
v
}
#[test]
fn bogus_tiny_core_sync_does_not_split_or_drop_real_au() {
// A real core frame followed by an extension substream that happens to
// contain a false core sync whose fsize decodes tiny. The bogus sync
// must NOT close the real access unit early (dropping the rest of the
// extension) nor emit a junk few-byte frame — it must be skipped, and
// the whole core + extension preserved as one access unit.
let mut parser = DtsParser::new();
// Frame 1: core(512) + an extension whose body embeds a bogus tiny
// core sync midway through.
let mut ext = make_dts_ext(256);
// Embed the bogus core sync inside the extension body (offset 64).
let bogus = bogus_tiny_core_sync();
ext[64..64 + bogus.len()].copy_from_slice(&bogus);
let mut frame1 = make_dts_core(512);
frame1.extend_from_slice(&ext);
// No next REAL core yet → frame 1 held.
assert!(
parser.parse(&make_pes(frame1, Some(90000))).is_empty(),
"bogus tiny core sync must not close the AU; wait for a real core"
);
// Frame 2's real core arrives — closes frame 1.
let f = parser.parse(&make_pes(make_dts_core(640), Some(93000)));
assert_eq!(f.len(), 1, "exactly one real access unit emitted");
assert_eq!(
f[0].data.len(),
512 + 256,
"AU must be the full core + extension, not split at the bogus sync"
);
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU keeps the core's PTS");
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].data.len(), 640);
}
#[test]
fn forced_emit_does_not_corrupt_next_au_pts() {
// When the buffer exceeds MAX_AU_BYTES with no next core sync, the
// parser force-emits for forward progress. The current PES at that
// point is an extension-substream PES (later PTS). The forced path must
// NOT make that extension PTS the base of the NEXT access unit.
let mut parser = DtsParser::new();
// Core PES at the real PTS, then a giant extension (no next core) that
// pushes the buffer past MAX_AU_BYTES, forcing an emit.
let core_pts = 90000i64;
assert!(
parser
.parse(&make_pes(make_dts_core(512), Some(core_pts)))
.is_empty()
);
let ext_pts = 120000i64; // later extension-PES timestamp
let big_ext = make_dts_ext(MAX_AU_BYTES + 1024);
let f = parser.parse(&make_pes(big_ext, Some(ext_pts)));
assert_eq!(f.len(), 1, "oversized buffer force-emits one AU");
assert_eq!(
f[0].pts_ns,
pts_to_ns(core_pts),
"forced AU keeps the core PTS"
);
// The next REAL core PES arrives with its own PTS. Its AU must inherit
// THIS core's PTS, not the prior extension PES timestamp.
let next_core_pts = 150000i64;
assert!(
parser
.parse(&make_pes(make_dts_core(512), Some(next_core_pts)))
.is_empty()
);
let next_next_pts = 180000i64;
let f2 = parser.parse(&make_pes(make_dts_core(512), Some(next_next_pts)));
assert_eq!(f2.len(), 1);
assert_eq!(
f2[0].pts_ns,
pts_to_ns(next_core_pts),
"AU after a forced emit must use the next core's PTS, not the \
stale extension PTS"
);
}
#[test]
+120 -10
View File
@@ -1,22 +1,52 @@
//! DVD bitmap subtitle (VobSub) parser.
//!
//! DVD subtitles are carried in PS private stream 1 with sub-stream IDs 0x20-0x3F.
//! Each subtitle display set may span multiple PES packets, but at the MKV level
//! we pass through the raw VobSub packets as-is — the container wraps them.
//! A single subpicture unit (SPU — one displayed bitmap) may span multiple PES
//! packets: only the first PES carries a PTS, continuations carry PTS=0. The SPU
//! begins with a 2-byte big-endian `SPU_size` giving the total byte length of the
//! whole unit. We reassemble across PES boundaries into one Frame so large
//! subtitles aren't split/garbled, inheriting the head PES's PTS.
//!
//! For MKV: codec ID "S_VOBSUB".
//! All frames are keyframes (each is a complete bitmap).
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
/// Upper bound on a single reassembled SPU. The SPU_size field is 16 bits, so a
/// well-formed unit is at most 0xFFFF bytes; cap accumulation here to bound
/// memory if the field is corrupt or the stream never completes a unit.
const MAX_SPU_BYTES: usize = 0xFFFF;
pub struct DvdSubParser {
/// Pre-formatted VobSub .idx palette header for codec_private.
codec_data: Option<Vec<u8>>,
/// In-progress SPU reassembly: (head PTS in ns, declared SPU_size, bytes).
pending: Option<(i64, usize, Vec<u8>)>,
}
impl DvdSubParser {
pub fn new(codec_data: Option<Vec<u8>>) -> Self {
Self { codec_data }
Self {
codec_data,
pending: None,
}
}
/// Emit `pending` as a Frame if it is complete (or `force` at EOF),
/// returning it and clearing the buffer. Returns None if nothing to emit.
fn take_if_complete(&mut self, force: bool) -> Option<Frame> {
let (pts_ns, size, buf) = self.pending.as_ref()?;
if force || buf.len() >= *size {
let (pts_ns, _, data) = self.pending.take().unwrap();
return Some(Frame {
pts_ns,
keyframe: true,
data,
duration_ns: None,
});
}
let _ = pts_ns;
None
}
}
@@ -25,13 +55,52 @@ impl CodecParser for DvdSubParser {
if pes.data.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
if self.pending.is_some() {
// Continuation of an in-progress SPU (PTS=0 on these). Append,
// bounded by MAX_SPU_BYTES.
if let Some((_, _, buf)) = self.pending.as_mut() {
let room = MAX_SPU_BYTES.saturating_sub(buf.len());
let take = room.min(pes.data.len());
buf.extend_from_slice(&pes.data[..take]);
}
if let Some(frame) = self.take_if_complete(false) {
out.push(frame);
}
return out;
}
// Start of a new SPU. The first 2 bytes are the big-endian total size.
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
vec![Frame {
pts_ns,
keyframe: true,
data: pes.data.clone(),
duration_ns: None,
}]
let declared = if pes.data.len() >= 2 {
((pes.data[0] as usize) << 8) | pes.data[1] as usize
} else {
// Too short to carry SPU_size — pass through as a lone frame.
return vec![Frame {
pts_ns,
keyframe: true,
data: pes.data.clone(),
duration_ns: None,
}];
};
let mut buf = pes.data.clone();
if buf.len() > MAX_SPU_BYTES {
buf.truncate(MAX_SPU_BYTES);
}
self.pending = Some((pts_ns, declared, buf));
if let Some(frame) = self.take_if_complete(false) {
out.push(frame);
}
out
}
fn flush(&mut self) -> Vec<Frame> {
// At EOF, emit whatever SPU bytes remain even if the declared size was
// never reached (truncated final subtitle is better than dropping it).
self.take_if_complete(true).into_iter().collect()
}
fn codec_private(&self) -> Option<Vec<u8>> {
@@ -152,12 +221,53 @@ mod tests {
#[test]
fn no_pts_defaults_to_zero() {
let mut parser = DvdSubParser::new(None);
let pes = make_pes(vec![0x01, 0x02], None);
// SPU_size = 2, single complete PES (the 2 size bytes themselves).
let pes = make_pes(vec![0x00, 0x02], None);
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 0);
}
#[test]
fn multi_pes_spu_reassembled() {
let mut parser = DvdSubParser::new(None);
// Declared SPU_size = 12 bytes total. First PES carries the 2 size
// bytes + 4 payload bytes and the only PTS; the next two PESs are
// continuations with PTS=0.
let head = vec![0x00, 0x0C, 0xAA, 0xBB, 0xCC, 0xDD];
let cont1 = vec![0x11, 0x22, 0x33];
let cont2 = vec![0x44, 0x55, 0x66];
let f = parser.parse(&make_pes(head.clone(), Some(90000)));
assert!(f.is_empty(), "incomplete SPU should not emit yet");
let f = parser.parse(&make_pes(cont1.clone(), Some(0)));
assert!(f.is_empty(), "still incomplete");
let frames = parser.parse(&make_pes(cont2.clone(), Some(0)));
assert_eq!(frames.len(), 1, "completed SPU emits exactly one frame");
// Reassembled bytes = head + cont1 + cont2, in order.
let mut expected = head;
expected.extend_from_slice(&cont1);
expected.extend_from_slice(&cont2);
assert_eq!(frames[0].data, expected);
// PTS inherited from the head PES (1s = 1e9 ns), not the PTS=0 tails.
assert_eq!(frames[0].pts_ns, 1_000_000_000);
assert!(frames[0].keyframe);
}
#[test]
fn flush_emits_truncated_trailing_spu() {
let mut parser = DvdSubParser::new(None);
// Declared 100 bytes but only 6 ever arrive before EOF.
let head = vec![0x00, 0x64, 0xDE, 0xAD, 0xBE, 0xEF];
let f = parser.parse(&make_pes(head.clone(), Some(90000)));
assert!(f.is_empty(), "incomplete SPU should not emit during parse");
let frames = parser.flush();
assert_eq!(frames.len(), 1, "EOF flush emits the partial SPU");
assert_eq!(frames[0].data, head);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
// ── YCbCr → RGB conversion tests ──────────────────────────────────────
#[test]
+169 -33
View File
@@ -13,6 +13,14 @@ const NAL_PPS: u8 = 8;
const NAL_AUD: u8 = 9;
pub struct H264Parser {
// First-seen SPS/PPS seed the MKV codecPrivate (avcC) — the only out-of-band
// copy the player gets. BD H.264 repeats the parameter sets at every IDR;
// a player re-applies the avcC copy at each keyframe. A stream may redefine
// a parameter set mid-title under the SAME id with a different body. Any
// occurrence whose body DIFFERS from the codecPrivate copy must therefore be
// emitted IN-BAND at each point it appears so it overrides the re-applied
// avcC set; otherwise those frames decode against the wrong parameter set.
// (Same defect class as the HEVC PPS-redefinition bug.)
sps: Option<Vec<u8>>,
pps: Option<Vec<u8>>,
}
@@ -32,16 +40,43 @@ impl H264Parser {
}
}
/// Handle an SPS/PPS NAL (mirrors the HEVC fix):
/// - First of its type → seeds codecPrivate (`first`); stripped from frame data
/// (the player gets it from avcC).
/// - Identical to the codecPrivate copy → stripped (the player re-applies it
/// from avcC at each keyframe; BD streams repeat param sets at every IDR).
/// - DIFFERENT body from the codecPrivate copy (a mid-title redefinition of the
/// same id) → emitted IN-BAND (length-prefixed) at EVERY occurrence so it
/// overrides the avcC copy the player re-applies at each keyframe.
fn handle_param_set(first: &mut Option<Vec<u8>>, nal: &[u8], frame_data: &mut Vec<u8>) {
match first {
None => {
first.replace(nal.to_vec()); // seeds codecPrivate; stripped here
}
Some(f) if f.as_slice() == nal => {} // == codecPrivate → player has it
Some(_) => {
// Differs from codecPrivate → emit in-band so it wins at this AU.
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
frame_data.extend_from_slice(nal);
}
}
}
impl CodecParser for H264Parser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
if pes.data.is_empty() {
return Vec::new();
}
// Use DTS when available (monotonic for B-frame content), fall back to PTS
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
// MKV block timecodes are PRESENTATION timestamps; frames are stored in
// 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);
// Scan NAL units for SPS, PPS, and IDR detection
// Single pass: detect IDR keyframes, seed/strip param sets, and convert
// Annex B (start-code prefixed) NALUs to length-prefixed NALUs (MKV with
// AVCDecoderConfigurationRecord expects a 4-byte length prefix per NAL).
let mut keyframe = false;
let mut frame_data = Vec::new();
@@ -49,34 +84,23 @@ impl CodecParser for H264Parser {
let nal_type = nal[0] & 0x1F;
match nal_type {
NAL_SPS => {
self.sps = Some(nal.to_vec());
// Param sets: seed avcC, strip if identical, emit in-band if a
// mid-title redefinition differs from the avcC copy.
NAL_SPS => handle_param_set(&mut self.sps, nal, &mut frame_data),
NAL_PPS => handle_param_set(&mut self.pps, nal, &mut frame_data),
// Access unit delimiters: drop.
NAL_AUD => {}
_ => {
if nal_type == NAL_SLICE_IDR {
keyframe = true;
}
let len = nal.len() as u32;
frame_data.extend_from_slice(&len.to_be_bytes());
frame_data.extend_from_slice(nal);
}
NAL_PPS => {
self.pps = Some(nal.to_vec());
}
NAL_SLICE_IDR => {
keyframe = true;
}
_ => {}
}
}
// Convert Annex B (start code prefixed) to length-prefixed NALUs.
// MKV with AVCDecoderConfigurationRecord expects 4-byte length prefix per NALU.
// Skip SPS/PPS/AUD NALUs — they're in codecPrivate, not in frame data.
for nal in NalIterator::new(&pes.data) {
let nal_type = nal[0] & 0x1F;
// Skip parameter sets and access unit delimiters
if nal_type == NAL_SPS || nal_type == NAL_PPS || nal_type == NAL_AUD {
continue;
}
// 4-byte big-endian length prefix
let len = nal.len() as u32;
frame_data.extend_from_slice(&len.to_be_bytes());
frame_data.extend_from_slice(nal);
}
if frame_data.is_empty() {
return Vec::new();
}
@@ -98,6 +122,14 @@ impl CodecParser for H264Parser {
return None;
}
// avcC encodes each NAL's length in a 16-bit field. A param set larger
// than 65535 bytes would truncate the length while the full bytes are
// appended → mis-framed record. Refuse rather than emit a corrupt avcC
// (param sets this large are non-conforming anyway).
if sps.len() > 0xFFFF || pps.len() > 0xFFFF {
return None;
}
// AVCDecoderConfigurationRecord (ISO 14496-15):
// configurationVersion = 1
// AVCProfileIndication = SPS[1]
@@ -451,10 +483,10 @@ mod tests {
assert!(frames.is_empty());
}
// --- DTS preferred over PTS when present ---
// --- PTS (presentation) used for the MKV block timecode, not DTS ---
#[test]
fn dts_preferred_over_pts() {
fn pts_preferred_over_dts() {
let mut parser = H264Parser::new();
let mut data = Vec::new();
@@ -464,13 +496,117 @@ mod tests {
let pes = PesPacket {
pid: 0x1011,
pts: Some(180000), // 2 seconds
dts: Some(90000), // 1 second
pts: Some(180000), // 2 seconds (presentation)
dts: Some(90000), // 1 second (decode)
data,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// DTS should be used, not PTS
assert_eq!(frames[0].pts_ns, 1_000_000_000);
// PTS must be used — MKV block timecodes are presentation timestamps.
assert_eq!(frames[0].pts_ns, 2_000_000_000);
}
// --- mid-title param-set redefinition emitted in-band ---
/// Collect the NAL types from a length-prefixed frame_data buffer.
fn frame_nal_types(fd: &[u8]) -> Vec<u8> {
let mut types = Vec::new();
let mut off = 0;
while off + 4 <= fd.len() {
let len = u32::from_be_bytes([fd[off], fd[off + 1], fd[off + 2], fd[off + 3]]) as usize;
off += 4;
if off + len > fd.len() {
break;
}
types.push(fd[off] & 0x1F);
off += len;
}
types
}
#[test]
fn first_param_sets_stripped_redefinition_emitted_inline() {
let mut parser = H264Parser::new();
// AU 1: SPS(id0,bodyA) + PPS(id0,bodyA) + IDR. Both param sets are the
// first of their type → seed avcC, stripped from frame data.
let mut au1 = Vec::new();
au1.extend_from_slice(&[0x00, 0x00, 0x01]);
au1.extend_from_slice(&[0x67, 0x42, 0x00, 0x1E, 0xAA]); // SPS body A
au1.extend_from_slice(&[0x00, 0x00, 0x01]);
au1.extend_from_slice(&[0x68, 0x11]); // PPS body A
au1.extend_from_slice(&[0x00, 0x00, 0x01]);
au1.extend_from_slice(&[0x65, 0x10, 0x20]); // IDR
let f1 = parser.parse(&make_pes(au1, Some(0)));
assert_eq!(f1.len(), 1);
// Frame 1 carries only the IDR — param sets stripped (in avcC).
assert_eq!(
frame_nal_types(&f1[0].data),
vec![5],
"AU1: only IDR in-band"
);
// AU 2: SPS identical to avcC, PPS REDEFINED (same id, different body) +
// IDR. The identical SPS is stripped; the redefined PPS must be emitted
// in-band so it overrides the avcC copy at this keyframe.
let mut au2 = Vec::new();
au2.extend_from_slice(&[0x00, 0x00, 0x01]);
au2.extend_from_slice(&[0x67, 0x42, 0x00, 0x1E, 0xAA]); // SPS == body A
au2.extend_from_slice(&[0x00, 0x00, 0x01]);
au2.extend_from_slice(&[0x68, 0x22]); // PPS body B (redefinition)
au2.extend_from_slice(&[0x00, 0x00, 0x01]);
au2.extend_from_slice(&[0x65, 0x30, 0x40]); // IDR
let f2 = parser.parse(&make_pes(au2, Some(90000)));
assert_eq!(f2.len(), 1);
let types = frame_nal_types(&f2[0].data);
assert!(
types.contains(&8),
"redefined PPS (type 8) must be emitted in-band, got {types:?}"
);
assert!(
!types.contains(&7),
"identical SPS (type 7) must stay stripped, got {types:?}"
);
assert!(types.contains(&5), "IDR (type 5) present, got {types:?}");
}
#[test]
fn repeated_identical_param_sets_stay_stripped() {
let mut parser = H264Parser::new();
let mut au = Vec::new();
au.extend_from_slice(&[0x00, 0x00, 0x01]);
au.extend_from_slice(&[0x67, 0x42, 0x00, 0x1E, 0xAA]);
au.extend_from_slice(&[0x00, 0x00, 0x01]);
au.extend_from_slice(&[0x68, 0x11]);
au.extend_from_slice(&[0x00, 0x00, 0x01]);
au.extend_from_slice(&[0x65, 0x10]);
// Two identical AUs.
parser.parse(&make_pes(au.clone(), Some(0)));
let f = parser.parse(&make_pes(au, Some(90000)));
assert_eq!(
frame_nal_types(&f[0].data),
vec![5],
"repeated identical SPS/PPS stay in avcC, not duplicated in-band"
);
}
#[test]
fn avcc_oversized_param_set_returns_none() {
// A param set > 65535 bytes can't be length-encoded in avcC's 16-bit
// field; codec_private must refuse rather than emit a truncated record.
let mut parser = H264Parser::new();
let mut data = Vec::new();
// Oversized SPS (header byte 0x67 + 70000 filler bytes).
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x67);
data.extend_from_slice(&vec![0x11u8; 70_000]);
// PPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&[0x68, 0x11]);
parser.parse(&make_pes(data, Some(0)));
assert!(
parser.codec_private().is_none(),
"oversized SPS must not produce a truncated avcC"
);
}
}
+631 -34
View File
@@ -21,6 +21,15 @@ const NAL_BLA_W_LP: u8 = 16;
const NAL_RSV_IRAP_VCL23: u8 = 23;
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
// redefine a parameter set mid-title under the SAME id with a different body
// (Fight Club redefines PPS id 0 partway through). Any occurrence whose body
// DIFFERS from this codecPrivate copy must therefore be emitted IN-BAND at
// each point it appears (i.e. at every keyframe of the redefined segment) so
// it overrides the re-applied codecPrivate set; otherwise those frames decode
// against the wrong parameter set → CABAC/cu_qp_delta desync.
vps: Option<Vec<u8>>,
sps: Option<Vec<u8>>,
pps: Option<Vec<u8>>,
@@ -42,14 +51,45 @@ impl HevcParser {
}
}
/// Handle a VPS/SPS/PPS NAL.
///
/// - First of its type → seeds codecPrivate (`first`); stripped from frame data
/// (the player gets it from hvcC).
/// - Identical to the codecPrivate copy → stripped (the player already re-applies
/// it from hvcC at each keyframe; BD streams repeat param sets at every IRAP).
/// - DIFFERENT body from the codecPrivate copy (a mid-title redefinition of the
/// same id) → emitted IN-BAND (length-prefixed) at EVERY occurrence, so it
/// overrides the hvcC copy the player re-applies at each keyframe. Emitting it
/// only once is not enough — the next keyframe's hvcC re-insertion would revert
/// it. This matches what a conforming muxer produces and fixes the Fight Club
/// PPS-id-0 redefinition.
fn handle_param_set(first: &mut Option<Vec<u8>>, nal: &[u8], frame_data: &mut Vec<u8>) {
match first {
None => {
first.replace(nal.to_vec()); // seeds codecPrivate; stripped here
}
Some(f) if f.as_slice() == nal => {} // == codecPrivate → player has it
Some(_) => {
// Differs from codecPrivate → emit in-band so it wins at this AU.
frame_data.extend_from_slice(&(nal.len() as u32).to_be_bytes());
frame_data.extend_from_slice(nal);
}
}
}
impl CodecParser for HevcParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
if pes.data.is_empty() {
return Vec::new();
}
// Use DTS when available (monotonic for B-frame content), fall back to PTS
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
// MKV block timecodes are PRESENTATION timestamps; frames are stored
// in decode order (the order they arrive here) and the player reorders
// for display by timecode. So use PTS, not DTS — using DTS makes the
// 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 data = &pes.data;
let mut keyframe = false;
// Pre-size: output is ~input bytes with a few 4-byte length
@@ -62,6 +102,11 @@ impl CodecParser for HevcParser {
while let Some(sc_pos) = find_start_code(data, pos) {
if let Some(nal_start) = skip_start_code(data, sc_pos) {
let next = find_start_code(data, nal_start).unwrap_or(data.len());
// Strip the leading zeros of the following start code. For a
// conforming bitstream this is lossless: rbsp_trailing_bits()
// sets a stop-one bit, so the final byte of any RBSP is never
// 0x00 — the only trailing zeros here belong to the next
// 00 00 (00) 01 prefix.
let mut end = next;
while end > nal_start && data[end - 1] == 0x00 {
end -= 1;
@@ -73,13 +118,13 @@ impl CodecParser for HevcParser {
match nal_type {
NAL_VPS => {
self.vps = Some(data[nal_start..end].to_vec());
handle_param_set(&mut self.vps, &data[nal_start..end], &mut frame_data)
}
NAL_SPS => {
self.sps = Some(data[nal_start..end].to_vec());
handle_param_set(&mut self.sps, &data[nal_start..end], &mut frame_data)
}
NAL_PPS => {
self.pps = Some(data[nal_start..end].to_vec());
handle_param_set(&mut self.pps, &data[nal_start..end], &mut frame_data)
}
NAL_AUD => {} // Skip access unit delimiters
t if (NAL_BLA_W_LP..=NAL_RSV_IRAP_VCL23).contains(&t) => {
@@ -120,48 +165,72 @@ impl CodecParser for HevcParser {
let sps = self.sps.as_ref()?;
let pps = self.pps.as_ref()?;
// hvcC encodes each NAL's length as a 16-bit field. A param set larger
// than 65535 bytes would silently truncate the length while the full
// bytes are appended → mis-framed record. Refuse rather than emit a
// corrupt hvcC (param sets this large are non-conforming anyway).
if vps.len() > 0xFFFF || sps.len() > 0xFFFF || pps.len() > 0xFFFF {
return None;
}
// Simplified: store as arrays in Annex B format
// Full HEVCDecoderConfigurationRecord is complex — for now, concatenate
let mut record = Vec::new();
// Minimal HEVCDecoderConfigurationRecord header
// Minimal HEVCDecoderConfigurationRecord header.
//
// The stored SPS NAL is [2-byte HEVC NAL header][SPS RBSP...].
// The RBSP begins at sps[2]; profile_tier_level() begins one byte
// later, after sps_video_parameter_set_id u(4) +
// sps_max_sub_layers_minus1 u(3) + sps_temporal_id_nesting_flag u(1)
// (= sps[2], a full byte). So the profile_tier_level fields are:
// sps[3] general_profile_space u(2)+tier u(1)+profile_idc u(5)
// sps[4..8] general_profile_compatibility_flags u(32)
// sps[8..14] general_constraint_indicator_flags 48 bits
// sps[14] general_level_idc u(8)
// (Byte-aligned read; emulation-prevention bytes within the first
// 15 SPS bytes are not handled — extremely rare and matches the
// pre-existing simplification.)
record.push(1); // configurationVersion
// General profile space, tier flag, profile IDC from SPS
if sps.len() > 3 {
record.push(sps[1]); // general_profile_space + general_tier_flag + general_profile_idc
// general_profile_space + general_tier_flag + general_profile_idc
record.push(if sps.len() > 3 { sps[3] } else { 0 });
// general_profile_compatibility_flags (4 bytes) — SPS bytes 4..8
if sps.len() > 7 {
record.extend_from_slice(&sps[4..8]);
} else {
record.push(0);
let avail = sps.len().saturating_sub(4).min(4);
record.extend_from_slice(&sps[sps.len().min(4)..sps.len().min(8)]);
record.extend_from_slice(&vec![0u8; 4 - avail]);
}
// general_profile_compatibility_flags (4 bytes) — from SPS bytes 2..6
if sps.len() > 5 {
record.extend_from_slice(&sps[2..6]);
// general_constraint_indicator_flags (6 bytes) — SPS bytes 8..14
if sps.len() > 13 {
record.extend_from_slice(&sps[8..14]);
} else {
record.extend_from_slice(&[0, 0, 0, 0]);
let avail = sps.len().saturating_sub(8).min(6);
record.extend_from_slice(&sps[sps.len().min(8)..sps.len().min(14)]);
record.extend_from_slice(&vec![0u8; 6 - avail]);
}
// general_constraint_indicator_flags (6 bytes) — from SPS bytes 6..12
if sps.len() > 11 {
record.extend_from_slice(&sps[6..12]);
} else {
let avail = sps.len().saturating_sub(6).min(6);
if avail > 0 {
record.extend_from_slice(&sps[6..6 + avail]);
record.extend_from_slice(&vec![0u8; 6 - avail]);
} else {
record.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
}
}
// general_level_idc
record.push(if sps.len() > 12 { sps[12] } else { 0 });
// general_level_idc — SPS byte 14
record.push(if sps.len() > 14 { sps[14] } else { 0 });
// min_spatial_segmentation_idc (4 + 12 bits)
record.extend_from_slice(&[0xF0, 0x00]);
// parallelismType (6 + 2 bits)
record.push(0xFC);
// chromaFormat (6 + 2 bits)
record.push(0xFC | 1); // 4:2:0
// bitDepthLumaMinus8 (5 + 3 bits)
record.push(0xF8);
// bitDepthChromaMinus8 (5 + 3 bits)
record.push(0xF8);
// chromaFormat / bit depths — parse the real values from the SPS RBSP.
// A hardcoded 8-bit 4:2:0 is wrong for 10-bit Main 10 UHD (essentially
// all UHD content). Fall back to 8-bit 4:2:0 only if the SPS can't be
// parsed (emulation-prevention is handled; sub-layer PTL is skipped).
let chroma = parse_sps_chroma(sps).unwrap_or(SpsChroma {
chroma_format_idc: 1,
bit_depth_luma_minus8: 0,
bit_depth_chroma_minus8: 0,
});
// chromaFormat (6 reserved bits set + 2-bit chroma_format_idc)
record.push(0xFC | (chroma.chroma_format_idc & 0x03));
// bitDepthLumaMinus8 (5 reserved bits set + 3-bit value)
record.push(0xF8 | (chroma.bit_depth_luma_minus8 & 0x07));
// bitDepthChromaMinus8 (5 reserved bits set + 3-bit value)
record.push(0xF8 | (chroma.bit_depth_chroma_minus8 & 0x07));
// avgFrameRate
record.extend_from_slice(&[0, 0]);
// constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne
@@ -194,6 +263,181 @@ impl CodecParser for HevcParser {
}
}
/// chroma_format_idc + bit depths parsed from an HEVC SPS RBSP, for the hvcC
/// fixed header. Without these the record falsely advertised 8-bit 4:2:0, wrong
/// for 10-bit Main 10 UHD (essentially all UHD content).
struct SpsChroma {
/// chroma_format_idc: 0 mono, 1 4:2:0, 2 4:2:2, 3 4:4:4.
chroma_format_idc: u8,
bit_depth_luma_minus8: u8,
bit_depth_chroma_minus8: u8,
}
/// Minimal MSB-first bit reader over a byte slice.
struct BitReader<'a> {
data: &'a [u8],
bit_pos: usize,
}
impl<'a> BitReader<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, bit_pos: 0 }
}
fn read_bit(&mut self) -> Option<u32> {
let byte = self.bit_pos / 8;
if byte >= self.data.len() {
return None;
}
let shift = 7 - (self.bit_pos % 8);
self.bit_pos += 1;
Some(((self.data[byte] >> shift) & 1) as u32)
}
fn read_bits(&mut self, n: u32) -> Option<u32> {
let mut v = 0u32;
for _ in 0..n {
v = (v << 1) | self.read_bit()?;
}
Some(v)
}
fn skip_bits(&mut self, n: u32) -> Option<()> {
for _ in 0..n {
self.read_bit()?;
}
Some(())
}
/// Exp-Golomb unsigned, ue(v). Bounded leading-zero count to avoid runaway
/// on corrupt input.
fn read_ue(&mut self) -> Option<u32> {
let mut zeros = 0u32;
while self.read_bit()? == 0 {
zeros += 1;
if zeros > 31 {
return None;
}
}
if zeros == 0 {
return Some(0);
}
let rest = self.read_bits(zeros)?;
Some((1u32 << zeros) - 1 + rest)
}
}
/// Strip HEVC/H.264 emulation-prevention bytes (00 00 03 → 00 00) from a NAL
/// RBSP so a bit reader sees the true coded values.
fn strip_emulation_prevention(rbsp: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(rbsp.len());
let mut zeros = 0usize;
for &b in rbsp {
if zeros >= 2 && b == 0x03 {
// Drop the emulation-prevention byte; reset the run.
zeros = 0;
continue;
}
out.push(b);
if b == 0x00 {
zeros += 1;
} else {
zeros = 0;
}
}
out
}
/// Parse chroma_format_idc and bit depths from a stored SPS NAL
/// (`[2-byte NAL header][RBSP...]`). Handles emulation-prevention and
/// sub-layer profile_tier_level. Returns `None` if the bitstream is too short
/// or malformed (caller falls back to the 8-bit 4:2:0 default).
fn parse_sps_chroma(sps: &[u8]) -> Option<SpsChroma> {
if sps.len() < 3 {
return None;
}
// RBSP begins after the 2-byte HEVC NAL header.
let rbsp = strip_emulation_prevention(&sps[2..]);
let mut r = BitReader::new(&rbsp);
// sps_video_parameter_set_id u(4)
r.skip_bits(4)?;
// sps_max_sub_layers_minus1 u(3)
let max_sub_layers_minus1 = r.read_bits(3)?;
// sps_temporal_id_nesting_flag u(1)
r.skip_bits(1)?;
// profile_tier_level( 1, sps_max_sub_layers_minus1 )
parse_profile_tier_level(&mut r, max_sub_layers_minus1)?;
// sps_seq_parameter_set_id ue(v)
r.read_ue()?;
// chroma_format_idc ue(v)
let chroma_format_idc = r.read_ue()? as u8;
if chroma_format_idc == 3 {
// separate_colour_plane_flag u(1)
r.skip_bits(1)?;
}
// pic_width_in_luma_samples ue(v), pic_height_in_luma_samples ue(v)
r.read_ue()?;
r.read_ue()?;
// conformance_window_flag u(1) + 4× ue(v) if set
if r.read_bit()? == 1 {
r.read_ue()?;
r.read_ue()?;
r.read_ue()?;
r.read_ue()?;
}
// bit_depth_luma_minus8 ue(v), bit_depth_chroma_minus8 ue(v)
let bit_depth_luma_minus8 = r.read_ue()? as u8;
let bit_depth_chroma_minus8 = r.read_ue()? as u8;
Some(SpsChroma {
chroma_format_idc,
bit_depth_luma_minus8,
bit_depth_chroma_minus8,
})
}
/// Consume a profile_tier_level(profilePresentFlag=1, maxNumSubLayersMinus1)
/// structure from the bit reader (HEVC 7.3.3).
fn parse_profile_tier_level(r: &mut BitReader, max_sub_layers_minus1: u32) -> Option<()> {
// general: profile_space u(2) + tier u(1) + profile_idc u(5) = 8 bits,
// profile_compatibility_flags u(32), 4× constraint/flags + reserved = 44
// bits, general_inbld/reserved = 1 bit (total constraint area 48 bits),
// general_level_idc u(8). 88 bits = 11 bytes... but the spec packs the
// general PTL as 8 + 32 + 48 + 8 = 96 bits = 12 bytes. Skip 96 bits.
r.skip_bits(96)?;
if max_sub_layers_minus1 > 0 {
// sub_layer_profile_present_flag[i] u(1) + sub_layer_level_present_flag[i]
// u(1), for i in 0..max_sub_layers_minus1.
let mut profile_present = [false; 8];
let mut level_present = [false; 8];
for i in 0..max_sub_layers_minus1 as usize {
profile_present[i] = r.read_bit()? == 1;
level_present[i] = r.read_bit()? == 1;
}
// reserved_zero_2bits for i in max_sub_layers_minus1..8
if max_sub_layers_minus1 < 8 {
for _ in max_sub_layers_minus1..8 {
r.skip_bits(2)?;
}
}
for i in 0..max_sub_layers_minus1 as usize {
if profile_present[i] {
// sub_layer profile block: 8 + 32 + 48 = 88 bits.
r.skip_bits(88)?;
}
if level_present[i] {
// sub_layer_level_idc u(8)
r.skip_bits(8)?;
}
}
}
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -268,6 +512,103 @@ mod tests {
);
}
#[test]
fn hvcc_profile_tier_level_offsets() {
// The hvcC fixed header must read profile_tier_level from the SPS
// RBSP, not from the NAL header. Stored SPS = [2-byte NAL header][RBSP].
// RBSP layout (byte-aligned):
// sps[2] sps_vps_id/max_sub_layers/temporal_nesting
// sps[3] general_profile_space+tier+profile_idc
// sps[4..8] general_profile_compatibility_flags
// sps[8..14] general_constraint_indicator_flags
// sps[14] general_level_idc
let mut parser = HevcParser::new();
// Distinct, recognizable values for each field.
let sps_rbsp: [u8; 13] = [
0xAB, // sps[2] (vps_id etc.) — must NOT leak into profile fields
0x21, // sps[3] profile byte: space=0, tier=0, profile_idc=1
0x60, 0x00, 0x00, 0x00, // sps[4..8] compat flags
0x90, 0x00, 0x00, 0x00, 0x00, 0x00, // sps[8..14] constraint flags
0x7B, // sps[14] level_idc = 123
];
let mut data = Vec::new();
// VPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32));
data.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
// SPS — 2-byte header + the structured RBSP above
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(33));
data.extend_from_slice(&sps_rbsp);
// PPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(34));
data.extend_from_slice(&[0xDD, 0xEE]);
let pes = make_pes(data, Some(0));
parser.parse(&pes);
let cp = parser
.codec_private()
.expect("codec_private should be Some");
// record[0] = configurationVersion
assert_eq!(cp[0], 1, "configurationVersion");
// record[1] = general_profile_space+tier+profile_idc <- sps[3]
assert_eq!(
cp[1], 0x21,
"profile byte must come from SPS RBSP, not NAL hdr"
);
// record[2..6] = general_profile_compatibility_flags <- sps[4..8]
assert_eq!(&cp[2..6], &[0x60, 0x00, 0x00, 0x00], "compatibility flags");
// record[6..12] = general_constraint_indicator_flags <- sps[8..14]
assert_eq!(
&cp[6..12],
&[0x90, 0x00, 0x00, 0x00, 0x00, 0x00],
"constraint flags"
);
// record[12] = general_level_idc <- sps[14]
assert_eq!(cp[12], 0x7B, "level_idc must come from sps[14]");
}
#[test]
fn hvcc_short_sps_does_not_panic() {
// A truncated SPS must still produce a fixed header without panicking
// and zero-pad the missing profile/level bytes.
let mut parser = HevcParser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32));
data.extend_from_slice(&[0xAA]);
// SPS with only 3 RBSP bytes (stored len = 5): forces every guard path
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(33));
data.extend_from_slice(&[0x11, 0x22, 0x33]);
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(34));
data.extend_from_slice(&[0xDD]);
let pes = make_pes(data, Some(0));
parser.parse(&pes);
let cp = parser
.codec_private()
.expect("codec_private should be Some");
// sps stored = [hdr0, hdr1, 0x11, 0x22, 0x33], len 5.
// profile byte = sps[3] = 0x22; everything past sps[4]=0x33 is absent.
assert_eq!(cp[0], 1);
assert_eq!(cp[1], 0x22, "profile byte = sps[3]");
// compat flags: only sps[4]=0x33 present, rest zero-padded.
assert_eq!(&cp[2..6], &[0x33, 0x00, 0x00, 0x00]);
// constraint flags: none present, all zero.
assert_eq!(&cp[6..12], &[0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
// level_idc: absent, zero.
assert_eq!(cp[12], 0x00);
}
#[test]
fn codec_private_none_before_params() {
let parser = HevcParser::new();
@@ -447,6 +788,77 @@ mod tests {
);
}
// --- parameter-set redefinition (Fight Club bug) ---
/// A parameter set REDEFINED mid-stream (same id, different body) must be
/// emitted INLINE so the decoder re-activates it. Fight Club redefines PPS
/// id 0 partway through the title; the old parser kept only the first PPS,
/// so the second segment decoded against the wrong PPS (CABAC desync).
#[test]
fn redefined_pps_emitted_inline() {
let mut parser = HevcParser::new();
let pps = |body: u8| {
let mut v = vec![0x00, 0x00, 0x01];
v.extend_from_slice(&hevc_nal_header(34)); // PPS
v.extend_from_slice(&[body, body]);
v
};
let slice = || {
let mut v = vec![0x00, 0x00, 0x01];
v.extend_from_slice(&hevc_nal_header(1)); // TRAIL_R
v.extend_from_slice(&[0x10, 0x20]);
v
};
// count PPS (type 34) NALs in length-prefixed frame data
let count_pps = |fd: &[u8]| {
let (mut n, mut o) = (0usize, 0usize);
while o + 4 <= fd.len() {
let len = u32::from_be_bytes([fd[o], fd[o + 1], fd[o + 2], fd[o + 3]]) as usize;
o += 4;
if o < fd.len() && (fd[o] >> 1) & 0x3F == 34 {
n += 1;
}
o += len;
}
n
};
// PES1: first PPS-A → seeds codecPrivate, stripped from frame.
let mut d = pps(0xAA);
d.extend(slice());
let f = parser.parse(&make_pes(d, Some(0)));
assert_eq!(count_pps(&f[0].data), 0, "first PPS goes to codecPrivate");
// PES2: PPS-B (redefinition, different body) → emitted INLINE.
let mut d = pps(0xBB);
d.extend(slice());
let f = parser.parse(&make_pes(d, Some(1)));
assert_eq!(count_pps(&f[0].data), 1, "redefined PPS must be inline");
// PES3: PPS-B repeated — still differs from codecPrivate(A), so emitted
// AGAIN. Every keyframe of the redefined segment must carry it, because
// the player re-applies the hvcC (codecPrivate) copy at each keyframe;
// emitting once would be reverted at the next keyframe.
let mut d = pps(0xBB);
d.extend(slice());
let f = parser.parse(&make_pes(d, Some(2)));
assert_eq!(
count_pps(&f[0].data),
1,
"redefined PPS re-emitted every occurrence"
);
// PES4: back to PPS-A (== codecPrivate) → stripped (hvcC supplies it).
let mut d = pps(0xAA);
d.extend(slice());
let f = parser.parse(&make_pes(d, Some(3)));
assert_eq!(
count_pps(&f[0].data),
0,
"occurrence equal to codecPrivate stripped"
);
}
// --- empty PES ---
#[test]
@@ -474,6 +886,33 @@ mod tests {
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
// --- PTS (presentation), not DTS, drives the MKV block timecode ---
// Regression for B-frame presentation: writing DTS as the block timecode
// presents frames in decode order (visible judder) and breaks seeking.
#[test]
fn pts_preferred_over_dts() {
let mut parser = HevcParser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(1)); // TRAIL_R slice
data.extend_from_slice(&[0x10, 0x20]);
let pes = PesPacket {
pid: 0x1011,
pts: Some(180000), // 2 s (presentation)
dts: Some(90000), // 1 s (decode)
data,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(
frames[0].pts_ns, 2_000_000_000,
"block timecode must be PTS"
);
}
// --- Dolby Vision enhancement layer ---
#[test]
@@ -573,4 +1012,162 @@ mod tests {
offset += length;
}
}
// --- hvcC chroma / bit-depth from SPS ---
/// MSB-first bit writer for building a test SPS RBSP.
struct BitWriter {
bytes: Vec<u8>,
nbits: usize,
}
impl BitWriter {
fn new() -> Self {
Self {
bytes: Vec::new(),
nbits: 0,
}
}
fn put_bit(&mut self, b: u32) {
if self.nbits % 8 == 0 {
self.bytes.push(0);
}
if b & 1 != 0 {
let i = self.nbits / 8;
let shift = 7 - (self.nbits % 8);
self.bytes[i] |= 1 << shift;
}
self.nbits += 1;
}
fn put_bits(&mut self, v: u32, n: u32) {
for i in (0..n).rev() {
self.put_bit((v >> i) & 1);
}
}
fn put_ue(&mut self, v: u32) {
let val = v + 1;
let bits = 32 - val.leading_zeros();
for _ in 0..bits - 1 {
self.put_bit(0);
}
for i in (0..bits).rev() {
self.put_bit((val >> i) & 1);
}
}
}
/// Build a stored SPS NAL ([2-byte header][RBSP]) with the given
/// chroma_format_idc and bit depths, max_sub_layers_minus1 = 0.
fn make_sps_with_chroma(chroma_idc: u32, bd_luma_m8: u32, bd_chroma_m8: u32) -> Vec<u8> {
let mut w = BitWriter::new();
w.put_bits(0, 4); // sps_video_parameter_set_id
w.put_bits(0, 3); // sps_max_sub_layers_minus1 = 0
w.put_bit(1); // sps_temporal_id_nesting_flag
// general profile_tier_level: 96 bits (12 bytes) of zeros is fine here.
for _ in 0..96 {
w.put_bit(0);
}
w.put_ue(0); // sps_seq_parameter_set_id
w.put_ue(chroma_idc); // chroma_format_idc
if chroma_idc == 3 {
w.put_bit(0); // separate_colour_plane_flag
}
w.put_ue(3840); // pic_width_in_luma_samples
w.put_ue(2160); // pic_height_in_luma_samples
w.put_bit(0); // conformance_window_flag = 0
w.put_ue(bd_luma_m8); // bit_depth_luma_minus8
w.put_ue(bd_chroma_m8); // bit_depth_chroma_minus8
let mut sps = hevc_nal_header(33).to_vec();
sps.extend_from_slice(&w.bytes);
sps
}
fn codec_private_from_sps(sps_nal: &[u8]) -> Vec<u8> {
let mut parser = HevcParser::new();
// VPS + the given SPS + PPS, all length-prefixed in one PES.
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32));
data.extend_from_slice(&[0xAA, 0xBB]);
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(sps_nal);
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(34));
data.extend_from_slice(&[0xDD, 0xEE]);
parser.parse(&make_pes(data, Some(0)));
parser.codec_private().expect("codec_private")
}
#[test]
fn hvcc_emits_10bit_420_from_sps() {
// Main 10 UHD: chroma_format_idc=1 (4:2:0), bit depths = 10 (minus8 = 2).
let sps = make_sps_with_chroma(1, 2, 2);
let cp = codec_private_from_sps(&sps);
// chromaFormat at cp[16], bit depths at cp[17]/cp[18].
assert_eq!(cp[16], 0xFC | 1, "chroma_format_idc = 1 (4:2:0)");
assert_eq!(cp[17], 0xF8 | 2, "bit_depth_luma_minus8 = 2 (10-bit)");
assert_eq!(cp[18], 0xF8 | 2, "bit_depth_chroma_minus8 = 2 (10-bit)");
}
#[test]
fn hvcc_emits_8bit_420_from_sps() {
// 8-bit 4:2:0 must still report correctly (not a regression).
let sps = make_sps_with_chroma(1, 0, 0);
let cp = codec_private_from_sps(&sps);
assert_eq!(cp[16], 0xFC | 1);
assert_eq!(cp[17], 0xF8);
assert_eq!(cp[18], 0xF8);
}
#[test]
fn hvcc_emits_444_12bit_from_sps() {
// 4:4:4 (idc=3) with 12-bit depth (minus8 = 4).
let sps = make_sps_with_chroma(3, 4, 4);
let cp = codec_private_from_sps(&sps);
assert_eq!(cp[16], 0xFC | 3, "chroma_format_idc = 3 (4:4:4)");
assert_eq!(cp[17], 0xF8 | 4, "bit_depth_luma_minus8 = 4 (12-bit)");
assert_eq!(cp[18], 0xF8 | 4);
}
#[test]
fn hvcc_handles_emulation_prevention_in_sps() {
// Insert an emulation-prevention byte (00 00 03) into the SPS RBSP and
// confirm the chroma/bit-depth parse still lands on the right values.
// Build a 10-bit 4:2:0 SPS, then splice 00 00 03 into the RBSP tail
// (after the fields we parse) — the strip must not corrupt earlier bits.
let mut sps = make_sps_with_chroma(1, 2, 2);
// Append a benign 00 00 03 sequence to the RBSP.
sps.extend_from_slice(&[0x00, 0x00, 0x03, 0x00]);
let cp = codec_private_from_sps(&sps);
assert_eq!(cp[16], 0xFC | 1);
assert_eq!(cp[17], 0xF8 | 2);
assert_eq!(cp[18], 0xF8 | 2);
}
#[test]
fn hvcc_oversized_param_set_returns_none() {
// A param set larger than 65535 bytes cannot be length-encoded in hvcC's
// 16-bit field; codec_private must refuse rather than emit a truncated,
// mis-framed record.
let mut parser = HevcParser::new();
let mut data = Vec::new();
// VPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32));
data.extend_from_slice(&[0xAA, 0xBB]);
// Oversized SPS: header + 70000 bytes of payload (avoid 00 00 0x runs by
// using 0x11 filler so it stays one NAL).
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(33));
data.extend_from_slice(&vec![0x11u8; 70_000]);
// PPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(34));
data.extend_from_slice(&[0xDD, 0xEE]);
parser.parse(&make_pes(data, Some(0)));
assert!(
parser.codec_private().is_none(),
"oversized param set must not produce a (truncated) hvcC"
);
}
}
+88 -10
View File
@@ -1,24 +1,39 @@
//! BD/DVD LPCM (Linear PCM) audio parser.
//!
//! BD LPCM PES packets have a 4-byte header:
//! BD LPCM PES packets (TS stream type 0x80) carry a 4-byte header on the
//! elementary-stream payload:
//! Bytes 0-1: audio frame number
//! Byte 2: reserved
//! Byte 3: quantization (bits 7-6), sample rate (bits 5-4), channel assignment (bits 3-0)
//! This header is part of the ES payload, so the BD parser must strip it.
//!
//! DVD LPCM (private stream 1, sub-stream 0xA0-0xA7) has a 3-byte header.
//! DVD LPCM lives in private stream 1 (sub-stream 0xA0-0xA7). Its 7-byte
//! private sub-header (sub_id + frames + first-access-unit-ptr(2) + emphasis +
//! quant/freq + channels) is stripped by `PsDemuxer` while demuxing the
//! Program Stream. By the time a DVD LPCM `PesPacket` reaches this parser its
//! `data` is already raw PCM, so the parser must NOT strip any further bytes —
//! doing so drops one sample pair per PES and drifts the audio.
//!
//! The raw PCM data follows the header. No framing is needed — each PES
//! payload minus its header is one complete audio frame.
//! The two origins are distinguished by the `strip_header` flag: BD = strip,
//! DVD = leave intact. The raw PCM data is otherwise one complete audio frame
//! per PES; no framing is needed.
//!
//! For MKV: codec ID "A_PCM/INT/BIG" (BD) or "A_PCM/INT/LIT" (DVD).
//! All frames are keyframes (uncompressed audio).
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
/// BD LPCM header size in bytes.
/// BD LPCM header size in bytes (present on BD-TS LPCM, absent on DVD-PS LPCM
/// because `PsDemuxer` already stripped the private sub-header).
const BD_LPCM_HEADER_SIZE: usize = 4;
pub struct LpcmParser;
pub struct LpcmParser {
/// Whether to strip the 4-byte BD LPCM header from each PES payload.
///
/// `true` for BD-TS LPCM (header still present), `false` for DVD-PS LPCM
/// (header already removed by `PsDemuxer`).
strip_header: bool,
}
impl Default for LpcmParser {
fn default() -> Self {
@@ -27,23 +42,36 @@ impl Default for LpcmParser {
}
impl LpcmParser {
/// BD-TS LPCM parser: strips the 4-byte BD LPCM header from each PES.
pub fn new() -> Self {
Self
Self { strip_header: true }
}
/// DVD-PS LPCM parser: `PsDemuxer` already stripped the private sub-header,
/// so the payload is raw PCM and no further bytes are removed.
pub fn new_dvd() -> Self {
Self {
strip_header: false,
}
}
}
impl CodecParser for LpcmParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
// Skip the BD LPCM header (4 bytes).
let offset = if self.strip_header {
BD_LPCM_HEADER_SIZE
} else {
0
};
// If the PES is too short to contain header + data, return nothing.
if pes.data.len() <= BD_LPCM_HEADER_SIZE {
if pes.data.len() <= offset {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
vec![Frame {
pts_ns,
keyframe: true,
data: pes.data[BD_LPCM_HEADER_SIZE..].to_vec(),
data: pes.data[offset..].to_vec(),
duration_ns: None,
}]
}
@@ -84,6 +112,56 @@ mod tests {
assert_eq!(frames[0].pts_ns, 1_000_000_000); // 90000 ticks = 1 second
}
#[test]
fn bd_lpcm_strips_4_byte_header() {
// BD-TS LPCM: the 4-byte BD header is part of the ES payload and must
// be stripped, leaving exactly the PCM bytes.
let mut parser = LpcmParser::new();
let header = vec![0x00, 0x01, 0x00, 0b1001_0001];
let pcm = vec![0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88];
let mut data = header;
data.extend_from_slice(&pcm);
let frames = parser.parse(&make_pes(data, Some(0)));
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, pcm, "BD must strip exactly 4 header bytes");
}
#[test]
fn dvd_lpcm_preserves_all_pcm_bytes() {
// DVD-PS LPCM: PsDemuxer already removed the 7-byte private sub-header,
// so the payload handed to this parser is raw PCM. The DVD parser must
// NOT strip any further bytes (the round-2 audit Finding 3 bug: the BD
// 4-byte strip dropped one sample pair per PES, drifting the audio).
let mut parser = LpcmParser::new_dvd();
let pcm = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0x01, 0x02];
let frames = parser.parse(&make_pes(pcm.clone(), Some(90000)));
assert_eq!(frames.len(), 1);
assert_eq!(
frames[0].data, pcm,
"DVD must preserve every PCM byte — no second strip"
);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn dvd_lpcm_emits_short_payload_bd_would_drop() {
// A 4-byte raw-PCM DVD payload (1 sample pair at 16-bit stereo). The BD
// parser drops <= 4 bytes as "header only"; the DVD parser must emit it.
let mut bd = LpcmParser::new();
let mut dvd = LpcmParser::new_dvd();
let pcm = vec![0xAA, 0xBB, 0xCC, 0xDD];
assert!(
bd.parse(&make_pes(pcm.clone(), Some(0))).is_empty(),
"BD treats 4 bytes as header-only"
);
let frames = dvd.parse(&make_pes(pcm.clone(), Some(0)));
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, pcm);
}
#[test]
fn always_keyframe() {
let mut parser = LpcmParser::new();
+23 -1
View File
@@ -52,6 +52,18 @@ pub trait CodecParser: Send {
/// Some (TrueHD): multiple access units per PES.
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame>;
/// Drain any access unit still buffered after the last PES.
///
/// Parsers that buffer across PES boundaries to assemble a complete
/// access unit (e.g. DTS-HD, whose extension substreams arrive in
/// separate PES packets) hold the final unit until they can prove it's
/// complete. At end-of-stream there is no following packet to prove it,
/// so the demuxer calls `flush()` once after the last PES to emit it.
/// Default: nothing buffered, no tail.
fn flush(&mut self) -> Vec<Frame> {
Vec::new()
}
/// Get codec initialization data (e.g., SPS+PPS for H.264).
/// Returns None until enough data has been seen.
fn codec_private(&self) -> Option<Vec<u8>>;
@@ -90,7 +102,16 @@ impl CodecParser for PassthroughParser {
/// 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.
pub fn parser_for_codec(codec: Codec, codec_data: Option<Vec<u8>>) -> Box<dyn CodecParser> {
///
/// `is_dvd_ps` selects the DVD program-stream variant where it matters: DVD
/// LPCM arrives with its private sub-header already stripped by the
/// `PsDemuxer`, so the LPCM parser must NOT strip the 4-byte BD LPCM header
/// again (that would drop one PCM sample pair per PES → progressive drift).
pub fn parser_for_codec(
codec: Codec,
codec_data: Option<Vec<u8>>,
is_dvd_ps: bool,
) -> Box<dyn CodecParser> {
match codec {
Codec::H264 => Box::new(h264::H264Parser::new()),
Codec::Hevc => Box::new(hevc::HevcParser::new()),
@@ -100,6 +121,7 @@ pub fn parser_for_codec(codec: Codec, codec_data: Option<Vec<u8>>) -> Box<dyn Co
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()),
Codec::Lpcm if is_dvd_ps => Box::new(lpcm::LpcmParser::new_dvd()),
Codec::Lpcm => Box::new(lpcm::LpcmParser::new()),
Codec::DvdSub => Box::new(dvdsub::DvdSubParser::new(codec_data)),
_ => Box::new(PassthroughParser::new(true)),
+79 -4
View File
@@ -96,9 +96,14 @@ impl CodecParser for Mpeg2Parser {
return Vec::new();
}
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
// MKV block timecodes are PRESENTATION timestamps; frames are stored in
// 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 data = &pes.data;
let mut keyframe = false;
let mut has_picture = false;
// Scan for start codes in the elementary stream data.
let mut pos = 0;
@@ -176,6 +181,7 @@ impl CodecParser for Mpeg2Parser {
PICTURE_CODE => {
// Picture header: bytes after start code contain temporal_reference
// (10 bits) + picture_coding_type (3 bits).
has_picture = true;
if sc + 5 < data.len() {
let picture_coding_type = (data[sc + 5] >> 3) & 0x07;
if picture_coding_type == PICTURE_TYPE_I {
@@ -190,6 +196,23 @@ impl CodecParser for Mpeg2Parser {
}
}
// A PES that carried a sequence header but no picture start code is a
// parameter-set-only access unit: it has no coded picture to emit.
// Emitting it as a standalone keyframe would put bare sequence-header
// bytes into frame data with no picture. The sequence header is
// captured into codec_private above and is re-emitted in-band on the
// next real picture's PES, so dropping the empty access unit loses
// nothing. Mirrors how the H.264/HEVC parsers skip parameter-set-only
// access units.
//
// Conservative: only drop when this PES actually contained a sequence
// header and no picture. A PES with neither (e.g. a slice
// continuation) still passes through unchanged, preserving real
// keyframe detection.
if !has_picture && contains_seq_header(data) {
return Vec::new();
}
vec![Frame {
pts_ns,
keyframe,
@@ -241,6 +264,21 @@ fn parse_aspect_ratio(hdr: &[u8]) -> Option<(u8, u8)> {
Some(ASPECT_RATIOS[ar_code])
}
/// Returns true if `data` contains a sequence-header start code (00 00 01 B3).
fn contains_seq_header(data: &[u8]) -> bool {
let mut pos = 0;
while let Some(sc) = find_start_code(data, pos) {
if sc + 3 >= data.len() {
break;
}
if data[sc + 3] == SEQ_HEADER_CODE {
return true;
}
pos = sc + 4;
}
false
}
/// Find the position of the next start code (00 00 01) at or after `from`.
fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
if data.len() < from + 3 {
@@ -429,15 +467,15 @@ mod tests {
assert!(has_ext, "codec_private should include sequence extension");
}
// --- I-frame with sequence header = keyframe ---
// --- sequence header + picture = keyframe ---
#[test]
fn sequence_header_implies_keyframe() {
fn sequence_header_with_picture_is_keyframe() {
let mut parser = Mpeg2Parser::new();
let mut data = Vec::new();
data.extend_from_slice(&make_seq_header(720, 480, 3, 4));
// Even without an explicit picture header, a sequence header implies I-frame.
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
data.extend_from_slice(&[0xFF; 16]);
let pes = make_pes(data, Some(0));
@@ -445,6 +483,43 @@ mod tests {
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe);
// codecPrivate is still captured.
assert!(parser.codec_private().is_some());
}
// --- parameter-set-only PES (seq header, no picture) emits no frame ---
#[test]
fn sequence_header_only_pes_emits_no_frame() {
let mut parser = Mpeg2Parser::new();
// A PES carrying only a sequence header (+ extension), no picture.
let mut data = Vec::new();
data.extend_from_slice(&make_seq_header(1920, 1080, 3, 4));
data.extend_from_slice(&[0x00, 0x00, 0x01, SEQ_EXT_CODE]);
data.extend_from_slice(&[0x14, 0x8A, 0x00, 0x01, 0x00, 0x00]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
// No coded picture → no frame emitted, but the sequence header is
// still captured for codecPrivate.
assert!(
frames.is_empty(),
"parameter-set-only PES should not emit a frame"
);
assert!(
parser.codec_private().is_some(),
"sequence header should still be captured into codec_private"
);
// A following picture-bearing PES emits the real keyframe.
let mut data2 = Vec::new();
data2.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
data2.extend_from_slice(&[0xFF; 16]);
let frames2 = parser.parse(&make_pes(data2, Some(3600)));
assert_eq!(frames2.len(), 1);
assert!(frames2[0].keyframe);
}
// --- PTS conversion ---
+82 -1
View File
@@ -18,6 +18,13 @@
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
const SEGMENT_PCS: u8 = 0x16;
// Upper bound on a pending display set's accumulated bytes. Real PGS
// display sets are small (a 1080p RLE bitmap plus palette is well under
// 1 MB); a stream that keeps appending non-PCS segments without ever
// emitting a PCS is malformed. Cap accumulation to bound memory and
// drop further appends until the next PCS resyncs the parser. Mirrors
// the MAX_*_BYTES / MAX_*_BUF caps in the DTS and AC-3 parsers.
const MAX_PGS_PENDING_BYTES: usize = 4 * 1024 * 1024;
// Offset within the PES payload at which number_of_composition_objects
// lives in a PCS: 3-byte segment header + 10 bytes of PCS fields
// (video_w/h, frame_rate, comp_num, comp_state, palette_update,
@@ -90,7 +97,12 @@ impl CodecParser for PgsParser {
// a pending display, append; otherwise emit as-is.
None => {
if let Some((_, ref mut buf)) = self.pending {
buf.extend_from_slice(&pes.data);
// Bound accumulation: a well-formed display set is small.
// Past the cap, drop further appends (malformed stream);
// the next PCS will take/replace `pending` and resync.
if buf.len() + pes.data.len() <= MAX_PGS_PENDING_BYTES {
buf.extend_from_slice(&pes.data);
}
} else {
out.push(Frame {
pts_ns,
@@ -105,6 +117,25 @@ impl CodecParser for PgsParser {
out
}
fn flush(&mut self) -> Vec<Frame> {
// A display set is only emitted when the *next* PCS arrives
// (either an empty clear PCS or a replacing display PCS). At
// end-of-stream there is no follower, so without this the last
// subtitle of every PGS track would be silently dropped. Emit
// the pending set with no duration — the trailing block lingers
// until end of file, which is exactly the desired behavior for
// the final on-screen subtitle (see the module doc).
match self.pending.take() {
Some((start_pts, data)) => vec![Frame {
pts_ns: start_pts,
keyframe: true,
data,
duration_ns: None,
}],
None => Vec::new(),
}
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
}
@@ -180,6 +211,56 @@ mod tests {
assert!(data.windows(5).any(|w| w == [0x15, 0x00, 0x02, 0xAA, 0xBB]));
}
#[test]
fn pending_buffer_is_capped() {
let mut parser = PgsParser::new();
// Open a display set.
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
// Flood with non-PCS segments far exceeding the cap.
let chunk = vec![0x15u8; 256 * 1024]; // 256 KB ODS-like segment
let floods = (MAX_PGS_PENDING_BYTES / chunk.len()) + 32;
for _ in 0..floods {
let frames = parser.parse(&make_pes(chunk.clone(), Some(90000)));
assert!(frames.is_empty(), "non-PCS appends should not emit");
}
// The pending buffer must not have grown without bound.
let pending_len = parser.pending.as_ref().map(|(_, b)| b.len()).unwrap_or(0);
assert!(
pending_len <= MAX_PGS_PENDING_BYTES,
"pending buffer {pending_len} exceeded cap {MAX_PGS_PENDING_BYTES}"
);
// A following PCS still resyncs and emits the (capped) pending set.
let frames = parser.parse(&make_pes(pcs_bytes(0), Some(180000)));
assert_eq!(frames.len(), 1);
}
#[test]
fn flush_emits_final_pending_subtitle() {
let mut parser = PgsParser::new();
// Display PCS at PTS 90000 — buffered as pending, no follower.
let display = pcs_bytes(1);
let frames = parser.parse(&make_pes(display.clone(), Some(90000)));
assert!(frames.is_empty(), "display PCS should be pending");
// EOF: without flush() this last subtitle would be dropped.
let frames = parser.flush();
assert_eq!(frames.len(), 1, "final pending subtitle must flush");
assert_eq!(frames[0].pts_ns, 1_000_000_000);
assert_eq!(frames[0].data, display);
// Trailing block lingers to EOF — no duration per module doc.
assert_eq!(frames[0].duration_ns, None);
}
#[test]
fn flush_with_nothing_pending_is_empty() {
let mut parser = PgsParser::new();
assert!(parser.flush().is_empty());
}
#[test]
fn codec_private_none() {
let parser = PgsParser::new();
+135 -2
View File
@@ -16,6 +16,12 @@ use super::{CodecParser, Frame, PesPacket, pts_to_ns};
/// Duration of one TrueHD access unit in nanoseconds (1/1200 second).
const AU_DURATION_NS: i64 = 833_333;
/// Hard cap on the reassembly buffer. A valid TrueHD/MAT access unit is
/// well under 32 KiB; if the buffer grows far past that without yielding a
/// frame the stream is malformed, so we drop it and resync rather than grow
/// without bound. Parity with the AC-3 / DTS / PGS caps.
const MAX_TRUEHD_BUF: usize = 256 * 1024;
pub struct TrueHdParser {
buf: Vec<u8>,
next_pts_ns: i64,
@@ -90,8 +96,17 @@ impl CodecParser for TrueHdParser {
return Vec::new();
}
if let Some(pts) = pes.pts {
self.next_pts_ns = pts_to_ns(pts);
// Capture the PTS base ONLY at an access-unit boundary, i.e. when no AU
// is mid-assembly in `buf`. TrueHD access units span PES packets; a PES
// that merely continues an AU already in progress carries its own (later)
// PTS, which must NOT override the running per-AU timestamp. Adopting it
// mid-AU would snap that AU's PTS backward/forward and break the
// monotonic +AU_DURATION_NS cadence (A/V drift). Once the buffer is empty
// the next PES legitimately begins a new AU and seeds the base.
if self.buf.is_empty() {
if let Some(pts) = pes.pts {
self.next_pts_ns = pts_to_ns(pts);
}
}
self.buf.extend_from_slice(&pes.data);
@@ -145,6 +160,12 @@ impl CodecParser for TrueHdParser {
self.next_pts_ns += AU_DURATION_NS;
}
// Bound memory on malformed input: a stream that never yields a
// complete frame must not grow the buffer without limit.
if self.buf.len() > MAX_TRUEHD_BUF {
self.buf.clear();
}
frames
}
@@ -153,6 +174,52 @@ impl CodecParser for TrueHdParser {
}
}
/// Per-bit channel counts for the TrueHD 8-channel and 6-channel presentation
/// channel-assignment masks (per the MLP spec / FFmpeg `thd_channels`). 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];
/// Decode the true channel count from a TrueHD major-sync `format_info` word
/// (the 32 bits immediately after the 0xF8726FBA sync). Returns the richest
/// presentation's channel count — the 8-channel (e.g. 7.1) presentation when
/// present, else the 6-channel (5.1) one. This is the real layout that the MPLS
/// `audio_format` base field (often 5.1 even on a 7.1/Atmos track) understates.
pub fn truehd_channels(format_info: u32) -> Option<u8> {
let ch8 = (format_info & 0x1FFF) as u16; // 8ch_presentation_channel_assignment (13 bits)
let ch6 = ((format_info >> 15) & 0x1F) as u16; // 6ch_presentation_channel_assignment (5 bits)
let count = |mask: u16, tbl: &[u8]| -> u8 {
tbl.iter()
.enumerate()
.filter(|(i, _)| mask & (1 << i) != 0)
.map(|(_, &c)| c)
.sum()
};
if ch8 != 0 {
Some(count(ch8, &THD_8CH))
} else if ch6 != 0 {
Some(count(ch6, &THD_6CH))
} else {
None
}
}
/// Scan a demuxed TrueHD elementary-stream chunk for the first major sync and
/// decode its true channel count. The stream may interleave AC-3; we scan for
/// the major-sync word anywhere and read the following `format_info`.
pub fn truehd_channels_from_stream(data: &[u8]) -> Option<u8> {
let mut p = 0;
while p + 8 <= data.len() {
let w = u32::from_be_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]);
if (w & 0xFFFF_FFFE) == 0xF872_6FBA {
let fi = u32::from_be_bytes([data[p + 4], data[p + 5], data[p + 6], data[p + 7]]);
return truehd_channels(fi);
}
p += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -242,9 +309,75 @@ mod tests {
assert_eq!(frames[0].data.len(), 200);
}
#[test]
fn continuation_pes_pts_does_not_override_au_in_progress() {
// An AU split across two PES packets: the FIRST PES (pts 90000) begins
// the AU; the SECOND PES (pts 99999, a later timestamp) merely continues
// it. The emitted AU must keep the first PES's PTS, not adopt the
// continuation PES's later timestamp.
let mut parser = TrueHdParser::new();
let unit = make_truehd_unit(200);
let mid = 100;
let pes1 = make_pes(unit[..mid].to_vec(), Some(90000));
assert!(parser.parse(&pes1).is_empty(), "AU held mid-assembly");
// Continuation PES carries a later PTS that must be ignored for this AU.
let pes2 = make_pes(unit[mid..].to_vec(), Some(99999));
let frames = parser.parse(&pes2);
assert_eq!(frames.len(), 1);
assert_eq!(
frames[0].pts_ns,
pts_to_ns(90000),
"AU keeps the PTS of the PES that began it, not the continuation PES"
);
}
#[test]
fn new_au_after_empty_buffer_takes_new_pes_pts() {
// After an AU fully drains (buffer empty), the next PES legitimately
// seeds a fresh PTS base.
let mut parser = TrueHdParser::new();
let f1 = parser.parse(&make_pes(make_truehd_unit(200), Some(90000)));
assert_eq!(f1.len(), 1);
assert_eq!(f1[0].pts_ns, pts_to_ns(90000));
// Buffer is now empty; a new PES with a new PTS starts a new AU.
let f2 = parser.parse(&make_pes(make_truehd_unit(200), Some(180000)));
assert_eq!(f2.len(), 1);
assert_eq!(
f2[0].pts_ns,
pts_to_ns(180000),
"new AU after empty buffer adopts the new PES PTS"
);
}
#[test]
fn codec_private_none() {
let parser = TrueHdParser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn truehd_channels_71_from_8ch_presentation() {
// 8ch presentation assignment bits 0-4 (LR,C,LFE,LsRs,back-LR) = 2+1+1+2+2 = 8.
let format_info = 0x1F; // low 13 bits = 0x1F
assert_eq!(truehd_channels(format_info), Some(8));
}
#[test]
fn truehd_channels_51_from_6ch_presentation() {
// No 8ch presentation; 6ch bits 0-3 (LR,C,LFE,LsRs) = 2+1+1+2 = 6.
let format_info = 0xF << 15; // 6ch field = 0xF, 8ch field = 0
assert_eq!(truehd_channels(format_info), Some(6));
}
#[test]
fn truehd_channels_scan_finds_major_sync() {
// [junk][major sync 0xF8726FBA][format_info: 8ch=0x1F -> 7.1]
let mut data = vec![0xAA, 0xBB];
data.extend_from_slice(&0xF872_6FBAu32.to_be_bytes());
data.extend_from_slice(&0x0000_001Fu32.to_be_bytes());
assert_eq!(truehd_channels_from_stream(&data), Some(8));
}
}
+64 -10
View File
@@ -41,9 +41,13 @@ impl CodecParser for Vc1Parser {
return Vec::new();
}
// Use DTS when available (monotonic for B-frame content), fall back to PTS
let ts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
// MKV block timecodes are PRESENTATION timestamps; frames are stored in
// 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 mut has_seq_header = false;
let mut has_entry_point = false;
let mut frame_start: Option<usize> = None;
// Scan for start codes (00 00 01 XX)
@@ -67,6 +71,7 @@ impl CodecParser for Vc1Parser {
SC_ENTRY_POINT => {
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
self.entry_point = Some(data[i..end].to_vec());
has_entry_point = true;
}
SC_FRAME => {
// Frame data starts at this start code
@@ -85,11 +90,23 @@ impl CodecParser for Vc1Parser {
// Keyframe = this PES contains a sequence header (I-frame indicator in BD)
let keyframe = has_seq_header;
// Strip sequence header + entry point from frame data — those are in codecPrivate.
// Only include data from the frame start code onwards.
// Strip sequence header + entry point from frame data — those are in
// codecPrivate, not coded-picture data. Only include data from the
// frame start code onwards.
let frame_data = match frame_start {
Some(start) => &data[start..],
None => data, // no frame start code found, pass through entire PES
None => {
// No frame start code. If this PES carried only parameter sets
// (sequence header / entry point, captured above into
// codecPrivate), there is no coded picture to emit — drop it
// rather than passing parameter bytes through as a bogus
// keyframe. Mirrors how the H.264/HEVC parsers skip
// parameter-set-only access units.
if has_seq_header || has_entry_point {
return Vec::new();
}
data // genuine picture payload with no leading 0x0D — pass through
}
};
vec![Frame {
@@ -343,6 +360,42 @@ mod tests {
assert_eq!(&frames[0].data[0..4], &[0x00, 0x00, 0x01, SC_FRAME]);
}
// --- parameter-set-only PES (seq header + entry point, no frame SC) ---
#[test]
fn param_set_only_pes_emits_no_frame() {
let mut parser = Vc1Parser::new();
// Sequence header + entry point, but NO frame start code (0x0D).
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_ENTRY_POINT]);
data.extend_from_slice(&[0x11, 0x22, 0x33, 0x44]);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
// No coded picture → no frame emitted (parameter bytes must not be
// passed through as a bogus keyframe).
assert!(
frames.is_empty(),
"parameter-set-only PES should not emit a frame"
);
// But codecPrivate is still captured.
assert!(parser.seq_header.is_some());
assert!(parser.entry_point.is_some());
assert!(parser.codec_private().is_some());
// A following frame-bearing PES still emits its picture.
let mut data2 = Vec::new();
data2.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
data2.extend_from_slice(&[0x55, 0x66, 0x77]);
let frames2 = parser.parse(&make_pes(data2, Some(180000)));
assert_eq!(frames2.len(), 1);
assert_eq!(&frames2[0].data[0..4], &[0x00, 0x00, 0x01, SC_FRAME]);
}
// --- empty PES ---
#[test]
@@ -369,10 +422,10 @@ mod tests {
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
// --- DTS preferred over PTS ---
// --- PTS (presentation) used for the MKV block timecode, not DTS ---
#[test]
fn dts_preferred_over_pts() {
fn pts_preferred_over_dts() {
let mut parser = Vc1Parser::new();
let mut data = Vec::new();
@@ -381,13 +434,14 @@ mod tests {
let pes = PesPacket {
pid: 0x1011,
pts: Some(180000),
dts: Some(90000),
pts: Some(180000), // presentation
dts: Some(90000), // decode
data,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
// PTS must be used — MKV block timecodes are presentation timestamps.
assert_eq!(frames[0].pts_ns, 2_000_000_000);
}
// --- find_next_sc utility ---
+61 -37
View File
@@ -195,7 +195,8 @@ impl DiscStream {
};
pids.push(pid);
pid_to_track.push((pid, idx));
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
let is_dvd_ps = matches!(content_format, crate::disc::ContentFormat::MpegPs);
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
}
let mut ts_demuxer = None;
@@ -507,24 +508,30 @@ impl crate::pes::Stream for DiscStream {
// PS demuxer flush (DVD)
if let Some(ref mut demuxer) = self.ps_demuxer {
for ps in &demuxer.flush() {
let track = match ps.stream_id {
0xE0..=0xEF => 0,
0xC0..=0xDF => 1,
0xBD => ps
.sub_stream_id
.map(|s| (s & 0x1F) as usize + 1)
.unwrap_or(1),
_ => continue,
};
if track >= self.title.streams.len() {
// Route by the REAL DVD PID (see consume_ps in
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
// heuristic mis-routed VobSub into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else {
tracing::warn!(
target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id,
ps.sub_stream_id,
);
continue;
}
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(p, _)| *p)
.unwrap_or(0);
};
let Some((_, track)) =
self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
else {
tracing::warn!(
target: "mux",
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
pid,
ps.stream_id,
ps.sub_stream_id,
);
continue;
};
let pes = super::ts::PesPacket {
pid,
pts: ps.pts.map(|p| p as i64),
@@ -541,6 +548,19 @@ impl crate::pes::Stream for DiscStream {
}
}
}
// Drain any access unit a codec parser buffered past the last
// PES (DTS-HD's final core+extension unit, assembled across
// PES boundaries).
let pid_to_track = &self.pid_to_track;
let pending = &mut self.pending_frames;
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() {
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
}
}
return Ok(self.pending_frames.pop_front());
}
@@ -593,26 +613,30 @@ impl crate::pes::Stream for DiscStream {
} else if let Some(ref mut demuxer) = self.ps_demuxer {
let packets = demuxer.feed(&self.read_buf[..bytes]);
for ps in &packets {
let track = match ps.stream_id {
0xE0..=0xEF => 0,
0xC0..=0xDF => 1,
0xBD => ps
.sub_stream_id
.map(|s| (s & 0x1F) as usize + 1)
.unwrap_or(1),
_ => continue,
};
if track >= self.title.streams.len() {
// Route by the REAL DVD PID (see consume_ps in
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
// heuristic mis-routed VobSub into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else {
tracing::warn!(
target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id,
ps.sub_stream_id,
);
continue;
}
// Convert PsPacket to PesPacket for codec parser (same as BD-TS path)
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(p, _)| *p)
.unwrap_or(0);
};
let Some((_, track)) =
self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
else {
tracing::warn!(
target: "mux",
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
pid,
ps.stream_id,
ps.sub_stream_id,
);
continue;
};
let pes = super::ts::PesPacket {
pid,
+50 -13
View File
@@ -264,6 +264,13 @@ pub fn read_element_header(r: &mut impl Read) -> io::Result<(u32, u64, usize)> {
/// Read an unsigned integer value of `len` bytes.
pub fn read_uint_val(r: &mut impl Read, len: usize) -> io::Result<u64> {
// An EBML unsigned integer is at most 8 bytes. A malformed element
// claiming `len > 8` would index past this stack buffer and panic
// (DoS on untrusted input) — reject it at the source so every caller
// is safe, not just the ones that pre-check.
if len > 8 {
return Err(crate::error::Error::MkvInvalid.into());
}
let mut buf = [0u8; 8];
r.read_exact(&mut buf[..len])?;
let mut val = 0u64;
@@ -273,23 +280,33 @@ pub fn read_uint_val(r: &mut impl Read, len: usize) -> io::Result<u64> {
Ok(val)
}
/// Read a float value (4 or 8 bytes).
/// Read a float value. EBML floats are exactly 0, 4, or 8 bytes.
///
/// The previous `else` branch read a fixed 8 bytes for ANY non-4 length,
/// so a malformed element with `len > 8` left `len - 8` unconsumed bytes
/// (mis-read as the next EBML header → desync of the rest of the parent
/// element) and `len < 4` over-read. Consume exactly `len` bytes and
/// reject anything that isn't a valid float width.
pub fn read_float_val(r: &mut impl Read, len: usize) -> io::Result<f64> {
if len == 4 {
let mut buf = [0u8; 4];
r.read_exact(&mut buf)?;
Ok(f32::from_be_bytes(buf) as f64)
} else {
let mut buf = [0u8; 8];
r.read_exact(&mut buf)?;
Ok(f64::from_be_bytes(buf))
match len {
0 => Ok(0.0),
4 => {
let mut buf = [0u8; 4];
r.read_exact(&mut buf)?;
Ok(f32::from_be_bytes(buf) as f64)
}
8 => {
let mut buf = [0u8; 8];
r.read_exact(&mut buf)?;
Ok(f64::from_be_bytes(buf))
}
_ => Err(crate::error::Error::MkvInvalid.into()),
}
}
/// Read a UTF-8 string value of `len` bytes.
pub fn read_string_val(r: &mut impl Read, len: usize) -> io::Result<String> {
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?;
let mut buf = read_exact_bounded(r, len)?;
// Strip trailing nulls
while buf.last() == Some(&0) {
buf.pop();
@@ -299,8 +316,22 @@ pub fn read_string_val(r: &mut impl Read, len: usize) -> io::Result<String> {
/// Read binary data of `len` bytes.
pub fn read_binary_val(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> {
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?;
read_exact_bounded(r, len)
}
/// Read exactly `len` bytes WITHOUT trusting `len` to size the allocation.
///
/// `vec![0u8; len]` on an attacker-controlled EBML size would allocate
/// gigabytes before the read fails. Instead we cap the reader to `len`
/// and grow the buffer as bytes actually arrive: a malformed element that
/// claims a huge length but supplies few bytes allocates only what it
/// delivers, then errors on the short read.
fn read_exact_bounded(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> {
let mut buf = Vec::new();
let got = r.take(len as u64).read_to_end(&mut buf)?;
if got != len {
return Err(io::ErrorKind::UnexpectedEof.into());
}
Ok(buf)
}
@@ -362,6 +393,12 @@ pub const MATRIX_COEFFICIENTS: u32 = 0x55B1;
pub const PRIMARIES: u32 = 0x55BB;
pub const RANGE: u32 = 0x55B9;
// Dolby Vision — BlockAdditionMapping carries the DOVIDecoderConfigurationRecord
// (dvcC) so players / mediainfo recognise the track as Dolby Vision.
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED;
// Audio
pub const AUDIO: u32 = 0xE1;
pub const SAMPLING_FREQUENCY: u32 = 0xB5;
+151 -15
View File
@@ -3,10 +3,24 @@
//! Format: [8B magic] [4B json_len] [JSON] [padding to 192B boundary] [BD-TS data...]
//! Other tools skip the header during TS sync recovery (scan for 0x47).
use crate::disc::{AudioStream, ColorSpace, DiscTitle, Stream, SubtitleStream, VideoStream};
use crate::disc::{
AudioStream, ColorSpace, DiscTitle, HdrFormat, Stream, SubtitleStream, VideoStream,
};
use serde::{Deserialize, Serialize};
use std::io::{self, Read, Write};
/// Derive the color space from the HDR format, for metadata written before the
/// color space was persisted (pre-0.30.7). All HDR formats use BT.2020 wide
/// gamut; SDR uses BT.709.
fn color_space_from_hdr(hdr: HdrFormat) -> ColorSpace {
match hdr {
HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::Hlg | HdrFormat::DolbyVision => {
ColorSpace::Bt2020
}
HdrFormat::Sdr => ColorSpace::Bt709,
}
}
/// Magic bytes: "FMKV" + version 1 + 2 reserved bytes.
const MAGIC: [u8; 8] = [b'F', b'M', b'K', b'V', 0x00, 0x01, 0x00, 0x00];
@@ -42,6 +56,11 @@ pub enum MetaStream {
frame_rate: String,
#[serde(default)]
hdr: String,
/// Color space id (e.g. "bt709", "bt2020"). Empty/absent in pre-0.30.7
/// metadata — `to_title` then derives it from `hdr` so HDR color
/// primaries/transfer/matrix still round-trip.
#[serde(default)]
color_space: String,
#[serde(default)]
label: String,
#[serde(default)]
@@ -91,6 +110,7 @@ impl M2tsMeta {
resolution: v.resolution.to_string(),
frame_rate: v.frame_rate.to_string(),
hdr: v.hdr.id().into(),
color_space: v.color_space.id().into(),
label: v.label.clone(),
secondary: v.secondary,
codec_private: title
@@ -137,23 +157,38 @@ impl M2tsMeta {
resolution,
frame_rate,
hdr,
color_space,
label,
secondary,
codec_private: _,
} => Stream::Video(VideoStream {
pid: *pid,
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
resolution: resolution
.parse()
.unwrap_or(crate::disc::Resolution::Unknown),
frame_rate: frame_rate
.parse()
.unwrap_or(crate::disc::FrameRate::Unknown),
hdr: hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr),
color_space: ColorSpace::Bt709,
secondary: *secondary,
label: label.clone(),
}),
} => {
let hdr_fmt = hdr.parse().unwrap_or(crate::disc::HdrFormat::Sdr);
// Prefer the explicitly stored color space. Pre-0.30.7
// metadata has none, so derive it from the HDR format:
// every HDR variant (HDR10/HDR10+/HLG/Dolby Vision) is
// BT.2020; SDR is BT.709.
let cs = if color_space.is_empty() {
color_space_from_hdr(hdr_fmt)
} else {
color_space
.parse::<ColorSpace>()
.unwrap_or(ColorSpace::Unknown)
};
Stream::Video(VideoStream {
pid: *pid,
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
resolution: resolution
.parse()
.unwrap_or(crate::disc::Resolution::Unknown),
frame_rate: frame_rate
.parse()
.unwrap_or(crate::disc::FrameRate::Unknown),
hdr: hdr_fmt,
color_space: cs,
secondary: *secondary,
label: label.clone(),
})
}
MetaStream::Audio {
pid,
codec,
@@ -289,3 +324,104 @@ pub fn read_header(r: &mut impl Read) -> io::Result<Option<M2tsMeta>> {
// Serialization uses Codec::id() / HdrFormat::id() and Display impls.
// Deserialization uses FromStr impls (.parse()) on each enum.
#[cfg(test)]
mod tests {
use super::*;
use crate::disc::{Codec, FrameRate, Resolution};
fn video_title(hdr: HdrFormat, cs: ColorSpace) -> DiscTitle {
let mut t = DiscTitle::empty();
t.streams.push(Stream::Video(VideoStream {
pid: 0x1011,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: FrameRate::F23_976,
hdr,
color_space: cs,
secondary: false,
label: String::new(),
}));
t
}
fn round_trip_color_space(title: &DiscTitle) -> ColorSpace {
let meta = M2tsMeta::from_title(title);
let back = meta.to_title();
match &back.streams[0] {
Stream::Video(v) => v.color_space,
_ => panic!("expected video stream"),
}
}
#[test]
fn color_space_round_trips_bt2020() {
// The regression: HDR10 / BT.2020 must survive from_title → to_title,
// not collapse to the hardcoded BT.709.
let cs = round_trip_color_space(&video_title(HdrFormat::Hdr10, ColorSpace::Bt2020));
assert_eq!(cs, ColorSpace::Bt2020, "BT.2020 must round-trip");
}
#[test]
fn color_space_round_trips_bt709() {
let cs = round_trip_color_space(&video_title(HdrFormat::Sdr, ColorSpace::Bt709));
assert_eq!(cs, ColorSpace::Bt709);
}
#[test]
fn color_space_serialized_in_json() {
let meta = M2tsMeta::from_title(&video_title(HdrFormat::Hdr10, ColorSpace::Bt2020));
let json = serde_json::to_string(&meta).unwrap();
assert!(
json.contains("bt2020"),
"color_space must be serialized: {json}"
);
}
#[test]
fn legacy_metadata_without_color_space_derives_from_hdr() {
// Pre-0.30.7 JSON has no color_space field. to_title must derive the
// color space from the HDR format so HDR color metadata is preserved.
let json = r#"{
"v": 1,
"title": "x",
"duration": 0.0,
"streams": [
{"type":"video","pid":4113,"codec":"hevc","resolution":"2160p",
"frame_rate":"23.976","hdr":"hdr10","label":"","secondary":false}
]
}"#;
let meta: M2tsMeta = serde_json::from_str(json).unwrap();
let back = meta.to_title();
match &back.streams[0] {
Stream::Video(v) => {
assert_eq!(v.hdr, HdrFormat::Hdr10);
assert_eq!(
v.color_space,
ColorSpace::Bt2020,
"HDR10 must derive BT.2020 when color_space absent"
);
}
_ => panic!("expected video stream"),
}
}
#[test]
fn legacy_sdr_without_color_space_derives_bt709() {
let json = r#"{
"v": 1,
"title": "x",
"duration": 0.0,
"streams": [
{"type":"video","pid":4113,"codec":"h264","resolution":"1080p",
"frame_rate":"24","hdr":"sdr","label":"","secondary":false}
]
}"#;
let meta: M2tsMeta = serde_json::from_str(json).unwrap();
let back = meta.to_title();
match &back.streams[0] {
Stream::Video(v) => assert_eq!(v.color_space, ColorSpace::Bt709),
_ => panic!("expected video stream"),
}
}
}
+256 -10
View File
@@ -34,6 +34,24 @@ pub struct MkvTrack {
pub sample_rate: f64,
pub channels: u8,
pub bit_depth: u8,
// Dolby Vision: the dvcC (DOVIDecoderConfigurationRecord) for the DV layer,
// emitted as a BlockAdditionMapping. `None` for non-DV tracks.
pub dv_config: Option<Vec<u8>>,
}
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
/// BlockAdditionMapping. For disc Profile 7 dual-layer the base, enhancement,
/// and RPU are all present (lossless FEL/MEL preserved as a second track).
pub fn dolby_vision_config(profile: u8, level: u8, bl_compat_id: u8) -> Vec<u8> {
let mut v = vec![0u8; 24];
v[0] = 1; // dv_version_major
v[1] = 0; // dv_version_minor
// profile(7) | level(6) | rpu_present(1) | el_present(1) | bl_present(1)
v[2] = ((profile & 0x7F) << 1) | ((level >> 5) & 0x01);
v[3] = ((level & 0x1F) << 3) | (1 << 2) | (1 << 1) | 1; // rpu = el = bl = 1
v[4] = (bl_compat_id & 0x0F) << 4;
// v[5..24] reserved = 0
v
}
impl MkvTrack {
@@ -83,23 +101,33 @@ impl MkvTrack {
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
// The DV layer (hdr=DolbyVision) carries the dvcC so the track is
// recognised as Dolby Vision (disc Profile 7 dual-layer).
dv_config: if matches!(v.hdr, HdrFormat::DolbyVision) {
Some(dolby_vision_config(7, 6, 0))
} else {
None
},
}
}
pub fn audio(a: &AudioStream) -> Self {
// Codec ID strings must distinguish the DTS family — strict
// players (Plex transcoder, some hardware decoders, some AV
// receivers) reject lossless DTS-HD MA payload when the
// track advertises plain `A_DTS` because it implies the
// bitstream is the 1.5 Mbps "core" only. Fix is just to
// emit the right ID per BD-STN codec field.
// The Matroska codec-ID registry defines `A_DTS` for the entire
// DTS family — the spec text for `A_DTS` explicitly states it
// "Supports DTS, DTS-ES, DTS-96/26, DTS-HD High Resolution Audio
// and DTS-HD Master Audio." Players distinguish core vs HD-HRA vs
// HD-MA by parsing the DTS bitstream extension substreams, not by
// the container codec ID. The previously-emitted `A_DTS/MA` and
// `A_DTS/HR` suffixes are NOT registered codec IDs; strict parsers
// (libmatroska) and some hardware renderers fail to recognise the
// track at all. Emit plain `A_DTS` for every DTS variant — the
// lossless MA / HRA payload bytes are unchanged, only the
// container codec-ID string differs.
let codec_id = match a.codec {
Codec::Ac3 => "A_AC3",
Codec::Ac3Plus => "A_EAC3",
Codec::TrueHd => "A_TRUEHD",
Codec::DtsHdMa => "A_DTS/MA",
Codec::DtsHdHr => "A_DTS/HR",
Codec::Dts => "A_DTS",
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => "A_DTS",
Codec::Lpcm => "A_PCM/INT/BIG",
_ => "A_AC3",
};
@@ -128,6 +156,7 @@ impl MkvTrack {
sample_rate: sr,
channels: ch,
bit_depth: 0,
dv_config: None,
}
}
@@ -156,6 +185,7 @@ impl MkvTrack {
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
dv_config: None,
}
}
}
@@ -182,6 +212,11 @@ pub struct MkvMuxer<W: Write + Seek> {
cluster_size_pos: u64,
cluster_ts_ms: i64,
base_pts_ms: Option<i64>,
/// Last block timecode (ms, relative to base_pts) written PER TRACK, to
/// enforce strictly-monotonic per-track timestamps — players/ffmpeg reject
/// non-monotonic DTS, and some audio PES PTS land on the same millisecond
/// (or tick back 1ms from rounding).
last_pts_ms: std::collections::HashMap<usize, i64>,
cues: Vec<CuePoint>,
frame_count: u64,
seek_fixups: Vec<SeekPositionFixup>,
@@ -193,6 +228,25 @@ pub struct MkvMuxer<W: Write + Seek> {
/// New cluster every 5 seconds.
const CLUSTER_DURATION_MS: i64 = 5000;
/// Maximum block-relative timestamp expressible in the signed 16-bit
/// SimpleBlock/Block field (`i16::MAX` ms). A frame further than this from
/// the open cluster's timestamp forces a new cluster (see `write_frame`) so
/// the `as i16` cast can never wrap.
const MAX_BLOCK_REL_MS: i64 = i16::MAX as i64;
/// Force a per-track block timestamp to be strictly later than the previous one
/// written for that track. `prev` is the last timestamp for the track (`None`
/// for the first frame). Fixes non-monotonic DTS: some audio PES PTS truncate to
/// the same millisecond as the prior frame (or tick back 1ms from rounding),
/// which ffmpeg/strict players reject. The nudge is at most a few ms — sub-frame
/// and inaudible — and never moves a timestamp earlier.
fn monotonic_ts(prev: Option<i64>, pts_ms: i64) -> i64 {
match prev {
Some(p) => pts_ms.max(p.saturating_add(1)),
None => pts_ms,
}
}
impl<W: Write + Seek> MkvMuxer<W> {
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks, Chapters.
pub fn new(
@@ -333,6 +387,17 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut writer, vid_pos)?;
}
// Dolby Vision signaling — BlockAdditionMapping is a child of the
// TrackEntry (sibling of Video). Carries the dvcC so players /
// mediainfo recognise the track as Dolby Vision.
if let Some(ref dvcc) = track.dv_config {
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
// BlockAddIDType = "dvcC" fourcc (DOVIDecoderConfigurationRecord).
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, 0x6476_6343)?;
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, dvcc)?;
ebml::end_master(&mut writer, map_pos)?;
}
// Audio-specific
if track.track_type == ebml::TRACK_TYPE_AUDIO && track.sample_rate > 0.0 {
let aud_pos = ebml::start_master(&mut writer, ebml::AUDIO)?;
@@ -378,6 +443,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
cluster_size_pos: 0,
cluster_ts_ms: 0,
base_pts_ms: None,
last_pts_ms: std::collections::HashMap::new(),
cues: Vec::new(),
frame_count: 0,
seek_fixups,
@@ -406,7 +472,14 @@ impl<W: Write + Seek> MkvMuxer<W> {
let base = *self.base_pts_ms.get_or_insert(raw_ms);
let pts_ms = raw_ms - base;
// Cluster boundaries must coincide with a video keyframe so every
// Enforce strictly-monotonic per-track block timestamps. Some audio PES
// PTS truncate to the same millisecond as the previous frame (or, rarely,
// tick back 1ms), which surfaces as "non-monotonic DTS" and is rejected
// by ffmpeg/strict players. Nudge to prev+1ms — sub-frame, inaudible,
// and A/V sync is unaffected at millisecond granularity.
let pts_ms = monotonic_ts(self.last_pts_ms.get(&track_idx).copied(), pts_ms);
// Cluster boundaries normally coincide with a video keyframe so every
// Cues entry resolves to a seekable IDR at the cluster start.
let is_video_key = keyframe && track_idx == 0;
let needs_new_cluster = !self.cluster_open
@@ -422,8 +495,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
track: track_idx + 1,
cluster_pos: self.cluster_pos - self.segment_start,
});
} else if (pts_ms - self.cluster_ts_ms) > MAX_BLOCK_REL_MS {
// The block-relative timestamp is a signed 16-bit value, so a
// frame more than i16::MAX ms (~32.767 s) past the current
// cluster's timestamp would silently wrap on the `as i16` cast,
// corrupting A/V sync. The keyframe-driven boundary above only
// fires on a video keyframe — a long audio-only stretch, or a
// very long GOP with no intervening keyframe, can drift past the
// i16 range. Force a fresh cluster here even without a keyframe
// to keep the cast in range. This cluster is not keyframe-aligned
// so it gets no Cues entry (Cues stay IDR-only for seekability).
self.start_cluster(pts_ms)?;
}
// Committed to writing this frame — record its (monotonic) timestamp so
// the next block on this track is forced strictly later.
self.last_pts_ms.insert(track_idx, pts_ms);
let relative_ts = (pts_ms - self.cluster_ts_ms) as i16;
match duration_ns {
Some(dur_ns) => {
@@ -612,6 +700,7 @@ mod tests {
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
dv_config: None,
}
}
@@ -636,9 +725,60 @@ mod tests {
sample_rate: 48000.0,
channels: 6,
bit_depth: 0,
dv_config: None,
}
}
fn audio_stream(codec: Codec) -> AudioStream {
use crate::disc::{AudioChannels, LabelPurpose, SampleRate};
AudioStream {
pid: 0x1100,
codec,
channels: AudioChannels::Surround51,
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
}
}
#[test]
fn dts_variants_map_to_registered_a_dts_codec_id() {
// The Matroska codec-ID registry defines `A_DTS` for the whole DTS
// family (core, DTS-HD HRA, DTS-HD MA). The `/MA` and `/HR` suffixes
// are not registered and break strict parsers, so every DTS variant
// must emit plain `A_DTS`.
for codec in [Codec::Dts, Codec::DtsHdMa, Codec::DtsHdHr] {
let track = MkvTrack::audio(&audio_stream(codec));
assert_eq!(
track.codec_id, "A_DTS",
"{codec:?} must map to registered codec ID A_DTS, got {}",
track.codec_id
);
}
// Sanity: the non-DTS variants keep their distinct IDs.
assert_eq!(MkvTrack::audio(&audio_stream(Codec::Ac3)).codec_id, "A_AC3");
assert_eq!(
MkvTrack::audio(&audio_stream(Codec::TrueHd)).codec_id,
"A_TRUEHD"
);
}
#[test]
fn dolby_vision_config_profile7() {
// dvcC for disc Profile 7 dual-layer: version 1.0, profile 7, all of
// bl/el/rpu present. 24 bytes.
let c = dolby_vision_config(7, 6, 0);
assert_eq!(c.len(), 24);
assert_eq!(c[0], 1); // dv_version_major
assert_eq!(c[1], 0); // dv_version_minor
// profile in the top 7 bits of byte 2
assert_eq!(c[2] >> 1, 7, "dv_profile must be 7");
// rpu/el/bl present flags in byte 3 (low 3 bits after level)
assert_eq!(c[3] & 0b0000_0111, 0b0000_0111, "rpu+el+bl all present");
}
#[test]
fn mkv_writes_ebml_header() {
let buf = Cursor::new(Vec::new());
@@ -719,6 +859,33 @@ mod tests {
);
}
#[test]
fn monotonic_ts_forces_strictly_increasing() {
// First frame passes through unchanged.
assert_eq!(monotonic_ts(None, 1000), 1000);
// A repeated millisecond is nudged to prev+1.
assert_eq!(monotonic_ts(Some(1000), 1000), 1001);
// A backwards tick is nudged forward, never earlier.
assert_eq!(monotonic_ts(Some(1001), 1000), 1002);
// A genuine advance is left alone.
assert_eq!(monotonic_ts(Some(1000), 1040), 1040);
// Simulate a stream of audio PTS that round to dup/back-tick ms and
// confirm the emitted sequence is strictly increasing.
let raw = [1000i64, 1000, 1000, 999, 1032, 1032, 1064];
let mut prev: Option<i64> = None;
let mut out = Vec::new();
for &p in &raw {
let t = monotonic_ts(prev, p);
out.push(t);
prev = Some(t);
}
assert!(
out.windows(2).all(|w| w[1] > w[0]),
"not strictly monotonic: {out:?}"
);
assert_eq!(out, [1000, 1001, 1002, 1003, 1032, 1033, 1064]);
}
#[test]
fn mkv_multiple_tracks() {
let buf = Cursor::new(Vec::new());
@@ -1323,6 +1490,85 @@ mod tests {
);
}
/// Collect every (cluster_ts_ms, block_relative_ts_i16, absolute_ms) for
/// all SimpleBlocks across all clusters, so a test can assert that the
/// reconstructed absolute timestamp (cluster_ts + relative_ts) is correct
/// and that no relative_ts ever wrapped the i16 range.
fn all_block_timestamps(data: &[u8]) -> Vec<(i64, i16, i64)> {
let mut out = Vec::new();
for (body_start, body_size, cluster_ts) in find_clusters(data) {
let body = &data[body_start..body_start + body_size as usize];
let mut cursor = Cursor::new(body);
// Skip CLUSTER_TIMESTAMP.
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap();
while (cursor.position() as usize) < body.len() {
let (id, sz, _) = ebml::read_element_header(&mut cursor).unwrap();
if id == ebml::SIMPLE_BLOCK {
let bstart = cursor.position() as usize;
let b0 = body[bstart];
let vint_len = if b0 & 0x80 != 0 { 1 } else { 2 };
let ts_pos = bstart + vint_len;
let rel = i16::from_be_bytes([body[ts_pos], body[ts_pos + 1]]);
out.push((cluster_ts as i64, rel, cluster_ts as i64 + rel as i64));
}
cursor.seek(io::SeekFrom::Current(sz as i64)).unwrap();
}
}
out
}
#[test]
fn long_audio_gap_forces_cluster_no_i16_overflow() {
// Regression for the `(pts_ms - cluster_ts_ms) as i16` truncation:
// a single video keyframe at t=0 opens one cluster, then a long
// audio-only stretch (no further video keyframe) drifts well past
// i16::MAX ms (~32.767 s). Without the overflow guard the audio
// blocks past 32.767 s would write a wrapped (negative) relative
// timestamp into the SimpleBlock. With the guard a fresh cluster is
// forced so every relative_ts stays in range and reconstructs to the
// true absolute timestamp.
let tracks = [make_video_track(), make_audio_track()];
let mut frames: Vec<(usize, i64, bool, Vec<u8>)> = Vec::new();
// One video keyframe at t=0 (opens the first cluster).
frames.push((0, 0, true, vec![0xAB; 16]));
// Audio frames every 100 ms out to 60 s — past the 32.767 s i16 limit
// and past two i16 spans, with NO further video keyframe.
let mut t_ms = 0i64;
while t_ms <= 60_000 {
frames.push((1, t_ms * 1_000_000, true, vec![0xCD; 16]));
t_ms += 100;
}
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
let blocks = all_block_timestamps(&data);
assert!(!blocks.is_empty());
// Every block's relative timestamp must be within i16 range (it is by
// type), AND must reconstruct to a non-negative, monotonic-ish
// absolute timestamp matching the source — i.e. no silent wrap.
for (cluster_ts, rel, abs) in &blocks {
assert!(
*rel as i64 >= 0 && (*rel as i64) <= MAX_BLOCK_REL_MS,
"block relative_ts {rel} out of [0, i16::MAX] range \
(cluster_ts={cluster_ts}, abs={abs}) — i16 overflow"
);
}
// The latest audio frame is at 60_000 ms; its reconstructed absolute
// timestamp must equal that, proving no truncation occurred.
let max_abs = blocks.iter().map(|(_, _, abs)| *abs).max().unwrap();
assert_eq!(max_abs, 60_000, "last block must reconstruct to 60_000 ms");
// The overflow guard must have opened more than one cluster (the
// single keyframe alone would otherwise yield exactly one).
let clusters = find_clusters(&data);
assert!(
clusters.len() >= 2,
"expected the i16 guard to force extra clusters, got {}",
clusters.len()
);
}
#[test]
fn pre_first_keyframe_frames_dropped() {
let tracks = [make_video_track()];
+203 -16
View File
@@ -14,6 +14,49 @@ fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> {
Ok(())
}
// ── Sanity caps for untrusted EBML element sizes ──────────────
//
// Sizes come straight from the EBML stream (file or network) and are
// otherwise cast to `usize` and used to allocate/read. An adversarial
// or corrupt container can claim a multi-GB element and trigger an OOM
// allocation, or claim an integer element wider than 8 bytes and panic
// the fixed 8-byte reader. Every untrusted size is validated against
// one of these caps before allocation.
/// Largest accepted SIMPLE_BLOCK payload. A block is a small vint track
/// header + 2-byte rel-ts + 1-byte flags + one frame of elementary data.
/// UHD HEVC keyframes run a few MB; 64 MiB is generously above any real
/// single-frame block while still bounding a hostile allocation.
const MAX_BLOCK_SIZE: u64 = 64 * 1024 * 1024;
/// Largest accepted CODEC_PRIVATE payload. hvcC/avcC/setup blobs are a
/// few KB in practice; 16 MiB is far above any legitimate value.
const MAX_CODEC_PRIVATE: u64 = 16 * 1024 * 1024;
/// Largest accepted string element (TITLE/CODEC_ID/LANGUAGE/TRACK_NAME).
const MAX_STRING_LEN: u64 = 64 * 1024;
/// EBML unsigned-int elements are at most 8 bytes wide.
const MAX_UINT_LEN: u64 = 8;
/// Reject an untrusted element size that exceeds `cap` before it is used
/// to allocate or read. Returns the size as `usize` when within bounds.
fn checked_size(size: u64, cap: u64) -> io::Result<usize> {
if size > cap {
return Err(crate::error::Error::MkvInvalid.into());
}
Ok(size as usize)
}
/// Read a bounded unsigned int. Guards against `size > 8` (which would
/// otherwise index out of the fixed 8-byte buffer in `read_uint_val`)
/// before delegating.
fn read_uint_bounded(r: &mut impl Read, size: u64) -> io::Result<u64> {
ebml::read_uint_val(r, checked_size(size, MAX_UINT_LEN)?)
}
/// Read a bounded UTF-8 string element.
fn read_string_bounded(r: &mut impl Read, size: u64) -> io::Result<String> {
ebml::read_string_val(r, checked_size(size, MAX_STRING_LEN)?)
}
use crate::disc::*;
use std::io::{self, Read};
@@ -108,11 +151,12 @@ impl crate::pes::Stream for MkvStream {
match id {
ebml::CLUSTER => continue,
ebml::CLUSTER_TIMESTAMP => {
rs.cluster_ts_ms = ebml::read_uint_val(&mut rs.reader, size as usize)? as i64;
rs.cluster_ts_ms = read_uint_bounded(&mut rs.reader, size)? as i64;
continue;
}
ebml::SIMPLE_BLOCK => {
let block = ebml::read_binary_val(&mut rs.reader, size as usize)?;
let block =
ebml::read_binary_val(&mut rs.reader, checked_size(size, MAX_BLOCK_SIZE)?)?;
if block.len() < 4 {
continue;
}
@@ -198,7 +242,10 @@ impl crate::pes::Stream for MkvStream {
/// Returns (DiscTitle, codec_privates: Vec<(track_number, codec_private_bytes)>)
fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
let mut title = String::new();
let mut duration_ms = 0.0f64;
// EBML `DURATION` is a float expressed in TimestampScale ticks, not
// milliseconds (Matroska spec). Named accordingly; converted to
// seconds below as ticks * ts_scale_ns / 1e9.
let mut duration_ticks = 0.0f64;
let mut ts_scale: u64 = 1_000_000;
let mut streams: Vec<crate::disc::Stream> = Vec::new();
let mut codec_privates: Vec<(u16, Vec<u8>)> = Vec::new();
@@ -235,9 +282,9 @@ fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
let (cid, cs, hlen) = ebml::read_element_header(r)?;
remaining = remaining.saturating_sub(hlen as u64 + cs);
match cid {
ebml::TIMESTAMP_SCALE => ts_scale = ebml::read_uint_val(r, cs as usize)?,
ebml::DURATION => duration_ms = ebml::read_float_val(r, cs as usize)?,
ebml::TITLE => title = ebml::read_string_val(r, cs as usize)?,
ebml::TIMESTAMP_SCALE => ts_scale = read_uint_bounded(r, cs)?,
ebml::DURATION => duration_ticks = ebml::read_float_val(r, cs as usize)?,
ebml::TITLE => title = read_string_bounded(r, cs)?,
_ => {
skip_bytes(r, cs)?;
}
@@ -274,7 +321,7 @@ fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
let disc_title = DiscTitle {
playlist: title,
duration_secs: duration_ms * (ts_scale as f64) / 1_000_000_000.0,
duration_secs: duration_ticks * (ts_scale as f64) / 1_000_000_000.0,
streams,
..DiscTitle::empty()
};
@@ -296,20 +343,25 @@ fn parse_track(
let (cid, cs, hlen) = ebml::read_element_header(r)?;
remaining = remaining.saturating_sub(hlen as u64 + cs);
match cid {
ebml::TRACK_NUMBER => tnum = ebml::read_uint_val(r, cs as usize)? as u16,
ebml::TRACK_TYPE => ttype = ebml::read_uint_val(r, cs as usize)?,
ebml::CODEC_ID => codec_id = ebml::read_string_val(r, cs as usize)?,
ebml::CODEC_PRIVATE => codec_priv = Some(ebml::read_binary_val(r, cs as usize)?),
ebml::LANGUAGE => lang = ebml::read_string_val(r, cs as usize)?,
ebml::TRACK_NAME => name = ebml::read_string_val(r, cs as usize)?,
ebml::FLAG_FORCED => forced = ebml::read_uint_val(r, cs as usize)? != 0,
ebml::TRACK_NUMBER => tnum = read_uint_bounded(r, cs)? as u16,
ebml::TRACK_TYPE => ttype = read_uint_bounded(r, cs)?,
ebml::CODEC_ID => codec_id = read_string_bounded(r, cs)?,
ebml::CODEC_PRIVATE => {
codec_priv = Some(ebml::read_binary_val(
r,
checked_size(cs, MAX_CODEC_PRIVATE)?,
)?)
}
ebml::LANGUAGE => lang = read_string_bounded(r, cs)?,
ebml::TRACK_NAME => name = read_string_bounded(r, cs)?,
ebml::FLAG_FORCED => forced = read_uint_bounded(r, cs)? != 0,
ebml::VIDEO => {
let mut vrem = cs;
while vrem > 0 {
let (vid, vs, vhlen) = ebml::read_element_header(r)?;
vrem = vrem.saturating_sub(vhlen as u64 + vs);
if vid == ebml::PIXEL_HEIGHT {
ph = ebml::read_uint_val(r, vs as usize)? as u32;
ph = read_uint_bounded(r, vs)? as u32;
} else {
skip_bytes(r, vs)?;
}
@@ -322,7 +374,7 @@ 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 = ebml::read_uint_val(r, as_ as usize)? as u8,
ebml::CHANNELS => ch = read_uint_bounded(r, as_)? as u8,
_ => {
skip_bytes(r, as_)?;
}
@@ -428,3 +480,138 @@ fn block_vint(d: &[u8]) -> (u64, usize) {
}
(0, 1) // Unsupported 5+ byte VINT — treat as track 0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pes::Stream as _;
use std::io::Cursor;
// `From<Error> for io::Error` encodes the numeric code into the
// Display string as "E{code}: ...". Check the prefix.
fn is_mkv_invalid(e: &io::Error) -> bool {
e.kind() == io::ErrorKind::InvalidData
&& e.to_string()
.starts_with(&format!("E{}", crate::error::E_MKV_INVALID))
}
#[test]
fn checked_size_rejects_over_cap() {
// Within cap → Ok with usize value.
assert_eq!(checked_size(100, 256).unwrap(), 100);
assert_eq!(checked_size(256, 256).unwrap(), 256);
// Over cap → MkvInvalid, never a giant allocation.
let e = checked_size(257, 256).unwrap_err();
assert!(is_mkv_invalid(&e));
// A hostile multi-GB block size is rejected as MkvInvalid.
let e = checked_size(4 * 1024 * 1024 * 1024, MAX_BLOCK_SIZE).unwrap_err();
assert!(is_mkv_invalid(&e));
}
#[test]
fn read_uint_bounded_rejects_oversized_int() {
// size > 8 would index out of the fixed 8-byte buffer in
// read_uint_val (panic / OOB). The guard turns it into a clean
// MkvInvalid error instead.
let mut data = Cursor::new(vec![0u8; 16]);
let e = read_uint_bounded(&mut data, 9).unwrap_err();
assert!(is_mkv_invalid(&e));
}
#[test]
fn read_uint_bounded_accepts_valid_width() {
// 8 bytes is the max legal EBML uint width and must still work.
let mut data = Cursor::new(vec![0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02]);
assert_eq!(read_uint_bounded(&mut data, 8).unwrap(), 0x0102);
}
#[test]
fn read_string_bounded_rejects_huge_string() {
// Claimed string length far above the cap must not allocate.
let mut data = Cursor::new(vec![0u8; 16]);
let e = read_string_bounded(&mut data, MAX_STRING_LEN + 1).unwrap_err();
assert!(is_mkv_invalid(&e));
}
/// Build a minimal MKV (EBML header + Segment + Info + Tracks) so the
/// reader is positioned in the cluster body, then append the given
/// cluster bytes. Returns the full byte stream.
fn minimal_mkv_with_cluster(cluster_body: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
// EBML header (empty body).
ebml::write_id(&mut out, ebml::EBML).unwrap();
ebml::write_size(&mut out, 0).unwrap();
// Segment (unknown size so the reader streams children).
ebml::write_id(&mut out, ebml::SEGMENT).unwrap();
ebml::write_unknown_size(&mut out).unwrap();
// Empty Info.
ebml::write_id(&mut out, ebml::INFO).unwrap();
ebml::write_size(&mut out, 0).unwrap();
// Empty Tracks.
ebml::write_id(&mut out, ebml::TRACKS).unwrap();
ebml::write_size(&mut out, 0).unwrap();
out.extend_from_slice(cluster_body);
out
}
#[test]
fn simple_block_oversized_size_is_rejected() {
// Cluster containing a SIMPLE_BLOCK that claims a 2 GiB payload.
// The reader must reject it (MkvInvalid) rather than attempt a
// multi-GB allocation. Header parse stops at CLUSTER, so the
// SIMPLE_BLOCK is hit on the first read().
let mut cluster = Vec::new();
ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap();
ebml::write_unknown_size(&mut cluster).unwrap();
ebml::write_id(&mut cluster, ebml::SIMPLE_BLOCK).unwrap();
ebml::write_size(&mut cluster, 2 * 1024 * 1024 * 1024).unwrap();
// No payload follows — but we must fail on the size check, before
// any read of the body.
let bytes = minimal_mkv_with_cluster(&cluster);
let mut stream = MkvStream::open(Cursor::new(bytes)).unwrap();
let e = stream.read().unwrap_err();
assert!(is_mkv_invalid(&e));
}
#[test]
fn well_formed_simple_block_round_trips() {
// A small, well-formed SIMPLE_BLOCK must still parse into a frame.
// We need at least one stream so the track index is in range, so
// give Tracks one video TRACK_ENTRY (track number 1).
let mut out = Vec::new();
ebml::write_id(&mut out, ebml::EBML).unwrap();
ebml::write_size(&mut out, 0).unwrap();
ebml::write_id(&mut out, ebml::SEGMENT).unwrap();
ebml::write_unknown_size(&mut out).unwrap();
ebml::write_id(&mut out, ebml::INFO).unwrap();
ebml::write_size(&mut out, 0).unwrap();
// Tracks → one TRACK_ENTRY (track number 1, type 1 = video).
let mut entry = Vec::new();
ebml::write_uint(&mut entry, ebml::TRACK_NUMBER, 1).unwrap();
ebml::write_uint(&mut entry, ebml::TRACK_TYPE, 1).unwrap();
let mut track_entry = Vec::new();
ebml::write_id(&mut track_entry, ebml::TRACK_ENTRY).unwrap();
ebml::write_size(&mut track_entry, entry.len() as u64).unwrap();
track_entry.extend_from_slice(&entry);
ebml::write_id(&mut out, ebml::TRACKS).unwrap();
ebml::write_size(&mut out, track_entry.len() as u64).unwrap();
out.extend_from_slice(&track_entry);
// Cluster with a SIMPLE_BLOCK: track vint=0x81 (track 1),
// rel_ts=0x0000, flags=0x80 (keyframe), then 4 bytes of data.
ebml::write_id(&mut out, ebml::CLUSTER).unwrap();
ebml::write_unknown_size(&mut out).unwrap();
let block = [0x81u8, 0x00, 0x00, 0x80, 0xAA, 0xBB, 0xCC, 0xDD];
ebml::write_id(&mut out, ebml::SIMPLE_BLOCK).unwrap();
ebml::write_size(&mut out, block.len() as u64).unwrap();
out.extend_from_slice(&block);
let mut stream = MkvStream::open(Cursor::new(out)).unwrap();
let frame = stream.read().unwrap().expect("expected a frame");
assert_eq!(frame.track, 0);
assert!(frame.keyframe);
assert_eq!(frame.data, vec![0xAA, 0xBB, 0xCC, 0xDD]);
}
}
+36 -17
View File
@@ -124,24 +124,31 @@ impl PipelinedPesStream {
fn consume_ps(&mut self, packets: Vec<super::ps::PsPacket>) {
for ps in packets {
let track = match ps.stream_id {
0xE0..=0xEF => 0,
0xC0..=0xDF => 1,
0xBD => ps
.sub_stream_id
.map(|s| (s & 0x1F) as usize + 1)
.unwrap_or(1),
_ => continue,
};
if track >= self.title.streams.len() {
// Route by the REAL DVD PID (matching the PIDs that
// `scan_dvd_titles` assigns) rather than a synthetic track
// index. The old `(sub_id & 0x1F) + 1` heuristic collided
// subtitle sub-id 0x20+j with audio track j+1, feeding
// VobSub PES into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else {
tracing::warn!(
target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id,
ps.sub_stream_id,
);
continue;
}
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(p, _)| *p)
.unwrap_or(0);
};
let Some((_, track)) = self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
else {
tracing::warn!(
target: "mux",
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
pid,
ps.stream_id,
ps.sub_stream_id,
);
continue;
};
let pes = PesPacket {
pid,
pts: ps.pts.map(|p| p as i64),
@@ -176,6 +183,18 @@ impl Stream for PipelinedPesStream {
}
false => {
self.eof = true;
// Drain any access unit a parser buffered past the last
// PES (e.g. DTS-HD's final core+extension unit).
let pid_to_track = &self.pid_to_track;
let pending = &mut self.pending_frames;
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() {
pending.push_back(PesFrame::from_codec_frame(track, frame));
}
}
return Ok(self.pending_frames.pop_front());
}
}
+95
View File
@@ -39,6 +39,39 @@ pub struct PsPacket {
pub data: Vec<u8>,
}
impl PsPacket {
/// Map this packet to the canonical DVD PID assigned by
/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can
/// be looked up in the title's `pid_to_track` map.
///
/// The PID space mirrors `dvd.rs` exactly:
/// - video stream id `0xE0..=0xEF` → `0xE0`
/// - private-stream-1 audio sub-id `0x80..=0x87` (AC-3),
/// `0x88..=0x8F` (DTS), `0xA0..=0xA7` (LPCM) → `0xBD00 + index`
/// - private-stream-1 subtitle sub-id `0x20..=0x3F` → `0x20 + index`
///
/// Returns `None` for stream/sub-stream combinations the DVD title
/// scanner does not assign a PID to (e.g. MPEG audio 0xC0-0xDF,
/// private stream 2, unrecognized sub-stream ranges). The caller is
/// expected to WARN-and-drop in that case rather than silently
/// mis-routing the packet.
pub fn dvd_pid(&self) -> Option<u16> {
match self.stream_id {
0xE0..=0xEF => Some(0xE0),
0xBD => match self.sub_stream_id? {
// AC-3 / DTS / LPCM audio → 0xBD00 + audio index.
s @ 0x80..=0x87 => Some(0xBD00 + (s - 0x80) as u16),
s @ 0x88..=0x8F => Some(0xBD00 + (s - 0x88) as u16),
s @ 0xA0..=0xA7 => Some(0xBD00 + (s - 0xA0) as u16),
// VobSub subtitle sub-id 0x20+j → PID 0x20+j (identity).
s @ 0x20..=0x3F => Some(s as u16),
_ => None,
},
_ => None,
}
}
}
/// MPEG-2 Program Stream demuxer.
///
/// Accepts raw PS bytes via `feed()` and produces demuxed PES packets.
@@ -549,6 +582,68 @@ mod tests {
assert_eq!(decoded, val);
}
// --- DVD PID mapping (track-routing collision regression) ---
fn mk(stream_id: u8, sub: Option<u8>) -> PsPacket {
PsPacket {
stream_id,
sub_stream_id: sub,
pts: None,
dts: None,
data: vec![0xAA],
}
}
#[test]
fn dvd_pid_matches_scanner_assignment() {
// Video → 0xE0 (matches dvd.rs VideoStream pid).
assert_eq!(mk(0xE0, None).dvd_pid(), Some(0xE0));
// AC-3 audio stream 0/1 → 0xBD00 / 0xBD01 (matches 0xBD00 + i).
assert_eq!(mk(0xBD, Some(0x80)).dvd_pid(), Some(0xBD00));
assert_eq!(mk(0xBD, Some(0x81)).dvd_pid(), Some(0xBD01));
// DTS / LPCM audio indices.
assert_eq!(mk(0xBD, Some(0x88)).dvd_pid(), Some(0xBD00));
assert_eq!(mk(0xBD, Some(0xA0)).dvd_pid(), Some(0xBD00));
// VobSub subtitle 0x20/0x21 → 0x20 / 0x21 (matches 0x20 + j).
assert_eq!(mk(0xBD, Some(0x20)).dvd_pid(), Some(0x20));
assert_eq!(mk(0xBD, Some(0x21)).dvd_pid(), Some(0x21));
// Unmappable: MPEG audio, private stream 2, bogus sub-id.
assert_eq!(mk(0xC0, None).dvd_pid(), None);
assert_eq!(mk(0xBF, None).dvd_pid(), None);
assert_eq!(mk(0xBD, Some(0x10)).dvd_pid(), None);
}
#[test]
fn subtitle_does_not_collide_with_audio_track() {
// Regression for the (sub_id & 0x1F)+1 bug: subtitle sub-id 0x20
// used to alias audio track 1. With the real PID it routes to its
// own subtitle PID (0x20), distinct from audio (0xBD00+).
let audio0 = mk(0xBD, Some(0x80)).dvd_pid().unwrap(); // 0xBD00
let sub0 = mk(0xBD, Some(0x20)).dvd_pid().unwrap(); // 0x20
assert_ne!(
audio0, sub0,
"subtitle sub-id 0x20 must NOT map to the audio PID"
);
// Mirror dvd.rs PID assignment for a title with [video, audio0,
// audio1, sub0, sub1] and confirm each PS packet lands on its
// own track via pid_to_track.
let pid_to_track: Vec<(u16, usize)> =
vec![(0xE0, 0), (0xBD00, 1), (0xBD01, 2), (0x20, 3), (0x21, 4)];
let route = |p: PsPacket| -> Option<usize> {
let pid = p.dvd_pid()?;
pid_to_track
.iter()
.find(|(x, _)| *x == pid)
.map(|(_, t)| *t)
};
assert_eq!(route(mk(0xE0, None)), Some(0));
assert_eq!(route(mk(0xBD, Some(0x80))), Some(1));
assert_eq!(route(mk(0xBD, Some(0x81))), Some(2));
assert_eq!(route(mk(0xBD, Some(0x20))), Some(3)); // sub0 → track 3, NOT 1
assert_eq!(route(mk(0xBD, Some(0x21))), Some(4)); // sub1 → track 4, NOT 2
}
// --- Helper: encode PTS for tests ---
fn encode_pts(pts: u64, marker_prefix: u8) -> [u8; 5] {
+110 -13
View File
@@ -162,15 +162,32 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
/// Options for opening an input stream.
#[derive(Default)]
pub struct InputOptions {
pub keydb_path: Option<String>,
/// Caller-supplied Unit Key (external Unit Key) — the second, mutually
/// exclusive key source. Takes precedence over `keydb_path`.
pub unit_key: Option<[u8; 16]>,
/// Caller-resolved per-CPS-unit AACS keys to apply to the scanned disc
/// (`(cps_unit, 16-byte key)`). Empty for an unencrypted disc or when the
/// caller has no key. The library does no lookup — a key source resolves
/// these and the caller passes them here.
pub unit_keys: Vec<(u32, [u8; 16])>,
pub title_index: Option<usize>,
/// Skip decryption — return raw encrypted bytes.
pub raw: bool,
}
/// Decide whether an ISO mux must abort for lack of a usable AACS key.
///
/// Returns `true` only when ALL hold: decryption is requested (`!raw`), the
/// disc carries AACS state (`has_aacs` — AACS-encrypted, not CSS/unencrypted),
/// and key resolution produced no usable key (`keys` is
/// [`crate::decrypt::DecryptKeys::None`]). In that case muxing would emit
/// undecryptable garbage, so the caller fails fast with [`Error::NoDiscKey`].
///
/// `--raw` (raw=true) always returns `false` — raw intentionally skips
/// decryption and needs no key. A non-AACS disc (`has_aacs=false`) always
/// returns `false`: unencrypted content has `None` keys legitimately, and CSS
/// DVDs resolve to `DecryptKeys::Css{..}` (never `None`).
fn aacs_key_missing(raw: bool, has_aacs: bool, keys: &crate::decrypt::DecryptKeys) -> bool {
!raw && has_aacs && matches!(keys, crate::decrypt::DecryptKeys::None)
}
/// Open a PES input stream (produces PES frames).
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
@@ -184,11 +201,6 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
}
StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?;
let scan_opts = crate::disc::ScanOptions {
keydb_path: opts.keydb_path.as_ref().map(Into::into),
unit_key: opts.unit_key,
..Default::default()
};
// FileSectorSource is the sole file-backed sector source.
// It carries the platform-tuned SEQUENTIAL fadvise hint
// (so the kernel readahead window widens) and the periodic
@@ -196,8 +208,38 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
// when the mux output is being written to the same disk.
let mut reader = crate::io::file_sector_source::FileSectorSource::open(path)?;
let capacity = reader.capacity_sectors();
let disc = crate::disc::Disc::scan_image(&mut reader, capacity, &scan_opts)
.map_err(|e| -> io::Error { e.into() })?;
let mut disc = crate::disc::Disc::scan_image(
&mut reader,
capacity,
&crate::disc::ScanOptions::default(),
)
.map_err(|e| -> io::Error { e.into() })?;
// Apply the caller-resolved keys (lookup-free); decrypt_keys() then
// yields them for the stream below. Propagate a failed application
// rather than silently muxing an undecryptable stream.
if !opts.unit_keys.is_empty() {
// These UKs were already resolved AND validated by the caller
// (the CLI's keydb loop), so no re-validation sample is needed.
disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()), &[])
.map_err(|e| -> io::Error { e.into() })?;
}
// No-key guard: if decryption is requested (not --raw) and the disc
// is AACS-encrypted but key resolution yielded no usable key, FAIL
// here — muxing an undecryptable stream produces ~100 MB of garbage
// (encrypted m2ts → no TS syncs → demuxer emits nothing). A cheap
// result-check on `decrypt_keys()`; no probe decryption needed.
// CSS (DVD) decrypts from compiled keys (`decrypt_keys()` returns
// `Css{..}`, never `None`), so this gate is AACS-only via `disc.aacs`.
if aacs_key_missing(opts.raw, disc.aacs.is_some(), &disc.decrypt_keys()) {
// Surface the disc hash (40-hex, no `0x` prefix) so the caller
// can name the disc. Empty if scan didn't capture it.
let disc_hash = disc
.aacs
.as_ref()
.map(|a| a.disc_hash.trim_start_matches("0x").to_string())
.unwrap_or_default();
return Err(crate::error::Error::NoDiscKey { disc_hash }.into());
}
if disc.titles.is_empty() {
return Err(crate::error::Error::NoStreams.into());
}
@@ -209,8 +251,15 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
}
.into());
}
let title = disc.titles[idx].clone();
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
// by probing the first DECRYPTED access units of the chosen title.
// A fresh reader avoids disturbing the mux reader below.
let keys = disc.decrypt_keys();
if let Ok(probe) = crate::io::file_sector_source::FileSectorSource::open(path) {
let mut dec = crate::sector::DecryptingSectorSource::new(probe, keys.clone());
crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]);
}
let title = disc.titles[idx].clone();
let format = disc.content_format;
// ISO file: 16 MiB batch — sequential read from fast
// storage, no bad sectors. Measured optimum on the rip1
@@ -335,7 +384,8 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
};
pids.push(pid);
pid_to_track.push((pid, idx));
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
let is_dvd_ps = matches!(format, ContentFormat::MpegPs);
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
}
let (ts, ps) = match format {
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),
@@ -443,3 +493,50 @@ fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>(
pid_to_track,
))
}
#[cfg(test)]
mod tests {
use super::aacs_key_missing;
use crate::decrypt::DecryptKeys;
fn aacs_keys() -> DecryptKeys {
DecryptKeys::Aacs {
unit_keys: vec![(1, [0x11u8; 16])],
read_data_key: None,
}
}
fn css_keys() -> DecryptKeys {
DecryptKeys::Css {
title_key: [0u8; 5],
}
}
#[test]
fn encrypted_no_key_aborts() {
// AACS disc, decryption requested, resolver yielded no key → abort.
assert!(aacs_key_missing(false, true, &DecryptKeys::None));
}
#[test]
fn encrypted_with_key_proceeds() {
// AACS disc with a usable key → proceed.
assert!(!aacs_key_missing(false, true, &aacs_keys()));
}
#[test]
fn not_encrypted_proceeds() {
// No AACS state: unencrypted (None keys) and CSS (Css keys) both OK.
assert!(!aacs_key_missing(false, false, &DecryptKeys::None));
assert!(!aacs_key_missing(false, false, &css_keys()));
}
#[test]
fn raw_never_aborts() {
// --raw skips decryption — must never hit the no-key abort, even on an
// AACS disc with no key resolved.
assert!(!aacs_key_missing(true, true, &DecryptKeys::None));
assert!(!aacs_key_missing(true, true, &aacs_keys()));
assert!(!aacs_key_missing(true, false, &DecryptKeys::None));
}
}
+205 -93
View File
@@ -35,6 +35,15 @@ struct PesAssembler {
pts: Option<i64>,
dts: Option<i64>,
active: bool,
/// PES-header bytes still to be skipped on the next continuation
/// packet(s). A PES header (9 + PES_header_data_length, up to 264
/// bytes) can exceed a single 184-byte TS payload, spilling into the
/// following continuation packet. Those spillover bytes are NOT
/// elementary-stream data and must be skipped, or the PES start code
/// (`00 00 01 …`) and timestamp bytes get injected into the ES — for
/// HEVC/H264 that reads as a spurious start code / corrupt slice
/// payload. Tracks how many header bytes remain across packets.
header_remaining: usize,
}
/// Initial capacity for a fresh PES buffer. Sized to cover the
@@ -54,6 +63,7 @@ impl PesAssembler {
pts: None,
dts: None,
active: false,
header_remaining: 0,
}
}
@@ -235,12 +245,35 @@ impl TsDemuxer {
let payload = &ts[payload_start..];
if pusi {
let (pts, dts, pes_data_start) = parse_pes_header(payload);
// `header_len` is the FULL (uncapped) PES-header length:
// 0 = malformed (payload is not a PES start), else 6/9+N.
let (pts, dts, header_len) = parse_pes_header(payload);
if let Some(prev) = asm.start(pts, dts) {
completed.push(prev);
}
if pes_data_start < payload.len() {
asm.push(&payload[pes_data_start..]);
if header_len == 0 {
// PUSI packet whose payload is not a valid PES start. Do
// NOT push it — those bytes are not elementary-stream data
// and would inject a spurious start code / garbage.
asm.header_remaining = 0;
} else if header_len <= payload.len() {
// Header fits in this packet (the common case).
asm.header_remaining = 0;
if header_len < payload.len() {
asm.push(&payload[header_len..]);
}
} else {
// Header spills past this packet — skip the remainder on
// the following continuation packet(s).
asm.header_remaining = header_len - payload.len();
}
} else if asm.header_remaining > 0 {
// Continuation packet still inside a PES header that spanned
// the boundary — consume header bytes before any ES data.
let skip = asm.header_remaining.min(payload.len());
asm.header_remaining -= skip;
if skip < payload.len() {
asm.push(&payload[skip..]);
}
} else {
asm.push(payload);
@@ -260,7 +293,14 @@ impl TsDemuxer {
}
/// Parse a PES packet header, extracting PTS and DTS.
/// Returns (pts, dts, offset_to_elementary_stream_data).
///
/// Returns `(pts, dts, header_len)` where `header_len` is the FULL,
/// UNCAPPED PES-header length in bytes (`9 + PES_header_data_length`, or
/// 6 for stream IDs without the standard extension). `0` signals the
/// payload is not a valid PES start (malformed / too short). The caller
/// must treat `header_len` as bytes-to-skip and carry any remainder past
/// this packet's payload into the next continuation packet — the header
/// can exceed one TS payload, and the spillover is header, not ES data.
fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
// PES packet: 00 00 01 [stream_id] [length:2] [flags...]
if data.len() < 9 || data[0] != 0x00 || data[1] != 0x00 || data[2] != 0x01 {
@@ -288,7 +328,10 @@ fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
let pts_dts_flags = (data[7] >> 6) & 0x03;
let header_data_len = data[8] as usize;
let data_start = (9 + header_data_len).min(data.len());
// Full, uncapped header length. PTS/DTS (if present) live in the
// first ~19 bytes, always within this packet's payload, so they parse
// here; only the *skip* length may extend into the next packet.
let header_len = 9 + header_data_len;
let mut pts = None;
let mut dts = None;
@@ -300,7 +343,7 @@ fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
dts = parse_timestamp(&data[14..19]);
}
(pts, dts, data_start)
(pts, dts, header_len)
}
/// Parse a 5-byte PTS/DTS timestamp (33 bits in 90kHz).
@@ -404,60 +447,55 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
let section_len =
(((data[pmt_start + 1] & 0x0F) as usize) << 8) | data[pmt_start + 2] as usize;
// section_length counts the bytes after this field, including the
// trailing 4-byte CRC; `< 4` would underflow `end` below. Guard it
// exactly like the PAT parser above.
if section_len < 4 {
offset += BD_TS_PACKET_SIZE;
continue;
}
let prog_info_len =
(((data[pmt_start + 10] & 0x0F) as usize) << 8) | data[pmt_start + 11] as usize;
let mut pos = pmt_start + 12 + prog_info_len;
let end = pmt_start + 3 + section_len - 4;
// Clamp the section end to the buffer; a malformed section_len or
// prog_info_len must never drive reads past `data`.
let end = (pmt_start + 3 + section_len - 4).min(data.len());
while pos + 5 <= data.len() && pos < end {
let stream_type = data[pos];
let es_pid = (((data[pos + 1] & 0x1F) as u16) << 8) | data[pos + 2] as u16;
let es_info_len = (((data[pos + 3] & 0x0F) as usize) << 8) | data[pos + 4] as usize;
let stream = match stream_type {
0x1B => Some(Stream::Video(VideoStream {
// Single source of truth for stream_type → Codec: reuse
// `Codec::from_coding_type` (the same table the BD STN /
// disc scanner uses) so the two mappings can never drift.
// We only retain the category (video/audio/subtitle) and
// per-kind default attribute logic here.
let codec = Codec::from_coding_type(stream_type);
let stream = match codec.kind() {
CodecKind::Video => {
// Default resolution by codec generation (HEVC →
// UHD, MPEG-2 → 1080i, else 1080p); refined later
// from the actual elementary stream.
let resolution = match codec {
Codec::Hevc => Resolution::R2160p,
Codec::Mpeg2 => Resolution::R1080i,
_ => Resolution::R1080p,
};
Some(Stream::Video(VideoStream {
pid: es_pid,
codec,
resolution,
frame_rate: FrameRate::Unknown,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
label: String::new(),
}))
}
CodecKind::Audio => Some(Stream::Audio(AudioStream {
pid: es_pid,
codec: Codec::H264,
resolution: Resolution::R1080p,
frame_rate: FrameRate::Unknown,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
label: String::new(),
})),
0x24 => Some(Stream::Video(VideoStream {
pid: es_pid,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: FrameRate::Unknown,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
label: String::new(),
})),
0xEA => Some(Stream::Video(VideoStream {
pid: es_pid,
codec: Codec::Vc1,
resolution: Resolution::R1080p,
frame_rate: FrameRate::Unknown,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
label: String::new(),
})),
0x02 => Some(Stream::Video(VideoStream {
pid: es_pid,
codec: Codec::Mpeg2,
resolution: Resolution::R1080i,
frame_rate: FrameRate::Unknown,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
secondary: false,
label: String::new(),
})),
0x81 => Some(Stream::Audio(AudioStream {
pid: es_pid,
codec: Codec::Ac3,
codec,
channels: AudioChannels::Surround51,
language: "und".into(),
sample_rate: SampleRate::S48,
@@ -465,55 +503,23 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})),
0x83 => Some(Stream::Audio(AudioStream {
CodecKind::Subtitle => Some(Stream::Subtitle(SubtitleStream {
pid: es_pid,
codec: Codec::TrueHd,
channels: AudioChannels::Surround51,
language: "und".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})),
0x84 | 0xA1 => Some(Stream::Audio(AudioStream {
pid: es_pid,
codec: Codec::Ac3Plus,
channels: AudioChannels::Surround51,
language: "und".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})),
0x85 | 0x86 => Some(Stream::Audio(AudioStream {
pid: es_pid,
codec: Codec::DtsHdMa,
channels: AudioChannels::Surround51,
language: "und".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})),
0x82 => Some(Stream::Audio(AudioStream {
pid: es_pid,
codec: Codec::Dts,
channels: AudioChannels::Surround51,
language: "und".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})),
0x90 => Some(Stream::Subtitle(SubtitleStream {
pid: es_pid,
codec: Codec::Pgs,
codec,
language: "und".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
})),
_ => None,
CodecKind::Unknown => {
tracing::warn!(
target: "mux",
"dropping PMT stream entry with unknown stream_type {:#04x} (PID {:#06x})",
stream_type,
es_pid,
);
None
}
};
if let Some(s) = stream {
@@ -561,4 +567,110 @@ mod tests {
let result = demux.feed(&[]);
assert!(result.is_empty());
}
// ── scan_streams PMT parsing ──────────────────────────────────────────
/// Wrap a 188-byte TS packet body in a 192-byte BD-TS packet
/// (4-byte timecode prefix the scanner skips).
fn bdts_packet(body: [u8; 184], pid: u16, pusi: bool) -> Vec<u8> {
let mut pkt = vec![0u8; BD_TS_PACKET_SIZE];
// 4-byte timecode prefix is ignored; leave zero.
pkt[4] = SYNC_BYTE;
pkt[5] = ((pid >> 8) as u8) & 0x1F;
if pusi {
pkt[5] |= 0x40;
}
pkt[6] = (pid & 0xFF) as u8;
pkt[7] = 0x10; // payload only, no adaptation field
pkt[8..8 + 184].copy_from_slice(&body);
pkt
}
/// Build a PAT TS packet pointing program 1 at `pmt_pid`.
fn pat_packet(pmt_pid: u16) -> Vec<u8> {
let mut body = [0xFFu8; 184];
let mut i = 0;
body[i] = 0x00; // pointer_field
i += 1;
body[i] = 0x00; // table_id = PAT
// section_length counts bytes after the length field: tsid(2) +
// version/current_next(1) + section_number(1) + last_section(1) +
// one 4-byte program entry + 4-byte CRC = 13.
body[i + 1] = 0xB0; // section_syntax + reserved + len high nibble
body[i + 2] = 0x0D; // section_length low byte = 13
body[i + 3] = 0x00; // tsid hi
body[i + 4] = 0x01; // tsid lo
body[i + 5] = 0xC1; // version/current_next
body[i + 6] = 0x00; // section_number
body[i + 7] = 0x00; // last_section_number
// program entry: program_number=1 → pmt_pid
body[i + 8] = 0x00;
body[i + 9] = 0x01;
body[i + 10] = 0xE0 | (((pmt_pid >> 8) as u8) & 0x1F);
body[i + 11] = (pmt_pid & 0xFF) as u8;
// (CRC bytes left as 0xFF — scanner doesn't validate CRC)
let _ = &mut i;
bdts_packet(body, 0, true)
}
/// Build a PMT TS packet listing the given `(stream_type, es_pid)` entries.
fn pmt_packet(pmt_pid: u16, entries: &[(u8, u16)]) -> Vec<u8> {
let mut body = [0xFFu8; 184];
body[0] = 0x00; // pointer_field
let s = 1; // table start
body[s] = 0x02; // table_id = PMT
// Fixed PMT fields after section_length: 2(prog) +1 +2 +2(pcr)
// +2(prog_info_len=0) = 9, then per-entry 5 bytes, then 4 CRC.
let entries_len = entries.len() * 5;
let section_length = 9 + entries_len + 4;
body[s + 1] = 0xB0 | (((section_length >> 8) as u8) & 0x0F);
body[s + 2] = (section_length & 0xFF) as u8;
body[s + 3] = 0x00; // program_number hi
body[s + 4] = 0x01; // program_number lo
body[s + 5] = 0xC1; // version/current_next
body[s + 6] = 0x00; // section_number
body[s + 7] = 0x00; // last_section_number
body[s + 8] = 0xE0; // PCR PID hi (reserved bits)
body[s + 9] = 0x00; // PCR PID lo
body[s + 10] = 0xF0; // program_info_length hi (=0)
body[s + 11] = 0x00; // program_info_length lo
let mut p = s + 12;
for &(stype, es_pid) in entries {
body[p] = stype;
body[p + 1] = 0xE0 | (((es_pid >> 8) as u8) & 0x1F);
body[p + 2] = (es_pid & 0xFF) as u8;
body[p + 3] = 0xF0; // ES_info_length hi (=0)
body[p + 4] = 0x00; // ES_info_length lo
p += 5;
}
bdts_packet(body, pmt_pid, true)
}
#[test]
fn scan_streams_maps_lpcm_via_from_coding_type() {
use crate::disc::{Codec, Stream};
let pmt_pid = 0x0100;
let mut data = pat_packet(pmt_pid);
// 0x80 = LPCM (present in from_coding_type, was MISSING from the
// old duplicate table in scan_streams). 0x1B = H.264 video.
data.extend(pmt_packet(pmt_pid, &[(0x1B, 0x1011), (0x80, 0x1100)]));
let streams = scan_streams(&data).expect("PMT should parse");
assert_eq!(streams.len(), 2, "video + LPCM audio");
let lpcm = streams
.iter()
.find(|s| matches!(s, Stream::Audio(a) if a.pid == 0x1100))
.expect("LPCM audio stream present");
if let Stream::Audio(a) = lpcm {
assert_eq!(a.codec, Codec::Lpcm, "0x80 must map to LPCM");
}
assert!(
streams
.iter()
.any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264)),
"H.264 video present"
);
}
}
+8 -7
View File
@@ -157,14 +157,15 @@ impl PrefetchedSectorSource {
Ok(b) => b,
Err(_) => return, // consumer dropped both channels
};
if buf.len() < bytes {
buf.resize(bytes, 0);
} else {
// Re-expose the full extent; previous truncate
// shrank the visible len without freeing pages.
// SAFETY: capacity is at least `bytes` after
// construction with `vec![0u8; batch_bytes]`.
if bytes <= buf.capacity() {
// Re-expose `bytes` without zero-filling pages that
// `read_sectors` is about to overwrite. The capacity
// guard makes the `set_len` provably sound even if a
// recycled buffer ever comes back smaller than the
// `vec![0u8; batch_bytes]` it was born with.
unsafe { buf.set_len(bytes) };
} else {
buf.resize(bytes, 0);
}
let lba = extent.start_lba + offset;
match reader.read_sectors(lba, sectors, &mut buf[..bytes], false) {
+15 -32
View File
@@ -126,31 +126,19 @@ fn disc_title_total_sectors() {
// ── ScanOptions tests ──────────────────────────────────────────────────────
#[test]
fn scan_options_default() {
fn scan_options_default_has_no_credentials() {
// Lookup-free: the only scan input is the optional live-drive credentials.
let opts = ScanOptions::default();
assert!(opts.keydb_path.is_none());
assert!(opts.credentials.is_none());
}
#[test]
fn scan_options_with_keydb() {
fn scan_options_with_credentials() {
let opts = ScanOptions {
keydb_path: Some(("/tmp/KEYDB.cfg").into()),
..Default::default()
credentials: Some(libfreemkv::DriveCredentials::default()),
};
assert_eq!(
opts.keydb_path.as_ref().unwrap().to_str().unwrap(),
"/tmp/KEYDB.cfg"
);
}
#[test]
fn scan_options_with_keydb_pathbuf() {
let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
let opts = ScanOptions {
keydb_path: Some(path.clone()),
..Default::default()
};
assert_eq!(opts.keydb_path.unwrap(), path);
assert!(opts.credentials.is_some());
assert!(opts.credentials.unwrap().host_certs.is_empty());
}
// ── detect_format integration tests ───────────────────────────────────────
@@ -511,21 +499,16 @@ fn batch_count_max_batch_sizes() {
}
#[test]
fn resolve_encryption_no_keydb() {
// A UDF image with /AACS directory but no keydb path -> aacs is None
fn scan_encrypted_resolves_no_keys() {
// A UDF image with an /AACS directory: the lookup-free scan detects
// encryption and captures inputs, but resolves NO key on its own — a
// caller applies one later via Disc::decrypt_with.
let mut reader = MockSectorReader::new();
build_udf_with_aacs_dir(&mut reader);
// No keydb configured and no standard keydb on the system
let opts = ScanOptions {
keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()),
..Default::default()
};
let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap();
let disc = Disc::scan_image(&mut reader, 1000, &ScanOptions::default()).unwrap();
// The disc detects encryption but can't resolve keys without a keydb
assert!(
disc.aacs.is_none(),
"aacs should be None when keydb is unavailable"
);
// No unit keys without an external key (the mock has no Unit_Key_RO.inf to
// capture, so the keyless state isn't even built) — either way, no keys.
assert!(matches!(disc.decrypt_keys(), libfreemkv::DecryptKeys::None));
}