mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e14c9b850 | ||
|
|
e1c8343f77 | ||
|
|
a956c6ad94 | ||
|
|
1805d92ca4 | ||
|
|
823f0ad430 | ||
|
|
477bdf1835 | ||
|
|
4d83b69c20 | ||
|
|
7dbbfc6726 | ||
|
|
e635c9556f | ||
|
|
d7b5c30f5d | ||
|
|
739a276a39 | ||
|
|
5f1028a62a | ||
|
|
ea15d212de | ||
|
|
00673c8ec3 | ||
|
|
bfefb4cb5b | ||
|
|
1b95193517 | ||
|
|
7dcac44136 | ||
|
|
4da559e39f | ||
|
|
eeca250f69 | ||
|
|
c51b3181f2 | ||
|
|
2a31a47434 | ||
|
|
b08662f95c | ||
|
|
4594196b6e | ||
|
|
9d6974df3e | ||
|
|
3394a5b3fe | ||
|
|
e2c7e20329 | ||
|
|
ff22385a61 | ||
|
|
d39d3933f5 | ||
|
|
31dc3d35c3 | ||
|
|
f7165a2020 | ||
|
|
d836f8b1e7 | ||
|
|
8919de5b03 | ||
|
|
0dc1f12108 | ||
|
|
6abc25a7e8 | ||
|
|
4d22cf632f | ||
|
|
611ad77586 | ||
|
|
60590193e3 | ||
|
|
5f24843d3c | ||
|
|
ecf9a4d01e | ||
|
|
8ec767339e | ||
|
|
335e8b68cc | ||
|
|
1137b4ea1f | ||
|
|
7788c560d1 | ||
|
|
84e0c2ca69 | ||
|
|
f4a881b25d |
@@ -4,3 +4,4 @@ Cargo.lock
|
||||
*.swo
|
||||
.DS_Store
|
||||
.cargo/
|
||||
.claude/worktrees/
|
||||
|
||||
+360
@@ -1,5 +1,365 @@
|
||||
# Changelog
|
||||
|
||||
## 0.25.14 (2026-05-21)
|
||||
|
||||
### Changed
|
||||
|
||||
- `Drive::is_libredrive_active()` renamed to `Drive::is_raw_read_active()`.
|
||||
Same semantics; old name removed. Mirrored on the internal
|
||||
`PlatformDriver::is_libredrive_active()` trait method (now
|
||||
`is_raw_read_active()`).
|
||||
- `Error::AacsLibredriveUnsupported` renamed to
|
||||
`Error::AacsRawReadUnsupported`; the underlying numeric code (E7016)
|
||||
is unchanged. The `E_AACS_LIBREDRIVE_UNSUPPORTED` constant is
|
||||
renamed to `E_AACS_RAW_READ_UNSUPPORTED`.
|
||||
|
||||
No behavioural change — purely a rename pass.
|
||||
|
||||
## 0.25.13 (2026-05-21)
|
||||
|
||||
### Added
|
||||
|
||||
- **`DrmScheme` top-level dispatcher.** New `drm` module with a
|
||||
`DrmScheme` enum (`Css`, `Aacs10`, `Aacs20`, `Aacs21`) and a
|
||||
`detect` + `load` pair that uniformly handles all four content
|
||||
protection schemes. Replaces the inlined dispatch in
|
||||
`disc::encrypt::resolve_encryption` and the scattered CSS routing
|
||||
in `disc::mod`. Both CSS call sites now route through the same
|
||||
entry point.
|
||||
- **AACS 2.1 Media Key Variant framework.** New `aacs::variants`
|
||||
module implementing the Media Key Variant derivation chain
|
||||
(`Kp + C → Kmp → ⊕KCD → Kpnew → Km → VUK`), Variant-scheme MKB
|
||||
record parsing (record types `0x82` / `0x83`), bit-0x02 SoftKCD
|
||||
and bit-0x04 online-challenge detection with dedicated error
|
||||
variants. Wired into `DrmScheme::Aacs21` but the dispatcher arm
|
||||
is commented out pending validation against a Variant-scheme
|
||||
disc. Per-manufacturer Key Correction Data must be supplied by
|
||||
the integrator; `KEY_CORRECTION_DATA_PLACEHOLDER` is the empty
|
||||
placeholder slot.
|
||||
- **`AacsVersion` enum.** Replaces the `aacs2: bool` field on
|
||||
`ContentCertificate`, `UnitKeyFile`, and `ResolvedKeys`.
|
||||
`parse_unit_key_ro` and `parse_content_cert` now take/emit the
|
||||
enum. `resolve_keys` is split into `resolve_keys_v1`,
|
||||
`resolve_keys_v2`, and `resolve_keys_v21` (the last not reachable
|
||||
from the dispatcher today).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Libredrive raw-read VID shortcut deleted.** v0.25.11 introduced a
|
||||
`do_handshake` branch that, on libredrive-active drives, skipped the
|
||||
AACS cert handshake and issued `READ_DISC_STRUCTURE` format 0x80
|
||||
with AGID=0 directly. The hypothesis was that firmware-uploaded
|
||||
drives would serve VID without auth. Empirical test on rip1 (BU40N
|
||||
+ Barbie UHD, 2026-05-21) showed the drive returns
|
||||
`0x05 / 0x6F / 0x02` (`ILLEGAL_REQUEST / Copy protection key
|
||||
exchange failure: KEY NOT ESTABLISHED`) to that CDB regardless of
|
||||
firmware-upload state. The AACS spec requires a successful
|
||||
`REPORT_KEY` / `SEND_KEY` exchange to establish an AGID before
|
||||
format 0x80 returns VID; that requirement is enforced by the drive
|
||||
itself and isn't bypassed by libredrive firmware. The shortcut
|
||||
fired for every libredrive-active drive, so v0.25.11 / v0.25.12
|
||||
Barbie scans were stuck at E7017 instead of progressing to the
|
||||
real wall (no DK walks MKB v77).
|
||||
- `Disc::do_handshake` now always routes through `do_handshake_cert`.
|
||||
`Drive::is_libredrive_active()` and the Mt1959 MMkv+LbDr marker
|
||||
detection are kept as informational signals (logged in the
|
||||
`handshake_entry` warn line) but no longer steer the auth path.
|
||||
- `read_volume_id_libredrive` deleted (~50 LOC).
|
||||
|
||||
The corollary: AACS resolution on HRL-burned drives + UHD discs now
|
||||
fails honestly. Either cert auth succeeds (firmware-upload may or
|
||||
may not bypass the HRL — that's the new empirical question) and we
|
||||
hit the actual DK wall (E7018 "No DK that walks this MKB" for v77+
|
||||
UHD without a v77+ DK in keydb), or cert auth fails and we surface
|
||||
E7015. Both are real verdicts; E7017's previous spurious dispatch
|
||||
is gone.
|
||||
|
||||
## 0.25.12 (2026-05-21)
|
||||
|
||||
No libfreemkv source changes — unified sync bump for autorip's
|
||||
`aacs_failure_message` two-line wording rewrite. See the autorip
|
||||
v0.25.12 release for details.
|
||||
|
||||
## 0.25.11 (2026-05-21)
|
||||
|
||||
### Added
|
||||
|
||||
- **Libredrive raw-read VID path.** When the Mt1959 unlock response
|
||||
confirms both the active-mode (`MMkv`) and mode-ID (`LbDr`) markers,
|
||||
`Drive::is_libredrive_active()` returns true and `do_handshake`
|
||||
skips the AACS cert dance entirely — VID is retrieved via
|
||||
`READ_DISC_STRUCTURE` format 0x80 with AGID=0, and bus encryption
|
||||
is already off. This is what unblocks UHD ripping on drives whose
|
||||
leaked host cert is on the AACS HRL.
|
||||
- New `Error` variants for finer-grained AACS failure reporting:
|
||||
`AacsHostCertRejected` (E7015), `AacsLibredriveUnsupported`
|
||||
(E7016), `AacsVidUnavailable` (E7017), `AacsMkUnavailable`
|
||||
(E7018), `AacsVukNotInKeydb` (E7019). Lets CLIs/UIs render which
|
||||
piece of the AACS chain failed instead of always saying "no keys."
|
||||
|
||||
### Fixed
|
||||
|
||||
- `validate_processing_key` now matches libaacs `_validate_pk`
|
||||
exactly: XORs `uv` into `mk[12..16]` (was omitted), AES-decrypts
|
||||
`mk_dv` and checks the `01 23 45 67 89 AB CD EF` magic (was
|
||||
AES-encrypt + 12-zero check). Pre-fix, every non-zero-uv
|
||||
processing key was rejected — i.e. essentially every real disc.
|
||||
- `mkb_find_cvalues` now prefers record type `0x07` (AACS 2.x) and
|
||||
falls back to `0x05` (AACS 1.0), so the walker handles both
|
||||
generations without an out-of-band version flag.
|
||||
- `resolve_keys` short-circuits paths 2/3/4 when VID is the zero
|
||||
sentinel — saves cycles and emits an honest "VID unavailable" log
|
||||
instead of the misleading "all paths failed."
|
||||
- AES-CMAC VID verification gains NIST SP 800-38B KAT + round-trip
|
||||
+ mutation + all-zero-rejection tests.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Built-in AACS keys** (added in 0.25.9). `src/aacs/builtin_keys.rs`
|
||||
deleted; `KeyDb::with_builtins`, `KeyDb::load_or_builtins`,
|
||||
`KeyDb::merge_from`, and internal dedup helpers gone. The
|
||||
compiled-in shortcut was a slim convenience that didn't move the
|
||||
hard problem (no v77+ DKs) and added a maintenance surface.
|
||||
- **Operator plugin slot** at `~/.config/freemkv/local_keys.cfg`
|
||||
(added in 0.25.9). `local_plugin_path` and `KeyDb::merge_local_plugin`
|
||||
removed. Single source: the main `keydb.cfg`.
|
||||
- `KeyDb::load` reverts to the pre-0.25.9 form — read the file or
|
||||
return I/O error; no layering, no fallback.
|
||||
- `Disc::scan` reverts to surfacing `KeydbLoad { path: "<no keydb
|
||||
in search paths>" }` for an encrypted disc with no keydb — same
|
||||
sentinel autorip's message switch already handles.
|
||||
|
||||
CSS player keys in `src/css/auth.rs` remain compiled in; they're
|
||||
1999-era public inputs separate from the AACS pipeline and were
|
||||
never part of the 0.25.9 additions.
|
||||
|
||||
## 0.25.9 (2026-05-20)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MKB record-type identification.** `mkb_find_mk_dv` and `mkb_version`
|
||||
had their record-type constants swapped. The Verify Media Key record
|
||||
is Type 0x81 (AACS 1.0) or Type 0x86 (AACS 2.0/2.1), not 0x10. The
|
||||
Type-and-Version record is Type 0x10, not 0x81. PK and DK derivation
|
||||
paths therefore failed silently on every disc, masking how often the
|
||||
fallback paths could have succeeded. Fixed; mk_dv extracted at
|
||||
offset 4 of the verify record, MKB version read at offset 8 of the
|
||||
Type 0x10 record body. Tests added for both forms.
|
||||
|
||||
### Added
|
||||
|
||||
- **Built-in AACS keys.** Four device keys (covering MKB v01-v82+) and
|
||||
three processing keys (covering v63-v68) are now compiled into
|
||||
`libfreemkv` directly. DVDs and Blu-rays (AACS 1.0) decrypt with
|
||||
zero external files. Combined with the existing 31 CSS player keys
|
||||
in `css/auth.rs`, the library is self-sufficient for all DVD and
|
||||
AACS 1.0 content.
|
||||
- **Operator plugin slot.** `~/.config/freemkv/local_keys.cfg` is
|
||||
loaded automatically (same format as `keydb.cfg`) and layered
|
||||
additively on top of the built-ins and the main keydb.cfg. For
|
||||
operators who derive their own keys, this is the additive surface
|
||||
to drop them into — main keydb.cfg from upstream auto-update stays
|
||||
separate and overwritable.
|
||||
- `KeyDb::with_builtins()`, `KeyDb::load_or_builtins()`,
|
||||
`KeyDb::empty()` constructors.
|
||||
|
||||
### Changed
|
||||
|
||||
- `Disc::scan` no longer errors when keydb.cfg is absent. With
|
||||
built-ins covering DVD/BD, missing keydb is treated as "no UHD
|
||||
keys available" — the AACS 2.0 / UHD code path surfaces a
|
||||
specific error only when the disc actually needs keys that
|
||||
aren't in built-ins, main keydb, or local plugin.
|
||||
|
||||
## 0.25.8 (2026-05-20)
|
||||
|
||||
### Changed
|
||||
|
||||
- Synchronized to autorip 0.25.8. No libfreemkv code changes —
|
||||
unified versioning bump only. autorip 0.25.7 image was DOA
|
||||
(missing +x on the binary); see autorip changelog.
|
||||
|
||||
## 0.25.7 (2026-05-20)
|
||||
|
||||
### Fixed — BU40N firmware wedge on KEYDB-miss discs
|
||||
|
||||
`disc::encrypt::do_handshake` used to fire up to 16 AACS authenticate
|
||||
attempts back-to-back with no pause between them. Each attempt is
|
||||
5-10 SCSI REPORT_KEY/SEND_KEY exchanges. On a disc whose host cert
|
||||
isn't in our KEYDB (or one the drive rejects), that's 80-160 SCSI
|
||||
commands hammered at the drive in a few hundred milliseconds — and
|
||||
the BU40N (plus most consumer optical drives) responds by entering
|
||||
a fast-fail firmware wedge state where every subsequent CDB returns
|
||||
`ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB` (sense 0x05/0x24) until the
|
||||
drive is physically power-cycled.
|
||||
|
||||
Live wedge event on rip1 2026-05-20 during a Barbie UHD scan
|
||||
(KEYDB miss) confirmed the diagnosis and motivated this fix.
|
||||
|
||||
Defence-in-depth:
|
||||
- `MAX_CERT_ATTEMPTS` capped at 3 (was 16). If three different
|
||||
host certs all fail, more won't help — the drive doesn't have a
|
||||
match.
|
||||
- 1-second sleep between attempts. Gives the drive's firmware time
|
||||
to recover internal state between auth challenges.
|
||||
- Bail immediately on any sense code with sense_key = 0x05
|
||||
(`ILLEGAL_REQUEST`). The drive isn't merely rejecting our cert
|
||||
— it's saying "I won't talk to you anymore" — so trying more
|
||||
certs would deepen the wedge.
|
||||
|
||||
## 0.25.6 (2026-05-20)
|
||||
|
||||
### Changed
|
||||
|
||||
- Synchronized to autorip 0.25.6 release. No behavioural changes.
|
||||
|
||||
## 0.25.5 (2026-05-20)
|
||||
|
||||
### Changed
|
||||
|
||||
- Synchronized to autorip 0.25.5 release. No behavioural changes.
|
||||
|
||||
## 0.25.4 (2026-05-20)
|
||||
|
||||
### Changed
|
||||
|
||||
- Synchronized to autorip 0.25.4 release. No behavioural changes
|
||||
in the library — the v0.25.4 release ships the autorip-side
|
||||
in-container NFS mount option.
|
||||
|
||||
## 0.25.3 (2026-05-19)
|
||||
|
||||
### Changed
|
||||
|
||||
- Synchronized to autorip 0.25.3 release. No behavioural changes
|
||||
in the library — the v0.25.3 release ships the autorip-side
|
||||
parallel mux pipeline.
|
||||
|
||||
## 0.25.2 (2026-05-19)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **DTS-HD codec ID** — `MkvTrack::audio` now emits `A_DTS/MA` for
|
||||
DTS-HD MA and `A_DTS/HR` for DTS-HD HR instead of mislabelling
|
||||
both as plain `A_DTS`. Strict players (Plex transcoder, some
|
||||
hardware decoders, AV receivers) reject lossless DTS-HD MA
|
||||
payload when the track advertises `A_DTS` because the ID
|
||||
implies the 1.5 Mbps core-only bitstream.
|
||||
- **PGS subtitle BlockDuration** — the PGS parser is now stateful:
|
||||
it pairs each display PCS with the following empty/clear PCS to
|
||||
compute a duration, and the MKV muxer emits a `BlockGroup` +
|
||||
`BlockDuration` for subtitles that carry one. Without this the
|
||||
last bitmap lingered on screen until the next display set
|
||||
replaced it (or until end of file).
|
||||
|
||||
### Changed
|
||||
|
||||
- `codec::Frame` gains `duration_ns: Option<u64>` (set by parsers
|
||||
that can compute one; currently only PGS).
|
||||
- `pes::PesFrame` gains `duration_ns: Option<u64>` (in-memory only;
|
||||
not part of the on-wire serialization).
|
||||
- `MkvMuxer::write_frame` now takes a final `duration_ns: Option<u64>`
|
||||
parameter. When `Some`, the frame is emitted as a `BlockGroup`
|
||||
with `BlockDuration` instead of a `SimpleBlock`.
|
||||
|
||||
## 0.25.1 (2026-05-19)
|
||||
|
||||
### New — autorip event_fn plumbing
|
||||
|
||||
- `PrefetchedSectorSource::new_with_events(reader, extents, batch,
|
||||
halt, event_fn)` — same producer-thread pipeline as `new()`, plus
|
||||
an optional callback fired from the producer with `BytesRead`
|
||||
events after every successful batch. Lets the autorip multipass
|
||||
+ resume mux paths drive their progress UI from the highway
|
||||
without polling the consumer side.
|
||||
- `build_iso_pipeline` gains an `event_fn` parameter so callers can
|
||||
wire the same callback through one ctor.
|
||||
|
||||
### Changed
|
||||
|
||||
- `pes::Stream` trait: new default `errors() -> u64` method
|
||||
(default returns 0). Lets `Box<dyn Stream>` callers query the
|
||||
skip-on-error counter without downcasting. `DiscStream` overrides
|
||||
to surface its existing `errors` field.
|
||||
|
||||
### Removed (breaking)
|
||||
|
||||
- `DiscStream::new_pipeline` and `DiscStream::read_pipeline`
|
||||
deleted. All file-backed mux now uses `build_iso_pipeline` →
|
||||
`PipelinedPesStream`. `DiscStream` is now the single-threaded
|
||||
inline reader used by autorip's live-drive single-pass path only.
|
||||
- `DiscStream::demux_thread` and `demux_rx` fields removed.
|
||||
- `M2tsStream::open` deleted; `Mode::Read` variant removed.
|
||||
`m2ts://` URLs go through the internal `build_m2ts_pipeline`
|
||||
helper in `mux/resolve.rs` → `PipelinedPesStream`. `M2tsStream`
|
||||
is a write-only sink now.
|
||||
|
||||
## 0.25.0 (2026-05-19)
|
||||
|
||||
### New — the freemkv mux throughput "highway"
|
||||
|
||||
Three-stage pipelined PES read path. Read+decrypt runs on a
|
||||
producer thread, M2TS demux runs on a second thread, codec parse
|
||||
runs on the caller's thread. Communication between stages is via
|
||||
bounded `crossbeam_channel` with a recycled buffer pool — no
|
||||
allocations or memcpys in the steady-state hot loop.
|
||||
|
||||
**Throughput on the rip1 testbed (Civil_War UHD, 62 GiB ISO →
|
||||
`null://`, single-thread caller):**
|
||||
|
||||
| | MB/s |
|
||||
| ------------------------------------- | -----: |
|
||||
| 0.23.2 baseline | 60 |
|
||||
| + memchr SIMD HEVC start-code scan | 69 |
|
||||
| + ts.feed no-copy boundary | 72 |
|
||||
| + `PrefetchedSectorSource` (producer) | 124 |
|
||||
| + `DemuxThread` (3-stage pipeline) | 135 |
|
||||
| + zero-copy recycled buffer pool | 148 |
|
||||
| + 16 KiB initial PesAssembler buffer | 162 |
|
||||
| + mimalloc allocator in freemkv CLI | 200+ |
|
||||
| **+ warm cache** | **660** |
|
||||
|
||||
The new public API:
|
||||
|
||||
- `libfreemkv::PrefetchedSectorSource` — wraps any `SectorSource`,
|
||||
spawns a producer thread, exposes recycled-buffer channels.
|
||||
- `libfreemkv::io::byte_prefetcher::BytePrefetcher` —
|
||||
`std::io::Read` analogue for byte-stream sources (m2ts files,
|
||||
sockets, stdin).
|
||||
- `libfreemkv::mux::demux_thread::DemuxThread` — the M2TS demux
|
||||
worker; `spawn_zero_copy` takes either prefetcher's channels.
|
||||
- `libfreemkv::PipelinedPesStream` — the read-side `Stream` impl
|
||||
that runs codec parse on the caller thread.
|
||||
- `libfreemkv::build_iso_pipeline(reader, title, keys, batch,
|
||||
format, halt)` — the canonical ctor that wires all three stages
|
||||
for an ISO file source.
|
||||
|
||||
### Changed (breaking)
|
||||
|
||||
- `IsoSectorReader` (the naive duplicate of `FileSectorSource`)
|
||||
deleted. `FileSectorSource` is the sole file-backed sector
|
||||
source; it carries the SEQUENTIAL fadvise hint, the periodic
|
||||
DONTNEED page-cache eviction, and (new) the per-read
|
||||
`readahead()` async-prefetch syscall.
|
||||
- `mux::input("iso://...", &opts)` now returns a
|
||||
`PipelinedPesStream`. Function signature
|
||||
`input(&str, &InputOptions) -> io::Result<Box<dyn Stream>>` is
|
||||
unchanged; callers that treated the return value as
|
||||
`Box<dyn Stream>` keep working.
|
||||
|
||||
### Other
|
||||
|
||||
- `FileSectorSource` exposes per-OS `prefetch()` hooks
|
||||
(`readahead(2)` on Linux, `fcntl(F_RDADVISE)` on macOS, no-op on
|
||||
Windows + other).
|
||||
- AACS decrypt thread-pool: env var renamed from
|
||||
`FREEMKV_DECRYPT_THREADS` to `FREEMKV_THREADS`; default raised
|
||||
to `cores.clamp(1, 64)`.
|
||||
- `PesAssembler` initial buffer capacity 256 KiB → 16 KiB (avoids
|
||||
the 64-page first-touch fault tax on every PES boundary).
|
||||
- HEVC / H.264 `find_start_code` swapped onto
|
||||
`memchr::memmem::find` (SIMD).
|
||||
|
||||
## 0.18.4 (2026-05-09)
|
||||
|
||||
### Build / CI hardening — no library code changes
|
||||
|
||||
+12
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.22.1"
|
||||
version = "0.26.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -34,6 +34,17 @@ tracing = "0.1"
|
||||
# throughput at ~1 MB/s (see (internal)/memory/
|
||||
# feedback_send_with_halt_poll_throttle.md, 0.21.7).
|
||||
crossbeam-channel = "0.5"
|
||||
# Persistent work-stealing thread pool for parallel AACS unit
|
||||
# decryption. Per-call std::thread::scope spawned fresh OS threads
|
||||
# and that overhead dominated for typical batch sizes (60 units).
|
||||
# rayon's global pool initialises once on first use.
|
||||
rayon = "1"
|
||||
# SIMD-accelerated bytestring search. Drives the HEVC/H.264 start-code
|
||||
# scan in `mux::codec::h264::find_start_code` — naive byte-by-byte
|
||||
# walk is ~500 MB/s single-thread on x86_64; memchr's vectorised
|
||||
# `memmem::find` for the 3-byte `00 00 01` needle hits ~5 GB/s on
|
||||
# AVX2-capable hosts.
|
||||
memchr = "2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
|
||||
|
||||
Built-in keys cover DVDs and Blu-rays (AACS 1.0). For UHD (AACS 2.0 / 2.1) discs, an optional `keydb.cfg` supplies disc-specific volume unique keys.
|
||||
|
||||
**12+ MB/s** sustained read speeds on BD. Full init: unlock, firmware upload, speed calibration — all from pure Rust.
|
||||
|
||||
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
||||
@@ -18,7 +20,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.18"
|
||||
libfreemkv = "0.25"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -56,8 +58,7 @@ output.finish()?;
|
||||
For damaged discs the library exposes two flat verbs — `Disc::sweep` for the
|
||||
forward Pass 1 and `Disc::patch` for retrying bad ranges. The library never
|
||||
loops; the multipass policy is the caller's job. See
|
||||
[`docs/rip-recovery.md`](docs/rip-recovery.md) and the design notes in
|
||||
`(internal)/memory/0_18_redesign.md`.
|
||||
[`docs/rip-recovery.md`](docs/rip-recovery.md).
|
||||
|
||||
```rust
|
||||
use libfreemkv::{SweepOptions, PatchOptions};
|
||||
@@ -122,7 +123,11 @@ loop {
|
||||
|
||||
Streams implement `FrameSource` (read) and/or `FrameSink` (write); direction is type-checked. `input()` / `output()` resolve URL strings to PES stream instances. All URLs use the `scheme://path` format — bare paths are rejected.
|
||||
|
||||
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
|
||||
### Keys
|
||||
|
||||
DVDs (CSS) decrypt out of the box — the 1999-era public player keys are compiled into the library.
|
||||
|
||||
Blu-rays and UHD (AACS) require a `keydb.cfg` at `~/.config/freemkv/keydb.cfg` (or passed via `ScanOptions`). The file holds all DKs, PKs, host certs, and per-disc VUKs. No AACS key material is compiled into the binary.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
+2464
-206
File diff suppressed because it is too large
Load Diff
+89
-2
@@ -1235,8 +1235,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aes_cmac() {
|
||||
// Basic CMAC test — at minimum verify it produces consistent output
|
||||
fn test_aes_cmac_deterministic() {
|
||||
// Same (data, key) must always produce the same MAC.
|
||||
let key = [
|
||||
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
|
||||
0x4f, 0x3c,
|
||||
@@ -1248,6 +1248,93 @@ mod tests {
|
||||
assert_ne!(mac1, [0u8; 16]); // shouldn't be all zeros
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aes_cmac_nist_kat_full_block() {
|
||||
// NIST SP 800-38B Appendix D.1, Example 2 (Mlen = 128):
|
||||
// K = 2b7e1516 28aed2a6 abf71588 09cf4f3c
|
||||
// M = 6bc1bee2 2e409f96 e93d7e11 7393172a
|
||||
// T = 070a16b4 6b4d4144 f79bdd9d d04a287c
|
||||
let key = [
|
||||
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
|
||||
0x4f, 0x3c,
|
||||
];
|
||||
let data = [
|
||||
0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
|
||||
0x17, 0x2a,
|
||||
];
|
||||
let expected = [
|
||||
0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a,
|
||||
0x28, 0x7c,
|
||||
];
|
||||
let mac = aes_cmac_16(&data, &key);
|
||||
assert_eq!(mac, expected, "AES-CMAC-128 must match NIST SP 800-38B KAT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vid_mac_verify_roundtrip() {
|
||||
// Simulate the drive-side: pick a (bus_key, vid), compute the MAC, and
|
||||
// verify the host-side check accepts it. Then mutate VID and MAC each
|
||||
// in turn and verify both mutations cause a mismatch (the path that
|
||||
// would yield Error::AacsVidMac in read_volume_id).
|
||||
let bus_key = [
|
||||
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
|
||||
0x32, 0x10,
|
||||
];
|
||||
let vid = [
|
||||
0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
|
||||
0x77, 0x88,
|
||||
];
|
||||
|
||||
// Drive returns vid + mac where mac == AES-CMAC-128(bus_key, vid).
|
||||
let drive_mac = aes_cmac_16(&vid, &bus_key);
|
||||
let calc_mac = aes_cmac_16(&vid, &bus_key);
|
||||
assert_eq!(calc_mac, drive_mac, "honest drive: MACs must match");
|
||||
|
||||
// Mutate the MAC: a malicious drive that swapped VID but returned its
|
||||
// original MAC would produce a mismatch here.
|
||||
let mut bad_mac = drive_mac;
|
||||
bad_mac[0] ^= 0x01;
|
||||
assert_ne!(calc_mac, bad_mac, "mutated MAC must be rejected");
|
||||
|
||||
// Mutate the VID: even one bit of VID drift produces a wildly different
|
||||
// CMAC (this is what catches a substituted VID with a stale MAC).
|
||||
let mut bad_vid = vid;
|
||||
bad_vid[15] ^= 0x01;
|
||||
let calc_for_bad_vid = aes_cmac_16(&bad_vid, &bus_key);
|
||||
assert_ne!(
|
||||
calc_for_bad_vid, drive_mac,
|
||||
"MAC over mutated VID must not match original MAC"
|
||||
);
|
||||
|
||||
// Wrong bus key (e.g. handshake replayed against the wrong session)
|
||||
// also produces a different MAC over the same VID.
|
||||
let mut wrong_key = bus_key;
|
||||
wrong_key[0] ^= 0xff;
|
||||
let calc_with_wrong_key = aes_cmac_16(&vid, &wrong_key);
|
||||
assert_ne!(
|
||||
calc_with_wrong_key, drive_mac,
|
||||
"MAC under wrong bus key must not match"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vid_mac_all_zero_mac_rejected() {
|
||||
// Defensive: a buggy or hostile drive that returns all-zero MAC must
|
||||
// be rejected (the real MAC over any non-trivial VID is nearly never
|
||||
// 0...0). This guards against a class of "drive returned garbage"
|
||||
// failures masquerading as success.
|
||||
let bus_key = [
|
||||
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
|
||||
0x4f, 0x3c,
|
||||
];
|
||||
let vid = [
|
||||
0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
|
||||
0x17, 0x2a,
|
||||
];
|
||||
let calc_mac = aes_cmac_16(&vid, &bus_key);
|
||||
assert_ne!(calc_mac, [0u8; 16], "real CMAC must not be all zeros");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_host_cert_from_keydb() {
|
||||
// Verify the host cert from our KEYDB
|
||||
|
||||
+12
-1
@@ -89,6 +89,17 @@ pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
|
||||
}
|
||||
|
||||
impl KeyDb {
|
||||
/// Construct an empty KeyDb. Used by unit tests; production code
|
||||
/// reaches a populated KeyDb via [`KeyDb::load`] or [`KeyDb::parse`].
|
||||
pub fn empty() -> Self {
|
||||
KeyDb {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
host_certs: Vec::new(),
|
||||
disc_entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a KEYDB.cfg file from a string.
|
||||
pub fn parse(data: &str) -> Self {
|
||||
let mut db = KeyDb {
|
||||
@@ -152,7 +163,7 @@ impl KeyDb {
|
||||
db
|
||||
}
|
||||
|
||||
/// Load KEYDB.cfg from a file path.
|
||||
/// Load a KEYDB.cfg from disk.
|
||||
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
Ok(Self::parse(&data))
|
||||
|
||||
+526
-109
@@ -1,8 +1,40 @@
|
||||
//! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing.
|
||||
|
||||
use super::decrypt::{aes_ecb_decrypt, aes_ecb_encrypt};
|
||||
use super::decrypt::aes_ecb_decrypt;
|
||||
use super::keydb::{DeviceKey, KeyDb};
|
||||
|
||||
// ── AACS version ────────────────────────────────────────────────────────────
|
||||
|
||||
/// AACS protection generation a disc carries.
|
||||
///
|
||||
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
|
||||
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
|
||||
/// and is upgraded to `V21` only after the MKB walk turns up record types
|
||||
/// `0x82` / `0x83` (Media Key Variant Data and Variant Number).
|
||||
///
|
||||
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
|
||||
/// bytes for V20 / V21.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AacsVersion {
|
||||
/// AACS 1.0 — original BD-ROM.
|
||||
V10,
|
||||
/// AACS 2.0 — UHD-BD, classical Media Key derivation.
|
||||
V20,
|
||||
/// AACS 2.1 — UHD-BD with Media Key Variant chain on top of V20.
|
||||
V21,
|
||||
}
|
||||
|
||||
impl AacsVersion {
|
||||
/// Stride (in bytes) between successive encrypted unit keys in
|
||||
/// `Unit_Key_RO.inf`.
|
||||
fn unit_key_stride(self) -> usize {
|
||||
match self {
|
||||
AacsVersion::V10 => 48,
|
||||
AacsVersion::V20 | AacsVersion::V21 => 64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── VUK derivation ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Derive VUK from Media Key and Volume ID.
|
||||
@@ -33,8 +65,8 @@ pub struct UnitKeyFile {
|
||||
pub num_bdmv_dir: u8,
|
||||
/// Whether SKB MKB is used
|
||||
pub use_skb_mkb: bool,
|
||||
/// Whether this is AACS 2.0
|
||||
pub aacs2: bool,
|
||||
/// AACS generation this file's stride matches
|
||||
pub version: AacsVersion,
|
||||
/// Encrypted unit keys (CPS unit number, encrypted key)
|
||||
pub encrypted_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
|
||||
@@ -76,8 +108,8 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
|
||||
/// [uk_pos+48..] encrypted keys, 16 bytes each
|
||||
/// AACS 1.0: 48-byte stride
|
||||
/// AACS 2.0: 64-byte stride (48 + 16 extra)
|
||||
pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
|
||||
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
|
||||
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
@@ -103,14 +135,14 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
aacs2,
|
||||
version,
|
||||
encrypted_keys: Vec::new(),
|
||||
title_cps_unit: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Stride between keys
|
||||
let stride = if aacs2 { 64 } else { 48 };
|
||||
let stride = version.unit_key_stride();
|
||||
|
||||
// Validate size
|
||||
let keys_start = uk_pos + 48; // first key at uk_pos + 48
|
||||
@@ -155,7 +187,7 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
aacs2,
|
||||
version,
|
||||
encrypted_keys,
|
||||
title_cps_unit,
|
||||
})
|
||||
@@ -169,8 +201,9 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
|
||||
/// This is the fast path — no subset-difference tree traversal needed.
|
||||
///
|
||||
/// MKB format:
|
||||
/// Record type 0x10 = Verify Media Key Record (has mk_dv)
|
||||
/// Record type 0x81 = Type and Version Record (has MKB version)
|
||||
/// Record type 0x10 = Type and Version Record (has MKB version)
|
||||
/// Record type 0x81 = Verify Media Key Record, AACS 1.0 (has mk_dv)
|
||||
/// Record type 0x86 = Verify Media Key Record, AACS 2.0/2.1 (has mk_dv)
|
||||
/// Record type 0x04 = Subset-Difference Index (has UVS entries)
|
||||
/// Record type 0x07 = Explicit Subset-Difference Record (has cvalues)
|
||||
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
|
||||
@@ -208,46 +241,45 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt
|
||||
|
||||
/// Validate a processing key against a cvalue/UV pair.
|
||||
/// Returns the Media Key if valid.
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. `mk = AES-128D(pk, cvalue)`
|
||||
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only)
|
||||
/// 3. `dec_vd = AES-128D(mk, mk_dv)`
|
||||
/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid.
|
||||
fn validate_processing_key(
|
||||
pk: &[u8; 16],
|
||||
cvalue: &[u8],
|
||||
_uv: &[u8],
|
||||
uv: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
) -> Option<[u8; 16]> {
|
||||
if cvalue.len() < 16 {
|
||||
if cvalue.len() < 16 || uv.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
// mk = AES-DEC(pk, cvalue) XOR cvalue
|
||||
|
||||
// Step 1: mk = AES-128D(pk, cvalue)
|
||||
let mut cv = [0u8; 16];
|
||||
cv.copy_from_slice(&cvalue[..16]);
|
||||
let mut mk = aes_ecb_decrypt(pk, &cv);
|
||||
for i in 0..16 {
|
||||
mk[i] ^= cv[i];
|
||||
|
||||
// Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]).
|
||||
for a in 0..4 {
|
||||
mk[12 + a] ^= uv[a];
|
||||
}
|
||||
|
||||
// Verify: AES-ECB(mk, mk_dv) should produce a specific pattern
|
||||
let _verify = aes_ecb_encrypt(&mk, mk_dv);
|
||||
// mk_dv verification: the first 12 bytes of AES(mk, mk_dv) should be all 0xDEADBEEF...
|
||||
// Actually per AACS spec: verify record value is AES(mk, all_zeros)
|
||||
// No — the mk_dv IS the verification value. We compute AES-ECB(mk, verify_data)
|
||||
// and check it matches.
|
||||
// From libaacs _validate_pk:
|
||||
// crypto_aes128d(pk, rec + a*16, mk) → decrypt cvalue with PK
|
||||
// mk[i] ^= rec[i] → XOR with cvalue
|
||||
// crypto_aes128e(mk, mk_dv, test) → encrypt mk_dv with derived mk
|
||||
// if first 12 bytes of test are zero → valid media key
|
||||
let test = aes_ecb_encrypt(&mk, mk_dv);
|
||||
// AACS spec: Verify Media Key record — first 12 bytes must be zero
|
||||
if test[..12] == [0u8; 12] {
|
||||
// Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic.
|
||||
let dec_vd = aes_ecb_decrypt(&mk, mk_dv);
|
||||
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
if dec_vd[..8] == VERIFY_MAGIC {
|
||||
return Some(mk);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find Verify Media Key Record (type 0x10) in MKB.
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
let mut pos = 0;
|
||||
let mut type10_seen: Vec<(usize, usize)> = Vec::new();
|
||||
let mut verify_rec_seen: Vec<(u8, usize, usize)> = Vec::new();
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
@@ -255,17 +287,18 @@ fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
break;
|
||||
}
|
||||
|
||||
if rec_type == 0x10 {
|
||||
type10_seen.push((pos, rec_len));
|
||||
if rec_type == 0x81 || rec_type == 0x86 {
|
||||
verify_rec_seen.push((rec_type, pos, rec_len));
|
||||
}
|
||||
|
||||
if rec_type == 0x10 && rec_len >= 20 {
|
||||
// mk_dv is at offset 4 (after record header)
|
||||
if (rec_type == 0x81 || rec_type == 0x86) && rec_len >= 20 {
|
||||
// mk_dv is at offset 4 of the record (after the 4-byte header)
|
||||
let mut dv = [0u8; 16];
|
||||
dv.copy_from_slice(&mkb[pos + 4..pos + 20]);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_found",
|
||||
rec_type,
|
||||
pos,
|
||||
rec_len,
|
||||
"mk_dv extracted from MKB"
|
||||
@@ -277,9 +310,9 @@ fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_not_found",
|
||||
type10_seen = ?type10_seen,
|
||||
verify_rec_seen = ?verify_rec_seen,
|
||||
scanned_bytes = pos,
|
||||
"no 0x10 record with rec_len>=20 found"
|
||||
"no 0x81/0x86 record with rec_len>=20 found"
|
||||
);
|
||||
None
|
||||
}
|
||||
@@ -302,8 +335,25 @@ fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Find Conditional Values (cvalues) record (type 0x07) in MKB.
|
||||
/// Find the Media Key Data Record (cvalues table) in an MKB.
|
||||
///
|
||||
/// libaacs hard-codes record type `0x05` (matches AACS 1.0 and BD type-3/4
|
||||
/// MKBs), but on AACS 2.x Category-C MKBs the cvalues table moved to
|
||||
/// record type `0x07` and `0x05` now carries the host-revocation
|
||||
/// signature. To stay correct on both lines we prefer `0x07` first (the
|
||||
/// AACS 2.x layout used by every modern UHD disc) and fall back to
|
||||
/// `0x05` for AACS 1.0 MKBs.
|
||||
fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(body) = find_record_body(mkb, 0x07) {
|
||||
return Some(body);
|
||||
}
|
||||
find_record_body(mkb, 0x05)
|
||||
}
|
||||
|
||||
/// Walk an MKB and return the payload (header stripped) of the first
|
||||
/// record matching `rec_type`. Returns `None` if no such record exists or
|
||||
/// the record is empty.
|
||||
fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
@@ -311,16 +361,19 @@ fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
if rec_type == 0x07 && rec_len > 4 {
|
||||
if rec_type == rec_type_wanted && rec_len > 4 {
|
||||
return Some(mkb[pos + 4..pos + rec_len].to_vec());
|
||||
}
|
||||
if rec_len == 0 {
|
||||
break;
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get MKB version from Type and Version Record (type 0x81).
|
||||
/// Get MKB version from Type and Version Record (type 0x10).
|
||||
/// Version is a BE u32 at offset 8 of the record body (offset 12 from `pos`).
|
||||
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
@@ -330,12 +383,12 @@ pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
break;
|
||||
}
|
||||
|
||||
if rec_type == 0x81 && rec_len >= 8 {
|
||||
if rec_type == 0x10 && rec_len >= 12 {
|
||||
return Some(u32::from_be_bytes([
|
||||
mkb[pos + 4],
|
||||
mkb[pos + 5],
|
||||
mkb[pos + 6],
|
||||
mkb[pos + 7],
|
||||
mkb[pos + 8],
|
||||
mkb[pos + 9],
|
||||
mkb[pos + 10],
|
||||
mkb[pos + 11],
|
||||
]));
|
||||
}
|
||||
pos += rec_len;
|
||||
@@ -549,8 +602,12 @@ pub struct ContentCert {
|
||||
pub bus_encryption: bool,
|
||||
/// Content Certificate ID (6 bytes)
|
||||
pub cc_id: [u8; 6],
|
||||
/// AACS version: false = AACS 1.0, true = AACS 2.0
|
||||
pub aacs2: bool,
|
||||
/// AACS generation indicated by the certificate type byte.
|
||||
///
|
||||
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
|
||||
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
|
||||
/// V20 from V21 — Variant detection happens after the MKB walk.
|
||||
pub version: AacsVersion,
|
||||
}
|
||||
|
||||
/// Parse a Content Certificate (ContentXXX.cer) file.
|
||||
@@ -563,7 +620,11 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
// [0] certificate type (0x00 = AACS1, 0x01 = AACS2)
|
||||
// [1] bus_encryption_enabled (bit 0)
|
||||
// [2..8] cc_id (6 bytes)
|
||||
let aacs2 = data[0] != 0x00;
|
||||
let version = if data[0] == 0x00 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
};
|
||||
let bus_encryption = (data[1] & 0x01) != 0;
|
||||
let mut cc_id = [0u8; 6];
|
||||
cc_id.copy_from_slice(&data[2..8]);
|
||||
@@ -571,7 +632,7 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
Some(ContentCert {
|
||||
bus_encryption,
|
||||
cc_id,
|
||||
aacs2,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -588,44 +649,172 @@ pub struct ResolvedKeys {
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Title → CPS unit index mapping
|
||||
pub title_cps_unit: Vec<u16>,
|
||||
/// Whether AACS 2.0
|
||||
pub aacs2: bool,
|
||||
/// AACS generation that drove the resolution
|
||||
pub version: AacsVersion,
|
||||
/// Whether bus encryption is enabled (from Content Certificate)
|
||||
pub bus_encryption: bool,
|
||||
/// Which resolution path succeeded (1=KEYDB, 2=KEYDB derived, 3=PK, 4=DK)
|
||||
pub key_source: u8,
|
||||
}
|
||||
|
||||
/// Resolve all AACS keys for a disc given:
|
||||
/// - Unit_Key_RO.inf raw data
|
||||
/// - Content Certificate raw data (optional, for AACS version detection)
|
||||
/// - Volume ID (from SCSI handshake)
|
||||
/// - KEYDB
|
||||
///
|
||||
/// Tries in order:
|
||||
/// 1. Disc hash → KEYDB → VUK (fast path)
|
||||
/// 2. KEYDB media key + volume ID → VUK (if disc hash not in KEYDB but MK is)
|
||||
/// 3. MKB + processing keys → media key → VUK (full derivation)
|
||||
pub fn resolve_keys(
|
||||
unit_key_ro_data: &[u8],
|
||||
content_cert_data: Option<&[u8]>,
|
||||
volume_id: &[u8; 16],
|
||||
keydb: &KeyDb,
|
||||
mkb_data: Option<&[u8]>,
|
||||
) -> Option<ResolvedKeys> {
|
||||
// Detect AACS version
|
||||
let aacs2 = content_cert_data
|
||||
.and_then(parse_content_cert)
|
||||
.map(|cc| cc.aacs2)
|
||||
.unwrap_or(false);
|
||||
/// Inputs shared by every classical-path resolver. References only —
|
||||
/// callers retain ownership of all buffers.
|
||||
pub struct ResolveContext<'a> {
|
||||
/// `Unit_Key_RO.inf` raw bytes.
|
||||
pub unit_key_ro: &'a [u8],
|
||||
/// Content Certificate raw bytes (optional — used for bus-encryption flag).
|
||||
pub content_cert: Option<&'a [u8]>,
|
||||
/// 16-byte Volume ID from SCSI handshake. `[0u8; 16]` is the
|
||||
/// "no VID" sentinel and disables paths 2/3/4.
|
||||
pub volume_id: &'a [u8; 16],
|
||||
/// Key database.
|
||||
pub keydb: &'a KeyDb,
|
||||
/// MKB raw bytes (optional — paths 3/4 require it).
|
||||
pub mkb: Option<&'a [u8]>,
|
||||
}
|
||||
|
||||
let bus_encryption = content_cert_data
|
||||
/// AACS 1.0 key resolution. Parses `Unit_Key_RO.inf` with 48-byte
|
||||
/// stride. Tries paths 1 → 4 in order.
|
||||
pub fn resolve_keys_v1(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
resolve_keys_classical(ctx, AacsVersion::V10)
|
||||
}
|
||||
|
||||
/// AACS 2.0 key resolution. Parses `Unit_Key_RO.inf` with 64-byte
|
||||
/// stride. Tries paths 1 → 4 in order. When paths 3/4 succeed against
|
||||
/// an MKB carrying Variant records (`0x82` / `0x83`), the result's
|
||||
/// `version` is upgraded to [`AacsVersion::V21`] — derivation still
|
||||
/// runs through the classical V2 path; the V21-specific Variant chain
|
||||
/// is wired separately via [`resolve_keys_v21`].
|
||||
pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
let mut resolved = resolve_keys_classical(ctx, AacsVersion::V20)?;
|
||||
if let Some(mkb) = ctx.mkb {
|
||||
let recs = super::variants::walk_mkb(mkb);
|
||||
if super::variants::is_variant_mkb(&recs) {
|
||||
resolved.version = AacsVersion::V21;
|
||||
}
|
||||
}
|
||||
Some(resolved)
|
||||
}
|
||||
|
||||
/// AACS 2.1 key resolution via the Media Key Variant chain.
|
||||
///
|
||||
/// This is wired but not reachable from the production dispatcher — the
|
||||
/// Variant chain still requires an integrator-supplied Key Correction
|
||||
/// Data constant (see [`super::variants::KEY_CORRECTION_DATA_PLACEHOLDER`])
|
||||
/// and an empirically-validated `VARIANTS[uv]` table. Until both are
|
||||
/// available, [`super::variants::derive_media_key_variant`] returns
|
||||
/// errors that this wrapper logs and converts to `None`.
|
||||
///
|
||||
/// The chain still passes the disc hash → KEYDB path (1) and the
|
||||
/// KEYDB-derived MK+VID path (2) before attempting variant derivation;
|
||||
/// V21 discs already in the keydb behave identically to V20.
|
||||
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
// Paths 1 and 2 are version-agnostic — try them first via the
|
||||
// classical V20-stride parser.
|
||||
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?;
|
||||
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||
let bus_encryption = ctx
|
||||
.content_cert
|
||||
.and_then(parse_content_cert)
|
||||
.map(|cc| cc.bus_encryption)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Parse Unit_Key_RO.inf
|
||||
let uk_file = parse_unit_key_ro(unit_key_ro_data, aacs2)?;
|
||||
let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys {
|
||||
let unit_keys: Vec<(u32, [u8; 16])> = uk_file
|
||||
.encrypted_keys
|
||||
.iter()
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key)))
|
||||
.collect();
|
||||
ResolvedKeys {
|
||||
disc_hash: uk_file.disc_hash,
|
||||
vuk,
|
||||
unit_keys,
|
||||
title_cps_unit: uk_file.title_cps_unit.clone(),
|
||||
version: AacsVersion::V21,
|
||||
bus_encryption,
|
||||
key_source,
|
||||
}
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_v21_start",
|
||||
bus_encryption,
|
||||
disc_hash = %hash_hex,
|
||||
mkb_present = ctx.mkb.is_some(),
|
||||
"resolve_keys_v21: starting"
|
||||
);
|
||||
|
||||
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
|
||||
if let Some(vuk) = entry.vuk {
|
||||
return Some(build(vuk, 1));
|
||||
}
|
||||
}
|
||||
|
||||
if *ctx.volume_id == [0u8; 16] {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_v21_no_vid",
|
||||
"VID unavailable; v21 derivation requires VID"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
for entry in ctx.keydb.disc_entries.values() {
|
||||
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
|
||||
if did == *ctx.volume_id {
|
||||
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Variant chain — walk MKB, derive Km via the Media Key Variant
|
||||
// chain, then derive VUK off Km and the disc's VID.
|
||||
let mkb = ctx.mkb?;
|
||||
let recs = super::variants::walk_mkb(mkb);
|
||||
match super::variants::derive_media_key_variant(
|
||||
&recs,
|
||||
&ctx.keydb.device_keys,
|
||||
&super::variants::KEY_CORRECTION_DATA_PLACEHOLDER,
|
||||
ctx.volume_id,
|
||||
) {
|
||||
Ok((_km, kvu)) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_v21_variant_ok",
|
||||
"Media Key Variant chain produced Km + Kvu"
|
||||
);
|
||||
Some(build(kvu, 4))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_v21_variant_err",
|
||||
error_code = %e,
|
||||
"Media Key Variant chain failed"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve all AACS keys for a disc using the classical (single-stage
|
||||
/// Media Key derivation) paths. Used by both V10 and V20.
|
||||
///
|
||||
/// Tries in order:
|
||||
/// 1. Disc hash → KEYDB → VUK (fast path, no VID required)
|
||||
/// 2. KEYDB media key + volume ID → VUK
|
||||
/// 3. MKB + processing keys → media key → VUK
|
||||
/// 4. MKB + device keys → processing key → media key → VUK
|
||||
fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Option<ResolvedKeys> {
|
||||
let bus_encryption = ctx
|
||||
.content_cert
|
||||
.and_then(parse_content_cert)
|
||||
.map(|cc| cc.bus_encryption)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Parse Unit_Key_RO.inf at the version-appropriate stride.
|
||||
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
|
||||
|
||||
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||
|
||||
@@ -641,7 +830,7 @@ pub fn resolve_keys(
|
||||
vuk,
|
||||
unit_keys,
|
||||
title_cps_unit: uk_file.title_cps_unit.clone(),
|
||||
aacs2,
|
||||
version,
|
||||
bus_encryption,
|
||||
key_source,
|
||||
}
|
||||
@@ -650,15 +839,15 @@ pub fn resolve_keys(
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_start",
|
||||
aacs2,
|
||||
version = ?version,
|
||||
bus_encryption,
|
||||
disc_hash = %hash_hex,
|
||||
mkb_present = mkb_data.is_some(),
|
||||
mkb_present = ctx.mkb.is_some(),
|
||||
"resolve_keys: starting"
|
||||
);
|
||||
|
||||
// Path 1: Look up VUK by disc hash in KEYDB
|
||||
if let Some(entry) = keydb.find_disc(&hash_hex) {
|
||||
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb");
|
||||
if let Some(vuk) = entry.vuk {
|
||||
return Some(build(vuk, 1));
|
||||
@@ -668,21 +857,36 @@ pub fn resolve_keys(
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", "disc hash NOT in keydb");
|
||||
}
|
||||
|
||||
// Paths 2-4 all consume the Volume ID. Without it (handshake
|
||||
// skipped, raw-read bypass failed, etc.) every downstream
|
||||
// derivation produces garbage. Caller stamps `[0u8; 16]` as the
|
||||
// sentinel "no VID" — short-circuit here so we don't surface a
|
||||
// misleading "all paths failed" log when really the math is
|
||||
// structurally impossible.
|
||||
if *ctx.volume_id == [0u8; 16] {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_no_vid",
|
||||
"VID unavailable; paths 2/3/4 require VID and are skipped"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Path 2: Find entry with matching VID → derive VUK from MK + VID
|
||||
let mut path2_mk_did_count = 0usize;
|
||||
for entry in keydb.disc_entries.values() {
|
||||
for entry in ctx.keydb.disc_entries.values() {
|
||||
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
|
||||
path2_mk_did_count += 1;
|
||||
if did == *volume_id {
|
||||
if did == *ctx.volume_id {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "MK+VID entry matched volume_id");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 2));
|
||||
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", mk_did_entries = path2_mk_did_count, "no MK+VID entry matched volume_id");
|
||||
|
||||
// Path 3: MKB + processing keys → media key → VUK
|
||||
if let Some(mkb) = mkb_data {
|
||||
if let Some(mkb) = ctx.mkb {
|
||||
let mk_dv = mkb_find_mk_dv(mkb);
|
||||
let subdiff = mkb_find_subdiff_records(mkb);
|
||||
let cvalues = mkb_find_cvalues(mkb);
|
||||
@@ -697,18 +901,18 @@ pub fn resolve_keys(
|
||||
"MKB record scan results"
|
||||
);
|
||||
|
||||
if let Some(mk) = derive_media_key_from_pk(mkb, &keydb.processing_keys) {
|
||||
if let Some(mk) = derive_media_key_from_pk(mkb, &ctx.keydb.processing_keys) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "media key derived from processing key");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 3));
|
||||
return Some(build(derive_vuk(&mk, ctx.volume_id), 3));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = keydb.processing_keys.len(), "PK derivation failed");
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = ctx.keydb.processing_keys.len(), "PK derivation failed");
|
||||
|
||||
// Path 4: MKB + device keys → processing key → media key → VUK
|
||||
if let Some(mk) = derive_media_key_from_dk(mkb, &keydb.device_keys) {
|
||||
if let Some(mk) = derive_media_key_from_dk(mkb, &ctx.keydb.device_keys) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "media key derived from device key");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 4));
|
||||
return Some(build(derive_vuk(&mk, ctx.volume_id), 4));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = keydb.device_keys.len(), "DK derivation failed");
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = ctx.keydb.device_keys.len(), "DK derivation failed");
|
||||
} else {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB data available; paths 3/4 skipped");
|
||||
}
|
||||
@@ -923,10 +1127,10 @@ mod tests {
|
||||
data[key2_pos + i] = 0xBB;
|
||||
}
|
||||
|
||||
let parsed = parse_unit_key_ro(&data, false).unwrap();
|
||||
let parsed = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
|
||||
assert_eq!(parsed.app_type, 1);
|
||||
assert_eq!(parsed.num_bdmv_dir, 1);
|
||||
assert!(!parsed.aacs2);
|
||||
assert_eq!(parsed.version, AacsVersion::V10);
|
||||
assert_eq!(parsed.encrypted_keys.len(), 2);
|
||||
assert_eq!(parsed.encrypted_keys[0].0, 1); // CPS unit 1
|
||||
assert_eq!(parsed.encrypted_keys[0].1, [0xAA; 16]);
|
||||
@@ -935,23 +1139,137 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mkb_version_parse() {
|
||||
// Synthetic MKB with Type and Version record (0x81)
|
||||
let mut mkb = vec![0u8; 32];
|
||||
// Record: type=0x81, length=12 (BE24)
|
||||
mkb[0] = 0x81;
|
||||
mkb[1] = 0x00;
|
||||
mkb[2] = 0x00;
|
||||
mkb[3] = 0x0C;
|
||||
// Version = 77
|
||||
mkb[4] = 0x00;
|
||||
mkb[5] = 0x00;
|
||||
mkb[6] = 0x00;
|
||||
mkb[7] = 77;
|
||||
|
||||
fn mkb_version_recognizes_type_0x10() {
|
||||
// Type-and-Version record: type=0x10, rec_len=12 (BE24).
|
||||
// Body is 8 bytes; the version u32 sits at offset 8 of the record.
|
||||
let mkb = [
|
||||
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
assert_eq!(mkb_version(&mkb), Some(77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_version_returns_none_on_empty() {
|
||||
assert_eq!(mkb_version(&[]), None);
|
||||
assert_eq!(mkb_version(&[0x10, 0x00]), None);
|
||||
// Type 0x10 record but rec_len < 12 → no version available.
|
||||
let short = [0x10, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01];
|
||||
assert_eq!(mkb_version(&short), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_find_mk_dv_recognizes_type_0x81() {
|
||||
// First: type-0x10 type/version record (12 bytes), then type-0x81 verify record.
|
||||
// Verify record carries a known 16-byte mk_dv at offset 4 of the record body.
|
||||
let expected: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
];
|
||||
// type=0x81, rec_len=24 (4-byte header + 16-byte mk_dv + 4-byte trailing zeros)
|
||||
mkb.extend_from_slice(&[0x81, 0x00, 0x00, 0x18]);
|
||||
mkb.extend_from_slice(&expected);
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
||||
|
||||
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_processing_key_round_trip_with_nonzero_uv() {
|
||||
// Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the
|
||||
// libaacs _validate_pk relation, then confirm validate_processing_key
|
||||
// recovers mk. Catches the bugs that landed pre-fix:
|
||||
// * uv XOR step was missing → mk wrong whenever uv != 0
|
||||
// * AES-128E + 12-zero check instead of AES-128D + magic
|
||||
use super::super::decrypt::{aes_ecb_decrypt as dec, aes_ecb_encrypt as enc};
|
||||
|
||||
let pk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
// cvalue is what AES-128E(pk, mk') gives, where mk' = mk with the
|
||||
// last-4-bytes-uv XOR pre-undone:
|
||||
// mk_raw[12..16] = mk[12..16] XOR uv (so the validate step XORs
|
||||
// uv back in and recovers mk).
|
||||
let mut mk_raw = mk;
|
||||
for a in 0..4 {
|
||||
mk_raw[12 + a] ^= uv[a];
|
||||
}
|
||||
let cvalue = enc(&pk, &mk_raw);
|
||||
|
||||
// mk_dv is the encryption (under the correct mk) of the verify
|
||||
// magic, padded with arbitrary bytes — when decrypted with mk we
|
||||
// recover the magic.
|
||||
let mut plaintext_vd = [0u8; 16];
|
||||
plaintext_vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
// Trailing 8 bytes are don't-cares in the magic check.
|
||||
plaintext_vd[8..].copy_from_slice(&[0x11; 8]);
|
||||
let mk_dv = enc(&mk, &plaintext_vd);
|
||||
// Sanity: decrypting mk_dv with mk yields the magic.
|
||||
let _check = dec(&mk, &mk_dv);
|
||||
|
||||
let recovered = validate_processing_key(&pk, &cvalue, &uv, &mk_dv)
|
||||
.expect("validate_processing_key must accept a correct pk + uv pair");
|
||||
assert_eq!(recovered, mk, "recovered mk must match the planted mk");
|
||||
|
||||
// And a wrong pk must be rejected.
|
||||
let mut wrong_pk = pk;
|
||||
wrong_pk[0] ^= 0xFF;
|
||||
assert!(validate_processing_key(&wrong_pk, &cvalue, &uv, &mk_dv).is_none());
|
||||
|
||||
// And a uv mismatch must be rejected.
|
||||
let wrong_uv = [0x00u8, 0x00, 0x00, 0x00];
|
||||
assert!(validate_processing_key(&pk, &cvalue, &wrong_uv, &mk_dv).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_find_cvalues_prefers_0x07_then_falls_back_to_0x05() {
|
||||
// AACS 2.x: type 0x07 carries cvalues; 0x05 is the host-revocation
|
||||
// signature. Mixed-record MKB → 0x07 wins.
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
// type=0x05, body = [0xAA; 4]
|
||||
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xAA, 0xAA, 0xAA, 0xAA]);
|
||||
// type=0x07, body = [0xBB; 4]
|
||||
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x08, 0xBB, 0xBB, 0xBB, 0xBB]);
|
||||
let body = mkb_find_cvalues(&mkb).expect("cvalues record must be found");
|
||||
assert_eq!(body, vec![0xBB, 0xBB, 0xBB, 0xBB], "0x07 must be preferred");
|
||||
|
||||
// AACS 1.0: only 0x05 present → fall back to it.
|
||||
let mut mkb1 = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
];
|
||||
mkb1.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xCC, 0xCC, 0xCC, 0xCC]);
|
||||
let body = mkb_find_cvalues(&mkb1).expect("0x05 fallback must work for AACS 1.0");
|
||||
assert_eq!(body, vec![0xCC, 0xCC, 0xCC, 0xCC]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_find_mk_dv_recognizes_type_0x86() {
|
||||
// AACS 2.0 form uses type 0x86 for the verify record.
|
||||
let expected: [u8; 16] = [
|
||||
0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
|
||||
0x0B, 0x0C,
|
||||
];
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x18]);
|
||||
mkb.extend_from_slice(&expected);
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
||||
|
||||
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_keys_vuk_path() {
|
||||
// Test the full resolve chain using VUK path
|
||||
@@ -987,6 +1305,105 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a minimal Unit_Key_RO.inf with `num_unit_keys = 1`. The
|
||||
/// disc hash won't be in any synthetic keydb so path 1 misses,
|
||||
/// which lets us isolate the path-2/3/4 short-circuit behavior.
|
||||
fn minimal_unit_key_ro() -> Vec<u8> {
|
||||
let mut data = vec![0u8; 256];
|
||||
// uk_pos = 0x60
|
||||
data[3] = 0x60;
|
||||
data[16] = 1; // app_type = BD-ROM
|
||||
data[17] = 1; // num_bdmv_dir
|
||||
let uk_pos = 0x60usize;
|
||||
data[uk_pos + 1] = 1; // 1 unit key
|
||||
// Key at uk_pos + 48 — value doesn't matter, just needs to fit.
|
||||
for i in 0..16 {
|
||||
data[uk_pos + 48 + i] = 0xCC;
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_keys_skips_paths_2_through_4_when_vid_is_zero() {
|
||||
// No VID -> paths 2/3/4 cannot succeed. The function must
|
||||
// return None WITHOUT touching the MKB / device keys, so we
|
||||
// can pass an MKB that would otherwise cause expensive
|
||||
// derivation work — it must not be consumed.
|
||||
let uk_ro = minimal_unit_key_ro();
|
||||
let zero_vid = [0u8; 16];
|
||||
|
||||
// Populate keydb with a non-matching VID entry (path 2 would
|
||||
// miss anyway) plus dummy processing/device keys (paths 3/4
|
||||
// would also miss, but the short-circuit means they're never
|
||||
// attempted).
|
||||
let mut keydb = KeyDb::empty();
|
||||
keydb.disc_entries.insert(
|
||||
"0xDEADBEEF".to_string(),
|
||||
DiscEntry {
|
||||
disc_hash: "0xDEADBEEF".to_string(),
|
||||
title: "fixture".to_string(),
|
||||
media_key: Some([0x11u8; 16]),
|
||||
disc_id: Some([0x22u8; 16]),
|
||||
vuk: None,
|
||||
unit_keys: Vec::new(),
|
||||
},
|
||||
);
|
||||
keydb.processing_keys.push([0u8; 16]);
|
||||
|
||||
let ctx = ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &zero_vid,
|
||||
keydb: &keydb,
|
||||
mkb: None,
|
||||
};
|
||||
let result = resolve_keys_v1(&ctx);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"resolve_keys with VID=0 and no matching disc-hash entry must return None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_keys_path1_still_runs_when_vid_is_zero() {
|
||||
// Path 1 (disc-hash → VUK) doesn't need VID. Confirm the
|
||||
// short-circuit doesn't block it: install a keydb entry whose
|
||||
// disc_hash matches the fixture's hash, with a known VUK, and
|
||||
// verify resolve_keys returns it with key_source = 1.
|
||||
let uk_ro = minimal_unit_key_ro();
|
||||
let hash = disc_hash(&uk_ro);
|
||||
// `find_disc` lowercases the incoming hash; the entry map is
|
||||
// keyed lowercase too, so we have to lowercase here.
|
||||
let hash_hex = disc_hash_hex(&hash).to_lowercase();
|
||||
|
||||
let mut keydb = KeyDb::empty();
|
||||
let known_vuk = [0xABu8; 16];
|
||||
keydb.disc_entries.insert(
|
||||
hash_hex.clone(),
|
||||
DiscEntry {
|
||||
disc_hash: hash_hex,
|
||||
title: "fixture".to_string(),
|
||||
media_key: None,
|
||||
disc_id: None,
|
||||
vuk: Some(known_vuk),
|
||||
unit_keys: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
let vid = [0u8; 16];
|
||||
let ctx = ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &vid,
|
||||
keydb: &keydb,
|
||||
mkb: None,
|
||||
};
|
||||
let resolved =
|
||||
resolve_keys_v1(&ctx).expect("path 1 must run regardless of VID availability");
|
||||
assert_eq!(resolved.vuk, known_vuk);
|
||||
assert_eq!(resolved.key_source, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_cert_parse() {
|
||||
// AACS 1.0 cert
|
||||
@@ -994,14 +1411,14 @@ mod tests {
|
||||
data[0] = 0x00; // AACS 1.0
|
||||
data[1] = 0x00; // no bus encryption
|
||||
let cc = parse_content_cert(&data).unwrap();
|
||||
assert!(!cc.aacs2);
|
||||
assert_eq!(cc.version, AacsVersion::V10);
|
||||
assert!(!cc.bus_encryption);
|
||||
|
||||
// AACS 2.0 with bus encryption
|
||||
data[0] = 0x01; // AACS 2.0
|
||||
data[1] = 0x01; // bus encryption enabled
|
||||
let cc = parse_content_cert(&data).unwrap();
|
||||
assert!(cc.aacs2);
|
||||
assert_eq!(cc.version, AacsVersion::V20);
|
||||
assert!(cc.bus_encryption);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -17,6 +17,7 @@ pub mod decrypt;
|
||||
pub mod handshake;
|
||||
pub mod keydb;
|
||||
pub mod keys;
|
||||
pub mod variants;
|
||||
|
||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
@@ -26,7 +27,13 @@ pub use decrypt::{
|
||||
};
|
||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
||||
pub use keys::{
|
||||
ContentCert, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_from_dk,
|
||||
derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex, mkb_version,
|
||||
parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys,
|
||||
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
|
||||
resolve_keys_v2, resolve_keys_v21,
|
||||
};
|
||||
pub use variants::{
|
||||
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
||||
derive_media_key_variant, is_variant_mkb, variant_data_record, variant_key_data, variant_nonce,
|
||||
walk_mkb, walk_processing_key,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
//! AACS Media Key Variant chain.
|
||||
//!
|
||||
//! On AACS 2.1 the Media Key derivation gains a second stage on top of
|
||||
//! the classical subset-difference walk. The classical walk yields a
|
||||
//! Media Key Precursor (Kmp) rather than the final Media Key; the
|
||||
//! Precursor combines with disc-supplied Variant Key Data (VKD) and an
|
||||
//! integrator-supplied Key Correction Data (KCD) constant to produce
|
||||
//! the Media Key.
|
||||
//!
|
||||
//! This module is wiring only — `resolve_keys` is not aware of it. The
|
||||
//! entry point is [`derive_media_key_variant`]. The Variant scheme is
|
||||
//! detected via the new MKB record types `0x82` (Encrypted Media Key
|
||||
//! Variant Data + Variant Key Data) and `0x83` (Variant Number). When
|
||||
//! a disc carries neither, callers should fall back to the classical
|
||||
//! single-stage derivation in [`super::keys`].
|
||||
//!
|
||||
//! The chain follows the published spec:
|
||||
//!
|
||||
//! ```text
|
||||
//! Kmp = AES-128D(Kp, C) XOR uv
|
||||
//! Kpnew = Kmp XOR KCD
|
||||
//! Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE)
|
||||
//! VKD_idx = Kvn XOR VARIANTS[uv]
|
||||
//! VKD = vkd_table[VKD_idx * 16 .. +16]
|
||||
//! Km = AES-128D(Kpnew, VKD) XOR uv
|
||||
//! ```
|
||||
//!
|
||||
//! Two condition bits on `Kmp[15]` route off the hardcoded-KCD path
|
||||
//! (Soft Correction and Online Challenge). The chain refuses to run in
|
||||
//! either case — callers must handle those modes out of band.
|
||||
|
||||
use super::decrypt::aes_ecb_decrypt;
|
||||
use super::keydb::DeviceKey;
|
||||
|
||||
// ── Public constants ──────────────────────────────────────────────────────
|
||||
|
||||
/// Placeholder Key Correction Data. Sixteen zero bytes.
|
||||
///
|
||||
/// Integrators MUST supply a non-placeholder KCD via the `kcd` argument
|
||||
/// to [`derive_media_key_variant`]; the chain refuses to operate when
|
||||
/// the supplied KCD compares equal to this placeholder.
|
||||
pub const KEY_CORRECTION_DATA_PLACEHOLDER: [u8; 16] = [0u8; 16];
|
||||
|
||||
// ── MKB record walking ────────────────────────────────────────────────────
|
||||
|
||||
/// A single MKB record produced by [`walk_mkb`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MkbRecord {
|
||||
/// Byte offset of the record within the MKB.
|
||||
pub offset: usize,
|
||||
/// Record type byte.
|
||||
pub rec_type: u8,
|
||||
/// Record length in bytes (includes the 4-byte header).
|
||||
pub rec_len: usize,
|
||||
/// Record body (the bytes after the 4-byte header).
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Walk an MKB into a flat list of records.
|
||||
///
|
||||
/// MKB record framing per AACS: 1 byte type, 3 bytes BE length
|
||||
/// INCLUDING the 4-byte header, followed by payload. The walker stops
|
||||
/// at the first `(type=0, len=0)` end marker or at end of buffer.
|
||||
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = ((mkb[pos + 1] as usize) << 16)
|
||||
| ((mkb[pos + 2] as usize) << 8)
|
||||
| (mkb[pos + 3] as usize);
|
||||
if rec_type == 0 && rec_len == 0 {
|
||||
break;
|
||||
}
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
}
|
||||
let body = mkb[pos + 4..pos + rec_len].to_vec();
|
||||
out.push(MkbRecord {
|
||||
offset: pos,
|
||||
rec_type,
|
||||
rec_len,
|
||||
body,
|
||||
});
|
||||
pos += rec_len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True iff `records` contains at least one Media Key Variant record
|
||||
/// (type `0x82` or `0x83`).
|
||||
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
|
||||
records.iter().any(|r| matches!(r.rec_type, 0x82 | 0x83))
|
||||
}
|
||||
|
||||
/// Body of the Encrypted Media Key Variant Data record (type `0x82`).
|
||||
pub fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == 0x82)
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
/// 16-byte Nonce from the Variant Number record (type `0x83`). Returns
|
||||
/// the first 16 bytes of the body.
|
||||
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
let r = records.iter().find(|r| r.rec_type == 0x83)?;
|
||||
if r.body.len() < 16 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&r.body[..16]);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Body of the Variant Key Data record. Returns the first `0x82` body
|
||||
/// that is a non-empty multiple of 16 bytes.
|
||||
pub fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == 0x82 && !r.body.is_empty() && r.body.len() % 16 == 0)
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
// ── AES-G ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2.
|
||||
///
|
||||
/// The Media Key Variant chain uses AES-G to derive both the variant
|
||||
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
|
||||
/// (`Kvu = AES-G(Km, VID)`). See [`super::keys::derive_vuk`] for the
|
||||
/// classical VUK form — the math is identical, this exposes it as a
|
||||
/// neutral primitive for the variant chain.
|
||||
fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
||||
let mut out = aes_ecb_decrypt(x1, x2);
|
||||
for i in 0..16 {
|
||||
out[i] ^= x2[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
|
||||
|
||||
/// AES-G3 seed register initial value.
|
||||
const AESG3_SEED: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||
];
|
||||
|
||||
/// AES-G3 single step: AES-G against the seed register at offset `inc`.
|
||||
fn aesg3_step(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
let mut seed = AESG3_SEED;
|
||||
seed[15] = seed[15].wrapping_add(inc);
|
||||
aes_g(key, &seed)
|
||||
}
|
||||
|
||||
fn calc_v_mask(uv: u32) -> u32 {
|
||||
let mut v_mask: u32 = 0xFFFF_FFFF;
|
||||
while (uv & !v_mask) == 0 && v_mask != 0 {
|
||||
v_mask <<= 1;
|
||||
}
|
||||
v_mask
|
||||
}
|
||||
|
||||
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
|
||||
let mut left_child = aesg3_step(dk, 0);
|
||||
let mut pk = aesg3_step(dk, 1);
|
||||
let mut right_child = aesg3_step(dk, 2);
|
||||
let mut current_v_mask = dev_key_v_mask;
|
||||
|
||||
while current_v_mask != v_mask {
|
||||
let mut bit_pos: i32 = -1;
|
||||
for i in (0..32).rev() {
|
||||
if (current_v_mask & (1u32 << i)) == 0 {
|
||||
bit_pos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
|
||||
left_child
|
||||
} else {
|
||||
right_child
|
||||
};
|
||||
|
||||
left_child = aesg3_step(&curr_key, 0);
|
||||
pk = aesg3_step(&curr_key, 1);
|
||||
right_child = aesg3_step(&curr_key, 2);
|
||||
|
||||
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
|
||||
}
|
||||
|
||||
pk
|
||||
}
|
||||
|
||||
/// Outcome of a subset-difference walk against an MKB. Carries the
|
||||
/// processing key and the matching `uv` slot — both needed as inputs
|
||||
/// to the variant chain.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProcessingKeyMatch {
|
||||
/// Processing Key.
|
||||
pub kp: [u8; 16],
|
||||
/// Subset-difference node number that matched.
|
||||
pub uv: u32,
|
||||
/// 16-byte cvalue that the matched uv selected.
|
||||
pub cvalue: [u8; 16],
|
||||
/// Index of the matching cvalue within the cvalues record.
|
||||
pub cvalue_index: usize,
|
||||
}
|
||||
|
||||
fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == rec_type && !r.body.is_empty())
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
let r = records
|
||||
.iter()
|
||||
.find(|r| (r.rec_type == 0x81 || r.rec_type == 0x86) && r.body.len() >= 16)?;
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&r.body[..16]);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Walk an MKB and return the first `(Kp, uv, cvalue)` that
|
||||
/// `device_keys` covers. Returns `None` if no DK walks any uv.
|
||||
pub fn walk_processing_key(
|
||||
records: &[MkbRecord],
|
||||
device_keys: &[DeviceKey],
|
||||
) -> Option<ProcessingKeyMatch> {
|
||||
let mk_dv = mkb_find_mk_dv(records)?;
|
||||
let uvs = mkb_find_body(records, 0x04)?;
|
||||
let cvalues = mkb_find_body(records, 0x07).or_else(|| mkb_find_body(records, 0x05))?;
|
||||
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
|
||||
for dk in device_keys {
|
||||
let device_number = dk.node as u32;
|
||||
|
||||
for uvs_idx in 0..num_uvs {
|
||||
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
||||
let u_mask_shift = uvs[5 * uvs_idx];
|
||||
|
||||
if u_mask_shift & 0xC0 != 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(u_mask_shift as u32);
|
||||
let v_mask = calc_v_mask(uv);
|
||||
|
||||
if ((device_number & u_mask) == (uv & u_mask))
|
||||
&& ((device_number & v_mask) != (uv & v_mask))
|
||||
{
|
||||
let dev_key_v_mask = calc_v_mask(dk.uv);
|
||||
let dev_key_u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(dk.u_mask_shift as u32);
|
||||
|
||||
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
|
||||
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
|
||||
|
||||
if uvs_idx >= cvalues.len() / 16 {
|
||||
continue;
|
||||
}
|
||||
let mut cv = [0u8; 16];
|
||||
cv.copy_from_slice(&cvalues[uvs_idx * 16..(uvs_idx + 1) * 16]);
|
||||
|
||||
// Validate: AES-D(Kp, cv), XOR uv into low 4 bytes,
|
||||
// then AES-D(.., mk_dv) must reveal the verify magic.
|
||||
let mut km_candidate = aes_ecb_decrypt(&pk, &cv);
|
||||
let uv_bytes = uv.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
km_candidate[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
let dec_vd = aes_ecb_decrypt(&km_candidate, &mk_dv);
|
||||
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
// On a classical (non-variant) MKB this magic must
|
||||
// match. On a variant MKB it won't — `km_candidate`
|
||||
// is really Kmp and the magic check is moot. We
|
||||
// still gate the walk on cvalue indexing being
|
||||
// sane; the chain itself enforces the variant
|
||||
// semantics downstream.
|
||||
let classical_ok = dec_vd[..8] == VERIFY_MAGIC;
|
||||
let variant_present = is_variant_mkb(records);
|
||||
if !(classical_ok || variant_present) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Some(ProcessingKeyMatch {
|
||||
kp: pk,
|
||||
uv,
|
||||
cvalue: cv,
|
||||
cvalue_index: uvs_idx,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── Error reporting ───────────────────────────────────────────────────────
|
||||
|
||||
/// Outcome of [`derive_media_key_variant`] when the chain cannot
|
||||
/// produce a Media Key. Every variant is a classification only — no
|
||||
/// strings, no Display impl beyond the error code.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum MediaKeyVariantError {
|
||||
/// MKB carries no Variant records. Caller should fall back to the
|
||||
/// classical single-stage derivation.
|
||||
NotVariantMkb,
|
||||
/// MKB is missing a required record (mk_dv, subset-difference,
|
||||
/// cvalues, variant data, or variant nonce).
|
||||
MkbIncomplete,
|
||||
/// `device_keys` did not cover any uv slot in this MKB.
|
||||
ProcessingKeyUnavailable,
|
||||
/// `Kmp[15]` carries bit `0x02`: the soft-correction path applies
|
||||
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
|
||||
SoftCorrectionRequired,
|
||||
/// `Kmp[15]` carries bit `0x04`: the online-challenge path applies
|
||||
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
|
||||
OnlineChallengeRequired,
|
||||
/// Supplied KCD equals [`KEY_CORRECTION_DATA_PLACEHOLDER`]. The
|
||||
/// derivation refuses to run with the all-zero placeholder.
|
||||
KcdNotProvided,
|
||||
/// `VARIANTS[uv]` lookup for the matched uv is not implemented.
|
||||
VariantsTableUnavailable,
|
||||
/// VKD index resolved out of the supplied `vkd_table`.
|
||||
VkdIndexOutOfRange,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MediaKeyVariantError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let code: u16 = match self {
|
||||
MediaKeyVariantError::NotVariantMkb => 7100,
|
||||
MediaKeyVariantError::MkbIncomplete => 7101,
|
||||
MediaKeyVariantError::ProcessingKeyUnavailable => 7102,
|
||||
MediaKeyVariantError::SoftCorrectionRequired => 7103,
|
||||
MediaKeyVariantError::OnlineChallengeRequired => 7104,
|
||||
MediaKeyVariantError::KcdNotProvided => 7105,
|
||||
MediaKeyVariantError::VariantsTableUnavailable => 7106,
|
||||
MediaKeyVariantError::VkdIndexOutOfRange => 7107,
|
||||
};
|
||||
write!(f, "E{code}")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MediaKeyVariantError {}
|
||||
|
||||
// ── Chain ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Look up `VARIANTS[uv]` for the matched uv. The byte layout of the
|
||||
/// per-uv slot in the Variant Number record is undocumented and is
|
||||
/// disc-specific; this helper returns `None` until a Variant disc is
|
||||
/// available to fix the layout against.
|
||||
fn variants_for_uv(_records: &[MkbRecord], _uv_index: usize) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Run the Media Key Variant chain on an MKB.
|
||||
///
|
||||
/// Inputs:
|
||||
///
|
||||
/// - `mkb_records` : MKB pre-walked via [`walk_mkb`].
|
||||
/// - `device_keys` : pool of device keys; the chain runs against the
|
||||
/// first uv slot any DK covers.
|
||||
/// - `kcd` : integrator-supplied Key Correction Data. Must not
|
||||
/// equal [`KEY_CORRECTION_DATA_PLACEHOLDER`].
|
||||
/// - `vid` : 16-byte Volume ID for the disc. Used to derive
|
||||
/// the final VUK alongside the Media Key.
|
||||
///
|
||||
/// Returns `(Km, Kvu)` on success.
|
||||
pub fn derive_media_key_variant(
|
||||
mkb_records: &[MkbRecord],
|
||||
device_keys: &[DeviceKey],
|
||||
kcd: &[u8; 16],
|
||||
vid: &[u8; 16],
|
||||
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
|
||||
if !is_variant_mkb(mkb_records) {
|
||||
return Err(MediaKeyVariantError::NotVariantMkb);
|
||||
}
|
||||
|
||||
let pkm = walk_processing_key(mkb_records, device_keys)
|
||||
.ok_or(MediaKeyVariantError::ProcessingKeyUnavailable)?;
|
||||
|
||||
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let c_value = variant_data_record(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
if c_value.len() < 16 {
|
||||
return Err(MediaKeyVariantError::MkbIncomplete);
|
||||
}
|
||||
let mut c_block = [0u8; 16];
|
||||
c_block.copy_from_slice(&c_value[..16]);
|
||||
|
||||
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
|
||||
let mut kmp = aes_ecb_decrypt(&pkm.kp, &c_block);
|
||||
let uv_bytes = pkm.uv.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
kmp[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
|
||||
// Condition bits on Kmp[15] route off the hardcoded-KCD path.
|
||||
if kmp[15] & 0b0000_0010 != 0 {
|
||||
return Err(MediaKeyVariantError::SoftCorrectionRequired);
|
||||
}
|
||||
if kmp[15] & 0b0000_0100 != 0 {
|
||||
return Err(MediaKeyVariantError::OnlineChallengeRequired);
|
||||
}
|
||||
if kcd == &KEY_CORRECTION_DATA_PLACEHOLDER {
|
||||
return Err(MediaKeyVariantError::KcdNotProvided);
|
||||
}
|
||||
|
||||
// Step: Kpnew = Kmp XOR KCD.
|
||||
let mut kpnew = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
kpnew[i] = kmp[i] ^ kcd[i];
|
||||
}
|
||||
|
||||
// Step: Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE).
|
||||
let kvn_block = aes_g(&pkm.kp, &nonce);
|
||||
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
|
||||
|
||||
// Step: VKD_idx = Kvn XOR VARIANTS[uv].
|
||||
let v_for_uv = variants_for_uv(mkb_records, pkm.cvalue_index)
|
||||
.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?;
|
||||
let vkd_idx = kvn ^ v_for_uv;
|
||||
|
||||
// Step: VKD = vkd_table[VKD_idx * 16 .. +16].
|
||||
let off = (vkd_idx as usize) * 16;
|
||||
if off + 16 > vkd_table.len() {
|
||||
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
|
||||
}
|
||||
let mut vkd = [0u8; 16];
|
||||
vkd.copy_from_slice(&vkd_table[off..off + 16]);
|
||||
|
||||
// Step: Km = AES-128D(Kpnew, VKD) XOR uv.
|
||||
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
|
||||
for i in 0..4 {
|
||||
km[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
|
||||
// Step: Kvu = AES-G(Km, VID).
|
||||
let kvu = aes_g(&km, vid);
|
||||
|
||||
Ok((km, kvu))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
fn synthetic_mkb_classical() -> Vec<u8> {
|
||||
// Minimal MKB: type/version record + cvalues + mk_dv. No variant
|
||||
// records.
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xCD; 16]);
|
||||
mkb
|
||||
}
|
||||
|
||||
fn synthetic_mkb_with_variant() -> Vec<u8> {
|
||||
let mut mkb = synthetic_mkb_classical();
|
||||
// 0x82 — 16-byte body (Variant data / VKD slot).
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xEE; 16]);
|
||||
// 0x83 — 16-byte body (Variant Nonce).
|
||||
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0x55; 16]);
|
||||
mkb
|
||||
}
|
||||
|
||||
// ── Walker / record detection ──
|
||||
|
||||
#[test]
|
||||
fn walker_parses_synthetic_mkb() {
|
||||
let mkb = synthetic_mkb_classical();
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 3);
|
||||
assert_eq!(recs[0].rec_type, 0x10);
|
||||
assert_eq!(recs[1].rec_type, 0x07);
|
||||
assert_eq!(recs[2].rec_type, 0x86);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_detection_negative_on_classical() {
|
||||
let recs = walk_mkb(&synthetic_mkb_classical());
|
||||
assert!(!is_variant_mkb(&recs));
|
||||
assert!(variant_nonce(&recs).is_none());
|
||||
assert!(variant_key_data(&recs).is_none());
|
||||
assert!(variant_data_record(&recs).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_detection_positive_on_variant() {
|
||||
let recs = walk_mkb(&synthetic_mkb_with_variant());
|
||||
assert!(is_variant_mkb(&recs));
|
||||
assert_eq!(variant_nonce(&recs), Some([0x55; 16]));
|
||||
assert_eq!(variant_key_data(&recs), Some(&[0xEE; 16][..]));
|
||||
assert_eq!(variant_data_record(&recs), Some(&[0xEE; 16][..]));
|
||||
}
|
||||
|
||||
// ── Chain entry-point classification ──
|
||||
|
||||
#[test]
|
||||
fn chain_rejects_non_variant_mkb() {
|
||||
let recs = walk_mkb(&synthetic_mkb_classical());
|
||||
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
|
||||
.expect_err("classical MKB must be rejected");
|
||||
assert_eq!(err, MediaKeyVariantError::NotVariantMkb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_rejects_placeholder_kcd() {
|
||||
// To reach the KCD check we need a complete variant MKB AND a
|
||||
// DK that walks it. We construct both via the synthetic
|
||||
// fixture below.
|
||||
let (recs, dk, _kp, _expected_kmp) = synthetic_variant_setup(/*kmp15*/ 0x00);
|
||||
let err =
|
||||
derive_media_key_variant(&recs, &[dk], &KEY_CORRECTION_DATA_PLACEHOLDER, &[0u8; 16])
|
||||
.expect_err("placeholder KCD must be rejected");
|
||||
assert_eq!(err, MediaKeyVariantError::KcdNotProvided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_detects_soft_correction_bit() {
|
||||
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x02);
|
||||
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
|
||||
.expect_err("bit 0x02 must surface SoftCorrectionRequired");
|
||||
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_detects_online_challenge_bit() {
|
||||
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x04);
|
||||
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
|
||||
.expect_err("bit 0x04 must surface OnlineChallengeRequired");
|
||||
assert_eq!(err, MediaKeyVariantError::OnlineChallengeRequired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_surfaces_variants_table_gap_on_clean_kmp() {
|
||||
// With both condition bits clear and a non-placeholder KCD, the
|
||||
// chain advances to the per-uv VARIANTS[uv] lookup, which is
|
||||
// not yet wired. That returns VariantsTableUnavailable —
|
||||
// proving the bit checks and KCD check all passed.
|
||||
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
|
||||
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
|
||||
.expect_err("expected VariantsTableUnavailable at the per-uv lookup");
|
||||
assert_eq!(err, MediaKeyVariantError::VariantsTableUnavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_display_is_code_only() {
|
||||
// No English in Display — every variant emits "E7xxx" and
|
||||
// nothing else.
|
||||
let cases = [
|
||||
MediaKeyVariantError::NotVariantMkb,
|
||||
MediaKeyVariantError::MkbIncomplete,
|
||||
MediaKeyVariantError::ProcessingKeyUnavailable,
|
||||
MediaKeyVariantError::SoftCorrectionRequired,
|
||||
MediaKeyVariantError::OnlineChallengeRequired,
|
||||
MediaKeyVariantError::KcdNotProvided,
|
||||
MediaKeyVariantError::VariantsTableUnavailable,
|
||||
MediaKeyVariantError::VkdIndexOutOfRange,
|
||||
];
|
||||
for e in cases {
|
||||
let s = e.to_string();
|
||||
assert!(
|
||||
s.starts_with('E') && s.len() == 5,
|
||||
"error display must be E#### only, got {s:?}"
|
||||
);
|
||||
assert!(
|
||||
s.chars().skip(1).all(|c| c.is_ascii_digit()),
|
||||
"error display must be E + digits, got {s:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fixture construction ──
|
||||
|
||||
/// Build a synthetic variant MKB plus a DK that walks the single
|
||||
/// subset-difference slot it carries. `kmp15` is the value of the
|
||||
/// low byte of `Kmp[15]` that the chain will land on — pick `0x02`
|
||||
/// to exercise the SoftCorrection bit, `0x04` to exercise
|
||||
/// OnlineChallenge, `0x00` otherwise.
|
||||
///
|
||||
/// The fixture pins:
|
||||
/// - MKB subset-difference: `u_mask_shift=3, uv=2`. With these
|
||||
/// masks the discriminator bit (u_mask=1, v_mask=0) is bit 2.
|
||||
/// - one DK at `node=4, uv=2, u_mask_shift=3`. node 4 has bit 2 set
|
||||
/// (differs from uv=2 on bit 2 → disagrees on v_mask) while
|
||||
/// agreeing with uv on bits 3+ (the u_mask=1 region). dk.uv ==
|
||||
/// MKB.uv and dk.u_mask_shift == MKB.u_mask_shift make
|
||||
/// `dev_key_v_mask == v_mask`, so `calc_pk_from_dk` loops zero
|
||||
/// times — Kp = aesg3_step(dk, 1).
|
||||
/// - one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a
|
||||
/// Kmp whose byte-15 is exactly `kmp15`.
|
||||
/// - record 0x82 with a 16-byte body (acts as both Variant Data
|
||||
/// and Variant Key Data; satisfies the parser heuristics).
|
||||
/// - record 0x83 with a 16-byte Nonce.
|
||||
///
|
||||
/// Returns (records, dk, planted_kp, planted_kmp).
|
||||
fn synthetic_variant_setup(kmp15: u8) -> (Vec<MkbRecord>, DeviceKey, [u8; 16], [u8; 16]) {
|
||||
use crate::aacs::decrypt::aes_ecb_encrypt;
|
||||
|
||||
// Build header.
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
|
||||
// Subset-difference (0x04): u_mask_shift=3, uv=00 00 00 02.
|
||||
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
|
||||
mkb.extend_from_slice(&[0x03, 0x00, 0x00, 0x00, 0x02]);
|
||||
|
||||
// Pick a known DK; with dk.uv == MKB.uv (==2) and
|
||||
// dk.u_mask_shift == MKB.u_mask_shift (==1), dev_key_v_mask
|
||||
// equals the MKB's v_mask and the calc_pk_from_dk loop is a
|
||||
// no-op — Kp = aesg3_step(dk, 1).
|
||||
let dk_bytes: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let kp = aesg3_step(&dk_bytes, 1);
|
||||
|
||||
// Plant Kmp with chosen byte-15, then compute C such that
|
||||
// AES-D(Kp, C) ⊕ uv == Kmp. uv=2 → low-4 bytes XOR is 00 00 00 02.
|
||||
let mut kmp = [0x42u8; 16];
|
||||
kmp[15] = kmp15;
|
||||
let mut aes_d_result = kmp;
|
||||
aes_d_result[15] ^= 0x02;
|
||||
let c_block = aes_ecb_encrypt(&kp, &aes_d_result);
|
||||
|
||||
// cvalues record (0x07): one 16-byte cvalue. The walker
|
||||
// indexes it for the magic-check step; on a variant MKB the
|
||||
// magic check fails but `variant_present` is true so the
|
||||
// walker still returns the match. Content is don't-care.
|
||||
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
|
||||
// Verify Media Key (0x86): body content is don't-care.
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xCD; 16]);
|
||||
|
||||
// 0x82 record: holds C (Encrypted Media Key Variant Data) AND
|
||||
// doubles as the VKD table (single 16-byte entry → VKDidx must
|
||||
// resolve to 0 for `chain_surfaces_variants_table_gap` test —
|
||||
// but the test never reaches the VKD lookup since the
|
||||
// VARIANTS[uv] helper is not yet wired).
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&c_block);
|
||||
|
||||
// 0x83 record: 16-byte Nonce.
|
||||
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0x77; 16]);
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
|
||||
let dk = DeviceKey {
|
||||
key: dk_bytes,
|
||||
node: 4,
|
||||
uv: 2,
|
||||
u_mask_shift: 3,
|
||||
};
|
||||
(recs, dk, kp, kmp)
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -4,13 +4,14 @@
|
||||
//! 1. Bus authentication (challenge-response) → bus key
|
||||
//! 2. Read disc key block (READ DVD STRUCTURE) → XOR with bus key → decrypt with player keys → disc key
|
||||
//! 3. Read title key (REPORT KEY format 0x04) → XOR with bus key → decrypt with disc key → title key
|
||||
//!
|
||||
//! Based on libdvdcss (VideoLAN) and Stevenson 1999 analysis.
|
||||
|
||||
use crate::drive::Drive;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
// ── Player keys (from libdvdcss, Stevenson's PlayerKey cracker) ───────────
|
||||
// ── Built-in public DVD CSS player keys ────────────────────────────────────
|
||||
//
|
||||
// These 31 5-byte player keys are long-public CSS inputs. With them
|
||||
// compiled in, DVD ripping works with no external key file required.
|
||||
|
||||
const PLAYER_KEYS: [[u8; 5]; 31] = [
|
||||
[0x01, 0xaf, 0xe3, 0x12, 0x80],
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod lfsr;
|
||||
pub(crate) mod tables;
|
||||
|
||||
use crate::disc::Extent;
|
||||
use crate::drive::Drive;
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
/// CSS decryption state for a DVD title.
|
||||
@@ -25,6 +26,50 @@ pub struct CssState {
|
||||
pub title_key: [u8; 5],
|
||||
}
|
||||
|
||||
/// Inputs for CSS key acquisition.
|
||||
///
|
||||
/// The acquisition path depends on which inputs the caller supplies:
|
||||
///
|
||||
/// - With `drive` + `auth_lba` set, [`resolve`] runs the full SCSI bus
|
||||
/// auth + title-key path (live BU40N / DVD drive).
|
||||
/// - With `reader` + `extents` set, [`resolve`] falls back to the
|
||||
/// crack path (Stevenson known-plaintext attack on encrypted PES
|
||||
/// headers; works on disc images and on drives whose CSS auth path
|
||||
/// is unavailable).
|
||||
///
|
||||
/// `live_drive` always wins when both modes are populated.
|
||||
pub struct CssContext<'a> {
|
||||
/// Live SCSI drive — when present, [`resolve`] tries the auth path.
|
||||
pub drive: Option<&'a mut Drive>,
|
||||
/// LBA of a known-scrambled sector for the auth path's title-key
|
||||
/// query. Required when `drive` is set.
|
||||
pub auth_lba: Option<u32>,
|
||||
/// Sector source for the crack path.
|
||||
pub reader: Option<&'a mut dyn SectorSource>,
|
||||
/// Extents to scan for the crack path. Required when `reader` is
|
||||
/// set.
|
||||
pub extents: Option<&'a [Extent]>,
|
||||
}
|
||||
|
||||
/// Acquire a CSS title key using whichever inputs the context provides.
|
||||
///
|
||||
/// Order of attempts:
|
||||
/// 1. SCSI auth path (when `drive` and `auth_lba` are set).
|
||||
/// 2. Crack path (when `reader` and `extents` are set).
|
||||
///
|
||||
/// Returns `None` if neither path is configured or both fail.
|
||||
pub fn resolve(ctx: &mut CssContext<'_>) -> Option<CssState> {
|
||||
if let (Some(drive), Some(lba)) = (ctx.drive.as_deref_mut(), ctx.auth_lba) {
|
||||
if let Ok(title_key) = auth::authenticate_and_read_title_key(drive, lba) {
|
||||
return Some(CssState { title_key });
|
||||
}
|
||||
}
|
||||
if let (Some(reader), Some(extents)) = (ctx.reader.as_deref_mut(), ctx.extents) {
|
||||
return crack_key(reader, extents);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Crack the CSS title key by reading encrypted sectors and applying
|
||||
/// a known-plaintext attack on MPEG-2 headers.
|
||||
///
|
||||
|
||||
+153
-16
@@ -3,9 +3,131 @@
|
||||
//! Decrypts sectors in-place using resolved keys from disc scanning.
|
||||
//! Handles AACS 1.0, AACS 2.0, and CSS transparently.
|
||||
//! The caller never sees encrypted data unless explicitly bypassed.
|
||||
//!
|
||||
//! ## Parallel AACS decrypt
|
||||
//!
|
||||
//! Each AACS aligned unit (6144 bytes) is decrypted INDEPENDENTLY of
|
||||
//! every other unit — per-unit key derivation from the unit_key plus
|
||||
//! the unit's own first-16-byte header. There is no cross-unit
|
||||
//! dependency, so a buffer of N units can be decrypted on N threads
|
||||
//! in parallel via a persistent rayon thread pool.
|
||||
//!
|
||||
//! Small buffers (< [`PARALLEL_MIN_UNITS`] units) fall through to the
|
||||
//! serial path to avoid pool dispatch overhead beating the per-unit
|
||||
//! AES work.
|
||||
//!
|
||||
//! ## Thread-count configuration — three layers
|
||||
//!
|
||||
//! Resolution order (highest wins):
|
||||
//! 1. The most recent [`set_decrypt_threads`] call with `n > 0`.
|
||||
//! Calling this *replaces* the live thread pool — useful for a
|
||||
//! settings-page slider in a long-running daemon.
|
||||
//! 2. `FREEMKV_THREADS` env var, if set and `> 0`. Single knob
|
||||
//! covering decrypt today, intended to also drive any future
|
||||
//! input-side / output-side worker pools.
|
||||
//! 3. Default: all available cores. Algorithm optimisation comes
|
||||
//! first — we measure single-thread performance to find serial
|
||||
//! bottlenecks before throwing parallelism at it — but once a
|
||||
//! pool is engaged we use the whole box. Hard cap at
|
||||
//! [`MAX_THREADS`] (rayon stack memory).
|
||||
|
||||
use crate::aacs;
|
||||
use crate::css;
|
||||
use rayon::prelude::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// Minimum units in a buffer before we pay the pool-dispatch cost of
|
||||
/// fanning out. Below this, serial is faster.
|
||||
const PARALLEL_MIN_UNITS: usize = 8;
|
||||
|
||||
/// Hard upper bound on configurable thread count. Anything larger is
|
||||
/// almost certainly a misconfiguration; rayon would happily allocate
|
||||
/// thousands of worker stacks otherwise.
|
||||
pub const MAX_THREADS: usize = 64;
|
||||
|
||||
/// Process-wide decrypt thread count override. `0` means "use env
|
||||
/// var, else default" — see [`decrypt_threads`] for the resolution
|
||||
/// order.
|
||||
static DECRYPT_THREADS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Current rayon pool. `RwLock<Option<Arc<...>>>` so that
|
||||
/// [`set_decrypt_threads`] can swap the pool out without leaking the
|
||||
/// old one and without blocking ongoing decrypt work (in-flight calls
|
||||
/// hold an `Arc` clone via [`decrypt_pool`] and finish on the old
|
||||
/// pool; new calls pick up the new pool).
|
||||
static DECRYPT_POOL: RwLock<Option<Arc<rayon::ThreadPool>>> = RwLock::new(None);
|
||||
|
||||
/// Configure how many threads to use for AACS unit decryption. A value
|
||||
/// of `0` resets to the env / default resolution. `1` forces serial.
|
||||
/// `N > 1` builds a new rayon pool of size N and atomically replaces
|
||||
/// the live pool.
|
||||
///
|
||||
/// Thread-safe. Live decrypt calls keep their previously-acquired
|
||||
/// pool reference for the rest of the call — no mid-call pool
|
||||
/// switch. Subsequent calls see the new pool.
|
||||
///
|
||||
/// Pool construction is ~ms-scale; safe to call from a settings POST
|
||||
/// handler.
|
||||
pub fn set_decrypt_threads(n: usize) {
|
||||
let clamped = n.min(MAX_THREADS);
|
||||
DECRYPT_THREADS.store(clamped, Ordering::Relaxed);
|
||||
// Drop the existing pool. Next decrypt_pool() call rebuilds with
|
||||
// the new resolved thread count.
|
||||
if let Ok(mut guard) = DECRYPT_POOL.write() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get (or lazily build) the active rayon thread pool. Returns an
|
||||
/// `Arc` so in-flight work survives a concurrent
|
||||
/// [`set_decrypt_threads`] swap.
|
||||
fn decrypt_pool() -> Arc<rayon::ThreadPool> {
|
||||
// Fast path: pool already built.
|
||||
if let Ok(guard) = DECRYPT_POOL.read() {
|
||||
if let Some(pool) = guard.as_ref() {
|
||||
return Arc::clone(pool);
|
||||
}
|
||||
}
|
||||
// Slow path: build a new one under the write lock. Double-check
|
||||
// after acquiring in case another caller built it first.
|
||||
let mut guard = DECRYPT_POOL.write().expect("DECRYPT_POOL RwLock poisoned");
|
||||
if let Some(pool) = guard.as_ref() {
|
||||
return Arc::clone(pool);
|
||||
}
|
||||
let n = decrypt_threads();
|
||||
let pool = Arc::new(
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(n)
|
||||
.thread_name(|i| format!("freemkv-decrypt-{i}"))
|
||||
.build()
|
||||
.expect("rayon decrypt pool build failed"),
|
||||
);
|
||||
*guard = Some(Arc::clone(&pool));
|
||||
pool
|
||||
}
|
||||
|
||||
/// Current effective decrypt thread count. Resolution order:
|
||||
/// 1. Most recent [`set_decrypt_threads`] value (if > 0)
|
||||
/// 2. `FREEMKV_THREADS` env var (if set and > 0)
|
||||
/// 3. Default: all available cores, capped at [`MAX_THREADS`].
|
||||
pub fn decrypt_threads() -> usize {
|
||||
let explicit = DECRYPT_THREADS.load(Ordering::Relaxed);
|
||||
if explicit > 0 {
|
||||
return explicit;
|
||||
}
|
||||
let env = std::env::var("FREEMKV_THREADS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
if env > 0 {
|
||||
return env.min(MAX_THREADS);
|
||||
}
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(2);
|
||||
cores.clamp(1, MAX_THREADS)
|
||||
}
|
||||
|
||||
/// Resolved decryption state from disc scanning.
|
||||
/// Passed to `decrypt_sectors()` — the caller doesn't need to know
|
||||
@@ -57,29 +179,44 @@ pub fn decrypt_sectors(
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
};
|
||||
let rdk = read_data_key.as_ref();
|
||||
let rdk: Option<[u8; 16]> = *read_data_key;
|
||||
let unit_len = aacs::ALIGNED_UNIT_LEN;
|
||||
let nthreads = decrypt_threads();
|
||||
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
|
||||
let nunits = chunks.len();
|
||||
|
||||
for chunk in buf.chunks_mut(unit_len) {
|
||||
// Per-unit decrypt closure. The is_unit_encrypted check is
|
||||
// a byte-0 heuristic; on a misfire we snapshot+restore via
|
||||
// the original bytes so non-m2ts (e.g. MPLS/CLPI nav files)
|
||||
// survive. See test `nav_file_unit_survives_decrypt_attempt`.
|
||||
let decrypt_one = |chunk: &mut [u8]| {
|
||||
if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) {
|
||||
// `is_unit_encrypted` is a byte-0 heuristic: it fires on any
|
||||
// unit whose first byte has the top 2 bits set, which is
|
||||
// correct for m2ts source packets (where those bits are the
|
||||
// copy-control marker) but false-positives on any other binary
|
||||
// data with similarly-shaped first bytes — notably MPLS/CLPI
|
||||
// navigation files that begin with ASCII magic ('M', 'H'…)
|
||||
// and survive sweep mixed in with encrypted m2ts payloads.
|
||||
// `decrypt_unit_full` self-checks via TS-sync verification and
|
||||
// returns false on a misfire, but it has already mutated the
|
||||
// chunk by then. Snapshot and restore on verification failure
|
||||
// — same pattern `decrypt_unit_try_keys` uses for multi-key
|
||||
// discs. Real m2ts units verify and stay decrypted; nav-file
|
||||
// sectors get scrambled briefly and then put back as-was.
|
||||
let original: Vec<u8> = chunk.to_vec();
|
||||
if !aacs::decrypt_unit_full(chunk, &uk, rdk) {
|
||||
if !aacs::decrypt_unit_full(chunk, &uk, rdk.as_ref()) {
|
||||
chunk.copy_from_slice(&original);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
|
||||
// Serial path: avoids thread-pool overhead for tiny
|
||||
// buffers; also the only path when caller pinned
|
||||
// single-threaded via FREEMKV_THREADS=1.
|
||||
for chunk in chunks {
|
||||
decrypt_one(chunk);
|
||||
}
|
||||
} else {
|
||||
// Parallel path via rayon's persistent global pool.
|
||||
// The pool is built once on first use (lazy_static-style)
|
||||
// and reused across every decrypt_sectors call — no
|
||||
// per-call OS thread spawn, no thread-creation latency
|
||||
// amortised per batch. Each unit decrypts independently
|
||||
// (own key derivation), so par_iter is sound.
|
||||
decrypt_pool().install(|| {
|
||||
chunks.into_par_iter().for_each(|chunk| {
|
||||
decrypt_one(chunk);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
DecryptKeys::Css { title_key } => {
|
||||
|
||||
+280
-28
@@ -14,19 +14,173 @@ pub(super) struct HandshakeResult {
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// SCSI handshake result — volume ID and bus keys from ECDH authentication.
|
||||
/// Only available when scanning from a real drive (not ISO images).
|
||||
/// Acquire the Volume ID. Tries the per-drive OEM CDB path first
|
||||
/// when the drive reports `is_unlocked()` (extended-access state),
|
||||
/// and falls back to the cert-based AACS mutual-auth handshake
|
||||
/// otherwise.
|
||||
///
|
||||
/// The OEM path is a single READ_BUFFER CDB built from the drive
|
||||
/// profile's `read_vid_cdb` template. The response carries a 3-byte
|
||||
/// header (validated against `00 22 00`) followed by the 16-byte
|
||||
/// VID at bytes [4..20]. Crucially, no AGID setup is required —
|
||||
/// the drive's runtime firmware serves the VID directly when in
|
||||
/// extended-access state.
|
||||
///
|
||||
/// The cert path is the standard AACS spec flow: ECDH key
|
||||
/// agreement, bus-key derivation, then `REPORT_DISC_STRUCTURE`
|
||||
/// format 0x80 to retrieve VID under bus-key MAC.
|
||||
pub(super) fn read_vid(
|
||||
session: &mut crate::drive::Drive,
|
||||
opts: &ScanOptions,
|
||||
) -> Result<[u8; 16]> {
|
||||
if session.is_unlocked() {
|
||||
let profile = session
|
||||
.drive_profile()
|
||||
.ok_or(Error::DriveProfileMissing)?
|
||||
.clone();
|
||||
return Self::read_vid_oem(session, &profile);
|
||||
}
|
||||
Self::read_vid_cert(session, opts)
|
||||
}
|
||||
|
||||
/// OEM VID retrieval — issues the per-drive READ_BUFFER CDB and
|
||||
/// parses the response.
|
||||
///
|
||||
/// Response layout (36 bytes):
|
||||
/// * [0..3] 3-byte response signature; expected `00 22 00`
|
||||
/// * [3] reserved
|
||||
/// * [4..20] 16-byte Volume ID
|
||||
/// * [20..36] reserved / per-drive padding
|
||||
fn read_vid_oem(
|
||||
session: &mut crate::drive::Drive,
|
||||
profile: &crate::profile::DriveProfile,
|
||||
) -> Result<[u8; 16]> {
|
||||
const RESPONSE_LEN: usize = 36;
|
||||
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
|
||||
|
||||
let cdb = profile.read_vid_cdb.ok_or(Error::VidCdbUnavailable)?;
|
||||
let mut buf = vec![0u8; RESPONSE_LEN];
|
||||
let result = session.scsi_execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)?;
|
||||
if result.bytes_transferred < RESPONSE_LEN {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_short_response",
|
||||
bytes_transferred = result.bytes_transferred,
|
||||
"OEM VID CDB returned short response"
|
||||
);
|
||||
return Err(Error::AacsVidRead);
|
||||
}
|
||||
if buf[0..3] != EXPECTED_HEADER {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_bad_header",
|
||||
header_0 = buf[0],
|
||||
header_1 = buf[1],
|
||||
header_2 = buf[2],
|
||||
"OEM VID response header mismatch"
|
||||
);
|
||||
return Err(Error::AacsVidRead);
|
||||
}
|
||||
let mut vid = [0u8; 16];
|
||||
vid.copy_from_slice(&buf[4..20]);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_ok",
|
||||
"OEM VID retrieved"
|
||||
);
|
||||
Ok(vid)
|
||||
}
|
||||
|
||||
/// Cert-based VID retrieval — runs the full AACS mutual-auth
|
||||
/// handshake and extracts VID from the bus-key-MAC'd
|
||||
/// `REPORT_DISC_STRUCTURE` response.
|
||||
fn read_vid_cert(session: &mut crate::drive::Drive, opts: &ScanOptions) -> Result<[u8; 16]> {
|
||||
match Self::do_handshake_cert(session, opts) {
|
||||
(Some(h), _) => Ok(h.volume_id),
|
||||
(None, Some(e)) => Err(e),
|
||||
(None, None) => Err(Error::AacsVidUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
/// SCSI handshake — drives the VID-acquisition flow and returns
|
||||
/// a structured `HandshakeResult` for downstream key resolution.
|
||||
/// Prefers the OEM path when `Drive::is_unlocked()` is true and
|
||||
/// falls back to cert-based mutual auth otherwise.
|
||||
///
|
||||
/// The OEM path produces only VID (no bus-key, so no
|
||||
/// `read_data_key`); the cert path can produce both. AACS 2.0
|
||||
/// content that needs read_data_key for bus decryption requires
|
||||
/// the cert path.
|
||||
///
|
||||
/// Returns `(handshake, error)`:
|
||||
/// * `(Some(_), None)` — VID acquired
|
||||
/// * `(None, Some(_))` — specific failure mode (see
|
||||
/// `AacsHostCertRejected` / `AacsRawReadUnsupported` /
|
||||
/// `AacsVidUnavailable` / `DriveProfileMissing` /
|
||||
/// `VidCdbUnavailable` variants in `error.rs`)
|
||||
/// * `(None, None)` — handshake not attempted (no keydb;
|
||||
/// resolution will proceed with VID=zero and rely on path 1
|
||||
/// disc-hash → VUK lookup)
|
||||
pub(super) fn do_handshake(
|
||||
session: &mut crate::drive::Drive,
|
||||
opts: &ScanOptions,
|
||||
) -> Option<HandshakeResult> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||
let unlocked = session.is_unlocked();
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_entry",
|
||||
unlocked,
|
||||
"do_handshake entered"
|
||||
);
|
||||
|
||||
if unlocked {
|
||||
// Try OEM VID retrieval first. If the drive's profile
|
||||
// doesn't carry the CDB template, or the response is
|
||||
// malformed, fall through to cert-based auth.
|
||||
match Self::read_vid(session, opts) {
|
||||
Ok(volume_id) => {
|
||||
return (
|
||||
Some(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Err(Error::DriveProfileMissing) | Err(Error::VidCdbUnavailable) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_oem_unavailable",
|
||||
"OEM VID path unavailable for this drive; trying cert handshake"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_oem_failed",
|
||||
error_code = e.code(),
|
||||
"OEM VID retrieval failed; trying cert handshake"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self::do_handshake_cert(session, opts)
|
||||
}
|
||||
|
||||
/// Cert-based AACS handshake. The legacy auth path; still used as
|
||||
/// the fallback when the OEM VID path isn't available or fails.
|
||||
fn do_handshake_cert(
|
||||
session: &mut crate::drive::Drive,
|
||||
opts: &ScanOptions,
|
||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
let keydb_path = match opts.resolve_keydb() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
@@ -35,7 +189,7 @@ impl Disc {
|
||||
phase = "handshake_no_keydb",
|
||||
"no KEYDB found in search paths; handshake skipped"
|
||||
);
|
||||
return None;
|
||||
return (None, None);
|
||||
}
|
||||
};
|
||||
let keydb = match KeyDb::load(&keydb_path) {
|
||||
@@ -48,7 +202,12 @@ impl Disc {
|
||||
keydb = %keydb_path.display(),
|
||||
"KEYDB load failed; handshake skipped"
|
||||
);
|
||||
return None;
|
||||
return (
|
||||
None,
|
||||
Some(Error::KeydbLoad {
|
||||
path: keydb_path.display().to_string(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,9 +220,36 @@ impl Disc {
|
||||
"handshake starting"
|
||||
);
|
||||
|
||||
const MAX_CERT_ATTEMPTS: usize = 16;
|
||||
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
|
||||
// host cert isn't in our KEYDB (or one the drive rejects),
|
||||
// that's 80-160 SCSI commands hammered at the drive in a
|
||||
// few hundred milliseconds — and the BU40N (and most consumer
|
||||
// optical drives) responds by entering a fast-fail firmware
|
||||
// wedge state where every subsequent CDB returns
|
||||
// ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB (sense 05/24) until
|
||||
// power-cycled. Hit live on rip1 2026-05-20 during a Barbie
|
||||
// UHD scan: KEYDB miss → 16 cert attempts in a tight loop →
|
||||
// wedge → forced host reboot + drive disconnect to recover.
|
||||
//
|
||||
// Defense-in-depth: cap attempts, sleep between, and bail
|
||||
// early on the drive's wedge sense so any later regression
|
||||
// can't undo the protection silently.
|
||||
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() {
|
||||
if idx > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
|
||||
}
|
||||
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
|
||||
Ok(mut auth) => {
|
||||
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
|
||||
@@ -76,7 +262,7 @@ impl Disc {
|
||||
error_code = e.code(),
|
||||
"auth ok but volume ID read failed"
|
||||
);
|
||||
return None;
|
||||
return (None, Some(Error::AacsVidUnavailable));
|
||||
}
|
||||
};
|
||||
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
||||
@@ -88,13 +274,33 @@ impl Disc {
|
||||
cert_index = idx,
|
||||
has_read_data_key = read_data_key.is_some(),
|
||||
);
|
||||
return Some(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key,
|
||||
});
|
||||
return (
|
||||
Some(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
last_err_code = Some(e.code());
|
||||
let code = e.code();
|
||||
last_err_code = Some(code);
|
||||
// Drive wedge senses (any with high byte 0x05 =
|
||||
// ILLEGAL_REQUEST). The drive isn't merely
|
||||
// rejecting our cert — it's saying "I won't talk
|
||||
// to you anymore." Trying more certs makes the
|
||||
// wedge worse. Bail out immediately.
|
||||
let sense_key = ((code >> 8) & 0xFF) as u8;
|
||||
if sense_key == 0x05 {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_wedge_detected",
|
||||
cert_index = idx,
|
||||
error_code = code,
|
||||
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
|
||||
);
|
||||
return (None, Some(Error::AacsHostCertRejected));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -105,10 +311,10 @@ impl Disc {
|
||||
host_cert_count,
|
||||
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
|
||||
last_error_code = last_err_code,
|
||||
"all host certs in KEYDB rejected by drive"
|
||||
"all host certs in KEYDB rejected by drive (capped at {} attempts to prevent firmware wedge)",
|
||||
MAX_CERT_ATTEMPTS
|
||||
);
|
||||
// All host certs failed — return None, not a fake success
|
||||
None
|
||||
(None, Some(Error::AacsHostCertRejected))
|
||||
}
|
||||
|
||||
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
||||
@@ -123,6 +329,7 @@ impl Disc {
|
||||
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(),
|
||||
@@ -181,22 +388,67 @@ impl Disc {
|
||||
);
|
||||
|
||||
// Use handshake volume ID if available, otherwise zeros
|
||||
// (KEYDB VUK lookup by disc hash works without volume ID)
|
||||
// (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
|
||||
let resolved = aacs::resolve_keys(
|
||||
&uk_ro_data,
|
||||
cc_data.as_deref(),
|
||||
&volume_id,
|
||||
&keydb,
|
||||
mkb_data.as_deref(),
|
||||
)
|
||||
.ok_or(Error::AacsNoKeys)?;
|
||||
// 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 aacs_ctx = aacs::ResolveContext {
|
||||
unit_key_ro: &uk_ro_data,
|
||||
content_cert: cc_data.as_deref(),
|
||||
volume_id: &volume_id,
|
||||
keydb: &keydb,
|
||||
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: if resolved.aacs2 { 2 } else { 1 },
|
||||
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),
|
||||
|
||||
+90
-26
@@ -1043,8 +1043,11 @@ impl Disc {
|
||||
/// The session must be open and unlocked (Drive::open handles this).
|
||||
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
|
||||
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
|
||||
// AACS handshake (Blu-ray/UHD)
|
||||
let handshake = Self::do_handshake(session, opts);
|
||||
// AACS handshake (Blu-ray/UHD). Routes through Disc::read_vid,
|
||||
// which prefers the per-drive OEM CDB path when the drive is
|
||||
// in the extended-access state and falls back to cert-based
|
||||
// mutual auth otherwise.
|
||||
let (handshake, handshake_error) = Self::do_handshake(session, opts);
|
||||
|
||||
// Request max read speed — removes riplock on DVD
|
||||
// (BD/UHD speed is set by firmware init, but DVD needs explicit SET CD SPEED)
|
||||
@@ -1059,31 +1062,56 @@ impl Disc {
|
||||
buffered.prefetch_ranges(&ranges);
|
||||
}
|
||||
|
||||
let mut disc = Self::scan_with(&mut buffered, capacity, handshake, opts, udf_fs)?;
|
||||
let mut disc = Self::scan_with(
|
||||
&mut buffered,
|
||||
capacity,
|
||||
handshake,
|
||||
handshake_error,
|
||||
opts,
|
||||
udf_fs,
|
||||
)?;
|
||||
|
||||
// CSS key extraction for DVDs (bus auth → disc key → title key).
|
||||
// Must be a single auth session — can't call authenticate() separately.
|
||||
// Route through the DRM dispatcher: probe a title sector, detect
|
||||
// CSS if scrambled, then load via the SCSI auth path.
|
||||
if disc.css.is_none()
|
||||
&& disc.content_format == ContentFormat::MpegPs
|
||||
&& !disc.titles.is_empty()
|
||||
{
|
||||
let lba = disc.titles[0].extents.iter().find_map(|ext| {
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let mut probe_buf = vec![0u8; 2048];
|
||||
let auth_lba = disc.titles[0].extents.iter().find_map(|ext| {
|
||||
if session
|
||||
.read_sectors(ext.start_lba, 1, &mut buf, true)
|
||||
.read_sectors(ext.start_lba, 1, &mut probe_buf, true)
|
||||
.is_ok()
|
||||
&& crate::css::is_scrambled(&buf)
|
||||
{
|
||||
return Some(ext.start_lba);
|
||||
let probe = crate::drm::DrmProbe {
|
||||
dvd_sample_sector: Some(&probe_buf),
|
||||
content_cert: None,
|
||||
mkb: None,
|
||||
};
|
||||
if crate::drm::DrmScheme::detect(&probe) == Some(crate::drm::DrmScheme::Css) {
|
||||
return Some(ext.start_lba);
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
|
||||
if let Some(lba) = lba {
|
||||
if let Ok(title_key) =
|
||||
crate::css::auth::authenticate_and_read_title_key(session, lba)
|
||||
if let Some(lba) = auth_lba {
|
||||
let css_ctx = crate::css::CssContext {
|
||||
drive: Some(session),
|
||||
auth_lba: Some(lba),
|
||||
reader: None,
|
||||
extents: None,
|
||||
};
|
||||
let mut ctx = crate::drm::DrmContext {
|
||||
aacs: None,
|
||||
css: Some(css_ctx),
|
||||
};
|
||||
if let Some(crate::drm::ResolvedScheme::Css(state)) =
|
||||
crate::drm::DrmScheme::Css.load(&mut ctx)
|
||||
{
|
||||
disc.css = Some(crate::css::CssState { title_key });
|
||||
disc.css = Some(state);
|
||||
disc.encrypted = true;
|
||||
}
|
||||
}
|
||||
@@ -1100,14 +1128,21 @@ impl Disc {
|
||||
opts: &ScanOptions,
|
||||
) -> Result<Self> {
|
||||
let udf_fs = udf::read_filesystem(reader)?;
|
||||
Self::scan_with(reader, capacity, None, opts, udf_fs)
|
||||
Self::scan_with(reader, capacity, None, None, opts, udf_fs)
|
||||
}
|
||||
|
||||
/// Core scan pipeline — works with any SectorSource.
|
||||
///
|
||||
/// `handshake_error` is plumbed from `do_handshake` so failures
|
||||
/// (cert rejected, raw-read unsupported, VID read failed) are
|
||||
/// preserved as `disc.aacs_error` for callers to render. When key
|
||||
/// resolution succeeds despite the handshake failure (built-in
|
||||
/// keys + disc-hash lookup hit) the error is dropped.
|
||||
fn scan_with(
|
||||
reader: &mut dyn SectorSource,
|
||||
capacity: u32,
|
||||
handshake: Option<HandshakeResult>,
|
||||
handshake_error: Option<Error>,
|
||||
opts: &ScanOptions,
|
||||
udf_fs: udf::UdfFs,
|
||||
) -> Result<Self> {
|
||||
@@ -1122,15 +1157,28 @@ impl Disc {
|
||||
{
|
||||
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 = e.code(),
|
||||
error_code = final_err.code(),
|
||||
keydb = %keydb_path.display(),
|
||||
handshake_ok = handshake.is_some(),
|
||||
"AACS key resolution failed"
|
||||
);
|
||||
(None, Some(e))
|
||||
(None, Some(final_err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1140,16 +1188,14 @@ impl Disc {
|
||||
phase = "scan_aacs_no_keydb",
|
||||
"encrypted disc but no KEYDB found in search paths"
|
||||
);
|
||||
// Reuse KeydbLoad with sentinel path — adding a new Error
|
||||
// variant would be a breaking change for downstream
|
||||
// exhaustive matches. The path string makes the cause
|
||||
// unambiguous to autorip's message switch.
|
||||
(
|
||||
None,
|
||||
Some(crate::error::Error::KeydbLoad {
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1190,9 +1236,27 @@ impl Disc {
|
||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||
let region = DiscRegion::Free;
|
||||
|
||||
// 6. CSS detection for DVDs
|
||||
// 6. CSS detection for DVDs — route through the DRM dispatcher.
|
||||
// Detection from a single probe sector would miss
|
||||
// DVDs whose first sector is unscrambled, so we go straight
|
||||
// to `DrmScheme::Css.load` with the crack-path context; the
|
||||
// crack path scans extents internally and bottoms out at
|
||||
// None on unencrypted media.
|
||||
let css = if content_format == ContentFormat::MpegPs && !titles.is_empty() {
|
||||
crate::css::crack_key(reader, &titles[0].extents)
|
||||
let css_ctx = crate::css::CssContext {
|
||||
drive: None,
|
||||
auth_lba: None,
|
||||
reader: Some(reader),
|
||||
extents: Some(&titles[0].extents),
|
||||
};
|
||||
let mut ctx = crate::drm::DrmContext {
|
||||
aacs: None,
|
||||
css: Some(css_ctx),
|
||||
};
|
||||
match crate::drm::DrmScheme::Css.load(&mut ctx) {
|
||||
Some(crate::drm::ResolvedScheme::Css(s)) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -184,6 +184,13 @@ impl Drive {
|
||||
self.profile.is_some()
|
||||
}
|
||||
|
||||
/// Borrow the matched drive profile, if any. Used by callers that
|
||||
/// need to issue per-drive OEM CDB templates (e.g. the OEM VID
|
||||
/// retrieval path in `disc::encrypt`).
|
||||
pub fn drive_profile(&self) -> Option<&DriveProfile> {
|
||||
self.profile.as_ref()
|
||||
}
|
||||
|
||||
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
|
||||
pub fn scsi_mut(&mut self) -> &mut dyn ScsiTransport {
|
||||
self.scsi.as_mut()
|
||||
@@ -432,6 +439,27 @@ impl Drive {
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the drive is currently in the extended-access state.
|
||||
///
|
||||
/// Detected by the platform driver during `init()` from the unlock
|
||||
/// response's mode markers. When true:
|
||||
/// - SCSI READ_10 returns plaintext sectors (no AACS bus
|
||||
/// encryption applied)
|
||||
/// - VID retrieval works via the per-drive OEM CDB in
|
||||
/// [`DriveProfile`] without the cert-based AACS handshake
|
||||
/// - Disc-side Host Revocation List enforcement is effectively
|
||||
/// bypassed by the alternate data path
|
||||
///
|
||||
/// AACS layer code branches on this: if true, issue the OEM
|
||||
/// `read_vid_cdb` to retrieve VID directly; if false, fall back
|
||||
/// to the cert-based mutual-auth handshake.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
match self.driver {
|
||||
Some(ref d) => d.is_unlocked(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read sectors from the disc. Single-shot — no inline retries, no
|
||||
/// SCSI reset.
|
||||
///
|
||||
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
//! Top-level DRM scheme dispatch.
|
||||
//!
|
||||
//! Four content-protection schemes ride through a single
|
||||
//! detect-then-load pipeline:
|
||||
//!
|
||||
//! | Scheme | Discriminator |
|
||||
//! |---------------------|------------------------------------------------|
|
||||
//! | [`DrmScheme::Css`] | DVD probe sector flagged scrambled |
|
||||
//! | [`DrmScheme::Aacs10`] | Content cert type byte `0x00` |
|
||||
//! | [`DrmScheme::Aacs20`] | Content cert type byte `!= 0x00`, no Variant |
|
||||
//! | [`DrmScheme::Aacs21`] | Content cert + MKB records `0x82` / `0x83` |
|
||||
//!
|
||||
//! Detection happens from a [`DrmProbe`] (raw inputs the caller has
|
||||
//! already extracted from the disc); resolution runs through a
|
||||
//! [`DrmContext`] (the full set of inputs the loaders need).
|
||||
//!
|
||||
//! The AACS 2.1 arm is wired but disabled. The dispatcher leaves
|
||||
//! [`crate::aacs::resolve_keys_v21`] reachable as a library entry point
|
||||
//! for fixture-driven validation, but production consumers go through
|
||||
//! [`DrmScheme::load`], which short-circuits V21 to `None` until the
|
||||
//! Variant chain has a real Variant-scheme disc to validate against.
|
||||
|
||||
use crate::aacs;
|
||||
use crate::css;
|
||||
|
||||
/// Which content-protection scheme governs a disc.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DrmScheme {
|
||||
/// DVD Content Scramble System.
|
||||
Css,
|
||||
/// AACS 1.0 — original BD-ROM.
|
||||
Aacs10,
|
||||
/// AACS 2.0 — UHD-BD, classical Media Key chain.
|
||||
Aacs20,
|
||||
/// AACS 2.1 — UHD-BD with Media Key Variant chain.
|
||||
Aacs21,
|
||||
}
|
||||
|
||||
/// Inputs to [`DrmScheme::detect`]. All borrows — caller retains
|
||||
/// ownership.
|
||||
pub struct DrmProbe<'a> {
|
||||
/// 2048-byte sample sector from inside a DVD title's extents. Used
|
||||
/// only for CSS scramble-flag detection. `None` for non-DVD discs.
|
||||
pub dvd_sample_sector: Option<&'a [u8]>,
|
||||
/// Content Certificate file bytes (typically `/AACS/Content000.cer`).
|
||||
/// `None` when the disc has no AACS directory.
|
||||
pub content_cert: Option<&'a [u8]>,
|
||||
/// MKB file bytes (typically `/AACS/MKB_RW.inf`). Required to
|
||||
/// distinguish AACS 2.0 from AACS 2.1.
|
||||
pub mkb: Option<&'a [u8]>,
|
||||
}
|
||||
|
||||
/// Inputs to [`DrmScheme::load`]. Carries everything needed by either
|
||||
/// the AACS or CSS loader.
|
||||
pub struct DrmContext<'a> {
|
||||
/// AACS resolver inputs — required when the scheme is any AACS
|
||||
/// variant.
|
||||
pub aacs: Option<aacs::ResolveContext<'a>>,
|
||||
/// CSS resolver inputs — required when the scheme is [`DrmScheme::Css`].
|
||||
pub css: Option<css::CssContext<'a>>,
|
||||
}
|
||||
|
||||
/// Resolved key material, tagged by scheme.
|
||||
#[derive(Debug)]
|
||||
pub enum ResolvedScheme {
|
||||
Css(css::CssState),
|
||||
Aacs(aacs::ResolvedKeys),
|
||||
}
|
||||
|
||||
impl DrmScheme {
|
||||
/// Detect which DRM scheme protects the disc described by `probe`.
|
||||
///
|
||||
/// Returns `None` for unencrypted media. The order is intentional:
|
||||
/// CSS is checked first (DVD-format probe), then AACS (Blu-ray
|
||||
/// format).
|
||||
pub fn detect(probe: &DrmProbe<'_>) -> Option<DrmScheme> {
|
||||
// CSS — DVD probe sector carries the scramble flag.
|
||||
if let Some(sector) = probe.dvd_sample_sector {
|
||||
if css::is_scrambled(sector) {
|
||||
return Some(DrmScheme::Css);
|
||||
}
|
||||
}
|
||||
|
||||
// AACS — content cert type byte distinguishes V10 from V20+.
|
||||
// V21 promotion requires MKB Variant records.
|
||||
let cc = probe.content_cert.and_then(aacs::parse_content_cert)?;
|
||||
match cc.version {
|
||||
aacs::AacsVersion::V10 => Some(DrmScheme::Aacs10),
|
||||
aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => {
|
||||
if let Some(mkb) = probe.mkb {
|
||||
let recs = aacs::variants::walk_mkb(mkb);
|
||||
if aacs::variants::is_variant_mkb(&recs) {
|
||||
return Some(DrmScheme::Aacs21);
|
||||
}
|
||||
}
|
||||
Some(DrmScheme::Aacs20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run key resolution for this scheme against `ctx`.
|
||||
///
|
||||
/// Returns `None` when the scheme's resolver could not produce keys
|
||||
/// (missing context, KEYDB miss, failed crypto walk, etc.) or when
|
||||
/// the scheme itself is gated off (see the inline comment on the
|
||||
/// `Aacs21` arm).
|
||||
pub fn load(self, ctx: &mut DrmContext<'_>) -> Option<ResolvedScheme> {
|
||||
match self {
|
||||
DrmScheme::Css => ctx
|
||||
.css
|
||||
.as_mut()
|
||||
.and_then(css::resolve)
|
||||
.map(ResolvedScheme::Css),
|
||||
DrmScheme::Aacs10 => ctx
|
||||
.aacs
|
||||
.as_ref()
|
||||
.and_then(aacs::resolve_keys_v1)
|
||||
.map(ResolvedScheme::Aacs),
|
||||
DrmScheme::Aacs20 => ctx
|
||||
.aacs
|
||||
.as_ref()
|
||||
.and_then(aacs::resolve_keys_v2)
|
||||
.map(ResolvedScheme::Aacs),
|
||||
// AACS 2.1 derivation is wired but disabled. KCD validation
|
||||
// against a Variant-scheme disc is pending. To enable,
|
||||
// uncomment the line below.
|
||||
// DrmScheme::Aacs21 => ctx
|
||||
// .aacs
|
||||
// .as_ref()
|
||||
// .and_then(aacs::resolve_keys_v21)
|
||||
// .map(ResolvedScheme::Aacs),
|
||||
DrmScheme::Aacs21 => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Build a minimal cert: type byte + bus-encryption byte + 6 zero
|
||||
// cc_id bytes.
|
||||
fn cert(type_byte: u8) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 8];
|
||||
v[0] = type_byte;
|
||||
v
|
||||
}
|
||||
|
||||
// Synthetic AACS 2.x MKB with no Variant records.
|
||||
fn mkb_classical() -> Vec<u8> {
|
||||
vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||
]
|
||||
}
|
||||
|
||||
// Synthetic AACS 2.x MKB with a 0x82 + 0x83 record pair.
|
||||
fn mkb_with_variant() -> Vec<u8> {
|
||||
let mut m = mkb_classical();
|
||||
m.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
|
||||
m.extend_from_slice(&[0xEE; 16]);
|
||||
m.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||
m.extend_from_slice(&[0x55; 16]);
|
||||
m
|
||||
}
|
||||
|
||||
// Synthetic scrambled DVD sector — byte 0x14 carries the CSS
|
||||
// scramble flag in bits 4-5.
|
||||
fn scrambled_dvd_sector() -> Vec<u8> {
|
||||
let mut s = vec![0u8; 2048];
|
||||
s[0x14] = 0x30;
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_none_for_unencrypted() {
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: None,
|
||||
mkb: None,
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_css_for_scrambled_dvd() {
|
||||
let sector = scrambled_dvd_sector();
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: Some(§or),
|
||||
content_cert: None,
|
||||
mkb: None,
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Css));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_aacs10_for_type0_cert() {
|
||||
let c = cert(0x00);
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: Some(&c),
|
||||
mkb: None,
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_aacs20_for_type1_cert_no_variant() {
|
||||
let c = cert(0x01);
|
||||
let mkb = mkb_classical();
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: Some(&c),
|
||||
mkb: Some(&mkb),
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_aacs21_for_type1_cert_with_variant() {
|
||||
let c = cert(0x01);
|
||||
let mkb = mkb_with_variant();
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: Some(&c),
|
||||
mkb: Some(&mkb),
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs21));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_returns_aacs20_when_mkb_absent() {
|
||||
// Type-1 cert but no MKB to upgrade with -> Aacs20.
|
||||
let c = cert(0x01);
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: Some(&c),
|
||||
mkb: None,
|
||||
};
|
||||
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_aacs21_returns_none() {
|
||||
// The Aacs21 dispatch arm is commented out; load() must
|
||||
// return None until KCD validation lands.
|
||||
let uk_ro = vec![0u8; 256];
|
||||
let vid = [0u8; 16];
|
||||
let keydb = aacs::KeyDb::empty();
|
||||
let ctx_aacs = aacs::ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &vid,
|
||||
keydb: &keydb,
|
||||
mkb: None,
|
||||
};
|
||||
let mut ctx = DrmContext {
|
||||
aacs: Some(ctx_aacs),
|
||||
css: None,
|
||||
};
|
||||
assert!(DrmScheme::Aacs21.load(&mut ctx).is_none());
|
||||
}
|
||||
|
||||
/// Exercises the V21 helper directly. Gated `#[ignore]` because
|
||||
/// the chain reaches `MediaKeyVariantError::VariantsTableUnavailable`
|
||||
/// without a real Variant-scheme disc to fix the per-uv table
|
||||
/// layout against — running it here would assert only the
|
||||
/// not-yet-wired error code. Kept as a wiring smoke-test for
|
||||
/// future enablement.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn resolve_keys_v21_helper_exists() {
|
||||
let uk_ro = vec![0u8; 256];
|
||||
let vid = [0xAAu8; 16];
|
||||
let keydb = aacs::KeyDb::empty();
|
||||
let mkb = mkb_with_variant();
|
||||
let ctx = aacs::ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &vid,
|
||||
keydb: &keydb,
|
||||
mkb: Some(&mkb),
|
||||
};
|
||||
// Just confirm the symbol is callable; we don't assert on the
|
||||
// result.
|
||||
let _ = aacs::resolve_keys_v21(&ctx);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@ pub const E_AACS_VID_MAC: u16 = 7010;
|
||||
pub const E_AACS_DATA_KEY: u16 = 7011;
|
||||
pub const E_DECRYPT_FAILED: u16 = 7013;
|
||||
pub const E_CSS_AUTH_FAILED: u16 = 7014;
|
||||
pub const E_AACS_HOST_CERT_REJECTED: u16 = 7015;
|
||||
pub const E_AACS_RAW_READ_UNSUPPORTED: u16 = 7016;
|
||||
pub const E_AACS_VID_UNAVAILABLE: u16 = 7017;
|
||||
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;
|
||||
|
||||
// Keydb (8xxx)
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
@@ -222,6 +229,30 @@ pub enum Error {
|
||||
AacsDataKey,
|
||||
DecryptFailed,
|
||||
CssAuthFailed,
|
||||
/// Host certificate rejected by the drive's revocation list (HRL hit).
|
||||
/// All available host certs failed mutual auth on this drive.
|
||||
AacsHostCertRejected,
|
||||
/// Drive cannot be put into raw-read mode and standard AACS cert
|
||||
/// auth failed. No path to decryption remains.
|
||||
AacsRawReadUnsupported,
|
||||
/// Volume ID could not be retrieved from the drive (neither via cert
|
||||
/// auth nor via the alternate VID read path). Downstream of step 1
|
||||
/// of the AACS chain.
|
||||
AacsVidUnavailable,
|
||||
/// No available path produced a Media Key (no MK+VID in keydb, no
|
||||
/// PK match, no DK derivation).
|
||||
AacsMkUnavailable,
|
||||
/// Disc-hash lookup in the keydb missed and no other path is
|
||||
/// available (typically because VID is missing).
|
||||
AacsVukNotInKeydb,
|
||||
/// Drive identity did not match any bundled profile; per-drive CDB
|
||||
/// templates aren't available so the OEM VID retrieval path can't
|
||||
/// run.
|
||||
DriveProfileMissing,
|
||||
/// Drive's profile is present but doesn't carry a VID-retrieval CDB
|
||||
/// template (older profile blob, or a drive class without an OEM
|
||||
/// VID path).
|
||||
VidCdbUnavailable,
|
||||
|
||||
// Keydb (8xxx)
|
||||
KeydbConnect {
|
||||
@@ -307,6 +338,13 @@ impl Error {
|
||||
Error::AacsDataKey => E_AACS_DATA_KEY,
|
||||
Error::DecryptFailed => E_DECRYPT_FAILED,
|
||||
Error::CssAuthFailed => E_CSS_AUTH_FAILED,
|
||||
Error::AacsHostCertRejected => E_AACS_HOST_CERT_REJECTED,
|
||||
Error::AacsRawReadUnsupported => E_AACS_RAW_READ_UNSUPPORTED,
|
||||
Error::AacsVidUnavailable => E_AACS_VID_UNAVAILABLE,
|
||||
Error::AacsMkUnavailable => E_AACS_MK_UNAVAILABLE,
|
||||
Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB,
|
||||
Error::DriveProfileMissing => E_DRIVE_PROFILE_MISSING,
|
||||
Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||
|
||||
+14
@@ -71,6 +71,20 @@ impl Default for Halt {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared poll interval for halt-aware loops.
|
||||
///
|
||||
/// `bounded_syscall` checks the cancellation flag and the deadline
|
||||
/// every [`POLL_INTERVAL`] while blocked on a worker; the same
|
||||
/// cadence governs `Pipeline::send_with_halt`'s `try_send` retry.
|
||||
/// 250 ms is the sweet spot between responsiveness (operator presses
|
||||
/// Stop, sees it take effect within ~quarter-second) and waste
|
||||
/// (atomic load + clock read is cheap but not free at thousands of
|
||||
/// hertz).
|
||||
///
|
||||
/// Centralised here so the half-dozen halt-polling loops across `io`
|
||||
/// can't drift apart silently.
|
||||
pub const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+1
-9
@@ -54,15 +54,7 @@ use std::sync::mpsc::{RecvTimeoutError, sync_channel};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::halt::Halt;
|
||||
|
||||
/// Granularity of the halt poll. The receive loop wakes every
|
||||
/// [`POLL_INTERVAL`] to (a) check the [`Halt`] token, then (b) check
|
||||
/// the overall deadline, then go back to waiting. 250 ms is a
|
||||
/// pragmatic balance: short enough that human-driven `/api/stop` feels
|
||||
/// responsive (< 0.5 s p99), long enough that the polling overhead is
|
||||
/// negligible against multi-second syscalls.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||
use crate::halt::{Halt, POLL_INTERVAL};
|
||||
|
||||
/// Failure outcome from a bounded syscall wrapper.
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! `BytePrefetcher` — `std::io::Read` analogue of
|
||||
//! [`crate::sector::PrefetchedSectorSource`].
|
||||
//!
|
||||
//! Spawns a producer thread that fills a bounded pool of `Vec<u8>`
|
||||
//! chunks from the underlying reader and ships them through a
|
||||
//! channel; the consumer pulls filled chunks, uses them, and sends
|
||||
//! the empty `Vec<u8>` back through a recycle channel so the
|
||||
//! producer can re-fill in place. Result: zero allocations and zero
|
||||
//! cross-thread frees in the steady-state hot loop.
|
||||
//!
|
||||
//! This is the byte-stream half of the freemkv mux highway —
|
||||
//! `BytePrefetcher` feeds [`crate::mux::demux_thread::DemuxThread`]
|
||||
//! for `m2ts://`, `network://`, `stdio://`, and any other stream
|
||||
//! whose source is an `io::Read` rather than a `SectorSource`.
|
||||
|
||||
use crate::halt::Halt;
|
||||
use crossbeam_channel::{Receiver, Sender, bounded};
|
||||
use std::io::Read;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
/// Items flowing through the forward channel.
|
||||
pub type Batch = std::io::Result<Vec<u8>>;
|
||||
|
||||
/// Forward channel depth — how many filled buffers the producer can
|
||||
/// stay ahead by. Two is enough to absorb a moderate consumer stall
|
||||
/// without piling up bytes.
|
||||
const FORWARD_DEPTH: usize = 2;
|
||||
|
||||
/// Recycle channel depth = forward + 1 so the producer always has at
|
||||
/// least one buffer to fill while the consumer holds one.
|
||||
const RECYCLE_DEPTH: usize = FORWARD_DEPTH + 1;
|
||||
|
||||
/// Default chunk size — 16 MiB matches the ISO-mux sector batch and
|
||||
/// is large enough that per-chunk overhead is amortised; small
|
||||
/// enough that the in-flight memory footprint stays bounded.
|
||||
pub const DEFAULT_CHUNK_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// Returned from [`BytePrefetcher::into_channels`]. Owns the
|
||||
/// producer-thread join handle so dropping the shell joins the
|
||||
/// producer.
|
||||
pub struct PrefetchShell {
|
||||
producer: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for PrefetchShell {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.producer.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawned byte prefetcher. Drop joins the producer thread.
|
||||
pub struct BytePrefetcher {
|
||||
rx: Receiver<Batch>,
|
||||
recycle_tx: Sender<Vec<u8>>,
|
||||
producer: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl BytePrefetcher {
|
||||
/// Spawn the producer thread. `reader` must be `Send` because it
|
||||
/// moves into the thread. `chunk_bytes` is the size of each
|
||||
/// recycled buffer; pick the natural batch size of the
|
||||
/// downstream demuxer (16 MiB for the BD-TS mux pipeline).
|
||||
pub fn new<R: Read + Send + 'static>(
|
||||
mut reader: R,
|
||||
chunk_bytes: usize,
|
||||
halt: Option<Halt>,
|
||||
) -> Self {
|
||||
let (tx, rx) = bounded::<Batch>(FORWARD_DEPTH);
|
||||
let (recycle_tx, recycle_rx) = bounded::<Vec<u8>>(RECYCLE_DEPTH);
|
||||
|
||||
// Seed the recycle pool. Without these the first
|
||||
// `recycle_rx.recv()` would block forever (no consumer has
|
||||
// returned a buffer yet).
|
||||
for _ in 0..RECYCLE_DEPTH {
|
||||
let _ = recycle_tx.send(vec![0u8; chunk_bytes]);
|
||||
}
|
||||
|
||||
let producer = std::thread::Builder::new()
|
||||
.name("freemkv-byte-prefetch".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let mut buf = match recycle_rx.recv() {
|
||||
Ok(b) => b,
|
||||
Err(_) => return, // consumer dropped both channels
|
||||
};
|
||||
// Re-expose the full extent (previous iteration
|
||||
// may have truncated after a short read).
|
||||
if buf.len() < chunk_bytes {
|
||||
buf.resize(chunk_bytes, 0);
|
||||
} else {
|
||||
// SAFETY: capacity is at least chunk_bytes
|
||||
// after construction.
|
||||
unsafe { buf.set_len(chunk_bytes) };
|
||||
}
|
||||
// Read up to one full chunk. Short reads are
|
||||
// valid and common — pipe `truncate` so the
|
||||
// consumer sees only the bytes that arrived.
|
||||
let n = match reader.read(&mut buf[..]) {
|
||||
Ok(0) => return, // EOF — drop tx, consumer sees RecvError
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
buf.truncate(n);
|
||||
if tx.send(Ok(buf)).is_err() {
|
||||
return; // consumer dropped
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("freemkv-byte-prefetch thread spawn failed");
|
||||
|
||||
Self {
|
||||
rx,
|
||||
recycle_tx,
|
||||
producer: Some(producer),
|
||||
}
|
||||
}
|
||||
|
||||
/// Peel off the channels for zero-copy pipeline consumption. The
|
||||
/// caller (typically [`crate::mux::demux_thread::DemuxThread`])
|
||||
/// drains `rx`, runs the demuxer in place on each filled buffer,
|
||||
/// and recycles back through `recycle_tx`.
|
||||
pub fn into_channels(self) -> (Receiver<Batch>, Sender<Vec<u8>>, PrefetchShell) {
|
||||
let mut me = self;
|
||||
let producer = me.producer.take();
|
||||
let rx = me.rx.clone();
|
||||
let recycle = me.recycle_tx.clone();
|
||||
std::mem::forget(me);
|
||||
(rx, recycle, PrefetchShell { producer })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BytePrefetcher {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.producer.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,3 +44,22 @@ pub(super) fn drop_window(file: &File, start: u64, len: u64) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Async-prefetch `len` bytes at `offset` into the page cache. The
|
||||
/// kernel `readahead(2)` syscall queues the I/O and returns
|
||||
/// immediately — it does NOT wait for completion. Called right after
|
||||
/// each consumed read so the next batch's I/O overlaps with the
|
||||
/// caller's processing of the current batch (decrypt + demux + mux).
|
||||
///
|
||||
/// Without this hint, with a synchronous demux consumer running at
|
||||
/// ~50 MB/s and a single-spindle disk capable of ~150 MB/s, the disk
|
||||
/// sits idle ~70% of each iteration because kernel readahead alone
|
||||
/// (capped at `/sys/block/<dev>/queue/read_ahead_kb`, default 128 KB)
|
||||
/// can only pre-stage a tiny slice of the next batch. An explicit
|
||||
/// `readahead()` of the same size as the current batch tells the
|
||||
/// kernel to queue the full next-batch read now.
|
||||
pub(super) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||
unsafe {
|
||||
libc::readahead(file.as_raw_fd(), offset as i64, len as usize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,3 +44,19 @@ pub(super) fn hint_sequential(file: &File, len_bytes: u64) {
|
||||
/// less prone to the pin-everything pathology that triggers the
|
||||
/// regression on Linux NFS clients.
|
||||
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
|
||||
/// Async-prefetch the byte range `[offset, offset+len)`. macOS uses
|
||||
/// the same `fcntl(F_RDADVISE, &radvisory)` primitive as the open-
|
||||
/// time sequential hint, just targeted at a moving window instead of
|
||||
/// the whole file. The kernel queues I/O for the requested range and
|
||||
/// returns immediately.
|
||||
pub(super) fn prefetch(file: &File, offset: u64, len: u64) {
|
||||
let bytes = (len as i64).min(RDADVISE_MAX_BYTES);
|
||||
let mut ra = RadAdvisory {
|
||||
ra_offset: offset as libc::off_t,
|
||||
ra_count: bytes as libc::c_int,
|
||||
};
|
||||
unsafe {
|
||||
libc::fcntl(file.as_raw_fd(), F_RDADVISE, &mut ra);
|
||||
}
|
||||
}
|
||||
|
||||
+125
-192
@@ -1,39 +1,43 @@
|
||||
//! [`FileSectorSource`] — read 2048-byte sectors from an ISO file on
|
||||
//! disk, with an internal 32 MiB read-ahead buffer.
|
||||
//! disk via direct `seek + read_exact` (`pread`-equivalent) calls,
|
||||
//! letting the kernel's own readahead policy manage prefetch.
|
||||
//!
|
||||
//! ## Why the buffer
|
||||
//! ## Why no app-level buffer
|
||||
//!
|
||||
//! On NFS-mounted ISOs, an unbuffered `pread(2048)` per sector pays an
|
||||
//! NFS round-trip for every sector. With `rsize=1 MiB` and a 100-150 ms
|
||||
//! NFS RTT, that's three orders of magnitude more round trips than
|
||||
//! necessary — the muxer goes read-bound on every read, even though
|
||||
//! the local NFS client could deliver MB/s on bigger requests.
|
||||
//! Pre-0.21.3 this source held a 32 MiB (later 4 MiB) read-ahead
|
||||
//! buffer to amortise per-sector NFS round-trips. Empirically that
|
||||
//! buffer hurt: 32 MiB refills bursted the NFS TCP connection hard
|
||||
//! enough to starve the concurrent writer, and even a 4 MiB window
|
||||
//! gave the kernel less freedom to pipeline reads with writes. Direct
|
||||
//! pread per call lets Linux's readahead widen as it detects the
|
||||
//! sequential pattern, and naturally interleaves with writeback.
|
||||
//!
|
||||
//! Internally this source keeps a [`READAHEAD_BUF_BYTES`] (32 MiB)
|
||||
//! window pre-read from the file. `read_sectors(lba, count)` slices
|
||||
//! into the window if `[lba, lba+count)` is contained in it; otherwise
|
||||
//! the window is refilled (full-size aligned to the requested LBA's
|
||||
//! buffer position).
|
||||
//! ## DONTNEED on the consumed window
|
||||
//!
|
||||
//! ## Access pattern assumption
|
||||
//! Without page-cache eviction an 85 GB streaming ISO read pins the
|
||||
//! entire file in memory, starves the concurrent writer, and collapses
|
||||
//! mux throughput (observed: 2.7 MB/s mux on 0.21.5 vs. 70 MB/s
|
||||
//! isolated NFS reads). Every [`READ_DROP_CHUNK_BYTES`] of consumed
|
||||
//! bytes we call `posix_fadvise(DONTNEED)` over that window, mirroring
|
||||
//! the write-side [`crate::io::writeback::WritebackPipeline`] policy.
|
||||
//!
|
||||
//! The buffer is sized for **forward-sequential** reads (sweep, mux).
|
||||
//! Reverse-mode patch is range-local, so a refill per range works out
|
||||
//! fine (the buffer covers the whole range for typical bad-range
|
||||
//! sizes). Random-access reads thrash the buffer — at which point the
|
||||
//! 32 MiB pre-read is wasted work. We accept that: the use case is
|
||||
//! mux + sweep, both forward-sequential.
|
||||
//!
|
||||
//! Backward seeks rebuffer from the new LBA; partial reads at EOF
|
||||
//! return only the bytes that exist (the underlying file is shorter
|
||||
//! than a full buffer slot).
|
||||
//!
|
||||
//! ## Platform open hints
|
||||
//! ## Platform open hint
|
||||
//!
|
||||
//! On `open()` each platform issues its "sequential access expected"
|
||||
//! hint to the kernel so OS-level readahead widens. The hint lives in
|
||||
//! a per-OS sibling module ([`linux::hint_sequential`] et al.) — no
|
||||
//! inline `#[cfg]` in this file.
|
||||
//! hint so OS-level readahead widens. The hint and the DONTNEED call
|
||||
//! live in per-OS sibling modules ([`linux::hint_sequential`] et al.)
|
||||
//! — no inline `#[cfg]` in this file.
|
||||
//!
|
||||
//! ## Read-ahead prefetch
|
||||
//!
|
||||
//! After every consumed read we issue an OS-level prefetch hint for
|
||||
//! the next equivalent-sized window (`platform::prefetch`). The
|
||||
//! kernel queues that I/O asynchronously and returns immediately, so
|
||||
//! the next batch's read overlaps with the caller's processing of
|
||||
//! the current batch (decrypt + demux + mux). Without this the disk
|
||||
//! sits idle ~70% of each iteration because kernel SEQUENTIAL
|
||||
//! readahead alone (capped at `read_ahead_kb`, default 128 KB) is
|
||||
//! far smaller than our 16 MiB app-level batch.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
@@ -60,66 +64,48 @@ use std::path::Path;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
/// Internal read-ahead buffer size. 32 MiB amortises one NFS round
|
||||
/// trip across ~16 k sectors — three orders of magnitude fewer trips
|
||||
/// than per-sector pread, and large enough to coast through a typical
|
||||
/// NFS server commit blip.
|
||||
///
|
||||
/// 0.21.2: shrunk from 32 MiB → 4 MiB. On NFS-backed ISOs with
|
||||
/// concurrent NFS writes (the mux phase), a 32 MiB refill bursts the
|
||||
/// TCP connection hard enough to starve the writer thread, observed
|
||||
/// empirically as a ~3× drop in sustained mux throughput on the
|
||||
/// rip1/unraid-1 setup. 4 MiB matches `rsize=1 MiB` × 4 round-trips
|
||||
/// and interleaves cleanly with writes.
|
||||
///
|
||||
/// Tweakable. Named const, not a magic number.
|
||||
pub const READAHEAD_BUF_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
const SECTOR_SIZE: usize = 2048;
|
||||
/// Sectors per refill: [`READAHEAD_BUF_BYTES`] / [`SECTOR_SIZE`]. The
|
||||
/// buffer always tries to hold this many, except at the tail of the
|
||||
/// file where less data exists.
|
||||
const BUF_SECTORS: u32 = (READAHEAD_BUF_BYTES / SECTOR_SIZE) as u32;
|
||||
|
||||
/// SectorSource backed by a file (ISO image) with an internal
|
||||
/// `READAHEAD_BUF_BYTES`-sized read-ahead window.
|
||||
///
|
||||
/// `read_sectors` is satisfied from the buffer when possible; otherwise
|
||||
/// a full-buffer refill is issued at the requested LBA's position and
|
||||
/// the call is re-tried against the freshly populated window.
|
||||
/// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop on the
|
||||
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
||||
/// cache stays bounded the same way the write side does.
|
||||
///
|
||||
/// 0.21.6: re-added after empirical discovery that Phase 1 had silently
|
||||
/// dropped this from the pre-Phase-1 (0.20.7) hot path. Without it,
|
||||
/// 85 GB of streaming ISO reads pin the entire file in the kernel page
|
||||
/// cache, starving the MKV writeback and collapsing mux throughput
|
||||
/// (observed: 2.7 MB/s mux on 0.21.5 vs. 70 MB/s isolated NFS reads).
|
||||
const READ_DROP_CHUNK_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// 32 MiB is the empirically tuned value on the rip1 test bed (single
|
||||
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the
|
||||
/// kernel-readahead overlap and slow the producer; larger windows
|
||||
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
||||
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
||||
const READ_DROP_CHUNK_BYTES_DEFAULT: u64 = 32 * 1024 * 1024;
|
||||
|
||||
fn read_drop_chunk_bytes() -> u64 {
|
||||
std::env::var("FREEMKV_READ_DROP_CHUNK_MIB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
.map(|n| n * 1024 * 1024)
|
||||
.unwrap_or(READ_DROP_CHUNK_BYTES_DEFAULT)
|
||||
}
|
||||
|
||||
/// SectorSource backed by a file (ISO image). Every `read_sectors`
|
||||
/// call is a direct `seek + read_exact` against the underlying file
|
||||
/// — kernel readahead handles prefetch, and every
|
||||
/// [`READ_DROP_CHUNK_BYTES_DEFAULT`] bytes of consumed data the
|
||||
/// platform's `DONTNEED` hook drops the consumed window from the
|
||||
/// page cache to bound memory pressure.
|
||||
pub struct FileSectorSource {
|
||||
file: File,
|
||||
/// Total file size in sectors. Constant after construction;
|
||||
/// surfaced via [`SectorSource::capacity_sectors`].
|
||||
capacity: u32,
|
||||
/// 0.21.3+: the app-level buffer is no longer touched on the hot
|
||||
/// path (every `read_sectors` is a direct pread). The fields are
|
||||
/// retained so a future per-source-type policy (e.g. a local-disk
|
||||
/// source where batched reads ARE beneficial) can re-enable
|
||||
/// buffering cleanly without re-plumbing the struct.
|
||||
#[allow(dead_code)]
|
||||
buf: Box<[u8]>,
|
||||
#[allow(dead_code)]
|
||||
buf_start_lba: u32,
|
||||
buf_len_sectors: u32,
|
||||
/// 0.21.6: bytes read since the last DONTNEED drop. Drives the
|
||||
/// per-`READ_DROP_CHUNK_BYTES` page-cache eviction in read_sectors.
|
||||
/// Bytes read since the last DONTNEED drop. Drives the per-
|
||||
/// [`read_drop_chunk_bytes`] page-cache eviction in read_sectors.
|
||||
bytes_read_since_drop: u64,
|
||||
/// 0.21.6: file offset at which the current drop window starts.
|
||||
/// The next DONTNEED drops from `drop_window_start` for
|
||||
/// File offset at which the current drop window starts. The next
|
||||
/// DONTNEED drops from `drop_window_start` for
|
||||
/// `bytes_read_since_drop` bytes.
|
||||
drop_window_start: u64,
|
||||
/// Cached drop chunk size (resolved from env once at open).
|
||||
drop_chunk_bytes: u64,
|
||||
}
|
||||
|
||||
impl FileSectorSource {
|
||||
@@ -148,59 +134,14 @@ impl FileSectorSource {
|
||||
// FS doesn't honour it).
|
||||
platform::hint_sequential(&file, len);
|
||||
|
||||
// Pre-allocate the buffer once. `vec![0u8; N].into_boxed_slice()`
|
||||
// is the canonical way to fix the allocation size up-front;
|
||||
// `Vec::with_capacity` would leave `len == 0` and force callers
|
||||
// to do unsafe length manipulation to write into it.
|
||||
let buf = vec![0u8; READAHEAD_BUF_BYTES].into_boxed_slice();
|
||||
|
||||
Ok(Self {
|
||||
file,
|
||||
capacity,
|
||||
buf,
|
||||
buf_start_lba: 0,
|
||||
buf_len_sectors: 0,
|
||||
bytes_read_since_drop: 0,
|
||||
drop_window_start: 0,
|
||||
drop_chunk_bytes: read_drop_chunk_bytes(),
|
||||
})
|
||||
}
|
||||
|
||||
/// True if `[lba, lba + count)` is wholly inside the current
|
||||
/// buffer window. `count == 0` is vacuously true.
|
||||
#[allow(dead_code)]
|
||||
fn buffer_covers(&self, lba: u32, count: u32) -> bool {
|
||||
if self.buf_len_sectors == 0 {
|
||||
return false;
|
||||
}
|
||||
let end = match lba.checked_add(count) {
|
||||
Some(e) => e,
|
||||
None => return false,
|
||||
};
|
||||
let buf_end = self.buf_start_lba.saturating_add(self.buf_len_sectors);
|
||||
lba >= self.buf_start_lba && end <= buf_end
|
||||
}
|
||||
|
||||
/// Refill the buffer so it starts at `lba`. Read as many sectors
|
||||
/// as we have buffer space AND file capacity for. Caller has
|
||||
/// already checked `lba < capacity`.
|
||||
#[allow(dead_code)]
|
||||
fn refill(&mut self, lba: u32) -> Result<()> {
|
||||
debug_assert!(lba < self.capacity, "refill past capacity");
|
||||
// Don't read past EOF — clamp the request to remaining
|
||||
// sectors. partial-buffer-at-EOF behaviour is intentional.
|
||||
let want = BUF_SECTORS.min(self.capacity - lba);
|
||||
let want_bytes = want as usize * SECTOR_SIZE;
|
||||
let offset = lba as u64 * SECTOR_SIZE as u64;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file
|
||||
.read_exact(&mut self.buf[..want_bytes])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.buf_start_lba = lba;
|
||||
self.buf_len_sectors = want;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for FileSectorSource {
|
||||
@@ -226,20 +167,6 @@ impl SectorSource for FileSectorSource {
|
||||
if count == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
// 0.21.3: bypass the application-level buffer entirely.
|
||||
//
|
||||
// Empirically the 32 MiB readahead window (0.21.0–0.21.1) and the
|
||||
// 4 MiB shrink (0.21.2) both regressed mux throughput vs the
|
||||
// pre-Phase-1 0.20.7 baseline on NFS bidirectional workloads
|
||||
// (sweep ~25 MB/s OK; mux dropped from 18 → 7-8 → 5-6 MB/s).
|
||||
// Direct pread per call lets the kernel's own readahead policy
|
||||
// run, which interleaves naturally with concurrent NFS writes on
|
||||
// the same TCP connection.
|
||||
//
|
||||
// Buffer fields are retained (currently unused on this path) so
|
||||
// any future per-source policy can be reintroduced without
|
||||
// re-plumbing structure. `refill` / `buffer_covers` are kept too
|
||||
// (still exercised by the tests so the API contract is locked).
|
||||
let offset = lba as u64 * SECTOR_SIZE as u64;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(offset))
|
||||
@@ -247,15 +174,21 @@ impl SectorSource for FileSectorSource {
|
||||
self.file
|
||||
.read_exact(&mut out[..bytes])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.buf_len_sectors = 0;
|
||||
|
||||
// 0.21.6: periodic page-cache eviction on the read side. Without
|
||||
// Queue the next batch's read with the kernel before the
|
||||
// caller starts processing what we just returned. readahead()
|
||||
// is non-blocking — it queues I/O and returns, so the kernel
|
||||
// pulls those pages into cache while the consumer (decrypt +
|
||||
// demux + mux) runs. Next read_sectors call hits a warm cache.
|
||||
platform::prefetch(&self.file, offset + bytes as u64, bytes as u64);
|
||||
|
||||
// Periodic page-cache eviction on the read side. Without
|
||||
// this, an 85 GB streaming ISO read pins the entire file in
|
||||
// kernel page cache, which starves concurrent NFS writes (the
|
||||
// MKV output) and collapses mux throughput. Mirrors the
|
||||
// write-side WritebackPipeline's DONTNEED policy.
|
||||
// the kernel page cache, which starves concurrent writes and
|
||||
// collapses mux throughput. Mirrors the write-side
|
||||
// WritebackPipeline's DONTNEED policy.
|
||||
self.bytes_read_since_drop += bytes as u64;
|
||||
if self.bytes_read_since_drop >= READ_DROP_CHUNK_BYTES {
|
||||
if self.bytes_read_since_drop >= self.drop_chunk_bytes {
|
||||
let drop_start = self.drop_window_start;
|
||||
let drop_len = self.bytes_read_since_drop;
|
||||
platform::drop_window(&self.file, drop_start, drop_len);
|
||||
@@ -287,11 +220,15 @@ mod tests {
|
||||
f.flush().unwrap();
|
||||
}
|
||||
|
||||
/// Sectors used by spanning-boundary tests. Pick something that
|
||||
/// exercises multi-megabyte reads without making test ISOs huge.
|
||||
/// 8192 sectors = 16 MiB — large enough to cross any readahead
|
||||
/// chunk size we set the kernel hint to.
|
||||
const TEST_SPAN_SECTORS: u32 = 8192;
|
||||
|
||||
#[test]
|
||||
fn sequential_reads_match_file() {
|
||||
// Two full buffer windows + a tail = exercise refill across
|
||||
// boundaries.
|
||||
let total = BUF_SECTORS * 2 + 17;
|
||||
let total = TEST_SPAN_SECTORS * 2 + 17;
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("seq.iso");
|
||||
make_iso(&path, total);
|
||||
@@ -311,28 +248,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_sector_read_spanning_buffer_boundary() {
|
||||
// A read that lands exactly on the last sector of the buffer
|
||||
// plus the first sector of the next refill must rebuffer
|
||||
// mid-read. Bypass path triggers when count > BUF_SECTORS; we
|
||||
// want the in-window path, so count stays small but
|
||||
// straddles the boundary.
|
||||
let total = BUF_SECTORS * 2;
|
||||
fn multi_sector_read_across_chunk_boundary() {
|
||||
let total = TEST_SPAN_SECTORS * 2;
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("span.iso");
|
||||
make_iso(&path, total);
|
||||
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
|
||||
// Prime: read sector 0. (0.21.3+: app-level buffer is bypassed,
|
||||
// so we don't assert internal buf state here — just exercise
|
||||
// the read path.)
|
||||
let mut got = vec![0u8; SECTOR_SIZE];
|
||||
src.read_sectors(0, 1, &mut got, false).unwrap();
|
||||
|
||||
// Now read 4 sectors crossing what used to be the buffer
|
||||
// boundary. Still a valid SectorSource-contract test.
|
||||
let span_lba = BUF_SECTORS - 2;
|
||||
let span_lba = TEST_SPAN_SECTORS - 2;
|
||||
let mut buf4 = vec![0u8; SECTOR_SIZE * 4];
|
||||
src.read_sectors(span_lba, 4, &mut buf4, false).unwrap();
|
||||
for i in 0..4 {
|
||||
@@ -345,10 +269,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_seek_rebuffers() {
|
||||
// Read forward across two windows, then jump back to sector
|
||||
// 0. Buffer must refill from the start.
|
||||
let total = BUF_SECTORS * 2 + 5;
|
||||
fn backward_seek_reads_correct_bytes() {
|
||||
// Read forward then jump back: the SectorSource contract is
|
||||
// byte-correctness regardless of access pattern.
|
||||
let total = TEST_SPAN_SECTORS * 2 + 5;
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("back.iso");
|
||||
make_iso(&path, total);
|
||||
@@ -356,24 +280,17 @@ mod tests {
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
let mut got = vec![0u8; SECTOR_SIZE];
|
||||
|
||||
// Forward to the second window.
|
||||
src.read_sectors(BUF_SECTORS + 1, 1, &mut got, false)
|
||||
src.read_sectors(TEST_SPAN_SECTORS + 1, 1, &mut got, false)
|
||||
.unwrap();
|
||||
|
||||
// Backward to sector 0. (0.21.3+: app-level buffer is bypassed
|
||||
// so we only assert the byte-level contract, not internal
|
||||
// buffer state.)
|
||||
src.read_sectors(0, 1, &mut got, false).unwrap();
|
||||
assert!(got.iter().all(|b| *b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_buffer_at_eof() {
|
||||
// File is smaller than one buffer window. The buffer must
|
||||
// populate with only the available sectors and reads must
|
||||
// still succeed.
|
||||
fn read_at_eof_returns_correct_bytes() {
|
||||
// File smaller than the readahead chunk — reads near EOF must
|
||||
// still return correct bytes.
|
||||
let total: u32 = 100;
|
||||
assert!(total < BUF_SECTORS);
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("small.iso");
|
||||
make_iso(&path, total);
|
||||
@@ -382,39 +299,27 @@ mod tests {
|
||||
assert_eq!(src.capacity_sectors(), total);
|
||||
|
||||
let mut got = vec![0u8; SECTOR_SIZE];
|
||||
// First read at sector 0.
|
||||
src.read_sectors(0, 1, &mut got, false).unwrap();
|
||||
|
||||
// Read the very last sector. (0.21.3+: app-level buffer is
|
||||
// bypassed; the test still verifies that EOF-region reads
|
||||
// return correct bytes.)
|
||||
src.read_sectors(total - 1, 1, &mut got, false).unwrap();
|
||||
let expected = ((total - 1) & 0xff) as u8;
|
||||
assert!(got.iter().all(|b| *b == expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_read_bypasses_buffer() {
|
||||
// A request larger than the buffer must not deadlock the
|
||||
// refill (which only loads BUF_SECTORS at a time). Bypass
|
||||
// path handles it via direct pread.
|
||||
let total = BUF_SECTORS + 100;
|
||||
fn large_single_read() {
|
||||
// A multi-MB single read must work — the implementation has
|
||||
// no app-level chunking, so this just exercises the direct
|
||||
// pread path on a larger request.
|
||||
let total = TEST_SPAN_SECTORS + 100;
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("over.iso");
|
||||
let path = dir.path().join("big.iso");
|
||||
make_iso(&path, total);
|
||||
|
||||
let mut src = FileSectorSource::open(&path).unwrap();
|
||||
// Read more than BUF_SECTORS in one call. count is u16, so we
|
||||
// can't actually exceed BUF_SECTORS (16k) — but the path also
|
||||
// triggers via `out.len() / SECTOR_SIZE > BUF_SECTORS` check
|
||||
// implicitly because count > BUF_SECTORS. BUF_SECTORS for
|
||||
// 32 MiB is 16384, which does fit in u16 (max 65535). Cap
|
||||
// at BUF_SECTORS + 1 to exercise the bypass.
|
||||
let req = (BUF_SECTORS + 1) as u16;
|
||||
let req = (TEST_SPAN_SECTORS + 1) as u16;
|
||||
let req_bytes = req as usize * SECTOR_SIZE;
|
||||
let mut big = vec![0u8; req_bytes];
|
||||
src.read_sectors(0, req, &mut big, false).unwrap();
|
||||
// Spot-check sector 0 and the last requested sector.
|
||||
assert!(big[..SECTOR_SIZE].iter().all(|b| *b == 0));
|
||||
let last_lba = req as u32 - 1;
|
||||
let exp = (last_lba & 0xff) as u8;
|
||||
@@ -425,4 +330,32 @@ mod tests {
|
||||
.all(|b| *b == exp)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_chunk_size_env_override() {
|
||||
// Explicit 8 MiB via env var.
|
||||
// SAFETY: tests in this crate are single-threaded per the
|
||||
// default cargo test harness, but std::env::set_var is
|
||||
// declared `unsafe` since Rust 2024 (it can race with other
|
||||
// threads / TLS). For a test that runs in-process before any
|
||||
// FileSectorSource construction this is safe in practice.
|
||||
unsafe {
|
||||
std::env::set_var("FREEMKV_READ_DROP_CHUNK_MIB", "8");
|
||||
}
|
||||
assert_eq!(read_drop_chunk_bytes(), 8 * 1024 * 1024);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("FREEMKV_READ_DROP_CHUNK_MIB");
|
||||
}
|
||||
assert_eq!(read_drop_chunk_bytes(), READ_DROP_CHUNK_BYTES_DEFAULT);
|
||||
|
||||
// Garbage env value falls back to default.
|
||||
unsafe {
|
||||
std::env::set_var("FREEMKV_READ_DROP_CHUNK_MIB", "not-a-number");
|
||||
}
|
||||
assert_eq!(read_drop_chunk_bytes(), READ_DROP_CHUNK_BYTES_DEFAULT);
|
||||
unsafe {
|
||||
std::env::remove_var("FREEMKV_READ_DROP_CHUNK_MIB");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,3 +7,5 @@ use std::fs::File;
|
||||
pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) {}
|
||||
|
||||
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
|
||||
pub(super) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||
|
||||
@@ -22,3 +22,9 @@ pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) {
|
||||
/// equivalent. The kernel does its own working-set management. No-op
|
||||
/// for now.
|
||||
pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {}
|
||||
|
||||
/// Windows async-prefetch hint. With FILE_FLAG_SEQUENTIAL_SCAN at
|
||||
/// open the kernel already prefetches aggressively, so there's no
|
||||
/// per-range hint we'd add on top. No-op stub for parity with the
|
||||
/// posix platforms.
|
||||
pub(super) fn prefetch(_file: &File, _offset: u64, _len: u64) {}
|
||||
|
||||
+12
-9
@@ -11,9 +11,12 @@
|
||||
//! to exhibit the same pathology for this access pattern.
|
||||
//!
|
||||
//! `FileSectorSource` is the read-side dual — it implements
|
||||
//! [`crate::sector::SectorSource`] for an ISO file with an internal
|
||||
//! 32 MiB read-ahead buffer that amortises NFS round-trip latency
|
||||
//! across thousands of sector reads.
|
||||
//! [`crate::sector::SectorSource`] for an ISO file using direct
|
||||
//! `pread`-equivalent calls so the kernel's own readahead policy runs
|
||||
//! (which interleaves naturally with the concurrent writeback). It
|
||||
//! pairs that with periodic `posix_fadvise(DONTNEED)` drops on the
|
||||
//! consumed window so an 85 GB streaming ISO read doesn't fill the
|
||||
//! page cache and starve the concurrent MKV write.
|
||||
//!
|
||||
//! `Pipeline` + `Sink` (0.18) is the generic producer/consumer primitive
|
||||
//! used by sweep, patch, and mux to overlap reads with writes via a
|
||||
@@ -25,21 +28,21 @@
|
||||
|
||||
pub(crate) mod bounded;
|
||||
pub mod byte_channel;
|
||||
pub mod byte_prefetcher;
|
||||
pub mod file_sector_source;
|
||||
pub mod sink;
|
||||
mod writeback;
|
||||
mod writeback_file;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) mod platform_macos;
|
||||
|
||||
pub mod pipeline;
|
||||
|
||||
pub(crate) use writeback_file::WritebackFile;
|
||||
|
||||
// Re-exports for the 0.18 redesign. Sweep + patch are both wired up
|
||||
// (disc/sweep.rs, disc/patch.rs); mux migrates separately in autorip.
|
||||
// `WRITE_THROUGH_DEPTH` is patch-specific and has no other in-tree
|
||||
// caller — the targeted `#[allow]` keeps the re-export visible without
|
||||
// dragging the rest of the module under `dead_code`.
|
||||
#[allow(unused_imports)]
|
||||
// Re-exports for the 0.18 redesign. Sweep, patch, and mux are all
|
||||
// wired up (disc/sweep.rs, disc/patch.rs, autorip's ripper/mux.rs).
|
||||
pub use pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
|
||||
+14
-16
@@ -53,26 +53,26 @@ use crate::halt::Halt;
|
||||
/// caller has already lost the rip.
|
||||
pub const JOIN_TIMEOUT_SECS: u64 = 600;
|
||||
|
||||
/// Polling slice for the halt-aware send/finish loops. Mirrors the
|
||||
/// `bounded_syscall` cadence (250 ms) so halt observation feels equally
|
||||
/// responsive across both primitives.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Halt-check cadence for the send loop. Producer blocks on
|
||||
/// [`crossbeam_channel::Sender::send_timeout`] for this slice — the
|
||||
/// kernel wakes it the instant the consumer drains a slot, so on the
|
||||
/// happy path there's no throughput cap from this primitive at all
|
||||
/// (the cap is whatever the underlying medium can sustain). When the
|
||||
/// consumer is genuinely wedged, the timeout fires every 250 ms and
|
||||
/// the producer checks the halt token; that's the latency a stop
|
||||
/// request will observe.
|
||||
/// consumer is genuinely wedged, the timeout fires every
|
||||
/// [`crate::halt::POLL_INTERVAL`] and the producer checks the halt
|
||||
/// token; that's the latency a stop request will observe.
|
||||
///
|
||||
/// Single source of truth lives in [`crate::halt::POLL_INTERVAL`]
|
||||
/// (also used by `bounded_syscall`). Aliased here for readability of
|
||||
/// the send/finish call sites below.
|
||||
///
|
||||
/// 0.21.7 replaced an old `std::sync::mpsc::sync_channel` + 50 ms
|
||||
/// `thread::sleep` polling loop that capped mux throughput at
|
||||
/// ~20 frames/sec ≈ 1 MB/s on saturated channels. See
|
||||
/// (internal)/memory/feedback_send_with_halt_poll_throttle.md
|
||||
/// for the multi-day diagnostic that surfaced it.
|
||||
const SEND_HALT_CHECK_INTERVAL: Duration = Duration::from_millis(250);
|
||||
use crate::halt::POLL_INTERVAL;
|
||||
const SEND_HALT_CHECK_INTERVAL: Duration = POLL_INTERVAL;
|
||||
|
||||
/// Check if verbose debug logging is enabled via FREEMKV_DEBUG env var.
|
||||
pub fn debug_enabled() -> bool {
|
||||
@@ -100,8 +100,7 @@ pub const WRITE_PIPELINE_DEPTH: usize = 16;
|
||||
/// Channel depth for write-through pipelines. Each `send` fully
|
||||
/// drains before the next can enqueue. Use this when the producer
|
||||
/// must observe consumer side-effects (e.g. mapfile state) before
|
||||
/// emitting the next item.
|
||||
#[allow(dead_code)]
|
||||
/// emitting the next item. Currently used by `disc::patch`.
|
||||
pub const WRITE_THROUGH_DEPTH: usize = 1;
|
||||
|
||||
/// Outcome of [`Sink::apply`]: either keep feeding items
|
||||
@@ -162,11 +161,10 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// propagated rather than panicked.
|
||||
///
|
||||
/// Sweep uses [`Pipeline::spawn_named`] directly so the consumer
|
||||
/// thread shows up as `freemkv-sweep-consumer`; this function has
|
||||
/// no in-tree caller yet. Patch and mux migrate in later 0.18
|
||||
/// slices. The targeted `#[allow]` is removed when one of them
|
||||
/// lands on the default name.
|
||||
#[allow(dead_code)]
|
||||
/// thread shows up as `freemkv-sweep-consumer`; mux uses
|
||||
/// `freemkv-mux-consumer`. `Pipeline::spawn` (this function, with
|
||||
/// the default name) is used by `disc::patch` and by the unit
|
||||
/// tests in this module.
|
||||
pub fn spawn<S: Sink<I, Output = R>>(depth: usize, sink: S) -> Result<Self, Error> {
|
||||
Self::spawn_named("freemkv-pipeline-consumer", depth, sink)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Shared macOS `fcntl(F_PREALLOCATE)` definitions.
|
||||
//!
|
||||
//! The `libc` crate doesn't expose these symbols across all macOS SDK
|
||||
//! versions, so we define them locally with values from
|
||||
//! `/usr/include/sys/fcntl.h`. Two call sites (
|
||||
//! [`crate::io::writeback_file`] and [`crate::io::sink::preallocate`])
|
||||
//! need the same constants and `fstore_t` layout — keeping a single
|
||||
//! source of truth here prevents the two copies from drifting.
|
||||
//!
|
||||
//! Module-level cfg gate lives in the parent (`io/mod.rs`); this file
|
||||
//! is only compiled on macOS, so no inner `#![cfg]` is needed.
|
||||
|
||||
/// `fcntl(F_PREALLOCATE)` command number from `sys/fcntl.h`.
|
||||
pub(crate) const F_PREALLOCATE: libc::c_int = 42;
|
||||
|
||||
/// Anchor preallocation at the current physical EOF.
|
||||
pub(crate) const F_PEOFPOSMODE: libc::c_int = 3;
|
||||
|
||||
/// Prefer a contiguous allocation. Try this first; on `EINVAL` (no
|
||||
/// contiguous run of that size), fall back to `F_ALLOCATEALL`.
|
||||
pub(crate) const F_ALLOCATECONTIG: libc::c_uint = 0x0000_0002;
|
||||
|
||||
/// Allow non-contiguous allocation. Stronger guarantee than just
|
||||
/// asking for `F_ALLOCATECONTIG` because the kernel will piece
|
||||
/// together fragments rather than failing.
|
||||
pub(crate) const F_ALLOCATEALL: libc::c_uint = 0x0000_0004;
|
||||
|
||||
/// `fstore_t` from `sys/fcntl.h`. `repr(C)` because we hand it to
|
||||
/// `fcntl(F_PREALLOCATE)` which writes through the pointer.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Fstore {
|
||||
pub fst_flags: libc::c_uint,
|
||||
pub fst_posmode: libc::c_int,
|
||||
pub fst_offset: libc::off_t,
|
||||
pub fst_length: libc::off_t,
|
||||
pub fst_bytesalloc: libc::off_t,
|
||||
}
|
||||
@@ -7,23 +7,9 @@
|
||||
use std::fs::File;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
// Mirror the Darwin `fstore_t` struct from `<sys/fcntl.h>`. libc on
|
||||
// some Rust toolchains/versions doesn't ship this binding, so define
|
||||
// it locally with the layout the kernel ABI requires.
|
||||
#[repr(C)]
|
||||
struct Fstore {
|
||||
fst_flags: libc::c_uint,
|
||||
fst_posmode: libc::c_int,
|
||||
fst_offset: libc::off_t,
|
||||
fst_length: libc::off_t,
|
||||
fst_bytesalloc: libc::off_t,
|
||||
}
|
||||
|
||||
// Constants from <sys/fcntl.h>.
|
||||
const F_PREALLOCATE: libc::c_int = 42;
|
||||
const F_ALLOCATECONTIG: libc::c_uint = 0x0000_0002;
|
||||
const F_ALLOCATEALL: libc::c_uint = 0x0000_0004;
|
||||
const F_PEOFPOSMODE: libc::c_int = 3;
|
||||
use crate::io::platform_macos::{
|
||||
F_ALLOCATEALL, F_ALLOCATECONTIG, F_PEOFPOSMODE, F_PREALLOCATE, Fstore,
|
||||
};
|
||||
|
||||
pub(super) fn preallocate_impl(file: &File, size_bytes: u64) {
|
||||
let fd = file.as_raw_fd();
|
||||
|
||||
+129
-28
@@ -300,35 +300,22 @@ impl WritebackPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether `fd` lives on an NFS mount via `fstatfs`. Returns
|
||||
/// `false` on any error — we fail open, not closed: better to run the
|
||||
/// normal local-storage path on a misdetected NFS mount (and surface
|
||||
/// the freeze loudly via the timeout) than to needlessly disable
|
||||
/// writeback bounding on every local file because of a transient
|
||||
/// stat error.
|
||||
/// Probe whether `fd` lives on an NFS mount. Thin wrapper around
|
||||
/// [`crate::platform::fs_type::detect_fd`] so writeback policy and
|
||||
/// general-purpose fs-type classification stay in sync (same magic
|
||||
/// numbers, same musl-vs-glibc cast handling).
|
||||
///
|
||||
/// Fails open: any classification other than NFS counts as "not NFS"
|
||||
/// (including `Unknown` on `fstatfs` error) — better to run the
|
||||
/// normal local-storage path on a misdetected NFS mount and surface
|
||||
/// the freeze loudly via [`WAIT_AFTER_TIMEOUT`] than to needlessly
|
||||
/// disable writeback bounding on every local file because of a
|
||||
/// transient stat error.
|
||||
fn detect_nfs(fd: RawFd) -> bool {
|
||||
// `libc::statfs` is repr(C) with a fixed layout; zeroing is the
|
||||
// documented init pattern for the kernel uapi struct.
|
||||
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
|
||||
let rc = unsafe { libc::fstatfs(fd, &mut buf) };
|
||||
if rc != 0 {
|
||||
let errno = std::io::Error::last_os_error();
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackPipeline fstatfs(fd={fd}) failed: {errno} — defaulting is_nfs=false",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// `f_type` is signed (`__fsword_t`) on glibc and unsigned
|
||||
// (`c_ulong`) on musl. Cast both sides to i64 for a portable
|
||||
// comparison. On glibc x86_64 both already are i64 — clippy flags
|
||||
// the cast as unnecessary on that target only, but we need it for
|
||||
// musl, so silence the lint.
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
let f_type = buf.f_type as i64;
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
let nfs_magic = libc::NFS_SUPER_MAGIC as i64;
|
||||
f_type == nfs_magic
|
||||
matches!(
|
||||
crate::platform::fs_type::detect_fd(fd),
|
||||
crate::platform::fs_type::FsType::Nfs
|
||||
)
|
||||
}
|
||||
|
||||
/// Run `sync_file_range(WAIT_AFTER)` on a worker thread and wait up
|
||||
@@ -356,3 +343,117 @@ fn wait_after_with_timeout(fd: RawFd, off: u64, len: u64) -> Option<u64> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// Helper: build a `WritebackPipeline` over a local tempfile. On
|
||||
/// every test rig (linux dev box, CI) the tempfile lives on a
|
||||
/// local FS, so `is_nfs=false` and `skip_wait` returns false until
|
||||
/// we explicitly mark the pipeline degraded.
|
||||
fn local_pipeline(chunk_bytes: u64) -> (NamedTempFile, WritebackPipeline) {
|
||||
let f = NamedTempFile::new().expect("tempfile create");
|
||||
let pipeline = WritebackPipeline::new(f.as_file(), 0, chunk_bytes);
|
||||
(f, pipeline)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_pipeline_starts_active() {
|
||||
let (_f, p) = local_pipeline(32 * 1024 * 1024);
|
||||
assert!(!p.is_nfs, "local tempfile must not classify as NFS");
|
||||
assert!(!p.degraded.load(Ordering::Relaxed));
|
||||
assert!(!p.skip_wait(), "fresh local pipeline must not skip wait");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_flag_short_circuits_wait() {
|
||||
let (_f, p) = local_pipeline(32 * 1024 * 1024);
|
||||
assert!(!p.skip_wait());
|
||||
p.degraded.store(true, Ordering::Relaxed);
|
||||
assert!(
|
||||
p.skip_wait(),
|
||||
"degraded flag must force the wait+dontneed bypass"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_wait_grows_chunk_on_high_p95() {
|
||||
let (_f, mut p) = local_pipeline(16 * 1024 * 1024);
|
||||
// Fill the window with samples above the grow threshold.
|
||||
for _ in 0..ADAPTIVE_WINDOW {
|
||||
p.record_wait(ADAPTIVE_GROW_MS + 50);
|
||||
}
|
||||
assert!(
|
||||
p.chunk_bytes > 16 * 1024 * 1024,
|
||||
"chunk should have grown; got {}",
|
||||
p.chunk_bytes
|
||||
);
|
||||
assert!(p.chunk_bytes <= CHUNK_BYTES_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_wait_shrinks_chunk_on_low_p95() {
|
||||
let (_f, mut p) = local_pipeline(64 * 1024 * 1024);
|
||||
for _ in 0..ADAPTIVE_WINDOW {
|
||||
p.record_wait(1); // well under ADAPTIVE_SHRINK_MS
|
||||
}
|
||||
assert!(
|
||||
p.chunk_bytes < 64 * 1024 * 1024,
|
||||
"chunk should have shrunk; got {}",
|
||||
p.chunk_bytes
|
||||
);
|
||||
assert!(p.chunk_bytes >= CHUNK_BYTES_MIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_wait_no_op_below_window_fill() {
|
||||
let (_f, mut p) = local_pipeline(16 * 1024 * 1024);
|
||||
let initial = p.chunk_bytes;
|
||||
// Only push a few samples; window not full → no adaptation.
|
||||
for _ in 0..(ADAPTIVE_WINDOW - 1) {
|
||||
p.record_wait(ADAPTIVE_GROW_MS + 100);
|
||||
}
|
||||
assert_eq!(
|
||||
p.chunk_bytes, initial,
|
||||
"chunk must not change before window is full"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_wait_clamps_to_chunk_bounds() {
|
||||
// Grow past the max.
|
||||
let (_f, mut p) = local_pipeline(CHUNK_BYTES_MAX);
|
||||
for _ in 0..ADAPTIVE_WINDOW {
|
||||
p.record_wait(ADAPTIVE_GROW_MS + 1000);
|
||||
}
|
||||
assert_eq!(p.chunk_bytes, CHUNK_BYTES_MAX, "must clamp to MAX");
|
||||
|
||||
// Shrink past the min.
|
||||
let (_f, mut p) = local_pipeline(CHUNK_BYTES_MIN);
|
||||
for _ in 0..ADAPTIVE_WINDOW {
|
||||
p.record_wait(0);
|
||||
}
|
||||
assert_eq!(p.chunk_bytes, CHUNK_BYTES_MIN, "must clamp to MIN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_nfs_local_file_is_false() {
|
||||
// Local tempfile must not classify as NFS. This locks in the
|
||||
// consolidation through `crate::platform::fs_type::detect_fd`.
|
||||
let f = NamedTempFile::new().expect("tempfile create");
|
||||
use std::os::unix::io::AsRawFd;
|
||||
assert!(!detect_nfs(f.as_file().as_raw_fd()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_progress_below_chunk_is_noop() {
|
||||
let (_f, mut p) = local_pipeline(32 * 1024 * 1024);
|
||||
// No-op return before crossing the first chunk boundary.
|
||||
let before = p.chunk_count;
|
||||
p.note_progress(1024); // < 32 MiB
|
||||
assert_eq!(p.chunk_count, before);
|
||||
assert!(p.pending.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,31 +16,14 @@ use std::io;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::time::Duration;
|
||||
|
||||
/// libc `F_PREALLOCATE` — not exposed by the `libc` crate on all macOS
|
||||
/// SDK versions, so define it here.
|
||||
const F_PREALLOCATE: libc::c_int = 42;
|
||||
/// Allocate from current EOF.
|
||||
const F_PEOFPOSMODE: libc::c_int = 3;
|
||||
/// Hint: contiguous extent preferred.
|
||||
const F_ALLOCATECONTIG: libc::c_uint = 0x00000002;
|
||||
/// Allocate all the requested bytes (fall back to non-contig if needed).
|
||||
const F_ALLOCATEALL: libc::c_uint = 0x00000004;
|
||||
use crate::io::platform_macos::{
|
||||
F_ALLOCATEALL, F_ALLOCATECONTIG, F_PEOFPOSMODE, F_PREALLOCATE, Fstore,
|
||||
};
|
||||
|
||||
/// `fcntl(F_FULLFSYNC)` opcode. Documented in `man 2 fcntl` on macOS;
|
||||
/// not in the `libc` crate as a named constant.
|
||||
const F_FULLFSYNC: libc::c_int = 51;
|
||||
|
||||
/// `fstore_t` layout matches `sys/fcntl.h`. Repr is C-stable so we can
|
||||
/// build it manually.
|
||||
#[repr(C)]
|
||||
struct Fstore {
|
||||
fst_flags: libc::c_uint,
|
||||
fst_posmode: libc::c_int,
|
||||
fst_offset: libc::off_t,
|
||||
fst_length: libc::off_t,
|
||||
fst_bytesalloc: libc::off_t,
|
||||
}
|
||||
|
||||
pub(super) fn preallocate(file: &File, size_bytes: u64) {
|
||||
let mut fst = Fstore {
|
||||
fst_flags: F_ALLOCATECONTIG | F_ALLOCATEALL,
|
||||
|
||||
@@ -73,9 +73,21 @@ use std::path::Path;
|
||||
use super::writeback::WritebackPipeline;
|
||||
|
||||
/// Granularity at which the Linux writeback pipeline issues
|
||||
/// `sync_file_range` / `posix_fadvise(DONTNEED)` pairs. 32 MiB is the
|
||||
/// historical default — bounded-cache pressure stays at ~2 × this size.
|
||||
const WRITEBACK_CHUNK_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// `sync_file_range` pairs. 32 MiB is the empirically best value on
|
||||
/// the rip1 test bed (NFS to unraid-1 over 1 GbE, single-disk SAS):
|
||||
/// 8 MiB / 64 MiB / 128 MiB all measured worse in the 0.21.x mux
|
||||
/// iteration runs. Override via `FREEMKV_WRITEBACK_CHUNK_MIB` —
|
||||
/// faster backends (NVMe, RAID) may tolerate larger windows.
|
||||
const WRITEBACK_CHUNK_BYTES_DEFAULT: u64 = 32 * 1024 * 1024;
|
||||
|
||||
fn writeback_chunk_bytes() -> u64 {
|
||||
std::env::var("FREEMKV_WRITEBACK_CHUNK_MIB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
.map(|n| n * 1024 * 1024)
|
||||
.unwrap_or(WRITEBACK_CHUNK_BYTES_DEFAULT)
|
||||
}
|
||||
|
||||
pub(crate) struct WritebackFile {
|
||||
file: File,
|
||||
@@ -90,7 +102,7 @@ impl WritebackFile {
|
||||
/// or appended files).
|
||||
pub(crate) fn new(mut file: File) -> io::Result<Self> {
|
||||
let pos = file.stream_position()?;
|
||||
let pipeline = WritebackPipeline::new(&file, pos, WRITEBACK_CHUNK_BYTES);
|
||||
let pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes());
|
||||
Ok(Self {
|
||||
file,
|
||||
pipeline,
|
||||
|
||||
+5
-2
@@ -77,6 +77,7 @@ pub mod css;
|
||||
pub mod decrypt;
|
||||
pub mod disc;
|
||||
pub mod drive;
|
||||
pub mod drm;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod halt;
|
||||
@@ -154,7 +155,7 @@ pub use profile::DriveProfile;
|
||||
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
||||
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
||||
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
||||
pub use decrypt::{DecryptKeys, decrypt_sectors};
|
||||
pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads};
|
||||
|
||||
// ─── Disc structure ─────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -211,9 +212,11 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
// are the ISO-on-disk implementations. [`DecryptingSectorSource`] is the
|
||||
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
||||
// `SectorSource` to get plaintext sectors out.
|
||||
pub use mux::build_iso_pipeline;
|
||||
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
||||
pub use sector::{
|
||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, SectorSink, SectorSource,
|
||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, PrefetchedSectorSource, SectorSink,
|
||||
SectorSource,
|
||||
};
|
||||
pub use speed::DriveSpeed;
|
||||
pub use udf::{UdfFs, read_filesystem};
|
||||
|
||||
@@ -76,6 +76,7 @@ impl CodecParser for Ac3Parser {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: data[start..start + frame_size].to_vec(),
|
||||
duration_ns: None,
|
||||
});
|
||||
pos = start + frame_size;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ impl CodecParser for DtsParser {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: data[start..start + total_size].to_vec(),
|
||||
duration_ns: None,
|
||||
});
|
||||
pos = start + total_size;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ impl CodecParser for DvdSubParser {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ impl CodecParser for H264Parser {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: frame_data,
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -176,14 +177,16 @@ impl<'a> Iterator for NalIterator<'a> {
|
||||
}
|
||||
|
||||
/// Find the position of the next start code (00 00 01) at or after `from`.
|
||||
///
|
||||
/// Backed by `memchr::memmem::find` for SIMD-accelerated bytestring
|
||||
/// search. On AVX2-capable x86_64 this runs ~5–10× the byte-by-byte
|
||||
/// scan that preceded it; on a 200 KB UHD HEVC frame the saving is
|
||||
/// in the hundreds of microseconds per call.
|
||||
pub fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
|
||||
if data.len() < from + 3 {
|
||||
return None;
|
||||
}
|
||||
// Range excludes last 2 bytes since we read 3 bytes at each position.
|
||||
// data.len()-2 as exclusive upper bound means last checked index is data.len()-3,
|
||||
// which accesses data[len-3], data[len-2], data[len-1] — all valid.
|
||||
(from..data.len() - 2).find(|&i| data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01)
|
||||
memchr::memmem::find(&data[from..], b"\x00\x00\x01").map(|rel| from + rel)
|
||||
}
|
||||
|
||||
/// Skip past the start code at position `pos`, returning the first byte after it.
|
||||
|
||||
@@ -52,7 +52,10 @@ impl CodecParser for HevcParser {
|
||||
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
|
||||
let data = &pes.data;
|
||||
let mut keyframe = false;
|
||||
let mut frame_data = Vec::new();
|
||||
// Pre-size: output is ~input bytes with a few 4-byte length
|
||||
// prefixes added. UHD frames are 150-300 KB; the unsized Vec
|
||||
// growth chain otherwise reallocs 5-7× per frame.
|
||||
let mut frame_data = Vec::with_capacity(data.len() + 64);
|
||||
|
||||
// Single-pass NAL scan: extract params, detect keyframes, build length-prefixed output
|
||||
let mut pos = 0;
|
||||
@@ -107,6 +110,7 @@ impl CodecParser for HevcParser {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: frame_data,
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ impl CodecParser for LpcmParser {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data[BD_LPCM_HEADER_SIZE..].to_vec(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ pub struct Frame {
|
||||
pub keyframe: bool,
|
||||
/// Frame data (elementary stream bytes).
|
||||
pub data: Vec<u8>,
|
||||
/// Optional duration in nanoseconds — only set by parsers that
|
||||
/// can compute one (currently PGS, which pairs a display PCS
|
||||
/// with the following empty PCS). When `Some`, the MKV muxer
|
||||
/// emits a `BlockGroup` with `BlockDuration` instead of a
|
||||
/// `SimpleBlock`; without it players guess the display interval
|
||||
/// (subtitles linger past their end-time).
|
||||
pub duration_ns: Option<u64>,
|
||||
}
|
||||
|
||||
/// Convert 90kHz PTS to nanoseconds (round to nearest).
|
||||
@@ -71,6 +78,7 @@ impl CodecParser for PassthroughParser {
|
||||
pts_ns,
|
||||
keyframe: self.keyframe,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ impl CodecParser for Mpeg2Parser {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
+132
-29
@@ -1,12 +1,32 @@
|
||||
//! HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
||||
//!
|
||||
//! PGS segments: PCS, WDS, PDS, ODS, END.
|
||||
//! Each PES packet contains one or more segments.
|
||||
//! All segments are keyframes (no inter-segment dependencies).
|
||||
//! PGS segments: PCS, WDS, PDS, ODS, END. Each PES packet starts with
|
||||
//! one of those (segment_type byte at offset 0).
|
||||
//!
|
||||
//! Subtitle display lifecycle (BD spec):
|
||||
//! - A "display" PCS (number_of_composition_objects > 0) starts a
|
||||
//! visible subtitle. Its WDS/PDS/ODS follow.
|
||||
//! - A later "empty" PCS (number_of_composition_objects == 0) clears
|
||||
//! the screen.
|
||||
//!
|
||||
//! For Matroska output we collapse that pair into one block with
|
||||
//! `BlockDuration` set to (clear_pts - display_pts). Without a
|
||||
//! duration, hardware players linger on the last bitmap until the
|
||||
//! next subtitle replaces it — which can be many seconds, and on a
|
||||
//! disc where the final subtitle has no follower, until end of file.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct PgsParser;
|
||||
const SEGMENT_PCS: u8 = 0x16;
|
||||
// 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,
|
||||
// palette_id_ref) = 13.
|
||||
const PCS_NUM_OBJECTS_OFFSET: usize = 13;
|
||||
|
||||
pub struct PgsParser {
|
||||
pending: Option<(i64, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl Default for PgsParser {
|
||||
fn default() -> Self {
|
||||
@@ -16,7 +36,7 @@ impl Default for PgsParser {
|
||||
|
||||
impl PgsParser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self { pending: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +46,63 @@ impl CodecParser for PgsParser {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
}]
|
||||
|
||||
let is_pcs = pes.data[0] == SEGMENT_PCS;
|
||||
let pcs_num_objects = if is_pcs && pes.data.len() > PCS_NUM_OBJECTS_OFFSET {
|
||||
Some(pes.data[PCS_NUM_OBJECTS_OFFSET])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
match pcs_num_objects {
|
||||
// Clear/empty PCS — closes any pending display. Drop the
|
||||
// clear segment itself; BlockDuration covers the screen
|
||||
// wipe.
|
||||
Some(0) => {
|
||||
if let Some((start_pts, data)) = self.pending.take() {
|
||||
let duration = pts_ns.saturating_sub(start_pts).max(0) as u64;
|
||||
out.push(Frame {
|
||||
pts_ns: start_pts,
|
||||
keyframe: true,
|
||||
data,
|
||||
duration_ns: Some(duration),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Display PCS — start a new pending. If a prior display
|
||||
// was never explicitly cleared (replace-without-clear),
|
||||
// emit it with the new PCS's PTS as its end.
|
||||
Some(_) => {
|
||||
if let Some((start_pts, data)) = self.pending.take() {
|
||||
let duration = pts_ns.saturating_sub(start_pts).max(0) as u64;
|
||||
out.push(Frame {
|
||||
pts_ns: start_pts,
|
||||
keyframe: true,
|
||||
data,
|
||||
duration_ns: Some(duration),
|
||||
});
|
||||
}
|
||||
self.pending = Some((pts_ns, pes.data.clone()));
|
||||
}
|
||||
// Non-PCS first segment — either a continuation of the
|
||||
// current display set, or non-standard layout. If we have
|
||||
// a pending display, append; otherwise emit as-is.
|
||||
None => {
|
||||
if let Some((_, ref mut buf)) = self.pending {
|
||||
buf.extend_from_slice(&pes.data);
|
||||
} else {
|
||||
out.push(Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
@@ -52,29 +124,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_basic_segment() {
|
||||
let mut parser = PgsParser::new();
|
||||
// PGS segment data (PCS = presentation composition segment)
|
||||
let data = vec![0x16, 0x00, 0x00, 0x11, 0x01, 0x02, 0x03];
|
||||
let pes = make_pes(data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, data);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
// Minimum-viable PCS bytes: type 0x16, segment_length (2 bytes),
|
||||
// then 11 bytes of PCS fields ending in number_of_composition_objects.
|
||||
fn pcs_bytes(num_objects: u8) -> Vec<u8> {
|
||||
let mut v = vec![SEGMENT_PCS, 0x00, 0x0B];
|
||||
v.extend_from_slice(&[0x07, 0x80, 0x04, 0x38]); // 1920x1080
|
||||
v.push(0x10); // frame_rate
|
||||
v.extend_from_slice(&[0x00, 0x01]); // composition_number
|
||||
v.push(0x80); // composition_state = EpochStart
|
||||
v.push(0x00); // palette_update + reserved
|
||||
v.push(0x00); // palette_id_ref
|
||||
v.push(num_objects);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_keyframes() {
|
||||
fn display_then_clear_yields_duration() {
|
||||
let mut parser = PgsParser::new();
|
||||
for i in 0..3 {
|
||||
let data = vec![0x16, 0x00, i];
|
||||
let pes = make_pes(data, Some(90000 * i as i64));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe, "PGS segment should always be keyframe");
|
||||
}
|
||||
|
||||
// Display PCS at PTS 90000 (= 1s)
|
||||
let display = pcs_bytes(1);
|
||||
let frames = parser.parse(&make_pes(display.clone(), Some(90000)));
|
||||
assert!(frames.is_empty(), "display PCS should be pending");
|
||||
|
||||
// Empty PCS at PTS 270000 (= 3s)
|
||||
let clear = pcs_bytes(0);
|
||||
let frames = parser.parse(&make_pes(clear, Some(270000)));
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
assert_eq!(frames[0].duration_ns, Some(2_000_000_000));
|
||||
assert_eq!(frames[0].data, display);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_without_clear_still_emits_prior_with_duration() {
|
||||
let mut parser = PgsParser::new();
|
||||
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
||||
let frames = parser.parse(&make_pes(pcs_bytes(1), Some(180000)));
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
assert_eq!(frames[0].duration_ns, Some(1_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_pcs_segment_appends_to_pending() {
|
||||
let mut parser = PgsParser::new();
|
||||
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
||||
// ODS-like segment (type 0x15)
|
||||
let frames = parser.parse(&make_pes(vec![0x15, 0x00, 0x02, 0xAA, 0xBB], Some(90000)));
|
||||
assert!(frames.is_empty());
|
||||
// Clear closes the set; data should include the appended bytes.
|
||||
let frames = parser.parse(&make_pes(pcs_bytes(0), Some(180000)));
|
||||
assert_eq!(frames.len(), 1);
|
||||
let data = &frames[0].data;
|
||||
assert!(data.windows(5).any(|w| w == [0x15, 0x00, 0x02, 0xAA, 0xBB]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -139,6 +139,7 @@ impl CodecParser for TrueHdParser {
|
||||
pts_ns: self.next_pts_ns,
|
||||
keyframe: is_major_sync,
|
||||
data: self.buf[..unit_bytes].to_vec(),
|
||||
duration_ns: None,
|
||||
});
|
||||
self.buf.drain(..unit_bytes);
|
||||
self.next_pts_ns += AU_DURATION_NS;
|
||||
|
||||
@@ -96,6 +96,7 @@ impl CodecParser for Vc1Parser {
|
||||
pts_ns: ts_ns,
|
||||
keyframe,
|
||||
data: frame_data.to_vec(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! `DemuxThread` — runs the read+decrypt+demux pipeline on a
|
||||
//! dedicated thread, feeding completed `PesPacket` batches to the
|
||||
//! caller via a bounded channel.
|
||||
//!
|
||||
//! ## Why a second worker thread
|
||||
//!
|
||||
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
|
||||
//! already runs on a producer thread; the *consumer* (main) thread
|
||||
//! still serialises `ts_demuxer.feed` (M2TS parsing) with the codec
|
||||
//! parsers. Profiling on the rip1 testbed showed feed at ~37 % and
|
||||
//! codec parse at ~44 % of consumer wall time — i.e. feed is heavy
|
||||
//! enough that pipelining it with parse pays for itself.
|
||||
//!
|
||||
//! Splitting them: feed runs in [`DemuxThread`]; the consumer thread
|
||||
//! receives `Vec<PesPacket>` batches and runs codec parse + frame
|
||||
//! emission only. Total throughput becomes `1/max(feed, parse)`
|
||||
//! instead of `1/(feed + parse)`.
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! [`DemuxThread::spawn`] takes ownership of the inner reader and the
|
||||
//! demuxer state, returns a handle plus a `Receiver<DemuxBatch>`.
|
||||
//! Dropping the handle closes the channel which signals the thread
|
||||
//! to exit; the join in `Drop::drop` is bounded.
|
||||
|
||||
use crate::halt::Halt;
|
||||
use crate::sector::SectorSource;
|
||||
use crossbeam_channel::{Receiver, Sender, bounded};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
/// Output channel depth. Two batches in flight keeps the consumer
|
||||
/// (codec parser) busy without piling up demuxed bytes if it stalls.
|
||||
const DEMUX_CHANNEL_DEPTH: usize = 2;
|
||||
|
||||
/// One demuxed batch flowing from the demux thread to the consumer.
|
||||
pub enum DemuxBatch {
|
||||
/// Successfully demuxed PesPackets — non-empty.
|
||||
Ts(Vec<super::ts::PesPacket>),
|
||||
Ps(Vec<super::ps::PsPacket>),
|
||||
/// Underlying reader returned an error. Terminal.
|
||||
Err(std::io::Error),
|
||||
}
|
||||
|
||||
/// Spawned demux thread. Drop joins.
|
||||
///
|
||||
/// In zero-copy mode the thread also owns an opaque
|
||||
/// `producer_shell: Option<Box<dyn Send>>` — the join handle of the
|
||||
/// upstream producer (sector or byte prefetcher). Dropping the
|
||||
/// `DemuxThread` runs the shell's `Drop`, which joins the producer.
|
||||
/// `Box<dyn Send>` rather than a concrete type so the same demux
|
||||
/// worker can be wired behind either prefetcher kind.
|
||||
pub struct DemuxThread {
|
||||
handle: Option<JoinHandle<()>>,
|
||||
#[allow(dead_code)]
|
||||
producer_shell: Option<Box<dyn Send>>,
|
||||
}
|
||||
|
||||
impl DemuxThread {
|
||||
/// Spawn the demux thread. Returns the thread handle and a
|
||||
/// receiver for [`DemuxBatch`] items.
|
||||
///
|
||||
/// `reader` is the fully-composed read+decrypt stack (e.g.
|
||||
/// [`PrefetchedSectorSource`](crate::sector::PrefetchedSectorSource)
|
||||
/// wrapping
|
||||
/// [`DecryptingSectorSource`](crate::sector::DecryptingSectorSource)).
|
||||
/// `extents` is what the thread walks; it issues one
|
||||
/// `read_sectors` per batch of `batch_sectors` sectors (aligned
|
||||
/// to 3-sector AACS units when possible).
|
||||
pub fn spawn<S: SectorSource + Send + 'static>(
|
||||
mut reader: S,
|
||||
extents: Vec<crate::disc::Extent>,
|
||||
batch_sectors: u16,
|
||||
halt: Option<Halt>,
|
||||
ts: Option<super::ts::TsDemuxer>,
|
||||
ps: Option<super::ps::PsDemuxer>,
|
||||
) -> (Self, Receiver<DemuxBatch>) {
|
||||
let (tx, rx) = bounded::<DemuxBatch>(DEMUX_CHANNEL_DEPTH);
|
||||
let mut ts = ts;
|
||||
let mut ps = ps;
|
||||
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("freemkv-demux".into())
|
||||
.spawn(move || {
|
||||
let mut buf = vec![0u8; batch_sectors as usize * 2048];
|
||||
let mut ext_idx = 0usize;
|
||||
let mut offset: u32 = 0;
|
||||
let prof = std::env::var_os("FREEMKV_PROFILE").is_some();
|
||||
let mut prof_started = std::time::Instant::now();
|
||||
let mut prof_last_dump = prof_started;
|
||||
let mut prof_read_ns: u128 = 0;
|
||||
let mut prof_feed_ns: u128 = 0;
|
||||
let mut prof_send_ns: u128 = 0;
|
||||
let mut prof_bytes: u64 = 0;
|
||||
while ext_idx < extents.len() {
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let ext = &extents[ext_idx];
|
||||
let remaining = ext.sector_count.saturating_sub(offset);
|
||||
if remaining == 0 {
|
||||
ext_idx += 1;
|
||||
offset = 0;
|
||||
continue;
|
||||
}
|
||||
let mut sectors = remaining.min(batch_sectors as u32) as u16;
|
||||
if sectors >= 3 {
|
||||
sectors -= sectors % 3;
|
||||
}
|
||||
let bytes = sectors as usize * 2048;
|
||||
if buf.len() < bytes {
|
||||
buf.resize(bytes, 0);
|
||||
}
|
||||
let lba = ext.start_lba + offset;
|
||||
let t0 = if prof {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let n = match reader.read_sectors(lba, sectors, &mut buf[..bytes], false) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
let _ = tx.send(DemuxBatch::Err(e.into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let t1 = if prof {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
offset += sectors as u32;
|
||||
|
||||
// Demux this batch immediately so the channel
|
||||
// carries already-parsed PesPackets, not raw
|
||||
// sector bytes.
|
||||
if let Some(ref mut d) = ts {
|
||||
let pkts = d.feed(&buf[..n]);
|
||||
let t2 = if prof {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !pkts.is_empty() && tx.send(DemuxBatch::Ts(pkts)).is_err() {
|
||||
return; // consumer dropped
|
||||
}
|
||||
let t3 = if prof {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if prof {
|
||||
prof_read_ns += t1.unwrap().duration_since(t0.unwrap()).as_nanos();
|
||||
prof_feed_ns += t2.unwrap().duration_since(t1.unwrap()).as_nanos();
|
||||
prof_send_ns += t3.unwrap().duration_since(t2.unwrap()).as_nanos();
|
||||
prof_bytes += n as u64;
|
||||
let now = t3.unwrap();
|
||||
if now.duration_since(prof_last_dump)
|
||||
>= std::time::Duration::from_secs(5)
|
||||
{
|
||||
let el = now.duration_since(prof_started).as_millis().max(1);
|
||||
let mbps = prof_bytes as u128 * 1000 / 1_000_000 / el;
|
||||
eprintln!(
|
||||
"[demux] elapsed={}ms in={}MB/s read={}% feed={}% send={}%",
|
||||
el,
|
||||
mbps,
|
||||
prof_read_ns / 10_000 / el,
|
||||
prof_feed_ns / 10_000 / el,
|
||||
prof_send_ns / 10_000 / el,
|
||||
);
|
||||
prof_last_dump = now;
|
||||
prof_started = now;
|
||||
prof_read_ns = 0;
|
||||
prof_feed_ns = 0;
|
||||
prof_send_ns = 0;
|
||||
prof_bytes = 0;
|
||||
}
|
||||
}
|
||||
} else if let Some(ref mut d) = ps {
|
||||
let pkts = d.feed(&buf[..n]);
|
||||
if !pkts.is_empty() && tx.send(DemuxBatch::Ps(pkts)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// EOF — emit any flushed packets too.
|
||||
if let Some(ref mut d) = ts {
|
||||
let tail = d.flush();
|
||||
if !tail.is_empty() {
|
||||
let _ = tx.send(DemuxBatch::Ts(tail));
|
||||
}
|
||||
} else if let Some(ref mut d) = ps {
|
||||
let tail = d.flush();
|
||||
if !tail.is_empty() {
|
||||
let _ = tx.send(DemuxBatch::Ps(tail));
|
||||
}
|
||||
}
|
||||
// Sender drops here -> consumer sees RecvError → EOF.
|
||||
})
|
||||
.expect("freemkv-demux thread spawn failed");
|
||||
|
||||
(
|
||||
Self {
|
||||
handle: Some(handle),
|
||||
producer_shell: None,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Zero-copy variant. Instead of taking a `SectorSource` and
|
||||
/// memcpy-ing through its `read_sectors` API, this constructor
|
||||
/// consumes the prefetch channels directly: filled buffers come
|
||||
/// in via `prefetch_rx`, the demux thread feeds them, then
|
||||
/// returns them to `recycle_tx` for the producer to re-fill.
|
||||
/// Eliminates the 16 MiB memcpy per batch that the SectorSource
|
||||
/// adapter incurred (and, with the producer-side recycling pool,
|
||||
/// also eliminates the per-batch heap alloc / cross-thread free
|
||||
/// that was costing 40 %+ of demux-thread time before).
|
||||
///
|
||||
/// `producer_shell` is an opaque handle whose only purpose is to
|
||||
/// outlive the demux thread and join the upstream producer when
|
||||
/// dropped. Both
|
||||
/// [`crate::sector::PrefetchedSectorSource::into_channels`] and
|
||||
/// [`crate::io::byte_prefetcher::BytePrefetcher::into_channels`]
|
||||
/// hand back a shell that fits — pass either.
|
||||
pub fn spawn_zero_copy<S: Send + 'static>(
|
||||
prefetch_rx: Receiver<std::io::Result<Vec<u8>>>,
|
||||
recycle_tx: Sender<Vec<u8>>,
|
||||
producer_shell: S,
|
||||
halt: Option<Halt>,
|
||||
ts: Option<super::ts::TsDemuxer>,
|
||||
ps: Option<super::ps::PsDemuxer>,
|
||||
) -> (Self, Receiver<DemuxBatch>) {
|
||||
let (tx, rx) = bounded::<DemuxBatch>(DEMUX_CHANNEL_DEPTH);
|
||||
let mut ts = ts;
|
||||
let mut ps = ps;
|
||||
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("freemkv-demux".into())
|
||||
.spawn(move || {
|
||||
let prof = std::env::var_os("FREEMKV_PROFILE").is_some();
|
||||
let mut prof_started = std::time::Instant::now();
|
||||
let mut prof_last_dump = prof_started;
|
||||
let mut prof_read_ns: u128 = 0;
|
||||
let mut prof_feed_ns: u128 = 0;
|
||||
let mut prof_bytes: u64 = 0;
|
||||
loop {
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let t0 = if prof {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let buf = match prefetch_rx.recv() {
|
||||
Ok(Ok(b)) => b,
|
||||
Ok(Err(e)) => {
|
||||
let _ = tx.send(DemuxBatch::Err(e));
|
||||
return;
|
||||
}
|
||||
Err(_) => break, // producer done → EOF
|
||||
};
|
||||
let t1 = if prof {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let n = buf.len();
|
||||
if let Some(ref mut d) = ts {
|
||||
let pkts = d.feed(&buf);
|
||||
let t2 = if prof {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Recycle the buffer back to the producer
|
||||
// before pushing the demuxed packets. If the
|
||||
// recycle channel is closed the producer has
|
||||
// exited; we drop the buffer and continue.
|
||||
let _ = recycle_tx.send(buf);
|
||||
if !pkts.is_empty() && tx.send(DemuxBatch::Ts(pkts)).is_err() {
|
||||
return;
|
||||
}
|
||||
if prof {
|
||||
prof_read_ns += t1.unwrap().duration_since(t0.unwrap()).as_nanos();
|
||||
prof_feed_ns += t2.unwrap().duration_since(t1.unwrap()).as_nanos();
|
||||
prof_bytes += n as u64;
|
||||
let now = std::time::Instant::now();
|
||||
if now.duration_since(prof_last_dump)
|
||||
>= std::time::Duration::from_secs(5)
|
||||
{
|
||||
let el = now.duration_since(prof_started).as_millis().max(1);
|
||||
let mbps = prof_bytes as u128 * 1000 / 1_000_000 / el;
|
||||
eprintln!(
|
||||
"[demux] elapsed={}ms in={}MB/s read={}% feed={}%",
|
||||
el,
|
||||
mbps,
|
||||
prof_read_ns / 10_000 / el,
|
||||
prof_feed_ns / 10_000 / el,
|
||||
);
|
||||
prof_last_dump = now;
|
||||
prof_started = now;
|
||||
prof_read_ns = 0;
|
||||
prof_feed_ns = 0;
|
||||
prof_bytes = 0;
|
||||
}
|
||||
}
|
||||
} else if let Some(ref mut d) = ps {
|
||||
let pkts = d.feed(&buf);
|
||||
let _ = recycle_tx.send(buf);
|
||||
if !pkts.is_empty() && tx.send(DemuxBatch::Ps(pkts)).is_err() {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
let _ = recycle_tx.send(buf);
|
||||
}
|
||||
}
|
||||
// Flush tail packets at EOF.
|
||||
if let Some(ref mut d) = ts {
|
||||
let tail = d.flush();
|
||||
if !tail.is_empty() {
|
||||
let _ = tx.send(DemuxBatch::Ts(tail));
|
||||
}
|
||||
} else if let Some(ref mut d) = ps {
|
||||
let tail = d.flush();
|
||||
if !tail.is_empty() {
|
||||
let _ = tx.send(DemuxBatch::Ps(tail));
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("freemkv-demux thread spawn failed");
|
||||
|
||||
(
|
||||
Self {
|
||||
handle: Some(handle),
|
||||
producer_shell: Some(Box::new(producer_shell)),
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DemuxThread {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.handle.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
-8
@@ -1,7 +1,7 @@
|
||||
//! DiscStream — read any disc (physical drive or ISO file) → PES frames.
|
||||
//!
|
||||
//! One stream type for all disc sources. The source is a SectorSource —
|
||||
//! Drive (hardware) or IsoSectorReader (file). DiscStream doesn't care.
|
||||
//! Drive (hardware) or FileSectorSource (file). DiscStream doesn't care.
|
||||
//!
|
||||
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
|
||||
|
||||
@@ -149,7 +149,11 @@ pub struct DiscStream {
|
||||
// a percent without a separate API call.
|
||||
bytes_total_extents: u64,
|
||||
|
||||
// PES output
|
||||
// PES output — single-threaded inline demux + codec parse. The
|
||||
// pipeline-mode mux (3-stage threaded) lives in
|
||||
// [`super::pipelined_stream::PipelinedPesStream`]; this type is
|
||||
// the legacy in-thread path for live-disc reads where adaptive
|
||||
// batch retry on bad sectors lives in `fill_extents`.
|
||||
ts_demuxer: Option<super::ts::TsDemuxer>,
|
||||
ps_demuxer: Option<super::ps::PsDemuxer>,
|
||||
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||
@@ -403,6 +407,71 @@ impl DiscStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-stage profiling state — populated only when `FREEMKV_PROFILE`
|
||||
/// is set. Dumps a percentage breakdown to stderr every
|
||||
/// [`PROFILE_INTERVAL`]. Zero overhead in normal runs (Option check
|
||||
/// is the only added cost).
|
||||
struct StageProf {
|
||||
started: std::time::Instant,
|
||||
last_dump: std::time::Instant,
|
||||
fill_ns: u128,
|
||||
feed_ns: u128,
|
||||
consume_ns: u128,
|
||||
bytes_in: u64,
|
||||
}
|
||||
|
||||
const PROFILE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
thread_local! {
|
||||
static STAGE_PROF: std::cell::RefCell<Option<StageProf>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn prof_active() -> bool {
|
||||
std::env::var_os("FREEMKV_PROFILE").is_some()
|
||||
}
|
||||
|
||||
fn prof_tick(stage: &str, ns: u128, bytes: u64) {
|
||||
STAGE_PROF.with(|cell| {
|
||||
let mut slot = cell.borrow_mut();
|
||||
if slot.is_none() {
|
||||
if !prof_active() {
|
||||
return;
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
*slot = Some(StageProf {
|
||||
started: now,
|
||||
last_dump: now,
|
||||
fill_ns: 0,
|
||||
feed_ns: 0,
|
||||
consume_ns: 0,
|
||||
bytes_in: 0,
|
||||
});
|
||||
}
|
||||
let p = slot.as_mut().unwrap();
|
||||
match stage {
|
||||
"fill" => p.fill_ns += ns,
|
||||
"feed" => p.feed_ns += ns,
|
||||
"consume" => p.consume_ns += ns,
|
||||
_ => {}
|
||||
}
|
||||
p.bytes_in += bytes;
|
||||
let now = std::time::Instant::now();
|
||||
if now.duration_since(p.last_dump) < PROFILE_INTERVAL {
|
||||
return;
|
||||
}
|
||||
let elapsed_ms = now.duration_since(p.started).as_millis().max(1);
|
||||
let fill_pct = p.fill_ns / 10_000 / elapsed_ms;
|
||||
let feed_pct = p.feed_ns / 10_000 / elapsed_ms;
|
||||
let consume_pct = p.consume_ns / 10_000 / elapsed_ms;
|
||||
let mbps = p.bytes_in as u128 * 1000 / 1_000_000 / elapsed_ms;
|
||||
eprintln!(
|
||||
"[profile] elapsed={}ms in={}MB/s fill={}% feed={}% consume={}%",
|
||||
elapsed_ms, mbps, fill_pct, feed_pct, consume_pct,
|
||||
);
|
||||
p.last_dump = now;
|
||||
});
|
||||
}
|
||||
|
||||
impl crate::pes::Stream for DiscStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
@@ -414,6 +483,7 @@ impl crate::pes::Stream for DiscStream {
|
||||
}
|
||||
|
||||
loop {
|
||||
let t0 = std::time::Instant::now();
|
||||
if !self.fill_extents()? {
|
||||
self.eof = true;
|
||||
// Flush demuxer — last PES packet may still be in the assembler
|
||||
@@ -475,6 +545,8 @@ impl crate::pes::Stream for DiscStream {
|
||||
}
|
||||
|
||||
let bytes = self.buf_valid;
|
||||
let t1 = std::time::Instant::now();
|
||||
prof_tick("fill", t1.duration_since(t0).as_nanos(), bytes as u64);
|
||||
// Plaintext: the wrapped reader (DecryptingSectorSource)
|
||||
// applied AACS / CSS in-place during fill_extents'
|
||||
// read_sectors call. The pre-0.18 inline decrypt step
|
||||
@@ -482,21 +554,42 @@ impl crate::pes::Stream for DiscStream {
|
||||
|
||||
if let Some(ref mut demuxer) = self.ts_demuxer {
|
||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||
for pes in &packets {
|
||||
if let Some((_, track)) =
|
||||
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
|
||||
let t2 = std::time::Instant::now();
|
||||
prof_tick("feed", t2.duration_since(t1).as_nanos(), 0);
|
||||
let skip_parse = std::env::var_os("FREEMKV_SKIP_PARSE").is_some();
|
||||
for pes in packets {
|
||||
if let Some((_, track)) = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(pid, _)| *pid == pes.pid)
|
||||
.copied()
|
||||
{
|
||||
if let Some((_, parser)) =
|
||||
if skip_parse {
|
||||
// Profiling escape hatch — bypass the codec
|
||||
// parser and pass the raw PES bytes straight
|
||||
// through as a single PesFrame. Lets us
|
||||
// attribute consumer-thread time to
|
||||
// "demux + framing" vs "codec parse".
|
||||
self.pending_frames.push_back(crate::pes::PesFrame {
|
||||
track,
|
||||
pts: pes.pts.map(super::codec::pts_to_ns).unwrap_or(0),
|
||||
keyframe: false,
|
||||
data: pes.data,
|
||||
duration_ns: None,
|
||||
});
|
||||
} else if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
for frame in parser.parse(&pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(*track, frame),
|
||||
crate::pes::PesFrame::from_codec_frame(track, frame),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let t3 = std::time::Instant::now();
|
||||
prof_tick("consume", t3.duration_since(t2).as_nanos(), 0);
|
||||
} else if let Some(ref mut demuxer) = self.ps_demuxer {
|
||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||
for ps in &packets {
|
||||
@@ -570,6 +663,13 @@ impl crate::pes::Stream for DiscStream {
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
// FREEMKV_SKIP_PARSE bypasses codec parsers entirely for
|
||||
// bottleneck profiling, so codec_private is never populated.
|
||||
// Pretend headers are ready immediately in that mode so the
|
||||
// CLI loop doesn't hang waiting for them.
|
||||
if std::env::var_os("FREEMKV_SKIP_PARSE").is_some() {
|
||||
return true;
|
||||
}
|
||||
for (idx, s) in self.title.streams.iter().enumerate() {
|
||||
if let crate::disc::Stream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
@@ -579,6 +679,10 @@ impl crate::pes::Stream for DiscStream {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn errors(&self) -> u64 {
|
||||
self.errors
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -321,6 +321,12 @@ pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285;
|
||||
// Segment
|
||||
pub const SEGMENT: u32 = 0x1853_8067;
|
||||
|
||||
// SeekHead
|
||||
pub const SEEK_HEAD: u32 = 0x114D_9B74;
|
||||
pub const SEEK: u32 = 0x4DBB;
|
||||
pub const SEEK_ID: u32 = 0x53AB;
|
||||
pub const SEEK_POSITION: u32 = 0x53AC;
|
||||
|
||||
// Segment Info
|
||||
pub const INFO: u32 = 0x1549_A966;
|
||||
pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1;
|
||||
@@ -366,6 +372,9 @@ pub const BIT_DEPTH: u32 = 0x6264;
|
||||
pub const CLUSTER: u32 = 0x1F43_B675;
|
||||
pub const CLUSTER_TIMESTAMP: u32 = 0xE7;
|
||||
pub const SIMPLE_BLOCK: u32 = 0xA3;
|
||||
pub const BLOCK_GROUP: u32 = 0xA0;
|
||||
pub const BLOCK: u32 = 0xA1;
|
||||
pub const BLOCK_DURATION: u32 = 0x9B;
|
||||
|
||||
// Cues
|
||||
pub const CUES: u32 = 0x1C53_BB6B;
|
||||
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
//! ISO sector reader — file-backed SectorSource for Blu-ray ISO images.
|
||||
//!
|
||||
//! An ISO is a flat image of 2048-byte sectors. Sector N starts at byte offset N * 2048.
|
||||
//! Used by DiscStream::open_iso() and Disc::scan_image().
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorSource;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
const SECTOR_SIZE: u64 = 2048;
|
||||
|
||||
/// File-backed sector reader for ISO images.
|
||||
pub struct IsoSectorReader {
|
||||
file: File,
|
||||
capacity: u32,
|
||||
}
|
||||
|
||||
impl IsoSectorReader {
|
||||
pub fn open(path: &str) -> std::io::Result<Self> {
|
||||
let file = File::open(Path::new(path))?;
|
||||
let size = file.metadata()?.len();
|
||||
let sectors = size / SECTOR_SIZE;
|
||||
if sectors > u32::MAX as u64 {
|
||||
return Err(crate::error::Error::IsoTooLarge {
|
||||
path: path.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let capacity = sectors as u32;
|
||||
Ok(Self { file, capacity })
|
||||
}
|
||||
|
||||
pub fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for IsoSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
let bytes = count as usize * SECTOR_SIZE as usize;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(lba as u64 * SECTOR_SIZE))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file
|
||||
.read_exact(&mut buf[..bytes])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn iso_reader_read_sectors() {
|
||||
let mut data = vec![0u8; 4 * SECTOR_SIZE as usize];
|
||||
for i in 0..4u8 {
|
||||
let offset = i as usize * SECTOR_SIZE as usize;
|
||||
data[offset] = i + 1;
|
||||
data[offset + 2047] = i + 100;
|
||||
}
|
||||
|
||||
let dir = std::env::temp_dir().join("freemkv_test_iso_read");
|
||||
std::fs::write(&dir, &data).unwrap();
|
||||
|
||||
let mut reader = IsoSectorReader::open(dir.to_str().unwrap()).unwrap();
|
||||
assert_eq!(reader.capacity_sectors(), 4);
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
reader.read_sectors(0, 1, &mut buf, true).unwrap();
|
||||
assert_eq!(buf[0], 1);
|
||||
assert_eq!(buf[2047], 100);
|
||||
|
||||
reader.read_sectors(2, 1, &mut buf, true).unwrap();
|
||||
assert_eq!(buf[0], 3);
|
||||
assert_eq!(buf[2047], 102);
|
||||
|
||||
std::fs::remove_file(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_reader_capacity() {
|
||||
let data = vec![0u8; 10 * SECTOR_SIZE as usize];
|
||||
let dir = std::env::temp_dir().join("freemkv_test_iso_cap");
|
||||
std::fs::write(&dir, &data).unwrap();
|
||||
|
||||
let reader = IsoSectorReader::open(dir.to_str().unwrap()).unwrap();
|
||||
assert_eq!(reader.capacity_sectors(), 10);
|
||||
|
||||
std::fs::remove_file(&dir).ok();
|
||||
}
|
||||
}
|
||||
+138
-226
@@ -1,57 +1,21 @@
|
||||
//! M2tsStream — BD transport stream with embedded metadata header.
|
||||
//! M2tsStream — BD transport stream write sink.
|
||||
//!
|
||||
//! Write: prepends FMKV metadata header, then muxes PES frames into BD-TS.
|
||||
//! Read: extracts metadata header (or scans PMT), then demuxes BD-TS into PES frames.
|
||||
//! Write: prepends FMKV metadata header, then muxes PES frames into
|
||||
//! BD-TS. The read direction lives on the pipeline highway —
|
||||
//! `m2ts://` URLs route through
|
||||
//! [`super::resolve::input`] → `build_m2ts_pipeline` →
|
||||
//! [`super::pipelined_stream::PipelinedPesStream`], so this type is
|
||||
//! write-only.
|
||||
|
||||
use super::{meta, ts};
|
||||
use super::meta;
|
||||
use crate::disc::{DiscTitle, Stream as DiscStream};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::io::{self, Write};
|
||||
|
||||
type PesSetup = (
|
||||
Vec<u16>,
|
||||
Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||
Vec<(u16, usize)>,
|
||||
);
|
||||
|
||||
/// Size of initial scan buffer for PMT/stream detection.
|
||||
const SCAN_SIZE: usize = 1024 * 1024;
|
||||
|
||||
enum Mode {
|
||||
Write {
|
||||
muxer: super::tsmux::TsMuxer<Box<dyn Write + Send>>,
|
||||
},
|
||||
Read {
|
||||
reader: Box<dyn Read + Send>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Read as many bytes as possible into buf (multiple read calls if needed).
|
||||
/// Bounded by buf.len() — caller controls max bytes read.
|
||||
fn read_fill(r: &mut impl Read, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let mut total = 0;
|
||||
while total < buf.len() {
|
||||
match r.read(&mut buf[total..]) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => total += n,
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// BD transport stream with embedded metadata.
|
||||
/// BD transport stream write sink with embedded FMKV metadata
|
||||
/// header.
|
||||
pub struct M2tsStream {
|
||||
disc_title: DiscTitle,
|
||||
mode: Mode,
|
||||
// PES support
|
||||
demuxer: Option<ts::TsDemuxer>,
|
||||
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
pes_eof: bool,
|
||||
/// Codec private data per stream (from FMKV header).
|
||||
stored_codec_privates: Vec<Option<Vec<u8>>>,
|
||||
muxer: super::tsmux::TsMuxer<Box<dyn Write + Send>>,
|
||||
}
|
||||
|
||||
impl M2tsStream {
|
||||
@@ -81,205 +45,153 @@ impl M2tsStream {
|
||||
}
|
||||
Ok(Self {
|
||||
disc_title: title.clone(),
|
||||
mode: Mode::Write { muxer },
|
||||
demuxer: None,
|
||||
parsers: Vec::new(),
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track: Vec::new(),
|
||||
pes_eof: false,
|
||||
stored_codec_privates: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn setup_pes(streams: &[DiscStream]) -> PesSetup {
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)> = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (i, s) in streams.iter().enumerate() {
|
||||
let (pid, codec) = match s {
|
||||
DiscStream::Video(v) => (v.pid, v.codec),
|
||||
DiscStream::Audio(a) => (a.pid, a.codec),
|
||||
DiscStream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, i));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
|
||||
}
|
||||
(pids, parsers, pid_to_track)
|
||||
}
|
||||
|
||||
/// Open an M2TS stream for reading. Takes any Read source — file, pipe, socket.
|
||||
///
|
||||
/// Tries FMKV metadata header first. Falls back to PMT scan of first 1 MB.
|
||||
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
|
||||
// Read first chunk — enough for FMKV header or PMT scan
|
||||
let mut head = vec![0u8; SCAN_SIZE];
|
||||
let head_len = read_fill(&mut reader, &mut head)?;
|
||||
head.truncate(head_len);
|
||||
|
||||
// Try FMKV metadata header from the buffered head
|
||||
let mut cursor = io::Cursor::new(&head);
|
||||
if let Ok(Some(m)) = meta::read_header(&mut cursor) {
|
||||
let header_end = cursor.position() as usize;
|
||||
let title = m.to_title();
|
||||
let (pids, parsers, pid_to_track) = Self::setup_pes(&title.streams);
|
||||
// Chain: remaining head bytes + rest of reader
|
||||
let remaining_head = &head[header_end..];
|
||||
let chain: Box<dyn Read + Send> =
|
||||
Box::new(io::Cursor::new(remaining_head.to_vec()).chain(reader));
|
||||
return Ok(Self {
|
||||
disc_title: title.clone(),
|
||||
mode: Mode::Read { reader: chain },
|
||||
demuxer: if pids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ts::TsDemuxer::new(&pids))
|
||||
},
|
||||
parsers,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track,
|
||||
pes_eof: false,
|
||||
stored_codec_privates: title.codec_privates,
|
||||
});
|
||||
}
|
||||
|
||||
// No FMKV header — scan head for PMT
|
||||
let streams = ts::scan_streams(&head)
|
||||
.ok_or_else(|| -> io::Error { crate::error::Error::NoStreams.into() })?;
|
||||
|
||||
let (pids, parsers, pid_to_track) = Self::setup_pes(&streams);
|
||||
|
||||
// Chain: full head (it's all TS data) + rest of reader
|
||||
let chain: Box<dyn Read + Send> = Box::new(io::Cursor::new(head).chain(reader));
|
||||
|
||||
Ok(Self {
|
||||
disc_title: DiscTitle {
|
||||
duration_secs: 0.0, // unknown without seeking
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
},
|
||||
mode: Mode::Read { reader: chain },
|
||||
demuxer: if pids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ts::TsDemuxer::new(&pids))
|
||||
},
|
||||
parsers,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
pid_to_track,
|
||||
pes_eof: false,
|
||||
stored_codec_privates: Vec::new(),
|
||||
muxer,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::pes::Stream for M2tsStream {
|
||||
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
if self.pes_eof {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
loop {
|
||||
let reader = match &mut self.mode {
|
||||
Mode::Read { reader } => reader,
|
||||
_ => return Err(crate::error::Error::StreamWriteOnly.into()),
|
||||
};
|
||||
let mut buf = vec![0u8; 192 * 1024];
|
||||
let n = reader.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
self.pes_eof = true;
|
||||
// Flush demuxer — last PES packet may still be in the assembler
|
||||
if let Some(ref mut demuxer) = self.demuxer {
|
||||
for pes in &demuxer.flush() {
|
||||
if let Some((_, track)) =
|
||||
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(*track, frame),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(self.pending_frames.pop_front());
|
||||
}
|
||||
|
||||
if let Some(ref mut demuxer) = self.demuxer {
|
||||
let packets = demuxer.feed(&buf[..n]);
|
||||
for pes in &packets {
|
||||
if let Some((_, track)) =
|
||||
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending_frames.push_back(
|
||||
crate::pes::PesFrame::from_codec_frame(*track, frame),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
}
|
||||
// Write-only sink. The m2ts:// read direction is served by
|
||||
// `super::resolve::build_m2ts_pipeline` →
|
||||
// `PipelinedPesStream`; routing through this type for reads
|
||||
// was removed when the highway became the only ingress.
|
||||
Err(crate::error::Error::StreamWriteOnly.into())
|
||||
}
|
||||
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Write { muxer } => muxer.write_frame(frame.track, frame.pts, &frame.data),
|
||||
Mode::Read { .. } => Err(crate::error::Error::StreamReadOnly.into()),
|
||||
}
|
||||
self.muxer
|
||||
.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data)
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Write { muxer } => muxer.finish(),
|
||||
Mode::Read { .. } => Ok(()),
|
||||
}
|
||||
self.muxer.finish()
|
||||
}
|
||||
|
||||
fn info(&self) -> &crate::disc::DiscTitle {
|
||||
&self.disc_title
|
||||
}
|
||||
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
// First check stored codec_privates from FMKV header
|
||||
if let Some(Some(cp)) = self.stored_codec_privates.get(track) {
|
||||
return Some(cp.clone());
|
||||
fn codec_private(&self, _track: usize) -> Option<Vec<u8>> {
|
||||
// Write side doesn't have parsers; codec_private flows in
|
||||
// via the title metadata at `create` time and gets baked
|
||||
// into the FMKV header. Nothing to surface back here.
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{
|
||||
Codec, ColorSpace, ContentFormat, DiscTitle, FrameRate, HdrFormat, Resolution,
|
||||
Stream as DiscStream, VideoStream,
|
||||
};
|
||||
use crate::pes::{PesFrame, Stream as PesStreamTrait};
|
||||
|
||||
const VIDEO_PID: u16 = 0x1011;
|
||||
|
||||
fn make_title() -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: String::new(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 0.0,
|
||||
size_bytes: 0,
|
||||
clips: Vec::new(),
|
||||
streams: vec![DiscStream::Video(VideoStream {
|
||||
pid: VIDEO_PID,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F24,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
})],
|
||||
chapters: Vec::new(),
|
||||
extents: Vec::new(),
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: vec![Some({
|
||||
// Minimal hvcC with one VPS-like array entry.
|
||||
let marker: &[u8] = &[0x40, 0x01, 0x0C, 0x01];
|
||||
let mut hvcc = vec![0u8; 22];
|
||||
hvcc.push(1); // numArrays
|
||||
hvcc.push(32);
|
||||
hvcc.extend_from_slice(&1u16.to_be_bytes()); // numNalus
|
||||
hvcc.extend_from_slice(&(marker.len() as u16).to_be_bytes());
|
||||
hvcc.extend_from_slice(marker);
|
||||
hvcc
|
||||
})],
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_idr_pes_data() -> Vec<u8> {
|
||||
// 4-byte length prefix + NAL: type 19 (IDR_W_RADL).
|
||||
let mut nal = vec![(19u8 << 1) & 0x7E, 0x01];
|
||||
for i in 0..200 {
|
||||
nal.push((i & 0xFF) as u8);
|
||||
}
|
||||
// Fall back to parser-extracted codec_private
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(pid, _)| *pid)?;
|
||||
self.parsers
|
||||
.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
let mut out = Vec::with_capacity(4 + nal.len());
|
||||
out.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(&nal);
|
||||
out
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
for (idx, s) in self.disc_title.streams.iter().enumerate() {
|
||||
if let crate::disc::Stream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/// Writer wrapper that shares an Arc<Mutex<Vec<u8>>> so the test can
|
||||
/// inspect the bytes after the muxer drops.
|
||||
struct SharedSink(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
||||
impl Write for SharedSink {
|
||||
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(b);
|
||||
Ok(b.len())
|
||||
}
|
||||
true
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m2ts_stream_forwards_keyframe_to_rai() {
|
||||
let title = make_title();
|
||||
let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
|
||||
let sink = SharedSink(shared.clone());
|
||||
let mut stream = M2tsStream::create(sink, &title).unwrap();
|
||||
let frame = PesFrame {
|
||||
track: 0,
|
||||
pts: 0,
|
||||
keyframe: true,
|
||||
data: fake_idr_pes_data(),
|
||||
duration_ns: None,
|
||||
};
|
||||
stream.write(&frame).unwrap();
|
||||
stream.finish().unwrap();
|
||||
drop(stream);
|
||||
|
||||
let buf = shared.lock().unwrap().clone();
|
||||
|
||||
// Skip FMKV metadata header via meta::read_header.
|
||||
let mut cursor = std::io::Cursor::new(&buf);
|
||||
let _meta = super::meta::read_header(&mut cursor)
|
||||
.unwrap()
|
||||
.expect("FMKV header present");
|
||||
let header_end = cursor.position() as usize;
|
||||
let ts_bytes = &buf[header_end..];
|
||||
|
||||
// Find first PUSI packet on VIDEO_PID; verify RAI in AF flags.
|
||||
let pkt = ts_bytes
|
||||
.chunks(192)
|
||||
.find(|p| {
|
||||
let h = &p[4..];
|
||||
let pid = (((h[1] & 0x1F) as u16) << 8) | h[2] as u16;
|
||||
pid == VIDEO_PID && (h[1] & 0x40) != 0
|
||||
})
|
||||
.expect("video PUSI packet present");
|
||||
let h = &pkt[4..];
|
||||
let afc = (h[3] >> 4) & 0x03;
|
||||
assert!(afc & 0b10 != 0, "AF must be present");
|
||||
let af_len = h[4] as usize;
|
||||
assert!(af_len >= 1, "AF length must include flags byte");
|
||||
let flags = h[5];
|
||||
assert_eq!(flags & 0x40, 0x40, "RAI bit set");
|
||||
}
|
||||
}
|
||||
|
||||
+160
-13
@@ -161,16 +161,21 @@ impl<W: Write> M2tsMux<W> {
|
||||
}
|
||||
|
||||
/// Write one video PES frame. `data` is either length-prefixed
|
||||
/// NALUs (MKV-style) or already Annex B; both are accepted.
|
||||
pub fn write_video(&mut self, pts_ns: i64, data: &[u8]) -> io::Result<()> {
|
||||
/// NALUs (MKV-style) or already Annex B; both are accepted. `keyframe`
|
||||
/// drives the random_access_indicator bit on the first packet of this
|
||||
/// PES (and gates codec_private NAL prepending — those only attach to
|
||||
/// the first keyframe).
|
||||
pub fn write_video(&mut self, pts_ns: i64, keyframe: bool, data: &[u8]) -> io::Result<()> {
|
||||
let pts_90k = self.base_relative_pts(pts_ns);
|
||||
// PCR comes "before" the PTS it timestamps; clamp at 0 for the
|
||||
// first frame so we don't underflow.
|
||||
let pcr = pts_90k.saturating_sub(PCR_LEAD_90KHZ);
|
||||
|
||||
// Annex-B-ify the frame and prepend VPS/SPS/PPS once.
|
||||
// Annex-B-ify the frame and prepend VPS/SPS/PPS once, on the
|
||||
// FIRST keyframe (not first frame — non-key frames before the
|
||||
// first keyframe can't carry params usefully).
|
||||
let mut es = Vec::with_capacity(data.len() + 64);
|
||||
if !self.params_written {
|
||||
if keyframe && !self.params_written {
|
||||
if let Some(cp) = &self.video_codec_private {
|
||||
let payload = hvcc_payload(cp);
|
||||
if !payload.is_empty() {
|
||||
@@ -184,7 +189,7 @@ impl<W: Write> M2tsMux<W> {
|
||||
es.extend_from_slice(&annex_b);
|
||||
|
||||
let pes = build_video_pes(pts_90k, &es);
|
||||
self.write_pes(PID_VIDEO, &pes, Some(pcr))
|
||||
self.write_pes(PID_VIDEO, &pes, Some(pcr), keyframe)
|
||||
}
|
||||
|
||||
/// Write one audio PES frame. Returns `Ok(())` and silently drops
|
||||
@@ -197,7 +202,7 @@ impl<W: Write> M2tsMux<W> {
|
||||
}
|
||||
let pts_90k = self.base_relative_pts(pts_ns);
|
||||
let pes = build_audio_pes(pts_90k, data);
|
||||
self.write_pes(PID_AUDIO, &pes, None)
|
||||
self.write_pes(PID_AUDIO, &pes, None, false)
|
||||
}
|
||||
|
||||
/// Drain the underlying writer. No TS-level trailer is mandatory —
|
||||
@@ -234,7 +239,13 @@ impl<W: Write> M2tsMux<W> {
|
||||
/// The fit-the-tail logic on the last packet of the PES uses
|
||||
/// stuffing rather than a separate small packet, which is the
|
||||
/// standard MPEG-TS convention.
|
||||
fn write_pes(&mut self, pid: u16, pes: &[u8], pcr: Option<u64>) -> io::Result<()> {
|
||||
fn write_pes(
|
||||
&mut self,
|
||||
pid: u16,
|
||||
pes: &[u8],
|
||||
pcr: Option<u64>,
|
||||
is_keyframe_video: bool,
|
||||
) -> io::Result<()> {
|
||||
// PSI cadence is enforced per TS packet — interleave a fresh
|
||||
// PAT+PMT into the packet stream every PSI_INTERVAL_PACKETS so
|
||||
// long single-PES emissions (e.g. one 60 KB video frame) don't
|
||||
@@ -250,11 +261,21 @@ impl<W: Write> M2tsMux<W> {
|
||||
&& (self.packets_written == 0
|
||||
|| self.video_packets_since_pcr >= PCR_INTERVAL_PACKETS);
|
||||
|
||||
let af_body: Vec<u8> = if attach_pcr {
|
||||
// RAI rides only the FIRST packet of a keyframe video PES.
|
||||
let attach_rai = first && is_keyframe_video && pid == PID_VIDEO;
|
||||
|
||||
let mut af_body: Vec<u8> = if attach_pcr {
|
||||
build_pcr_adaptation(pcr.unwrap_or(0))
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if attach_rai {
|
||||
if af_body.is_empty() {
|
||||
af_body.push(0x40); // flags: RAI only
|
||||
} else {
|
||||
af_body[0] |= 0x40; // OR RAI into existing PCR flags
|
||||
}
|
||||
}
|
||||
|
||||
let remaining = pes.len() - offset;
|
||||
// Capacity for payload given AF body and 1-byte AF length.
|
||||
@@ -510,7 +531,7 @@ fn build_pcr_adaptation(pcr_90k: u64) -> Vec<u8> {
|
||||
// elementary_stream_priority(1) | PCR_flag(1) | OPCR_flag(1) |
|
||||
// splicing_point_flag(1) | transport_private_data_flag(1) |
|
||||
// adaptation_field_extension_flag(1) | PCR(48b).
|
||||
let mut af = vec![0x50]; // PCR_flag=1, random_access_indicator=1
|
||||
let mut af = vec![0x10]; // PCR_flag=1; RAI is OR'd in by the caller when applicable
|
||||
let pcr_base = pcr_90k & 0x1_FFFF_FFFF; // 33-bit
|
||||
let pcr_ext: u16 = 0; // 9-bit, we keep it zero (no sub-tick precision)
|
||||
// Encode PCR: 33b base | 6b reserved | 9b extension = 48b
|
||||
@@ -598,7 +619,7 @@ mod tests {
|
||||
let mut frame = Vec::new();
|
||||
frame.extend_from_slice(&4u32.to_be_bytes());
|
||||
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
|
||||
mux.write_video(0, &frame).unwrap();
|
||||
mux.write_video(0, true, &frame).unwrap();
|
||||
mux.finish().unwrap();
|
||||
drop(mux);
|
||||
|
||||
@@ -619,7 +640,7 @@ mod tests {
|
||||
let mut frame = Vec::new();
|
||||
frame.extend_from_slice(&3u32.to_be_bytes());
|
||||
frame.extend_from_slice(&[0x40, 0x01, 0x0C]);
|
||||
mux.write_video(0, &frame).unwrap();
|
||||
mux.write_video(0, true, &frame).unwrap();
|
||||
mux.write_audio(20_000_000, &[0x0B, 0x77, 0x12, 0x34])
|
||||
.unwrap();
|
||||
mux.finish().unwrap();
|
||||
@@ -641,7 +662,7 @@ mod tests {
|
||||
let mut frame = Vec::new();
|
||||
frame.extend_from_slice(&(big.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&big);
|
||||
mux.write_video(0, &frame).unwrap();
|
||||
mux.write_video(0, true, &frame).unwrap();
|
||||
mux.finish().unwrap();
|
||||
drop(mux);
|
||||
|
||||
@@ -663,7 +684,7 @@ mod tests {
|
||||
let mut frame = Vec::new();
|
||||
frame.extend_from_slice(&3u32.to_be_bytes());
|
||||
frame.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
|
||||
mux.write_video(pts, &frame).unwrap();
|
||||
mux.write_video(pts, pts == 0, &frame).unwrap();
|
||||
}
|
||||
mux.finish().unwrap();
|
||||
drop(mux);
|
||||
@@ -678,4 +699,130 @@ mod tests {
|
||||
assert_eq!(w[1], (w[0] + 1) & 0x0F);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the adaptation field body (length byte stripped) for one
|
||||
/// 188-byte TS packet, or None when the packet has no AF.
|
||||
fn af_body(packet: &[u8]) -> Option<Vec<u8>> {
|
||||
let afc = (packet[3] >> 4) & 0x03;
|
||||
if afc & 0b10 == 0 {
|
||||
return None;
|
||||
}
|
||||
let af_len = packet[4] as usize;
|
||||
if af_len == 0 {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
Some(packet[5..5 + af_len].to_vec())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rai_set_on_keyframe_pes_packet() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut mux = M2tsMux::new(&mut sink);
|
||||
let mut frame = Vec::new();
|
||||
frame.extend_from_slice(&4u32.to_be_bytes());
|
||||
frame.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
|
||||
mux.write_video(0, true, &frame).unwrap();
|
||||
mux.finish().unwrap();
|
||||
drop(mux);
|
||||
|
||||
// Find the first PUSI packet on PID_VIDEO.
|
||||
let pkt = sink
|
||||
.chunks(188)
|
||||
.find(|p| u16::from_be_bytes([p[1] & 0x1F, p[2]]) == PID_VIDEO && (p[1] & 0x40) != 0)
|
||||
.expect("video PUSI packet exists");
|
||||
let af = af_body(pkt).expect("AF present on first packet of keyframe video PES");
|
||||
assert!(!af.is_empty(), "AF flags byte present");
|
||||
assert_eq!(af[0] & 0x40, 0x40, "RAI bit set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcr_packet_without_keyframe_has_rai_clear() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut mux = M2tsMux::new(&mut sink);
|
||||
// First frame is the keyframe (gates codec_private; also gets PCR).
|
||||
let mut frame0 = Vec::new();
|
||||
frame0.extend_from_slice(&4u32.to_be_bytes());
|
||||
frame0.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
|
||||
mux.write_video(0, true, &frame0).unwrap();
|
||||
// Push enough non-key video frames to cross PCR_INTERVAL_PACKETS
|
||||
// video packets so a later PCR-bearing packet exists.
|
||||
// Each frame is ~50 KB → ~270 packets, well over 40.
|
||||
let big: Vec<u8> = (0..(50 * 1024)).map(|i| (i & 0xff) as u8).collect();
|
||||
for i in 1..3 {
|
||||
let mut frame = Vec::new();
|
||||
frame.extend_from_slice(&(big.len() as u32).to_be_bytes());
|
||||
frame.extend_from_slice(&big);
|
||||
mux.write_video((i as i64) * 40_000_000, false, &frame)
|
||||
.unwrap();
|
||||
}
|
||||
mux.finish().unwrap();
|
||||
drop(mux);
|
||||
|
||||
// The first PUSI video packet carries PCR + RAI (keyframe).
|
||||
// A later video PUSI packet with AF + PCR but NOT keyframe must
|
||||
// have RAI clear.
|
||||
let video_pusi: Vec<&[u8]> = sink
|
||||
.chunks(188)
|
||||
.filter(|p| u16::from_be_bytes([p[1] & 0x1F, p[2]]) == PID_VIDEO && (p[1] & 0x40) != 0)
|
||||
.collect();
|
||||
assert!(
|
||||
video_pusi.len() >= 2,
|
||||
"expected ≥2 video PES starts, got {}",
|
||||
video_pusi.len()
|
||||
);
|
||||
// Find a later one with AF that carries PCR (flags & 0x10 set).
|
||||
let later_pcr = video_pusi
|
||||
.iter()
|
||||
.skip(1)
|
||||
.find_map(|p| {
|
||||
let af = af_body(p)?;
|
||||
if !af.is_empty() && (af[0] & 0x10) != 0 {
|
||||
Some(af)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.expect("later PCR-bearing PUSI exists");
|
||||
assert_eq!(
|
||||
later_pcr[0] & 0x40,
|
||||
0,
|
||||
"RAI must be clear on non-keyframe PCR packet"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_video_with_pcr_combines_flags() {
|
||||
// PCR attaches only when video_packets_since_pcr >=
|
||||
// PCR_INTERVAL_PACKETS (40). The very first video packet emits a
|
||||
// PAT+PMT first, so packets_written != 0 and attach_pcr is false on
|
||||
// frame 0. We push: keyframe (no PCR) → many non-key (drives the
|
||||
// PCR counter past the interval) → second keyframe (PCR + RAI).
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut mux = M2tsMux::new(&mut sink);
|
||||
let mut small = Vec::new();
|
||||
small.extend_from_slice(&4u32.to_be_bytes());
|
||||
small.extend_from_slice(&[0x40, 0x01, 0x0C, 0x01]);
|
||||
mux.write_video(0, true, &small).unwrap();
|
||||
// ~50 KB ≈ 270 packets — well over PCR_INTERVAL_PACKETS.
|
||||
let big: Vec<u8> = (0..(50 * 1024)).map(|i| (i & 0xff) as u8).collect();
|
||||
let mut big_frame = Vec::new();
|
||||
big_frame.extend_from_slice(&(big.len() as u32).to_be_bytes());
|
||||
big_frame.extend_from_slice(&big);
|
||||
mux.write_video(40_000_000, false, &big_frame).unwrap();
|
||||
// Now a second keyframe — must combine RAI (keyframe) and PCR
|
||||
// (counter exceeded).
|
||||
mux.write_video(80_000_000, true, &small).unwrap();
|
||||
mux.finish().unwrap();
|
||||
drop(mux);
|
||||
|
||||
// Collect video PUSI packets and find the third (second keyframe).
|
||||
let video_pusi: Vec<&[u8]> = sink
|
||||
.chunks(188)
|
||||
.filter(|p| u16::from_be_bytes([p[1] & 0x1F, p[2]]) == PID_VIDEO && (p[1] & 0x40) != 0)
|
||||
.collect();
|
||||
assert!(video_pusi.len() >= 3, "three video PES starts expected");
|
||||
let af = af_body(video_pusi[2]).expect("AF present");
|
||||
assert!(!af.is_empty(), "AF flags byte present");
|
||||
assert_eq!(af[0], 0x50, "flags == RAI | PCR");
|
||||
}
|
||||
}
|
||||
|
||||
+564
-25
@@ -87,11 +87,19 @@ impl MkvTrack {
|
||||
}
|
||||
|
||||
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.
|
||||
let codec_id = match a.codec {
|
||||
Codec::Ac3 => "A_AC3",
|
||||
Codec::Ac3Plus => "A_EAC3",
|
||||
Codec::TrueHd => "A_TRUEHD",
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => "A_DTS",
|
||||
Codec::DtsHdMa => "A_DTS/MA",
|
||||
Codec::DtsHdHr => "A_DTS/HR",
|
||||
Codec::Dts => "A_DTS",
|
||||
Codec::Lpcm => "A_PCM/INT/BIG",
|
||||
_ => "A_AC3",
|
||||
};
|
||||
@@ -159,6 +167,12 @@ struct CuePoint {
|
||||
cluster_pos: u64, // relative to Segment start
|
||||
}
|
||||
|
||||
/// SeekHead entry that needs its 8-byte SeekPosition back-patched after Cues are written.
|
||||
struct SeekPositionFixup {
|
||||
target_id: u32,
|
||||
value_offset: u64, // absolute file offset of the 8-byte SeekPosition value
|
||||
}
|
||||
|
||||
/// MKV muxer. Call write_frame() for each frame, then finish() at the end.
|
||||
pub struct MkvMuxer<W: Write + Seek> {
|
||||
writer: W,
|
||||
@@ -170,6 +184,10 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
base_pts_ms: Option<i64>,
|
||||
cues: Vec<CuePoint>,
|
||||
frame_count: u64,
|
||||
seek_fixups: Vec<SeekPositionFixup>,
|
||||
info_offset: u64,
|
||||
tracks_offset: u64,
|
||||
chapters_offset: Option<u64>,
|
||||
}
|
||||
|
||||
/// New cluster every 5 seconds.
|
||||
@@ -200,7 +218,34 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::write_unknown_size(&mut writer)?;
|
||||
let segment_start = writer.stream_position()?;
|
||||
|
||||
// SeekHead with fixed-width SeekPosition placeholders. Order: Info, Tracks, [Chapters], Cues.
|
||||
let mut seek_fixups: Vec<SeekPositionFixup> = Vec::new();
|
||||
let seekhead_pos = ebml::start_master(&mut writer, ebml::SEEK_HEAD)?;
|
||||
let mut targets: Vec<u32> = vec![ebml::INFO, ebml::TRACKS];
|
||||
if !chapters.is_empty() {
|
||||
targets.push(ebml::CHAPTERS);
|
||||
}
|
||||
targets.push(ebml::CUES);
|
||||
let seek_id_be = (ebml::SEEK as u16).to_be_bytes();
|
||||
let seek_inner_id_be = (ebml::SEEK_ID as u16).to_be_bytes();
|
||||
let seek_pos_id_be = (ebml::SEEK_POSITION as u16).to_be_bytes();
|
||||
for target_id in &targets {
|
||||
writer.write_all(&[seek_id_be[0], seek_id_be[1], 0x92])?;
|
||||
writer.write_all(&[seek_inner_id_be[0], seek_inner_id_be[1], 0x84])?;
|
||||
writer.write_all(&target_id.to_be_bytes())?;
|
||||
writer.write_all(&[seek_pos_id_be[0], seek_pos_id_be[1], 0x88])?;
|
||||
let value_offset = writer.stream_position()?;
|
||||
writer.write_all(&[0u8; 8])?;
|
||||
seek_fixups.push(SeekPositionFixup {
|
||||
target_id: *target_id,
|
||||
value_offset,
|
||||
});
|
||||
}
|
||||
ebml::end_master(&mut writer, seekhead_pos)?;
|
||||
|
||||
// Info
|
||||
let info_start = writer.stream_position()?;
|
||||
let info_offset = info_start - segment_start;
|
||||
let info_pos = ebml::start_master(&mut writer, ebml::INFO)?;
|
||||
ebml::write_uint(&mut writer, ebml::TIMESTAMP_SCALE, 1_000_000)?; // 1ms precision
|
||||
if duration_secs > 0.0 {
|
||||
@@ -215,6 +260,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut writer, info_pos)?;
|
||||
|
||||
// Tracks
|
||||
let tracks_start = writer.stream_position()?;
|
||||
let tracks_offset = tracks_start - segment_start;
|
||||
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||
@@ -302,7 +349,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut writer, tracks_pos)?;
|
||||
|
||||
// Chapters
|
||||
let mut chapters_offset: Option<u64> = None;
|
||||
if !chapters.is_empty() {
|
||||
let chapters_start = writer.stream_position()?;
|
||||
chapters_offset = Some(chapters_start - segment_start);
|
||||
let chapters_pos = ebml::start_master(&mut writer, ebml::CHAPTERS)?;
|
||||
let edition_pos = ebml::start_master(&mut writer, ebml::EDITION_ENTRY)?;
|
||||
for (i, ch) in chapters.iter().enumerate() {
|
||||
@@ -330,42 +380,60 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
base_pts_ms: None,
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
seek_fixups,
|
||||
info_offset,
|
||||
tracks_offset,
|
||||
chapters_offset,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a single frame.
|
||||
///
|
||||
/// When `duration_ns` is `Some`, the frame is emitted as a
|
||||
/// `BlockGroup` with `BlockDuration` so the player knows exactly
|
||||
/// when to remove the on-screen artifact (the practical case is
|
||||
/// PGS subtitles — without it, the last bitmap lingers until the
|
||||
/// next display set replaces it). Otherwise a plain `SimpleBlock`.
|
||||
pub fn write_frame(
|
||||
&mut self,
|
||||
track_idx: usize,
|
||||
pts_ns: i64,
|
||||
keyframe: bool,
|
||||
data: &[u8],
|
||||
duration_ns: Option<u64>,
|
||||
) -> io::Result<()> {
|
||||
let raw_ms = pts_ns / 1_000_000;
|
||||
let base = *self.base_pts_ms.get_or_insert(raw_ms);
|
||||
let pts_ms = raw_ms - base;
|
||||
|
||||
// Start new cluster if needed
|
||||
if !self.cluster_open || (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS {
|
||||
if self.cluster_open {
|
||||
// Close current cluster (it's a master with unknown size — we use known size)
|
||||
// Actually, for streaming we keep clusters open-ended. Just start a new one.
|
||||
// Cluster boundaries must 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
|
||||
|| (is_video_key && (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS);
|
||||
|
||||
if needs_new_cluster {
|
||||
if !is_video_key {
|
||||
return Ok(());
|
||||
}
|
||||
self.start_cluster(pts_ms)?;
|
||||
|
||||
// Add cue point at cluster start for keyframes (video track 0)
|
||||
if keyframe && track_idx == 0 {
|
||||
self.cues.push(CuePoint {
|
||||
timestamp_ms: pts_ms,
|
||||
track: track_idx + 1,
|
||||
cluster_pos: self.cluster_pos - self.segment_start,
|
||||
});
|
||||
}
|
||||
self.cues.push(CuePoint {
|
||||
timestamp_ms: pts_ms,
|
||||
track: track_idx + 1,
|
||||
cluster_pos: self.cluster_pos - self.segment_start,
|
||||
});
|
||||
}
|
||||
|
||||
// Write SimpleBlock
|
||||
let relative_ts = (pts_ms - self.cluster_ts_ms) as i16;
|
||||
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
|
||||
match duration_ns {
|
||||
Some(dur_ns) => {
|
||||
let duration_ms = (dur_ns / 1_000_000).max(1);
|
||||
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, duration_ms)?;
|
||||
}
|
||||
None => {
|
||||
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
|
||||
}
|
||||
}
|
||||
self.frame_count += 1;
|
||||
|
||||
Ok(())
|
||||
@@ -377,6 +445,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
self.end_cluster()?;
|
||||
|
||||
// Write Cues
|
||||
let cues_start = self.writer.stream_position()?;
|
||||
let cues_offset = cues_start - self.segment_start;
|
||||
if !self.cues.is_empty() {
|
||||
let cues_pos = ebml::start_master(&mut self.writer, ebml::CUES)?;
|
||||
for cue in &self.cues {
|
||||
@@ -395,6 +465,21 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut self.writer, cues_pos)?;
|
||||
}
|
||||
|
||||
// Back-patch SeekHead SeekPosition values now that all element offsets are known.
|
||||
for fixup in &self.seek_fixups {
|
||||
let offset = match fixup.target_id {
|
||||
ebml::INFO => self.info_offset,
|
||||
ebml::TRACKS => self.tracks_offset,
|
||||
ebml::CHAPTERS => self.chapters_offset.unwrap_or(0),
|
||||
ebml::CUES => cues_offset,
|
||||
_ => 0,
|
||||
};
|
||||
self.writer
|
||||
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
|
||||
self.writer.write_all(&offset.to_be_bytes())?;
|
||||
}
|
||||
self.writer.seek(std::io::SeekFrom::End(0))?;
|
||||
|
||||
self.writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -447,6 +532,34 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_block_group(
|
||||
&mut self,
|
||||
track_num: usize,
|
||||
relative_ts: i16,
|
||||
keyframe: bool,
|
||||
data: &[u8],
|
||||
duration_ms: u64,
|
||||
) -> io::Result<()> {
|
||||
let track_vint = if track_num < 0x80 {
|
||||
vec![(track_num as u8) | 0x80]
|
||||
} else {
|
||||
vec![0x40 | ((track_num >> 8) as u8), track_num as u8]
|
||||
};
|
||||
let flags: u8 = if keyframe { 0x80 } else { 0x00 };
|
||||
let block_size = track_vint.len() + 2 + 1 + data.len();
|
||||
|
||||
let bg_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_GROUP)?;
|
||||
ebml::write_id(&mut self.writer, ebml::BLOCK)?;
|
||||
ebml::write_size(&mut self.writer, block_size as u64)?;
|
||||
self.writer.write_all(&track_vint)?;
|
||||
self.writer.write_all(&relative_ts.to_be_bytes())?;
|
||||
self.writer.write_all(&[flags])?;
|
||||
self.writer.write_all(data)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::BLOCK_DURATION, duration_ms)?;
|
||||
ebml::end_master(&mut self.writer, bg_pos)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -556,7 +669,7 @@ mod tests {
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF])
|
||||
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None)
|
||||
.unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(
|
||||
@@ -594,7 +707,9 @@ mod tests {
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
|
||||
muxer.write_frame(0, 0, true, &[0x01, 0x02, 0x03]).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
|
||||
.unwrap();
|
||||
muxer.finish().unwrap();
|
||||
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
@@ -610,13 +725,17 @@ mod tests {
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
|
||||
// Write frames to both tracks
|
||||
muxer.write_frame(0, 0, true, &[0x00, 0x00, 0x01]).unwrap();
|
||||
muxer.write_frame(1, 0, false, &[0x0B, 0x77, 0x00]).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01])
|
||||
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01])
|
||||
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None)
|
||||
.unwrap();
|
||||
// Should not panic
|
||||
let data = muxer.writer.into_inner();
|
||||
@@ -631,10 +750,12 @@ mod tests {
|
||||
|
||||
// Record position before first frame
|
||||
let pos_before_kf = muxer.writer.position();
|
||||
muxer.write_frame(0, 0, true, &[0xAA]).unwrap();
|
||||
muxer.write_frame(0, 0, true, &[0xAA], None).unwrap();
|
||||
let pos_after_kf = muxer.writer.position();
|
||||
|
||||
muxer.write_frame(0, 1_000_000, false, &[0xBB]).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 1_000_000, false, &[0xBB], None)
|
||||
.unwrap();
|
||||
let pos_after_nkf = muxer.writer.position();
|
||||
|
||||
let data = muxer.writer.into_inner();
|
||||
@@ -812,4 +933,422 @@ mod tests {
|
||||
"FlagForced element should not be present for non-forced subtitle"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Seekability tests: SeekHead, keyframe-aligned clusters, Cues
|
||||
// ============================================================
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Writer that lets the test inspect the buffer after `finish()` consumes the muxer.
|
||||
struct SharedWriter(Arc<Mutex<Cursor<Vec<u8>>>>);
|
||||
impl Write for SharedWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.0.lock().unwrap().write(buf)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.0.lock().unwrap().flush()
|
||||
}
|
||||
}
|
||||
impl Seek for SharedWriter {
|
||||
fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
|
||||
self.0.lock().unwrap().seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build interleaved frames at 24 fps video (IDR every gop_secs) + 48 kHz audio (1024 samples per frame).
|
||||
fn frames_for(duration_secs: f64, gop_secs: f64) -> Vec<(usize, i64, bool, Vec<u8>)> {
|
||||
let video_interval_ns: i64 = 1_000_000_000 / 24;
|
||||
let audio_interval_ns: i64 = (1024i64 * 1_000_000_000) / 48_000;
|
||||
let gop_frames = (gop_secs * 24.0).round() as i64;
|
||||
|
||||
let mut out: Vec<(usize, i64, bool, Vec<u8>)> = Vec::new();
|
||||
let total_ns = (duration_secs * 1_000_000_000.0) as i64;
|
||||
|
||||
let mut vi: i64 = 0;
|
||||
loop {
|
||||
let pts = vi * video_interval_ns;
|
||||
if pts >= total_ns {
|
||||
break;
|
||||
}
|
||||
let keyframe = vi % gop_frames == 0;
|
||||
out.push((0, pts, keyframe, vec![0xAB; 64]));
|
||||
vi += 1;
|
||||
}
|
||||
|
||||
let mut ai: i64 = 0;
|
||||
loop {
|
||||
let pts = ai * audio_interval_ns;
|
||||
if pts >= total_ns {
|
||||
break;
|
||||
}
|
||||
out.push((1, pts, true, vec![0xCD; 32]));
|
||||
ai += 1;
|
||||
}
|
||||
|
||||
out.sort_by_key(|f| f.1);
|
||||
out
|
||||
}
|
||||
|
||||
/// Mux frames through a SharedWriter and return the final buffer.
|
||||
fn mux_to_bytes(
|
||||
tracks: &[MkvTrack],
|
||||
chapters: &[Chapter],
|
||||
frames: &[(usize, i64, bool, Vec<u8>)],
|
||||
) -> (Vec<u8>, u64) {
|
||||
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, chapters).unwrap();
|
||||
for (t, pts, kf, data) in frames {
|
||||
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
|
||||
}
|
||||
let frame_count = muxer.frame_count;
|
||||
muxer.finish().unwrap();
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
(data, frame_count)
|
||||
}
|
||||
|
||||
/// Find the Segment header in the buffer and return (segment_id_pos, segment_start_pos).
|
||||
/// segment_start = position immediately after Segment's id + size bytes.
|
||||
fn locate_segment(data: &[u8]) -> (usize, usize) {
|
||||
let segment_id_pos = find_id(data, ebml::SEGMENT).expect("segment id not found");
|
||||
// Segment is written via write_id + write_unknown_size: 4 byte id + 8 byte size
|
||||
(segment_id_pos, segment_id_pos + 4 + 8)
|
||||
}
|
||||
|
||||
/// Walk Segment's top-level children. Returns Vec<(id, data_start_offset, data_size)>
|
||||
/// where data_start_offset is absolute file offset and data_size is the element body size.
|
||||
fn segment_children(data: &[u8]) -> Vec<(u32, usize, u64)> {
|
||||
let (_, seg_start) = locate_segment(data);
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = Cursor::new(&data[seg_start..]);
|
||||
while (cursor.position() as usize) < data.len() - seg_start {
|
||||
let pos_before = cursor.position();
|
||||
let (id, size, hdr_len) = match ebml::read_element_header(&mut cursor) {
|
||||
Ok(v) => v,
|
||||
Err(_) => break,
|
||||
};
|
||||
let data_abs = seg_start + pos_before as usize + hdr_len;
|
||||
out.push((id, data_abs, size));
|
||||
// Skip the body to advance to the next element.
|
||||
cursor
|
||||
.seek(io::SeekFrom::Current(size as i64))
|
||||
.expect("seek past element body");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Find every Cluster: returns Vec<(cluster_data_start_abs, cluster_data_size, cluster_timestamp_ms)>.
|
||||
fn find_clusters(data: &[u8]) -> Vec<(usize, u64, u64)> {
|
||||
let mut out = Vec::new();
|
||||
for (id, body_start, body_size) in segment_children(data) {
|
||||
if id == ebml::CLUSTER {
|
||||
let mut cursor = Cursor::new(&data[body_start..body_start + body_size as usize]);
|
||||
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
assert_eq!(
|
||||
tid,
|
||||
ebml::CLUSTER_TIMESTAMP,
|
||||
"cluster must start with timestamp"
|
||||
);
|
||||
let ts = ebml::read_uint_val(&mut cursor, tsize as usize).unwrap();
|
||||
out.push((body_start, body_size, ts));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the first SimpleBlock that appears in a cluster body slice.
|
||||
/// Returns (track_num, flags_byte). track_num decoded from VINT.
|
||||
fn first_simple_block(cluster_body: &[u8]) -> (u64, u8) {
|
||||
let mut cursor = Cursor::new(cluster_body);
|
||||
loop {
|
||||
let (id, size, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
if id == ebml::SIMPLE_BLOCK {
|
||||
let body_start = cursor.position() as usize;
|
||||
// Decode track VINT.
|
||||
let b0 = cluster_body[body_start];
|
||||
let (track_num, vint_len) = if b0 & 0x80 != 0 {
|
||||
((b0 & 0x7F) as u64, 1usize)
|
||||
} else if b0 & 0x40 != 0 {
|
||||
let b1 = cluster_body[body_start + 1];
|
||||
((((b0 & 0x3F) as u64) << 8) | b1 as u64, 2)
|
||||
} else {
|
||||
panic!("unsupported track vint width");
|
||||
};
|
||||
let flags = cluster_body[body_start + vint_len + 2];
|
||||
return (track_num, flags);
|
||||
}
|
||||
// Skip non-SimpleBlock child.
|
||||
cursor.seek(io::SeekFrom::Current(size as i64)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the Cues element body into Vec<(cue_time, cue_track, cue_cluster_position)>.
|
||||
fn parse_cues(data: &[u8]) -> Vec<(u64, u64, u64)> {
|
||||
let mut out = Vec::new();
|
||||
let (cues_id, cues_body_start, cues_body_size) = segment_children(data)
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| *id == ebml::CUES)
|
||||
.expect("cues element not found");
|
||||
assert_eq!(cues_id, ebml::CUES);
|
||||
let cues_body = &data[cues_body_start..cues_body_start + cues_body_size as usize];
|
||||
let mut cursor = Cursor::new(cues_body);
|
||||
while (cursor.position() as usize) < cues_body.len() {
|
||||
let (id, size, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
assert_eq!(id, ebml::CUE_POINT);
|
||||
let cp_end = cursor.position() + size;
|
||||
let mut cue_time = 0u64;
|
||||
let mut cue_track = 0u64;
|
||||
let mut cue_pos = 0u64;
|
||||
while cursor.position() < cp_end {
|
||||
let (sid, ssize, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
match sid {
|
||||
ebml::CUE_TIME => {
|
||||
cue_time = ebml::read_uint_val(&mut cursor, ssize as usize).unwrap();
|
||||
}
|
||||
ebml::CUE_TRACK_POSITIONS => {
|
||||
let ctp_end = cursor.position() + ssize;
|
||||
while cursor.position() < ctp_end {
|
||||
let (iid, isize_, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
match iid {
|
||||
ebml::CUE_TRACK => {
|
||||
cue_track =
|
||||
ebml::read_uint_val(&mut cursor, isize_ as usize).unwrap();
|
||||
}
|
||||
ebml::CUE_CLUSTER_POSITION => {
|
||||
cue_pos =
|
||||
ebml::read_uint_val(&mut cursor, isize_ as usize).unwrap();
|
||||
}
|
||||
_ => {
|
||||
cursor.seek(io::SeekFrom::Current(isize_ as i64)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
cursor.seek(io::SeekFrom::Current(ssize as i64)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push((cue_time, cue_track, cue_pos));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the SeekHead body into Vec<(seek_id, seek_position)>.
|
||||
fn parse_seekhead(data: &[u8]) -> Vec<(u32, u64)> {
|
||||
let mut out = Vec::new();
|
||||
let (sh_id, sh_body_start, sh_body_size) = segment_children(data)
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| *id == ebml::SEEK_HEAD)
|
||||
.expect("seekhead not found");
|
||||
assert_eq!(sh_id, ebml::SEEK_HEAD);
|
||||
let sh_body = &data[sh_body_start..sh_body_start + sh_body_size as usize];
|
||||
let mut cursor = Cursor::new(sh_body);
|
||||
while (cursor.position() as usize) < sh_body.len() {
|
||||
let (id, size, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
assert_eq!(id, ebml::SEEK);
|
||||
let seek_end = cursor.position() + size;
|
||||
let mut seek_id_val: u32 = 0;
|
||||
let mut seek_pos_val: u64 = 0;
|
||||
while cursor.position() < seek_end {
|
||||
let (sid, ssize, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
match sid {
|
||||
ebml::SEEK_ID => {
|
||||
let raw = ebml::read_uint_val(&mut cursor, ssize as usize).unwrap();
|
||||
seek_id_val = raw as u32;
|
||||
}
|
||||
ebml::SEEK_POSITION => {
|
||||
seek_pos_val = ebml::read_uint_val(&mut cursor, ssize as usize).unwrap();
|
||||
}
|
||||
_ => {
|
||||
cursor.seek(io::SeekFrom::Current(ssize as i64)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push((seek_id_val, seek_pos_val));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_starts_only_on_video_keyframe() {
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let frames = frames_for(30.0, 1.0);
|
||||
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||
let clusters = find_clusters(&data);
|
||||
assert!(!clusters.is_empty(), "expected at least one cluster");
|
||||
for (body_start, body_size, _ts) in clusters {
|
||||
let body = &data[body_start..body_start + body_size as usize];
|
||||
// Skip past the CLUSTER_TIMESTAMP element first.
|
||||
let mut cursor = Cursor::new(body);
|
||||
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();
|
||||
let after_ts = cursor.position() as usize;
|
||||
let (track_num, flags) = first_simple_block(&body[after_ts..]);
|
||||
assert_eq!(
|
||||
track_num, 1,
|
||||
"first block in cluster must be track 1 (video)"
|
||||
);
|
||||
assert_eq!(
|
||||
flags & 0x80,
|
||||
0x80,
|
||||
"first block in cluster must have keyframe flag set, got 0x{:02X}",
|
||||
flags
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cue_count_equals_cluster_count() {
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let frames = frames_for(30.0, 1.0);
|
||||
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||
let clusters = find_clusters(&data);
|
||||
let cues = parse_cues(&data);
|
||||
assert_eq!(
|
||||
clusters.len(),
|
||||
cues.len(),
|
||||
"cluster count {} != cue count {}",
|
||||
clusters.len(),
|
||||
cues.len()
|
||||
);
|
||||
// For 30s @ 5s min cluster duration with 1s GOP, expect 6 clusters / 6 cues.
|
||||
assert_eq!(
|
||||
clusters.len(),
|
||||
6,
|
||||
"expected 6 clusters for 30s @ 5s cluster duration"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cue_positions_resolve_to_clusters() {
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let frames = frames_for(30.0, 1.0);
|
||||
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||
let (_, seg_start) = locate_segment(&data);
|
||||
let cues = parse_cues(&data);
|
||||
assert!(!cues.is_empty());
|
||||
for (_time, _track, pos) in cues {
|
||||
let abs = seg_start + pos as usize;
|
||||
let mut cursor = Cursor::new(&data[abs..]);
|
||||
let (id, _size, _hdr_len) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
assert_eq!(
|
||||
id,
|
||||
ebml::CLUSTER,
|
||||
"cue position 0x{:X} did not resolve to a cluster",
|
||||
pos
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cue_times_match_cluster_timestamps() {
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let frames = frames_for(30.0, 1.0);
|
||||
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||
let (_, seg_start) = locate_segment(&data);
|
||||
let cues = parse_cues(&data);
|
||||
for (time, _track, pos) in cues {
|
||||
let abs = seg_start + pos as usize;
|
||||
let mut cursor = Cursor::new(&data[abs..]);
|
||||
let (id, size, _hdr_len) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
assert_eq!(id, ebml::CLUSTER);
|
||||
let body_start = abs + (cursor.position() as usize);
|
||||
let body = &data[body_start..body_start + size as usize];
|
||||
let mut bc = Cursor::new(body);
|
||||
let (tid, tsize, _) = ebml::read_element_header(&mut bc).unwrap();
|
||||
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
|
||||
let cluster_ts = ebml::read_uint_val(&mut bc, tsize as usize).unwrap();
|
||||
assert_eq!(
|
||||
cluster_ts, time,
|
||||
"cluster timestamp {} != cue time {}",
|
||||
cluster_ts, time
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekhead_is_first_child_of_segment() {
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let (data, _) = mux_to_bytes(&tracks, &[], &frames_for(10.0, 1.0));
|
||||
let children = segment_children(&data);
|
||||
assert!(!children.is_empty());
|
||||
assert_eq!(
|
||||
children[0].0,
|
||||
ebml::SEEK_HEAD,
|
||||
"first child of segment must be SeekHead, got id 0x{:X}",
|
||||
children[0].0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekhead_points_to_real_elements() {
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let (data, _) = mux_to_bytes(&tracks, &[], &frames_for(10.0, 1.0));
|
||||
let (_, seg_start) = locate_segment(&data);
|
||||
let entries = parse_seekhead(&data);
|
||||
let required = [ebml::INFO, ebml::TRACKS, ebml::CUES];
|
||||
for &want_id in &required {
|
||||
let entry = entries
|
||||
.iter()
|
||||
.find(|(id, _)| *id == want_id)
|
||||
.unwrap_or_else(|| panic!("seekhead missing entry for id 0x{:X}", want_id));
|
||||
let abs = seg_start + entry.1 as usize;
|
||||
let mut cursor = Cursor::new(&data[abs..]);
|
||||
let (got_id, _, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
assert_eq!(
|
||||
got_id, want_id,
|
||||
"seekhead entry for 0x{:X} resolves to wrong id 0x{:X}",
|
||||
want_id, got_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seekhead_omits_chapters_when_empty() {
|
||||
let tracks = [make_video_track()];
|
||||
let (data, _) = mux_to_bytes(&tracks, &[], &frames_for(5.0, 1.0));
|
||||
let entries = parse_seekhead(&data);
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
3,
|
||||
"expected 3 seek entries (Info, Tracks, Cues), got {}",
|
||||
entries.len()
|
||||
);
|
||||
assert!(
|
||||
entries.iter().all(|(id, _)| *id != ebml::CHAPTERS),
|
||||
"seekhead should not contain Chapters entry when chapters are empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_first_keyframe_frames_dropped() {
|
||||
let tracks = [make_video_track()];
|
||||
let frames = vec![
|
||||
(0usize, 0i64, false, vec![0x11; 16]),
|
||||
(0usize, 41_000_000i64, true, vec![0x22; 16]),
|
||||
];
|
||||
let (data, frame_count) = mux_to_bytes(&tracks, &[], &frames);
|
||||
assert_eq!(frame_count, 1, "muxer.frame_count must equal 1");
|
||||
let clusters = find_clusters(&data);
|
||||
assert_eq!(clusters.len(), 1, "expected exactly one cluster");
|
||||
let (body_start, body_size, _ts) = clusters[0];
|
||||
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();
|
||||
let mut sb_count = 0;
|
||||
while (cursor.position() as usize) < body.len() {
|
||||
let (id, sz, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
if id == ebml::SIMPLE_BLOCK {
|
||||
sb_count += 1;
|
||||
}
|
||||
cursor.seek(io::SeekFrom::Current(sz as i64)).unwrap();
|
||||
}
|
||||
assert_eq!(sb_count, 1, "expected exactly one SimpleBlock in output");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ impl crate::pes::Stream for MkvStream {
|
||||
pts: pts_ms * 1_000_000, // ms → ns
|
||||
keyframe,
|
||||
data,
|
||||
duration_ns: None,
|
||||
}));
|
||||
}
|
||||
_ => {
|
||||
@@ -150,9 +151,13 @@ impl crate::pes::Stream for MkvStream {
|
||||
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Write { muxer: Some(m) } => {
|
||||
m.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data)
|
||||
}
|
||||
Mode::Write { muxer: Some(m) } => m.write_frame(
|
||||
frame.track,
|
||||
frame.pts,
|
||||
frame.keyframe,
|
||||
&frame.data,
|
||||
frame.duration_ns,
|
||||
),
|
||||
Mode::Write { muxer: None } => Ok(()),
|
||||
Mode::Read(_) => Err(crate::error::Error::StreamReadOnly.into()),
|
||||
}
|
||||
|
||||
+4
-2
@@ -17,8 +17,9 @@
|
||||
|
||||
// Public modules — types here are intentionally part of the consumable API.
|
||||
pub mod codec;
|
||||
pub mod demux_thread;
|
||||
pub mod disc;
|
||||
pub mod iso;
|
||||
pub mod pipelined_stream;
|
||||
pub mod resolve;
|
||||
|
||||
// Internal modules — implementation details. Their *types* are re-exported
|
||||
@@ -54,11 +55,12 @@ pub(crate) mod ts;
|
||||
pub(crate) mod tsmux;
|
||||
|
||||
pub use disc::DiscStream;
|
||||
pub use iso::IsoSectorReader;
|
||||
pub use m2ts::M2tsStream;
|
||||
pub use mkvstream::MkvStream;
|
||||
pub use network::NetworkStream;
|
||||
pub use null::NullStream;
|
||||
pub use pipelined_stream::PipelinedPesStream;
|
||||
pub use resolve::build_iso_pipeline;
|
||||
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
pub use stdio::StdioStream;
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ mod tests {
|
||||
pts: 90000,
|
||||
keyframe: true,
|
||||
data: vec![0x47; 192],
|
||||
duration_ns: None,
|
||||
};
|
||||
pes::Stream::write(&mut writer, &frame).unwrap();
|
||||
pes::Stream::finish(&mut writer).unwrap();
|
||||
|
||||
@@ -47,6 +47,7 @@ mod tests {
|
||||
pts: 0,
|
||||
keyframe: true,
|
||||
data: vec![0x01, 0x02, 0x03],
|
||||
duration_ns: None,
|
||||
};
|
||||
sink.write(&frame).unwrap();
|
||||
let _ = sink.info();
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! `PipelinedPesStream` — the read-side of the freemkv mux
|
||||
//! highway.
|
||||
//!
|
||||
//! Given a [`crate::mux::demux_thread::DemuxThread`] (which has the
|
||||
//! producer + demux workers already spawned), a set of codec
|
||||
//! parsers, and the title metadata, this struct implements
|
||||
//! [`crate::pes::Stream`] by running codec parse on the caller's
|
||||
//! thread and emitting `PesFrame`s one at a time.
|
||||
//!
|
||||
//! The pipeline runs three threads in parallel:
|
||||
//!
|
||||
//! ```text
|
||||
//! Thread A: read + decrypt (PrefetchedSectorSource / BytePrefetcher)
|
||||
//! Thread B: M2TS demux (DemuxThread)
|
||||
//! Thread C: codec parse (this struct, on the caller's thread)
|
||||
//! ```
|
||||
//!
|
||||
//! Communication between A→B and B→C is via bounded channels with
|
||||
//! recycled buffer pools — no allocations or memcpys in the steady-
|
||||
//! state hot loop.
|
||||
//!
|
||||
//! This is the *only* read-side `Stream` impl in tree. Both ISO file
|
||||
//! mux ([`crate::mux::resolve`]) and BD-TS file mux ([`crate::mux::M2tsStream`])
|
||||
//! return a `PipelinedPesStream`; the differences are in how the
|
||||
//! producer thread (A) is configured — sector-aligned reads with
|
||||
//! AACS decrypt for ISO, raw byte reads for M2TS.
|
||||
|
||||
use super::codec::CodecParser;
|
||||
use super::demux_thread::{DemuxBatch, DemuxThread};
|
||||
use super::ts::PesPacket;
|
||||
use crate::disc::DiscTitle;
|
||||
use crate::pes::{PesFrame, Stream};
|
||||
use crossbeam_channel::Receiver;
|
||||
use std::io;
|
||||
|
||||
/// Stream impl that consumes pre-demuxed `PesPacket` batches from a
|
||||
/// [`DemuxThread`] and runs codec parse on the caller's thread.
|
||||
pub struct PipelinedPesStream {
|
||||
title: DiscTitle,
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
|
||||
demux_rx: Receiver<DemuxBatch>,
|
||||
/// Kept alive so dropping this stream joins the demux + producer
|
||||
/// workers deterministically. Never poked directly after spawn.
|
||||
#[allow(dead_code)]
|
||||
demux_thread: DemuxThread,
|
||||
|
||||
pending_frames: std::collections::VecDeque<PesFrame>,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
impl PipelinedPesStream {
|
||||
/// Wire up the stream. Caller has already spawned the
|
||||
/// `DemuxThread` (which in turn owns the producer); we take the
|
||||
/// receiver end + the join handle bundle so cleanup is bounded
|
||||
/// on drop.
|
||||
pub fn new(
|
||||
demux_thread: DemuxThread,
|
||||
demux_rx: Receiver<DemuxBatch>,
|
||||
title: DiscTitle,
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
title,
|
||||
parsers,
|
||||
pid_to_track,
|
||||
demux_rx,
|
||||
demux_thread,
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
eof: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull one batch of `PesPacket`s from the demux thread, run
|
||||
/// codec parse on each, enqueue resulting `PesFrame`s on
|
||||
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
|
||||
/// EOF (channel closed cleanly), Err on demuxer error.
|
||||
fn pump_one_batch(&mut self) -> io::Result<bool> {
|
||||
match self.demux_rx.recv() {
|
||||
Ok(DemuxBatch::Ts(packets)) => {
|
||||
self.consume_ts(packets);
|
||||
Ok(true)
|
||||
}
|
||||
Ok(DemuxBatch::Ps(packets)) => {
|
||||
self.consume_ps(packets);
|
||||
Ok(true)
|
||||
}
|
||||
Ok(DemuxBatch::Err(e)) => Err(e),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn consume_ts(&mut self, packets: Vec<PesPacket>) {
|
||||
let skip_parse = std::env::var_os("FREEMKV_SKIP_PARSE").is_some();
|
||||
for pes in packets {
|
||||
if let Some((_, track)) = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(pid, _)| *pid == pes.pid)
|
||||
.copied()
|
||||
{
|
||||
if skip_parse {
|
||||
// Profiling escape hatch — bypass codec parser.
|
||||
self.pending_frames.push_back(PesFrame {
|
||||
track,
|
||||
pts: pes.pts.map(super::codec::pts_to_ns).unwrap_or(0),
|
||||
keyframe: false,
|
||||
data: pes.data,
|
||||
duration_ns: None,
|
||||
});
|
||||
} else if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(&pes) {
|
||||
self.pending_frames
|
||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
continue;
|
||||
}
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(p, _)| *p)
|
||||
.unwrap_or(0);
|
||||
let pes = PesPacket {
|
||||
pid,
|
||||
pts: ps.pts.map(|p| p as i64),
|
||||
dts: ps.dts.map(|d| d as i64),
|
||||
data: ps.data,
|
||||
};
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||
for frame in parser.parse(&pes) {
|
||||
self.pending_frames
|
||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for PipelinedPesStream {
|
||||
fn read(&mut self) -> io::Result<Option<PesFrame>> {
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
if self.eof {
|
||||
return Ok(None);
|
||||
}
|
||||
loop {
|
||||
match self.pump_one_batch()? {
|
||||
true => {
|
||||
if let Some(frame) = self.pending_frames.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
// Batch contained no trackable packets — pull again.
|
||||
}
|
||||
false => {
|
||||
self.eof = true;
|
||||
return Ok(self.pending_frames.pop_front());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, _: &PesFrame) -> io::Result<()> {
|
||||
Err(crate::error::Error::StreamReadOnly.into())
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.title
|
||||
}
|
||||
|
||||
fn headers_ready(&self) -> bool {
|
||||
// Match the previous DiscStream semantics: video tracks need
|
||||
// codec_private before the consumer can write the container
|
||||
// header. FREEMKV_SKIP_PARSE forces ready (no parser ever
|
||||
// populates codec_private in that mode).
|
||||
if std::env::var_os("FREEMKV_SKIP_PARSE").is_some() {
|
||||
return true;
|
||||
}
|
||||
for (idx, s) in self.title.streams.iter().enumerate() {
|
||||
if let crate::disc::Stream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(p, _)| *p)?;
|
||||
self.parsers
|
||||
.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
}
|
||||
+170
-10
@@ -15,11 +15,13 @@
|
||||
//! Bare paths without a scheme are rejected.
|
||||
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
|
||||
|
||||
use super::disc::DiscStream;
|
||||
use super::network::NetworkStream;
|
||||
use super::null::NullStream;
|
||||
use super::pipelined_stream::PipelinedPesStream;
|
||||
use super::stdio::StdioStream;
|
||||
use super::{M2tsStream, MkvStream};
|
||||
use crate::disc::{ContentFormat, DiscTitle};
|
||||
use crate::sector::SectorSource;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -185,7 +187,12 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
},
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
let mut reader = super::iso::IsoSectorReader::open(&path.to_string_lossy())?;
|
||||
// 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
|
||||
// DONTNEED page-cache eviction that bounds memory pressure
|
||||
// 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() })?;
|
||||
@@ -203,21 +210,40 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
let title = disc.titles[idx].clone();
|
||||
let keys = disc.decrypt_keys();
|
||||
let format = disc.content_format;
|
||||
// ISO file: use large batch size (16 MB) — sequential read from fast storage, no bad sectors.
|
||||
// Physical drives need small batches for adaptive error handling and retry logic.
|
||||
// ISO file: 16 MiB batch — sequential read from fast
|
||||
// storage, no bad sectors. Measured optimum on the rip1
|
||||
// testbed; bumping to 32 MiB regressed (more cache
|
||||
// pressure, longer per-batch latency starves the consumer
|
||||
// between iterations). Physical drives keep smaller
|
||||
// batches for adaptive error handling.
|
||||
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
||||
let mut stream =
|
||||
DiscStream::new(Box::new(reader), title, keys, ISO_MUX_BATCH_SECTORS, format);
|
||||
if opts.raw {
|
||||
stream.set_raw();
|
||||
}
|
||||
|
||||
// Pass `DecryptKeys::None` to the decrypt decorator when
|
||||
// --raw is set — the read stack still flows through the
|
||||
// same producer+demux+parse pipeline, just without the
|
||||
// AACS / CSS step. Single highway for all ISO reads.
|
||||
let effective_keys = if opts.raw {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
} else {
|
||||
keys
|
||||
};
|
||||
let stream = build_iso_pipeline(
|
||||
reader,
|
||||
title,
|
||||
effective_keys,
|
||||
ISO_MUX_BATCH_SECTORS,
|
||||
format,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::open(path)?;
|
||||
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::open(reader)?))
|
||||
let stream = build_m2ts_pipeline(reader)?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
@@ -281,3 +307,137 @@ pub fn output(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Demuxer-side state derived from a `DiscTitle`: the codec parser
|
||||
/// table (keyed by PID), the PID-to-track index map, and an initial
|
||||
/// `TsDemuxer` / `PsDemuxer` (whichever the content format calls
|
||||
/// for).
|
||||
type DemuxState = (
|
||||
Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||
Vec<(u16, usize)>,
|
||||
Option<super::ts::TsDemuxer>,
|
||||
Option<super::ps::PsDemuxer>,
|
||||
);
|
||||
|
||||
/// Build the title's codec parser table + initial `TsDemuxer` /
|
||||
/// `PsDemuxer`. Used by both the ISO and M2TS pipeline builders.
|
||||
fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (idx, s) in title.streams.iter().enumerate() {
|
||||
let (pid, codec) = match s {
|
||||
crate::disc::Stream::Video(v) => (v.pid, v.codec),
|
||||
crate::disc::Stream::Audio(a) => (a.pid, a.codec),
|
||||
crate::disc::Stream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, idx));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
|
||||
}
|
||||
let (ts, ps) = match format {
|
||||
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),
|
||||
ContentFormat::BdTs => {
|
||||
if pids.is_empty() {
|
||||
(None, None)
|
||||
} else {
|
||||
(Some(super::ts::TsDemuxer::new(&pids)), None)
|
||||
}
|
||||
}
|
||||
};
|
||||
(parsers, pid_to_track, ts, ps)
|
||||
}
|
||||
|
||||
/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for
|
||||
/// a `FileSectorSource`-backed reader. Returns the resulting
|
||||
/// `PipelinedPesStream`.
|
||||
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
reader: S,
|
||||
title: DiscTitle,
|
||||
keys: crate::decrypt::DecryptKeys,
|
||||
batch_sectors: u16,
|
||||
format: ContentFormat,
|
||||
halt: Option<crate::halt::Halt>,
|
||||
event_fn: Option<crate::sector::prefetched::EventFn>,
|
||||
) -> PipelinedPesStream {
|
||||
let extents = title.extents.clone();
|
||||
let decrypting =
|
||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||
let prefetched = crate::sector::PrefetchedSectorSource::new_with_events(
|
||||
decrypting,
|
||||
extents,
|
||||
batch_sectors,
|
||||
halt.clone(),
|
||||
event_fn,
|
||||
);
|
||||
let (rx, recycle_tx, shell) = prefetched.into_channels();
|
||||
|
||||
let (parsers, pid_to_track, ts, ps) = build_demux_state(&title, format);
|
||||
let (demux_thread, demux_rx) =
|
||||
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps);
|
||||
PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track)
|
||||
}
|
||||
|
||||
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a
|
||||
/// byte-stream reader. Scans the head for FMKV header or PMT/PAT,
|
||||
/// rebuilds the title metadata, then wraps a chained reader (head +
|
||||
/// remainder) in a `BytePrefetcher` feeding the demux + parse
|
||||
/// threads.
|
||||
fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>(
|
||||
mut reader: R,
|
||||
) -> io::Result<PipelinedPesStream> {
|
||||
use super::meta;
|
||||
use std::io::Read;
|
||||
|
||||
const M2TS_SCAN_BYTES: usize = 1024 * 1024;
|
||||
let mut head = vec![0u8; M2TS_SCAN_BYTES];
|
||||
let head_len = {
|
||||
let mut filled = 0;
|
||||
while filled < head.len() {
|
||||
match reader.read(&mut head[filled..])? {
|
||||
0 => break,
|
||||
n => filled += n,
|
||||
}
|
||||
}
|
||||
filled
|
||||
};
|
||||
head.truncate(head_len);
|
||||
|
||||
// Try FMKV metadata header first; fall back to PMT scan.
|
||||
let mut cursor = io::Cursor::new(&head);
|
||||
let (title, head_consumed) = if let Ok(Some(m)) = meta::read_header(&mut cursor) {
|
||||
(m.to_title(), cursor.position() as usize)
|
||||
} else {
|
||||
let streams = super::ts::scan_streams(&head)
|
||||
.ok_or_else(|| -> io::Error { crate::error::Error::NoStreams.into() })?;
|
||||
let t = DiscTitle {
|
||||
duration_secs: 0.0,
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
(t, 0)
|
||||
};
|
||||
|
||||
// Chain: any un-consumed head bytes + the remainder of the
|
||||
// reader. The demuxer sees a contiguous M2TS byte stream.
|
||||
let remaining_head = head[head_consumed..].to_vec();
|
||||
let chained: Box<dyn Read + Send> = Box::new(io::Cursor::new(remaining_head).chain(reader));
|
||||
|
||||
let prefetcher = crate::io::byte_prefetcher::BytePrefetcher::new(
|
||||
chained,
|
||||
crate::io::byte_prefetcher::DEFAULT_CHUNK_BYTES,
|
||||
None,
|
||||
);
|
||||
let (rx, recycle_tx, shell) = prefetcher.into_channels();
|
||||
|
||||
let (parsers, pid_to_track, ts, ps) = build_demux_state(&title, ContentFormat::BdTs);
|
||||
let (demux_thread, demux_rx) =
|
||||
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, None, ts, ps);
|
||||
Ok(PipelinedPesStream::new(
|
||||
demux_thread,
|
||||
demux_rx,
|
||||
title,
|
||||
parsers,
|
||||
pid_to_track,
|
||||
))
|
||||
}
|
||||
|
||||
+108
-83
@@ -37,11 +37,20 @@ struct PesAssembler {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
/// Initial capacity for a fresh PES buffer. Sized to cover the
|
||||
/// common BD-TS audio / subtitle PES outright (a few KB to ~16 KB).
|
||||
/// Video PES (typically 150–300 KB on UHD) will grow this via the
|
||||
/// standard Vec doubling, but the doublings hit the allocator's
|
||||
/// slab caches instead of the 64-page first-touch faults that the
|
||||
/// previous `Vec::with_capacity(256 * 1024)` triggered on every PES
|
||||
/// boundary.
|
||||
const PES_BUFFER_INIT_CAP: usize = 16 * 1024;
|
||||
|
||||
impl PesAssembler {
|
||||
fn new(pid: u16) -> Self {
|
||||
Self {
|
||||
pid,
|
||||
buffer: Vec::with_capacity(256 * 1024),
|
||||
buffer: Vec::with_capacity(PES_BUFFER_INIT_CAP),
|
||||
pts: None,
|
||||
dts: None,
|
||||
active: false,
|
||||
@@ -55,7 +64,7 @@ impl PesAssembler {
|
||||
pid: self.pid,
|
||||
pts: self.pts,
|
||||
dts: self.dts,
|
||||
data: std::mem::replace(&mut self.buffer, Vec::with_capacity(256 * 1024)),
|
||||
data: std::mem::replace(&mut self.buffer, Vec::with_capacity(PES_BUFFER_INIT_CAP)),
|
||||
})
|
||||
} else {
|
||||
self.buffer.clear();
|
||||
@@ -124,97 +133,54 @@ impl TsDemuxer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a chunk of BD transport stream data. Handles non-192-byte-aligned input
|
||||
/// by buffering leftover bytes between calls. Returns completed PES packets.
|
||||
/// Feed a chunk of BD transport stream data. Handles non-192-byte-
|
||||
/// aligned input by buffering leftover bytes between calls. Returns
|
||||
/// completed PES packets.
|
||||
///
|
||||
/// 16 MiB ISO batches never divide evenly into 192-byte BD-TS
|
||||
/// packets, so every call after the first carries a ~64-byte
|
||||
/// remainder. The pre-0.24 implementation handled this by building
|
||||
/// a `combined` Vec containing remainder + the entire new input —
|
||||
/// a 16 MiB+ memcpy on every call. Now we splice exactly one
|
||||
/// boundary packet from a stack buffer, then process the rest of
|
||||
/// `data` in place. Zero-copy on the bulk path; one 192-byte copy
|
||||
/// on the boundary.
|
||||
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket> {
|
||||
let mut completed = Vec::with_capacity(4);
|
||||
|
||||
// Prepend any remainder from previous call
|
||||
let mut combined: Vec<u8> = Vec::new();
|
||||
let work: &[u8] = if !self.remainder.is_empty() {
|
||||
combined.reserve(self.remainder.len() + data.len());
|
||||
combined.extend_from_slice(&self.remainder);
|
||||
combined.extend_from_slice(data);
|
||||
self.remainder.clear();
|
||||
&combined
|
||||
} else {
|
||||
data
|
||||
};
|
||||
|
||||
let mut offset = 0;
|
||||
|
||||
while offset + BD_TS_PACKET_SIZE <= work.len() {
|
||||
let packet = &work[offset..offset + BD_TS_PACKET_SIZE];
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
|
||||
// Skip 4-byte TP_extra_header, check sync byte
|
||||
if packet[4] != SYNC_BYTE {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = &packet[4..]; // 188-byte standard TS packet
|
||||
|
||||
// Parse TS header
|
||||
let pid = (((ts[1] & 0x1F) as u16) << 8) | ts[2] as u16;
|
||||
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
||||
let adaptation = (ts[3] >> 4) & 0x03;
|
||||
|
||||
// Check if we're tracking this PID
|
||||
let idx = if (pid as usize) < self.pid_index.len() {
|
||||
self.pid_index[pid as usize]
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
if idx < 0 {
|
||||
continue;
|
||||
}
|
||||
let asm = &mut self.assemblers[idx as usize];
|
||||
|
||||
// Find payload start (skip adaptation field if present)
|
||||
let payload_start = if adaptation == 0x03 || adaptation == 0x02 {
|
||||
// Adaptation field present
|
||||
let af_len = ts[4] as usize;
|
||||
if af_len > 183 {
|
||||
continue; // Malformed: AF length exceeds TS payload
|
||||
}
|
||||
5 + af_len
|
||||
} else {
|
||||
4
|
||||
};
|
||||
|
||||
if payload_start >= TS_PACKET_SIZE {
|
||||
continue;
|
||||
}
|
||||
|
||||
// No payload
|
||||
if adaptation == 0x02 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let payload = &ts[payload_start..];
|
||||
|
||||
if pusi {
|
||||
// New PES packet starts here — parse PES header
|
||||
let (pts, dts, pes_data_start) = 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..]);
|
||||
}
|
||||
} else {
|
||||
// Continuation of current PES packet
|
||||
asm.push(payload);
|
||||
// Boundary packet: if a partial packet was left from the last
|
||||
// call, complete it from the head of `data` without touching
|
||||
// the rest of `data`.
|
||||
if !self.remainder.is_empty() {
|
||||
let need = BD_TS_PACKET_SIZE - self.remainder.len();
|
||||
if data.len() < need {
|
||||
// Still not a full packet — accumulate and wait.
|
||||
self.remainder.extend_from_slice(data);
|
||||
return completed;
|
||||
}
|
||||
let mut boundary = [0u8; BD_TS_PACKET_SIZE];
|
||||
boundary[..self.remainder.len()].copy_from_slice(&self.remainder);
|
||||
boundary[self.remainder.len()..].copy_from_slice(&data[..need]);
|
||||
self.remainder.clear();
|
||||
self.process_packet(&boundary, &mut completed);
|
||||
offset = need;
|
||||
}
|
||||
|
||||
// Save leftover bytes for next call (cap at one packet to prevent unbounded growth)
|
||||
if offset < work.len() {
|
||||
let leftover = &work[offset..];
|
||||
// Aligned-packets fast path — reads directly out of `data`.
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
let packet = &data[offset..offset + BD_TS_PACKET_SIZE];
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
self.process_packet(packet, &mut completed);
|
||||
}
|
||||
|
||||
// Save leftover bytes for next call (cap at one packet to
|
||||
// prevent unbounded growth on a desynchronised stream).
|
||||
if offset < data.len() {
|
||||
let leftover = &data[offset..];
|
||||
if leftover.len() < BD_TS_PACKET_SIZE {
|
||||
self.remainder.extend_from_slice(leftover);
|
||||
} else {
|
||||
// More than one full packet leftover — something is wrong, discard
|
||||
self.remainder.clear();
|
||||
}
|
||||
}
|
||||
@@ -222,6 +188,65 @@ impl TsDemuxer {
|
||||
completed
|
||||
}
|
||||
|
||||
/// Demux a single 192-byte BD-TS packet (4-byte TP_extra_header +
|
||||
/// 188-byte TS). Routes payload bytes into the per-PID
|
||||
/// `PesAssembler`; completed PES packets are pushed onto
|
||||
/// `completed` so the caller's allocation amortises across the
|
||||
/// batch.
|
||||
fn process_packet(&mut self, packet: &[u8], completed: &mut Vec<PesPacket>) {
|
||||
// Sync byte check skips malformed packets.
|
||||
if packet[4] != SYNC_BYTE {
|
||||
return;
|
||||
}
|
||||
let ts = &packet[4..]; // 188-byte standard TS packet
|
||||
|
||||
let pid = (((ts[1] & 0x1F) as u16) << 8) | ts[2] as u16;
|
||||
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
||||
let adaptation = (ts[3] >> 4) & 0x03;
|
||||
|
||||
let idx = if (pid as usize) < self.pid_index.len() {
|
||||
self.pid_index[pid as usize]
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
if idx < 0 {
|
||||
return;
|
||||
}
|
||||
let asm = &mut self.assemblers[idx as usize];
|
||||
|
||||
let payload_start = if adaptation == 0x03 || adaptation == 0x02 {
|
||||
let af_len = ts[4] as usize;
|
||||
if af_len > 183 {
|
||||
return; // Malformed: AF length exceeds TS payload
|
||||
}
|
||||
5 + af_len
|
||||
} else {
|
||||
4
|
||||
};
|
||||
|
||||
if payload_start >= TS_PACKET_SIZE {
|
||||
return;
|
||||
}
|
||||
// adaptation == 0x02 → AF only, no payload.
|
||||
if adaptation == 0x02 {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = &ts[payload_start..];
|
||||
|
||||
if pusi {
|
||||
let (pts, dts, pes_data_start) = 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..]);
|
||||
}
|
||||
} else {
|
||||
asm.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush all assemblers, returning any remaining PES packets.
|
||||
pub fn flush(&mut self) -> Vec<PesPacket> {
|
||||
let mut completed = Vec::new();
|
||||
|
||||
+272
-35
@@ -42,28 +42,39 @@ impl<W: Write> TsMuxer<W> {
|
||||
/// Write a PES frame as BD-TS packets.
|
||||
/// Video frame data is expected as length-prefixed NALUs (MKV/PES format)
|
||||
/// and is converted to Annex B for transport stream.
|
||||
pub fn write_frame(&mut self, track: usize, pts_ns: i64, data: &[u8]) -> io::Result<()> {
|
||||
pub fn write_frame(
|
||||
&mut self,
|
||||
track: usize,
|
||||
pts_ns: i64,
|
||||
keyframe: bool,
|
||||
data: &[u8],
|
||||
) -> io::Result<()> {
|
||||
if track >= self.pids.len() {
|
||||
return Ok(()); // unknown track, skip
|
||||
}
|
||||
let base = *self.base_pts_ns.get_or_insert(pts_ns);
|
||||
let pts_ns = pts_ns - base;
|
||||
|
||||
let pid = self.pids[track];
|
||||
let is_video = (0x1011..=0x101F).contains(&pid);
|
||||
|
||||
// For video: convert length-prefixed NALUs to Annex B (start codes)
|
||||
// On first keyframe, prepend parameter sets from codec_private
|
||||
// Drop non-key video before any keyframe — decoder has no IDR or
|
||||
// parameter sets to anchor on.
|
||||
if is_video && !keyframe && !self.params_written[track] {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let base = *self.base_pts_ns.get_or_insert(pts_ns);
|
||||
let pts_ns = pts_ns - base;
|
||||
|
||||
// For video: convert length-prefixed NALUs to Annex B (start codes).
|
||||
// Prepend codec_private parameter sets on the FIRST keyframe only.
|
||||
let es_data = if is_video && !data.is_empty() {
|
||||
let mut annex_b = Vec::new();
|
||||
// Prepend codec_private parameter sets on first keyframe
|
||||
if !self.params_written[track] {
|
||||
if keyframe && !self.params_written[track] {
|
||||
if let Some(ref cp) = self.codec_privates[track] {
|
||||
if let Some(params) = hvcc_to_annex_b(cp) {
|
||||
annex_b.extend_from_slice(¶ms);
|
||||
self.params_written[track] = true;
|
||||
}
|
||||
}
|
||||
self.params_written[track] = true;
|
||||
}
|
||||
annex_b.extend_from_slice(&length_prefixed_to_annex_b(data));
|
||||
annex_b
|
||||
@@ -85,8 +96,24 @@ impl<W: Write> TsMuxer<W> {
|
||||
let mut first = true;
|
||||
while offset < pes_packet.len() {
|
||||
let remaining = pes_packet.len() - offset;
|
||||
let payload_len = remaining.min(TS_PAYLOAD);
|
||||
let need_stuffing = payload_len < TS_PAYLOAD;
|
||||
|
||||
// Invariant: TP_extra(4) + TS_header(4) + AF(af_bytes) + payload(payload_len) = 192,
|
||||
// i.e. af_bytes + payload_len = TS_PAYLOAD (184).
|
||||
// RAI on first packet of a keyframe video PES requires AF with flags=0x40.
|
||||
let want_rai = first && keyframe && is_video;
|
||||
|
||||
// Pick payload_len and af_bytes per case.
|
||||
let (af_bytes, payload_len): (usize, usize) = if want_rai {
|
||||
// Minimum AF = 2 bytes (length=1, flags=0x40). Payload caps at 182.
|
||||
let max_payload = TS_PAYLOAD - 2;
|
||||
let p = remaining.min(max_payload);
|
||||
(TS_PAYLOAD - p, p)
|
||||
} else if remaining >= TS_PAYLOAD {
|
||||
(0, TS_PAYLOAD) // no AF, full payload
|
||||
} else {
|
||||
// Stuffing-only AF, payload = remaining.
|
||||
(TS_PAYLOAD - remaining, remaining)
|
||||
};
|
||||
|
||||
// TP_extra_header (4 bytes — arrival time, set to 0)
|
||||
let tp_extra = [0u8; 4];
|
||||
@@ -102,38 +129,45 @@ impl<W: Write> TsMuxer<W> {
|
||||
ts_header[1] |= 0x40; // PUSI
|
||||
}
|
||||
ts_header[2] = pid as u8;
|
||||
ts_header[3] = 0x10 | cc; // no adaptation, has payload
|
||||
ts_header[3] = if af_bytes > 0 {
|
||||
0x30 | cc // AF + payload
|
||||
} else {
|
||||
0x10 | cc // payload only
|
||||
};
|
||||
|
||||
if need_stuffing {
|
||||
// Adaptation field for stuffing
|
||||
let stuff_len = TS_PAYLOAD - payload_len;
|
||||
ts_header[3] = 0x30 | cc; // adaptation + payload
|
||||
self.writer.write_all(&tp_extra)?;
|
||||
self.writer.write_all(&ts_header)?;
|
||||
|
||||
self.writer.write_all(&tp_extra)?;
|
||||
self.writer.write_all(&ts_header)?;
|
||||
|
||||
// Write adaptation field: length byte + flags byte + 0xFF padding
|
||||
// stuff_len == 1: AF length = 0 (just the length byte, no flags)
|
||||
// stuff_len >= 2: AF length = stuff_len-1, flags = 0, rest 0xFF
|
||||
if af_bytes > 0 {
|
||||
static STUFF_FF: [u8; 184] = [0xFF; 184];
|
||||
if stuff_len == 1 {
|
||||
self.writer.write_all(&[0u8])?; // adaptation_field_length = 0
|
||||
if want_rai {
|
||||
// RAI AF: length byte + flags(0x40) + (af_bytes - 2) stuffing.
|
||||
let af_len_field = (af_bytes - 1) as u8;
|
||||
self.writer.write_all(&[af_len_field])?;
|
||||
self.writer.write_all(&[0x40u8])?;
|
||||
let stuff = af_bytes - 2;
|
||||
if stuff > 0 {
|
||||
self.writer.write_all(&STUFF_FF[..stuff])?;
|
||||
}
|
||||
} else {
|
||||
self.writer.write_all(&[(stuff_len - 1) as u8])?; // AF length
|
||||
self.writer.write_all(&[0u8])?; // flags
|
||||
if stuff_len > 2 {
|
||||
self.writer.write_all(&STUFF_FF[..stuff_len - 2])?;
|
||||
// Stuffing-only AF.
|
||||
// af_bytes == 1: length=0, no flags.
|
||||
// af_bytes >= 2: length = af_bytes-1, flags=0, rest 0xFF.
|
||||
if af_bytes == 1 {
|
||||
self.writer.write_all(&[0u8])?;
|
||||
} else {
|
||||
self.writer.write_all(&[(af_bytes - 1) as u8])?;
|
||||
self.writer.write_all(&[0u8])?;
|
||||
if af_bytes > 2 {
|
||||
self.writer.write_all(&STUFF_FF[..af_bytes - 2])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.writer
|
||||
.write_all(&pes_packet[offset..offset + payload_len])?;
|
||||
} else {
|
||||
self.writer.write_all(&tp_extra)?;
|
||||
self.writer.write_all(&ts_header)?;
|
||||
self.writer
|
||||
.write_all(&pes_packet[offset..offset + payload_len])?;
|
||||
}
|
||||
|
||||
self.writer
|
||||
.write_all(&pes_packet[offset..offset + payload_len])?;
|
||||
|
||||
offset += payload_len;
|
||||
first = false;
|
||||
}
|
||||
@@ -257,3 +291,206 @@ fn length_prefixed_to_annex_b(data: &[u8]) -> Vec<u8> {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const BD_PACKET_SIZE: usize = 192;
|
||||
const VIDEO_PID: u16 = 0x1011;
|
||||
|
||||
/// Parsed BD-TS packet (192 bytes total: 4 TP_extra + 4 TS header + 184 body).
|
||||
struct TsPacket {
|
||||
pid: u16,
|
||||
pusi: bool,
|
||||
#[allow(dead_code)]
|
||||
cc: u8,
|
||||
/// Adaptation field body (length byte stripped) when present.
|
||||
af: Option<Vec<u8>>,
|
||||
/// Payload bytes (after AF, if any).
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Walk 192-byte BD-TS packets.
|
||||
fn parse_bd_ts(buf: &[u8]) -> Vec<TsPacket> {
|
||||
let mut out = Vec::new();
|
||||
for chunk in buf.chunks(BD_PACKET_SIZE) {
|
||||
if chunk.len() != BD_PACKET_SIZE {
|
||||
break;
|
||||
}
|
||||
// Skip TP_extra_header (4 bytes), parse TS header.
|
||||
let h = &chunk[4..];
|
||||
assert_eq!(h[0], 0x47, "bad sync byte");
|
||||
let pusi = (h[1] & 0x40) != 0;
|
||||
let pid = (((h[1] & 0x1F) as u16) << 8) | h[2] as u16;
|
||||
let afc = (h[3] >> 4) & 0x03;
|
||||
let cc = h[3] & 0x0F;
|
||||
let body = &h[4..]; // 184 bytes
|
||||
|
||||
let (af, payload) = match afc {
|
||||
0b01 => (None, body.to_vec()),
|
||||
0b11 => {
|
||||
let af_len = body[0] as usize;
|
||||
let af_body = body[1..1 + af_len].to_vec();
|
||||
let payload = body[1 + af_len..].to_vec();
|
||||
(Some(af_body), payload)
|
||||
}
|
||||
0b10 => {
|
||||
let af_len = body[0] as usize;
|
||||
(Some(body[1..1 + af_len].to_vec()), Vec::new())
|
||||
}
|
||||
_ => (None, Vec::new()),
|
||||
};
|
||||
|
||||
out.push(TsPacket {
|
||||
pid,
|
||||
pusi,
|
||||
cc,
|
||||
af,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a fake HEVC NAL with a 4-byte length prefix.
|
||||
/// nal_type=19/20 are IDR; 1 is non-key (TRAIL_N/R).
|
||||
fn fake_hevc_nal(nal_type: u8, body_len: usize) -> Vec<u8> {
|
||||
let mut nal = Vec::with_capacity(2 + body_len);
|
||||
// 2-byte NAL header: forbidden_zero(1)=0 | nal_unit_type(6) | layer_id(6)=0 | tid_plus1(3)=1
|
||||
nal.push((nal_type & 0x3F) << 1);
|
||||
nal.push(0x01);
|
||||
for i in 0..body_len {
|
||||
nal.push((i & 0xFF) as u8);
|
||||
}
|
||||
let mut framed = Vec::with_capacity(4 + nal.len());
|
||||
framed.extend_from_slice(&(nal.len() as u32).to_be_bytes());
|
||||
framed.extend_from_slice(&nal);
|
||||
framed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_param_threads_through() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||
let idr = fake_hevc_nal(19, 100);
|
||||
mux.write_frame(0, 0, true, &idr).unwrap();
|
||||
let p = fake_hevc_nal(1, 80);
|
||||
mux.write_frame(0, 41_000_000, false, &p).unwrap();
|
||||
mux.finish().unwrap();
|
||||
}
|
||||
assert!(!sink.is_empty());
|
||||
let packets = parse_bd_ts(&sink);
|
||||
assert!(packets.iter().any(|p| p.pid == VIDEO_PID && p.pusi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rai_set_on_first_packet_of_keyframe_pes() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||
let idr = fake_hevc_nal(19, 200);
|
||||
mux.write_frame(0, 0, true, &idr).unwrap();
|
||||
mux.finish().unwrap();
|
||||
}
|
||||
let packets = parse_bd_ts(&sink);
|
||||
let first_pusi = packets
|
||||
.iter()
|
||||
.find(|p| p.pid == VIDEO_PID && p.pusi)
|
||||
.expect("video PUSI packet exists");
|
||||
let af = first_pusi.af.as_ref().expect("AF present on keyframe PES");
|
||||
assert!(!af.is_empty(), "AF body has flags byte");
|
||||
assert_eq!(af[0] & 0x40, 0x40, "RAI bit set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rai_clear_on_non_keyframe_pes() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||
let idr = fake_hevc_nal(19, 100);
|
||||
mux.write_frame(0, 0, true, &idr).unwrap();
|
||||
let p = fake_hevc_nal(1, 100);
|
||||
mux.write_frame(0, 41_000_000, false, &p).unwrap();
|
||||
mux.finish().unwrap();
|
||||
}
|
||||
let packets = parse_bd_ts(&sink);
|
||||
// Second PUSI packet on the video PID belongs to the non-key frame.
|
||||
let pusi_video: Vec<&TsPacket> = packets
|
||||
.iter()
|
||||
.filter(|p| p.pid == VIDEO_PID && p.pusi)
|
||||
.collect();
|
||||
assert!(pusi_video.len() >= 2, "two PUSI packets expected");
|
||||
let second = pusi_video[1];
|
||||
match &second.af {
|
||||
None => {}
|
||||
Some(af) if af.is_empty() => {} // length=0 case
|
||||
Some(af) => assert_eq!(af[0] & 0x40, 0, "RAI must be clear on non-key PES"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_prepended_only_on_first_keyframe() {
|
||||
// Build a minimal hvcC with one recognizable NAL.
|
||||
let marker: &[u8] = &[0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE];
|
||||
let mut hvcc = vec![0u8; 22];
|
||||
hvcc.push(1); // numArrays
|
||||
hvcc.push(32); // VPS NAL type byte (high bits arbitrary)
|
||||
hvcc.extend_from_slice(&1u16.to_be_bytes()); // numNalus
|
||||
hvcc.extend_from_slice(&(marker.len() as u16).to_be_bytes());
|
||||
hvcc.extend_from_slice(marker);
|
||||
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||
mux.set_codec_private(0, hvcc);
|
||||
// Non-IDR before any IDR: should be dropped.
|
||||
let p = fake_hevc_nal(1, 50);
|
||||
mux.write_frame(0, 0, false, &p).unwrap();
|
||||
// IDR: should carry codec_private NALs prepended.
|
||||
let idr = fake_hevc_nal(19, 50);
|
||||
mux.write_frame(0, 41_000_000, true, &idr).unwrap();
|
||||
mux.finish().unwrap();
|
||||
}
|
||||
let packets = parse_bd_ts(&sink);
|
||||
// Concatenate all video PID payloads in emission order.
|
||||
let video_bytes: Vec<u8> = packets
|
||||
.iter()
|
||||
.filter(|p| p.pid == VIDEO_PID)
|
||||
.flat_map(|p| p.payload.clone())
|
||||
.collect();
|
||||
// marker bytes must appear in the stream (codec_private was prepended).
|
||||
let pos_marker = video_bytes
|
||||
.windows(marker.len())
|
||||
.position(|w| w == marker)
|
||||
.expect("codec_private marker bytes present in TS payload");
|
||||
// Find IDR body byte (0x26 = (19<<1)). pos_idr must be AFTER marker.
|
||||
let idr_header = (19u8 << 1) & 0x7E;
|
||||
let pos_idr = video_bytes
|
||||
.iter()
|
||||
.position(|&b| b == idr_header)
|
||||
.expect("IDR NAL header present in TS payload");
|
||||
assert!(
|
||||
pos_marker < pos_idr,
|
||||
"codec_private must precede IDR in TS payload"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_key_before_first_keyframe_dropped() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||
let p = fake_hevc_nal(1, 80);
|
||||
mux.write_frame(0, 0, false, &p).unwrap();
|
||||
mux.finish().unwrap();
|
||||
}
|
||||
// Nothing should be emitted for that PID.
|
||||
let packets = parse_bd_ts(&sink);
|
||||
assert!(
|
||||
!packets.iter().any(|p| p.pid == VIDEO_PID),
|
||||
"non-key before first keyframe must be dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -17,6 +17,10 @@ pub struct PesFrame {
|
||||
pub keyframe: bool,
|
||||
/// Raw elementary stream data (NAL units, audio samples, etc).
|
||||
pub data: Vec<u8>,
|
||||
/// Optional duration in nanoseconds. In-memory only; not part of
|
||||
/// the on-wire serialization. Currently set by the PGS parser so
|
||||
/// the MKV muxer can emit `BlockDuration`.
|
||||
pub duration_ns: Option<u64>,
|
||||
}
|
||||
|
||||
impl PesFrame {
|
||||
@@ -64,6 +68,7 @@ impl PesFrame {
|
||||
pts,
|
||||
keyframe,
|
||||
data,
|
||||
duration_ns: None,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -74,6 +79,7 @@ impl PesFrame {
|
||||
pts: frame.pts_ns,
|
||||
keyframe: frame.keyframe,
|
||||
data: frame.data,
|
||||
duration_ns: frame.duration_ns,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +121,16 @@ pub trait Stream: Send {
|
||||
fn headers_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Cumulative count of read errors the stream skipped past (e.g.
|
||||
/// zero-filled bad sectors on a live drive). Default `0` for
|
||||
/// streams that don't have a notion of skip-on-error (file ISO,
|
||||
/// network, stdio, the pipeline highway, etc.); concrete impls
|
||||
/// with adaptive retry (`DiscStream` on the drive single-pass
|
||||
/// path) override.
|
||||
fn errors(&self) -> u64 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps any output stream and counts bytes written.
|
||||
@@ -183,6 +199,7 @@ mod tests {
|
||||
pts,
|
||||
keyframe: track == 0 && pts == 0,
|
||||
data: vec![track as u8, (pts & 0xff) as u8, 0xAA],
|
||||
duration_ns: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::io::RawFd;
|
||||
use std::path::Path;
|
||||
|
||||
use super::FsType;
|
||||
@@ -18,6 +19,27 @@ const XFS_SUPER_MAGIC: i64 = 0x5846_5342;
|
||||
const BTRFS_SUPER_MAGIC: i64 = 0x9123_683E;
|
||||
const TMPFS_MAGIC: i64 = 0x0102_1994;
|
||||
|
||||
/// Classify an `f_type` magic from `statfs`/`fstatfs`. Single source
|
||||
/// of truth for the magic comparisons used by both the path-based
|
||||
/// (`detect_impl`) and fd-based (`detect_fd_impl`) entry points.
|
||||
///
|
||||
/// Cast to `i64` because `statfs::f_type` is signed `__fsword_t` on
|
||||
/// glibc and unsigned `c_ulong` on musl; a portable comparison needs a
|
||||
/// common type. On glibc x86_64 both already are i64 — clippy flags
|
||||
/// the cast as unnecessary on that target only, but we need it for
|
||||
/// musl.
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
fn classify_f_type(f_type: i64) -> FsType {
|
||||
let nfs_magic = libc::NFS_SUPER_MAGIC as i64;
|
||||
if f_type == nfs_magic {
|
||||
return FsType::Nfs;
|
||||
}
|
||||
match f_type {
|
||||
EXT2_SUPER_MAGIC | XFS_SUPER_MAGIC | BTRFS_SUPER_MAGIC | TMPFS_MAGIC => FsType::Local,
|
||||
_ => FsType::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn detect_impl(path: &Path) -> FsType {
|
||||
let cpath = match CString::new(path.as_os_str().as_bytes()) {
|
||||
Ok(c) => c,
|
||||
@@ -30,20 +52,19 @@ pub(super) fn detect_impl(path: &Path) -> FsType {
|
||||
if rc != 0 {
|
||||
return FsType::Unknown;
|
||||
}
|
||||
// `f_type` is signed (`__fsword_t`) on glibc and unsigned
|
||||
// (`c_ulong`) on musl. Cast both sides to i64 for a portable
|
||||
// comparison. On glibc x86_64 both already are i64 — clippy flags
|
||||
// the cast as unnecessary on that target only, but we need it for
|
||||
// musl, so silence the lint.
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
let f_type = buf.f_type as i64;
|
||||
classify_f_type(buf.f_type as i64)
|
||||
}
|
||||
|
||||
/// fd-based variant of [`detect_impl`]. Same return semantics. Used
|
||||
/// by the writeback pipeline, which knows the open `File` but not its
|
||||
/// original path.
|
||||
pub(super) fn detect_fd_impl(fd: RawFd) -> FsType {
|
||||
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
|
||||
let rc = unsafe { libc::fstatfs(fd, &mut buf) };
|
||||
if rc != 0 {
|
||||
return FsType::Unknown;
|
||||
}
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
let nfs_magic = libc::NFS_SUPER_MAGIC as i64;
|
||||
if f_type == nfs_magic {
|
||||
return FsType::Nfs;
|
||||
}
|
||||
match f_type {
|
||||
EXT2_SUPER_MAGIC | XFS_SUPER_MAGIC | BTRFS_SUPER_MAGIC | TMPFS_MAGIC => FsType::Local,
|
||||
_ => FsType::Unknown,
|
||||
}
|
||||
classify_f_type(buf.f_type as i64)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ use other::detect_impl;
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows::detect_impl;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use linux::detect_fd_impl;
|
||||
|
||||
/// Best-effort classification of the filesystem under `path`.
|
||||
///
|
||||
/// Falls back to [`FsType::Unknown`] on any syscall error or unrecognised
|
||||
@@ -60,6 +63,25 @@ pub fn detect(path: &Path) -> FsType {
|
||||
detect_impl(path)
|
||||
}
|
||||
|
||||
/// fd-based classification. Same return semantics as [`detect`], but
|
||||
/// takes a `RawFd` so callers that only have an open file (notably
|
||||
/// [`crate::io::writeback::WritebackPipeline`]) don't have to
|
||||
/// round-trip through the path.
|
||||
///
|
||||
/// Only implemented on Linux; other platforms return
|
||||
/// [`FsType::Unknown`] (none of them have a writeback policy that
|
||||
/// keys off this classification today).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn detect_fd(fd: std::os::unix::io::RawFd) -> FsType {
|
||||
detect_fd_impl(fd)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[allow(dead_code)] // API parity with the linux impl; callers cfg-gate.
|
||||
pub fn detect_fd(_fd: i32) -> FsType {
|
||||
FsType::Unknown
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -18,4 +18,19 @@ pub(crate) trait PlatformDriver: Send {
|
||||
|
||||
/// True after successful init().
|
||||
fn is_ready(&self) -> bool;
|
||||
|
||||
/// True if the drive is currently in the extended-access state —
|
||||
/// per-drive runtime firmware uploaded AND the unlock response's
|
||||
/// marker bytes confirm the mode is live. When true:
|
||||
/// - host can issue the per-drive OEM CDBs in
|
||||
/// [`crate::profile::DriveProfile`]
|
||||
/// - VID retrieval works via the OEM CDB path (no cert-based
|
||||
/// mutual auth required)
|
||||
/// - SCSI READ_10 returns plaintext sectors (no bus encryption)
|
||||
///
|
||||
/// Default `false` — platforms without this mode always report
|
||||
/// inactive.
|
||||
fn is_unlocked(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
+188
-9
@@ -25,8 +25,16 @@ const SUB_CMD_INIT: u8 = 0x12;
|
||||
const SUB_CMD_PROBE: u8 = 0x14;
|
||||
const UNLOCK_RESPONSE_SIZE: u8 = 64;
|
||||
const VALIDATE_RESPONSE_SIZE: u8 = 4;
|
||||
/// Primary mode marker at bytes [12..16] of the unlock response — set
|
||||
/// by the platform firmware when the runtime image is loaded and the
|
||||
/// extended-access surface is live.
|
||||
const FIRMWARE_ACTIVE_OFFSET: usize = 12;
|
||||
const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
|
||||
/// Secondary mode marker repeated through bytes [16..64] of the unlock
|
||||
/// response. Confirms the runtime firmware is the one driving the
|
||||
/// response, not a stale image's residual buffer.
|
||||
const FIRMWARE_MODE_OFFSET: usize = 16;
|
||||
const FIRMWARE_MODE_SIG: [u8; 4] = [0x4C, 0x62, 0x44, 0x72];
|
||||
|
||||
// ── Init address (per disc type) ──────────────────────────────────────
|
||||
const INIT_ADDR_BD: u16 = 0x0100;
|
||||
@@ -46,7 +54,18 @@ pub struct Mt1959 {
|
||||
pub(crate) profile: DriveProfile,
|
||||
pub(crate) mode: u8,
|
||||
pub(crate) buffer_id: u8,
|
||||
pub(crate) unlocked: bool,
|
||||
/// True after `run_init` has completed the unlock handshake (and any
|
||||
/// required firmware upload). Gates probe + downstream control
|
||||
/// commands; says nothing about whether the drive is in
|
||||
/// extended-access mode.
|
||||
pub(crate) init_complete: bool,
|
||||
/// True when the unlock response carried both the per-drive
|
||||
/// signature AND the primary mode marker at offset 12 AND the
|
||||
/// secondary mode marker at offset 16. When true the drive is in
|
||||
/// the extended-access state — host can issue the per-drive
|
||||
/// OEM CDBs and read sectors without the cert-based AACS bus
|
||||
/// encryption / mutual-auth gate.
|
||||
unlocked: bool,
|
||||
probed: bool,
|
||||
}
|
||||
|
||||
@@ -61,6 +80,7 @@ impl Mt1959 {
|
||||
profile,
|
||||
mode,
|
||||
buffer_id,
|
||||
init_complete: false,
|
||||
unlocked: false,
|
||||
probed: false,
|
||||
}
|
||||
@@ -141,7 +161,20 @@ impl Mt1959 {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
|
||||
self.unlocked = true;
|
||||
// Extended-access state is active when BOTH the per-drive
|
||||
// signature matched AND the response carries the secondary
|
||||
// marker at offset 16 (repeated through bytes 16..64) AND the
|
||||
// primary mode marker at [12..16] is present. The active-mode
|
||||
// marker at [12..16] is the primary gate; the [16..20] marker
|
||||
// is the redundant confirmation the firmware writes through
|
||||
// the rest of the response. Requiring both before we tell the
|
||||
// upper layer "OEM path is live" keeps any partial / corrupted
|
||||
// response from steering us off the cert-auth fallback.
|
||||
self.unlocked = response.len() >= FIRMWARE_MODE_OFFSET + 4
|
||||
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] == FIRMWARE_ACTIVE_SIG
|
||||
&& response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG;
|
||||
|
||||
self.init_complete = true;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -177,11 +210,11 @@ impl Mt1959 {
|
||||
// ── Init (unlock + firmware) ───────────────────────────────────────
|
||||
|
||||
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let mut unlocked = false;
|
||||
let mut succeeded = false;
|
||||
for _attempt in 0..3 {
|
||||
match self.do_unlock(scsi) {
|
||||
Ok(_) => {
|
||||
unlocked = true;
|
||||
succeeded = true;
|
||||
break;
|
||||
}
|
||||
Err(Error::SignatureMismatch { .. }) => {
|
||||
@@ -202,7 +235,7 @@ impl Mt1959 {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unlocked {
|
||||
if !succeeded {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
Ok(())
|
||||
@@ -214,14 +247,14 @@ impl Mt1959 {
|
||||
/// per region. Two passes, then SET_CD_SPEED(max). After this the
|
||||
/// drive manages per-zone speeds internally.
|
||||
fn run_probe(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked {
|
||||
if !self.init_complete {
|
||||
self.do_unlock(scsi)?;
|
||||
}
|
||||
|
||||
// Detect disc type from capacity to select probe mode.
|
||||
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
|
||||
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
|
||||
// Verified from MakeMKV strace: BD and UHD use different init addresses.
|
||||
// Empirically verified via SCSI capture: BD and UHD use different init addresses.
|
||||
let cap_cdb = [
|
||||
SCSI_READ_CAPACITY,
|
||||
0x00,
|
||||
@@ -313,14 +346,14 @@ impl Mt1959 {
|
||||
|
||||
impl PlatformDriver for Mt1959 {
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if self.unlocked {
|
||||
if self.init_complete {
|
||||
return Ok(());
|
||||
}
|
||||
self.run_init(scsi)
|
||||
}
|
||||
|
||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked {
|
||||
if !self.init_complete {
|
||||
// Don't retry init here — if init() failed, probing can't work either.
|
||||
// Retrying causes repeated USB bus resets on BU40N.
|
||||
return Ok(());
|
||||
@@ -332,6 +365,152 @@ impl PlatformDriver for Mt1959 {
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.init_complete
|
||||
}
|
||||
|
||||
fn is_unlocked(&self) -> bool {
|
||||
self.unlocked
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::{DriveProfile, Identity};
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Minimal mock transport that returns a scripted response to the
|
||||
/// next `execute()` call. Only used for verifying that `do_unlock`
|
||||
/// classifies the response correctly — no general SCSI coverage.
|
||||
struct ScriptedTransport {
|
||||
response: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ScsiTransport for ScriptedTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let n = self.response.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.response[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_profile(signature: [u8; 4]) -> DriveProfile {
|
||||
DriveProfile {
|
||||
identity: Identity {
|
||||
vendor_id: "TEST".into(),
|
||||
product_revision: String::new(),
|
||||
vendor_specific: String::new(),
|
||||
firmware_date: String::new(),
|
||||
},
|
||||
signature,
|
||||
firmware: Vec::new(),
|
||||
unlock_init_value: 0,
|
||||
unlock_response_size: 0,
|
||||
read_vid_cdb: None,
|
||||
read_disc_keys_cdb: None,
|
||||
drive_nominal_speed_cdb: None,
|
||||
set_speed_max_cdb: None,
|
||||
read10_raw_2sec_cdb: None,
|
||||
read10_raw_1sec_cdb: None,
|
||||
read_buffer_verify_cdb: None,
|
||||
write_buffer_cdb: None,
|
||||
read_buffer_unlock_cdb: None,
|
||||
speed_zone_table: None,
|
||||
speed_calc_table: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a synthetic 64-byte unlock response.
|
||||
///
|
||||
/// `mode_marker`: bytes [12..16]. Pass `FIRMWARE_ACTIVE_SIG` for the
|
||||
/// active-mode primary marker.
|
||||
/// `id_marker`: bytes [16..20] (and repeated through [20..64] in
|
||||
/// real responses; only [16..20] is checked).
|
||||
fn build_response(signature: [u8; 4], mode_marker: [u8; 4], id_marker: [u8; 4]) -> Vec<u8> {
|
||||
let mut r = vec![0u8; 64];
|
||||
r[0..4].copy_from_slice(&signature);
|
||||
// bytes [4..12] left as zeros (version + reserved per format)
|
||||
r[12..16].copy_from_slice(&mode_marker);
|
||||
// Real firmware repeats the secondary marker through [16..64];
|
||||
// the parser only checks [16..20], so we just write the marker
|
||||
// once.
|
||||
r[16..20].copy_from_slice(&id_marker);
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_unlock_sets_unlocked_when_both_markers_present() {
|
||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
|
||||
let mut transport = ScriptedTransport { response };
|
||||
let mut mt = Mt1959::new(fixture_profile(sig), false);
|
||||
|
||||
let raw = mt.do_unlock(&mut transport).expect("unlock should succeed");
|
||||
assert_eq!(raw.len(), 64);
|
||||
assert!(mt.init_complete, "init_complete set after success");
|
||||
assert!(
|
||||
mt.is_unlocked(),
|
||||
"both markers present -> extended-access state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_unlock_init_complete_but_not_unlocked_when_id_marker_missing() {
|
||||
// Primary mode marker present (so init passes) but the
|
||||
// secondary marker is replaced with zeros — drive isn't in
|
||||
// extended-access state.
|
||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, [0u8; 4]);
|
||||
let mut transport = ScriptedTransport { response };
|
||||
let mut mt = Mt1959::new(fixture_profile(sig), false);
|
||||
|
||||
mt.do_unlock(&mut transport).expect("unlock should succeed");
|
||||
assert!(mt.init_complete);
|
||||
assert!(
|
||||
!mt.is_unlocked(),
|
||||
"missing secondary marker -> not in extended-access state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_unlock_rejects_signature_mismatch() {
|
||||
let response = build_response(
|
||||
[0xAA, 0xBB, 0xCC, 0xDD],
|
||||
FIRMWARE_ACTIVE_SIG,
|
||||
FIRMWARE_MODE_SIG,
|
||||
);
|
||||
let mut transport = ScriptedTransport { response };
|
||||
let mut mt = Mt1959::new(fixture_profile([0x99, 0x9E, 0xC3, 0x75]), false);
|
||||
|
||||
let err = mt.do_unlock(&mut transport).unwrap_err();
|
||||
assert!(matches!(err, Error::SignatureMismatch { .. }));
|
||||
assert!(!mt.init_complete);
|
||||
assert!(!mt.is_unlocked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_unlock_rejects_inactive_mode_marker() {
|
||||
// Signature matches but the primary marker at [12..16] is
|
||||
// missing -> drive is not in active mode; init_complete and the
|
||||
// unlocked flag must both stay false.
|
||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||
let response = build_response(sig, [0u8; 4], FIRMWARE_MODE_SIG);
|
||||
let mut transport = ScriptedTransport { response };
|
||||
let mut mt = Mt1959::new(fixture_profile(sig), false);
|
||||
|
||||
let err = mt.do_unlock(&mut transport).unwrap_err();
|
||||
assert!(matches!(err, Error::UnlockFailed));
|
||||
assert!(!mt.init_complete);
|
||||
assert!(!mt.is_unlocked());
|
||||
}
|
||||
}
|
||||
|
||||
+108
@@ -35,6 +35,42 @@ pub struct DriveProfile {
|
||||
pub signature: [u8; 4],
|
||||
#[serde(default, deserialize_with = "deserialize_base64")]
|
||||
pub firmware: Vec<u8>,
|
||||
|
||||
// ── OEM-extended-access CDB templates ──────────────────────────────
|
||||
//
|
||||
// All optional — older profile blobs that pre-date the CDB capture
|
||||
// pipeline simply omit these fields and decode as `None`. Encoded
|
||||
// in the JSON as lowercase hex strings without separators
|
||||
// (e.g. `"3c014410e29100002400"` for a 10-byte CDB).
|
||||
#[serde(default)]
|
||||
pub unlock_init_value: u8,
|
||||
#[serde(default)]
|
||||
pub unlock_response_size: u8,
|
||||
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read_vid_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read_disc_keys_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
|
||||
pub drive_nominal_speed_cdb: Option<[u8; 12]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
|
||||
pub set_speed_max_cdb: Option<[u8; 12]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read10_raw_2sec_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read10_raw_1sec_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read_buffer_verify_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub write_buffer_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read_buffer_unlock_cdb: Option<[u8; 10]>,
|
||||
|
||||
// Per-drive identifier tables — variable-length hex strings.
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
|
||||
pub speed_zone_table: Option<Vec<u8>>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
|
||||
pub speed_calc_table: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Chipset + variant — determined by which section the profile was found in.
|
||||
@@ -99,6 +135,78 @@ where
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
// ── Fixed-length hex deserializers for CDB templates ────────────────────
|
||||
//
|
||||
// Profile JSON encodes CDBs as lowercase hex strings without separators.
|
||||
// An empty string / null / missing field decodes as `None`.
|
||||
|
||||
fn parse_hex_bytes(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err("odd hex length");
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
for i in (0..s.len()).step_by(2) {
|
||||
let byte = u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex digit")?;
|
||||
out.push(byte);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn deserialize_opt_hex_bytes_10<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<[u8; 10]>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<String> = Option::deserialize(deserializer)?;
|
||||
let Some(s) = opt else { return Ok(None) };
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
|
||||
if bytes.len() != 10 {
|
||||
return Err(serde::de::Error::custom("expected 10 bytes"));
|
||||
}
|
||||
let mut out = [0u8; 10];
|
||||
out.copy_from_slice(&bytes);
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
fn deserialize_opt_hex_bytes_12<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<[u8; 12]>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<String> = Option::deserialize(deserializer)?;
|
||||
let Some(s) = opt else { return Ok(None) };
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
|
||||
if bytes.len() != 12 {
|
||||
return Err(serde::de::Error::custom("expected 12 bytes"));
|
||||
}
|
||||
let mut out = [0u8; 12];
|
||||
out.copy_from_slice(&bytes);
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
fn deserialize_opt_hex_bytes<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<Vec<u8>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<String> = Option::deserialize(deserializer)?;
|
||||
let Some(s) = opt else { return Ok(None) };
|
||||
if s.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────
|
||||
|
||||
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
|
||||
|
||||
+3
-1
@@ -6,7 +6,7 @@
|
||||
//! ISO images are opened for read OR write at construction time.
|
||||
//!
|
||||
//! - [`SectorSource`] is implemented by `Drive` (hardware) and
|
||||
//! [`FileSectorSource`] / `IsoSectorReader` (file-backed).
|
||||
//! [`FileSectorSource`] (file-backed).
|
||||
//! - [`SectorSink`] is implemented by [`FileSectorSink`]
|
||||
//! (ISO-backed) and sweep/patch consumer adapters.
|
||||
//! - [`DecryptingSectorSource`] is a decorator that wraps any
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
pub mod decrypting;
|
||||
pub mod file;
|
||||
pub mod prefetched;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
@@ -112,3 +113,4 @@ pub trait SectorSink: Send {
|
||||
pub use crate::io::file_sector_source::FileSectorSource;
|
||||
pub use decrypting::DecryptingSectorSource;
|
||||
pub use file::FileSectorSink;
|
||||
pub use prefetched::PrefetchedSectorSource;
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! `PrefetchedSectorSource` — runs the wrapped read+decrypt in a
|
||||
//! dedicated producer thread and surfaces the prepared plaintext
|
||||
//! buffers on demand via a bounded channel.
|
||||
//!
|
||||
//! ## Why
|
||||
//!
|
||||
//! The mux consumer (demux + codec parsing + frame output) is
|
||||
//! single-threaded by nature (streams are sequential). The mux
|
||||
//! producer (read sectors + AACS decrypt) is also single-threaded
|
||||
//! per-call but does CPU-heavy work (AES per 6144-byte unit). Running
|
||||
//! both on the same thread means the disk and decrypt cores sit idle
|
||||
//! while the demux runs, and vice versa.
|
||||
//!
|
||||
//! Splitting them across two threads with a bounded channel between
|
||||
//! lets both run in parallel — peak throughput becomes
|
||||
//! `min(producer_rate, consumer_rate)` instead of
|
||||
//! `1 / (1/producer + 1/consumer)`.
|
||||
//!
|
||||
//! ## Lifecycle
|
||||
//!
|
||||
//! The producer thread is spawned by [`PrefetchedSectorSource::new`].
|
||||
//! It walks the supplied extent list in order, reads the configured
|
||||
//! batch size at each LBA, and sends the resulting plaintext buffer
|
||||
//! into a [`crossbeam_channel::bounded`] channel of small depth (so
|
||||
//! the producer stays a couple of batches ahead without unbounded
|
||||
//! memory growth).
|
||||
//!
|
||||
//! When the channel sender drops (either because all extents were
|
||||
//! served or because the [`Halt`] token cancelled), the consumer
|
||||
//! observes `RecvError` on the next `read_sectors` and treats it as
|
||||
//! end-of-stream. Errors from the underlying reader are forwarded
|
||||
//! verbatim through the channel.
|
||||
//!
|
||||
//! ## Read API
|
||||
//!
|
||||
//! `read_sectors` ignores its `lba`/`count` arguments — the producer
|
||||
//! has already chosen what to read, in the order the extents dictate.
|
||||
//! This is sound for the mux read path, which always walks extents
|
||||
//! sequentially and never seeks. For random-access callers (sweep
|
||||
//! patch retries) this wrapper is the wrong tool — they should keep
|
||||
//! reading the underlying source directly.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::event::{Event, EventKind};
|
||||
use crate::halt::Halt;
|
||||
use crate::sector::SectorSource;
|
||||
use crossbeam_channel::{Receiver, Sender, bounded};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
/// Producer-thread event callback. Fires `BytesRead` after every
|
||||
/// successful batch read so the consumer side can update a UI
|
||||
/// progress indicator without polling. `Send + 'static` because the
|
||||
/// callback runs on the producer thread.
|
||||
pub type EventFn = Box<dyn Fn(Event) + Send + 'static>;
|
||||
|
||||
const PREFETCH_CHANNEL_DEPTH: usize = 2;
|
||||
|
||||
/// Smallest sector source the producer will issue per read. AACS
|
||||
/// alignment requires multiples of 3 sectors so a unit doesn't span
|
||||
/// two reads.
|
||||
const SECTOR_ALIGNMENT: u16 = 3;
|
||||
|
||||
/// Item flowing through the prefetch forward channel.
|
||||
pub type Batch = std::result::Result<Vec<u8>, std::io::Error>;
|
||||
|
||||
/// Producer-thread-backed [`SectorSource`] decorator. Construct it
|
||||
/// with the real reader, the extent list to walk, and the batch
|
||||
/// size; the wrapper spawns the producer immediately and starts
|
||||
/// filling the channel.
|
||||
pub struct PrefetchedSectorSource {
|
||||
rx: Receiver<Batch>,
|
||||
/// Recycle channel — consumer returns drained buffers here; the
|
||||
/// producer re-fills them in place. Lets the producer/consumer
|
||||
/// reuse a fixed pool of `PREFETCH_CHANNEL_DEPTH+1` buffers
|
||||
/// instead of `Vec::new()`-ing one per batch (musl mallocng
|
||||
/// cross-thread alloc/free was the dominant cost in the demux
|
||||
/// thread before this).
|
||||
recycle_tx: Sender<Vec<u8>>,
|
||||
/// Joined on drop so producer cleanup runs deterministically.
|
||||
producer: Option<JoinHandle<()>>,
|
||||
/// Cumulative bytes drained by `read_sectors` calls. Exposed via
|
||||
/// [`capacity_sectors`] indirectly: the consumer-side state needs
|
||||
/// this to advance its position bookkeeping in lockstep with what
|
||||
/// the producer fed.
|
||||
total_sectors: u32,
|
||||
}
|
||||
|
||||
impl PrefetchedSectorSource {
|
||||
/// Spawn the producer thread. `reader` must already be the fully
|
||||
/// composed read+decrypt stack (e.g.
|
||||
/// [`DecryptingSectorSource`](crate::sector::DecryptingSectorSource))
|
||||
/// — every byte the producer emits is what the consumer's demux
|
||||
/// will feed to its codec parsers.
|
||||
pub fn new<S>(
|
||||
reader: S,
|
||||
extents: Vec<crate::disc::Extent>,
|
||||
batch_sectors: u16,
|
||||
halt: Option<Halt>,
|
||||
) -> Self
|
||||
where
|
||||
S: SectorSource + Send + 'static,
|
||||
{
|
||||
Self::new_with_events(reader, extents, batch_sectors, halt, None)
|
||||
}
|
||||
|
||||
/// Same as [`new`] but with a callback fired from the producer
|
||||
/// thread after each successful batch — used by autorip's mux
|
||||
/// path to surface `BytesRead` progress to the UI without the
|
||||
/// consumer thread having to poll.
|
||||
pub fn new_with_events<S>(
|
||||
mut reader: S,
|
||||
extents: Vec<crate::disc::Extent>,
|
||||
batch_sectors: u16,
|
||||
halt: Option<Halt>,
|
||||
event_fn: Option<EventFn>,
|
||||
) -> Self
|
||||
where
|
||||
S: SectorSource + Send + 'static,
|
||||
{
|
||||
let total_sectors: u32 = extents.iter().map(|e| e.sector_count).sum();
|
||||
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
||||
let (tx, rx) = bounded::<Batch>(PREFETCH_CHANNEL_DEPTH);
|
||||
let (recycle_tx, recycle_rx) = bounded::<Vec<u8>>(PREFETCH_CHANNEL_DEPTH + 1);
|
||||
let batch_bytes = batch_sectors as usize * 2048;
|
||||
|
||||
// Seed the recycle pool so the producer always has a buffer
|
||||
// to fill on the first iteration. Without these, the first
|
||||
// `recycle_rx.recv()` would block forever (no consumer has
|
||||
// returned a buffer yet).
|
||||
for _ in 0..(PREFETCH_CHANNEL_DEPTH + 1) {
|
||||
let _ = recycle_tx.send(vec![0u8; batch_bytes]);
|
||||
}
|
||||
|
||||
let producer = std::thread::Builder::new()
|
||||
.name("freemkv-prefetch".into())
|
||||
.spawn(move || {
|
||||
let mut ext_idx = 0usize;
|
||||
let mut offset: u32 = 0;
|
||||
let mut bytes_read_total: u64 = 0;
|
||||
while ext_idx < extents.len() {
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let extent = &extents[ext_idx];
|
||||
let remaining = extent.sector_count.saturating_sub(offset);
|
||||
if remaining == 0 {
|
||||
ext_idx += 1;
|
||||
offset = 0;
|
||||
continue;
|
||||
}
|
||||
let mut sectors = remaining.min(batch_sectors as u32) as u16;
|
||||
if sectors >= SECTOR_ALIGNMENT {
|
||||
sectors -= sectors % SECTOR_ALIGNMENT;
|
||||
}
|
||||
let bytes = sectors as usize * 2048;
|
||||
let mut buf = match recycle_rx.recv() {
|
||||
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]`.
|
||||
unsafe { buf.set_len(bytes) };
|
||||
}
|
||||
let lba = extent.start_lba + offset;
|
||||
match reader.read_sectors(lba, sectors, &mut buf[..bytes], false) {
|
||||
Ok(n) => {
|
||||
buf.truncate(n);
|
||||
bytes_read_total = bytes_read_total.saturating_add(n as u64);
|
||||
if let Some(ref f) = event_fn {
|
||||
f(Event {
|
||||
kind: EventKind::BytesRead {
|
||||
bytes: bytes_read_total,
|
||||
total: bytes_total_extents,
|
||||
},
|
||||
});
|
||||
}
|
||||
if tx.send(Ok(buf)).is_err() {
|
||||
return; // consumer dropped
|
||||
}
|
||||
offset += sectors as u32;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(e.into()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop tx implicitly — consumer sees RecvError → EOF.
|
||||
})
|
||||
.expect("freemkv-prefetch producer spawn failed");
|
||||
|
||||
Self {
|
||||
rx,
|
||||
recycle_tx,
|
||||
producer: Some(producer),
|
||||
total_sectors,
|
||||
}
|
||||
}
|
||||
|
||||
/// Peel off the receivers for zero-copy pipeline mode. The
|
||||
/// caller (typically [`super::super::mux::demux_thread::DemuxThread`])
|
||||
/// pulls buffers from `rx`, consumes them, and pushes the empty
|
||||
/// `Vec<u8>` back through `recycle_tx` so the producer can
|
||||
/// re-fill it. The producer-thread `JoinHandle` stays with the
|
||||
/// returned `PrefetchedSectorSource` shell; drop that to join.
|
||||
///
|
||||
/// Returns `(forward_rx, recycle_tx, shell)`. The shell only
|
||||
/// holds the join handle and total_sectors for `capacity_sectors`
|
||||
/// queries; its `SectorSource` impl becomes invalid after this
|
||||
/// call (data has been moved out).
|
||||
pub fn into_channels(self) -> (Receiver<Batch>, Sender<Vec<u8>>, PrefetchShell) {
|
||||
let total = self.total_sectors;
|
||||
// Drop the SectorSource side; transfer the producer join
|
||||
// handle to a shell that just waits on Drop.
|
||||
let mut me = self;
|
||||
let producer = me.producer.take();
|
||||
let rx = me.rx.clone();
|
||||
let recycle = me.recycle_tx.clone();
|
||||
std::mem::forget(me);
|
||||
(rx, recycle, PrefetchShell { producer, total })
|
||||
}
|
||||
}
|
||||
|
||||
/// Returned from [`PrefetchedSectorSource::into_channels`]. Owns the
|
||||
/// producer thread join handle so dropping the shell joins the
|
||||
/// producer, even though the channels have been peeled off.
|
||||
pub struct PrefetchShell {
|
||||
producer: Option<JoinHandle<()>>,
|
||||
#[allow(dead_code)]
|
||||
total: u32,
|
||||
}
|
||||
|
||||
impl Drop for PrefetchShell {
|
||||
fn drop(&mut self) {
|
||||
if let Some(h) = self.producer.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PrefetchedSectorSource {
|
||||
fn drop(&mut self) {
|
||||
// Dropping the receiver closes the channel, which makes the
|
||||
// next producer `send` return Err and exits the loop. Joining
|
||||
// here gives us a deterministic shutdown — no detached thread
|
||||
// can outlive the source.
|
||||
if let Some(h) = self.producer.take() {
|
||||
let _ = h.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for PrefetchedSectorSource {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.total_sectors
|
||||
}
|
||||
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
_count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
// The producer has already decided the next batch. lba/count
|
||||
// are advisory; the consumer's fill_extents will advance its
|
||||
// own bookkeeping using the returned byte count, not the
|
||||
// requested count.
|
||||
match self.rx.recv() {
|
||||
Ok(Ok(filled)) => {
|
||||
let n = filled.len().min(buf.len());
|
||||
buf[..n].copy_from_slice(&filled[..n]);
|
||||
Ok(n)
|
||||
}
|
||||
Ok(Err(e)) => Err(crate::error::Error::IoError { source: e }),
|
||||
// Channel closed (producer finished or panicked).
|
||||
Err(_) => Ok(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -724,7 +724,7 @@ fn aacs_parse_unit_key_ro_minimal() {
|
||||
data[key_pos + i] = (0xA0 + i) as u8;
|
||||
}
|
||||
|
||||
let result = aacs::parse_unit_key_ro(&data, false);
|
||||
let result = aacs::parse_unit_key_ro(&data, aacs::AacsVersion::V10);
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"parse_unit_key_ro should succeed on valid data"
|
||||
|
||||
@@ -301,6 +301,7 @@ fn m2ts_stream_write_read() {
|
||||
pts: i as i64 * 1_000_000,
|
||||
keyframe: i == 0,
|
||||
data: vec![i; 100],
|
||||
duration_ns: None,
|
||||
};
|
||||
PesStream::write(&mut stream, &frame).unwrap();
|
||||
}
|
||||
@@ -322,6 +323,7 @@ fn m2ts_pes_frame_roundtrip() {
|
||||
pts: 1_234_567_890,
|
||||
keyframe: true,
|
||||
data: vec![0xDE; 200],
|
||||
duration_ns: None,
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
@@ -589,6 +591,7 @@ fn mkvstream_write_finish() {
|
||||
pts: i as i64 * 1_000_000,
|
||||
keyframe: i == 0,
|
||||
data: vec![i; 100],
|
||||
duration_ns: None,
|
||||
};
|
||||
PesStream::write(&mut stream, &frame).unwrap();
|
||||
}
|
||||
@@ -650,6 +653,7 @@ fn mkvstream_roundtrip_bdts() {
|
||||
pts: i as i64 * 1_000_000,
|
||||
keyframe: true,
|
||||
data: vec![i; 100],
|
||||
duration_ns: None,
|
||||
};
|
||||
PesStream::write(&mut stream, &frame).unwrap();
|
||||
}
|
||||
@@ -842,6 +846,7 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
pts: 1_000_000_000, // 1 second in ns
|
||||
keyframe: true,
|
||||
data: es_data,
|
||||
duration_ns: None,
|
||||
};
|
||||
PesStream::write(&mut stream2, &frame1).unwrap();
|
||||
|
||||
@@ -851,6 +856,7 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
pts: 1_041_700_000, // ~1 frame later in ns
|
||||
keyframe: false,
|
||||
data: es_data2,
|
||||
duration_ns: None,
|
||||
};
|
||||
PesStream::write(&mut stream2, &frame2).unwrap();
|
||||
PesStream::finish(&mut stream2).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user