mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61b4486ae9 | ||
|
|
72d39544ac | ||
|
|
1e394eaeb8 | ||
|
|
4d1aa6bbf7 | ||
|
|
9ce03b4d1d | ||
|
|
f099a4a31a | ||
|
|
e606b5e3a2 | ||
|
|
de809189ec | ||
|
|
253b0924e3 | ||
|
|
98a7892c34 | ||
|
|
b82d21756a | ||
|
|
02c584a3d9 | ||
|
|
564e66fd24 | ||
|
|
57d40e81a5 | ||
|
|
674819d937 | ||
|
|
c5e8421f9f | ||
|
|
7dcf9b5a0d | ||
|
|
f01c097784 | ||
|
|
c265fa349e | ||
|
|
6a59dcb7ba | ||
|
|
0eb1817ec7 |
@@ -39,29 +39,11 @@ jobs:
|
||||
# would always fail (no lockfile to lock against on a fresh runner).
|
||||
- run: cargo test
|
||||
|
||||
# crates.io publish is an INDEPENDENT job: it serves EXTERNAL consumers only.
|
||||
# The freemkv binaries no longer depend on it (they git-tag-pin libfreemkv via
|
||||
# a committed [patch.crates-io]), so this publish runs in parallel with their
|
||||
# release builds rather than gating them. It `needs: [verify, test]` so a
|
||||
# failing test suite still blocks publication to crates.io — external
|
||||
# consumers who `cargo add libfreemkv` must never receive a release whose
|
||||
# tests were failing. (The two upstream jobs run in parallel, so this gate
|
||||
# does not serialize publish behind test beyond their own completion.)
|
||||
publish:
|
||||
needs: [verify, test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# --no-verify: CI already compiled this exact commit (in the `test` job
|
||||
# and on every push via ci.yml). cargo publish's default re-verify does a
|
||||
# full cold release build of the packaged tarball, which here is pure
|
||||
# redundant work (~a cold lib build). Skip it.
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish --no-verify
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
# NOTE: there is no crates.io publish job. libfreemkv is git-tag-only
|
||||
# (`package.publish = false` — it git-deps the firmware crate freemkv-unlock,
|
||||
# which never ships to crates.io). Every consumer git-tag-pins libfreemkv via
|
||||
# a committed [patch.crates-io]; the git tag itself IS the release artifact.
|
||||
# A `cargo publish` here fails hard on `publish = false`, so it was removed.
|
||||
|
||||
release:
|
||||
# Only needs `verify`; the GitHub Release can be cut as soon as the version
|
||||
|
||||
+57
-5
@@ -1,5 +1,57 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.0] — 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Blu-ray 3D (MVC) support.** A 3D disc now rips to an MKV that preserves
|
||||
**both eyes** as a single MVC video track — the AVC base (left) view in each
|
||||
Block, and the MVC dependent (right-eye) view as a per-frame `BlockAdditional`
|
||||
under an `mvcC` `BlockAdditionMapping` (`MVCDecoderConfigurationRecord` per
|
||||
ISO/IEC 14496-15 §7.6.2), paired to the base by PTS. Remux only — no
|
||||
transcode, no side-by-side conversion. The Blu-ray scan reads the interleaved
|
||||
`STREAM/SSIF/<clip>.ssif`, enumerates the dependent view (stream_type `0x20`)
|
||||
by the BD-3D PID convention, and parses it in a parameter-set-passthrough mode
|
||||
so every dependent frame is a self-contained access unit. Verified on
|
||||
*300: Rise of an Empire*: one MVC track, ~8.7 GB dependent payload carried in
|
||||
per-frame BlockAdditionals, base view byte-identical to the 2D rip.
|
||||
|
||||
## [1.3.2] — 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
- **AACS 2.1 (FMTS) variant-decode foundation.** `UnitKey` gains a
|
||||
`variant_number` field (`0` = ordinary content, `1..=32` = a forensic
|
||||
variant) with `UnitKey::new` / `UnitKey::variant` constructors, and a new
|
||||
`aacs::variant_select` module resolves a disc's single forensic variant and
|
||||
classifies each aligned unit — decrypt with the default key, decrypt with the
|
||||
variant key, drop a foreign variant, or conceal a keyless forensic unit. This
|
||||
is the groundwork for selecting one variant's segments and dropping the other
|
||||
31; the decrypt-pipeline wiring lands with the variant key source.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`IndividualSegment.tbl`: the per-record field is the variant, not a segment
|
||||
number.** `Segment.number` → `Segment.variant`. Verified against a retail 2.1
|
||||
disc, the field cycles `1..=32` across the table (a per-variant tag) rather
|
||||
than counting up, so variant selection routes on the correct value.
|
||||
|
||||
## [1.3.1] — 2026-07-10
|
||||
|
||||
### Licensing
|
||||
|
||||
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
|
||||
including 1.3.0 remain under AGPL-3.0).
|
||||
|
||||
### Added
|
||||
|
||||
- **Authoritative HD-DVD title composition** from the Advanced-Content playlist
|
||||
(`ADV_OBJ/VPLST000.XPL`): each title's clips, real duration, display name, and
|
||||
chapters come from the disc's own playlist instead of a clip-name heuristic. A
|
||||
layer-break split (`FEATURE_1`+`FEATURE_2`, `feature`/`feature_Divide`) composes
|
||||
into ONE title with the two parts as clips and their title-time offsets. Falls
|
||||
back to the clip-name heuristic when no playlist is present.
|
||||
|
||||
## [1.3.0] — 2026-07-08
|
||||
|
||||
### Added
|
||||
@@ -122,7 +174,7 @@
|
||||
|
||||
- **Processing-Key resolution is ~15× faster on UHD.** A Processing Key is the
|
||||
key at its subset-difference node (one AES-G from the Media Key), so it is now
|
||||
tried directly against the MKB cvalue tables (matching libaacs `_calc_mk_pks`)
|
||||
tried directly against the MKB cvalue tables (direct PK × cvalue iteration)
|
||||
instead of BFS-walking the SD tree at unknown depth — which was both wrong for
|
||||
terminal PKs and slow on a large UHD MKB (~181k cvalues). PK derivation on UHD
|
||||
drops from ~37 s to ~2.4 s; the SD tree walk now lives solely in the device-key
|
||||
@@ -355,7 +407,7 @@ consumers are the in-tree toolchain crates.
|
||||
|
||||
- **Post-read decrypt-verify gate.** Every AACS unit read off the disc is now
|
||||
buffered, re-aligned to its clip-file 6144-byte unit grid, and verified
|
||||
(CPI flag → decrypt → strict all-32 TS-sync, matching libaacs `_verify_ts`)
|
||||
(CPI flag → decrypt → strict all-32 TS-sync)
|
||||
before it is signed off as good. A unit that no held or freshly-fetched key
|
||||
decrypts is treated exactly like a bad read — re-read by
|
||||
the patch pass, terminal loss only if truly unrecoverable — closing the
|
||||
@@ -376,7 +428,7 @@ consumers are the in-tree toolchain crates.
|
||||
|
||||
- **AACS decrypt acceptance is now standards-strict.** A key is accepted only
|
||||
when the decrypted unit has the TS sync byte on *all* 32 source packets
|
||||
(libaacs `_verify_ts`), replacing a majority-vote heuristic where a wrong key
|
||||
(all-32 TS-sync verify), replacing a majority-vote heuristic where a wrong key
|
||||
could coincidentally restore enough syncs to pass and silently corrupt a unit.
|
||||
- keydb download/save moved out of the library into freemkv-keysources;
|
||||
libfreemkv no longer has any keydb I/O (it already held no keys).
|
||||
@@ -384,7 +436,7 @@ consumers are the in-tree toolchain crates.
|
||||
### Fixed
|
||||
|
||||
- **AACS content-certificate bus-encryption flag read from the wrong bit.** The
|
||||
flag is bit 7 of byte 1 (libaacs `p[1] >> 7`) but was read as bit 0, so a
|
||||
flag is bit 7 of byte 1 (`p[1] >> 7`) but was read as bit 0, so a
|
||||
bus-encrypted disc parsed as *not* bus-encrypted — defeating the fail-loud
|
||||
guard that refuses to decrypt bus-wrapped data to garbage when no bus key was
|
||||
obtained. Also corrected the cc_id offset (byte 14) and the AACS2 type marker
|
||||
@@ -684,7 +736,7 @@ hardening.
|
||||
|
||||
- **Keyless DVD/CSS title-key recovery.** A CSS-protected DVD decrypts with no
|
||||
key database — the title key is recovered directly from the scrambled disc
|
||||
data via the Stevenson known-plaintext attack (ported from libdvdcss) and
|
||||
data via the Stevenson known-plaintext attack and
|
||||
validated by descrambling a sector and confirming the known plaintext
|
||||
reappears, so a wrong key fails cleanly instead of producing silent garbage
|
||||
(`src/css/stevenson.rs`). `Disc::scan_image` recovers the same title key from
|
||||
|
||||
+1
-1
@@ -24,4 +24,4 @@ cargo test
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree your code will be licensed under AGPL-3.0.
|
||||
By contributing, you agree your code will be licensed under MIT.
|
||||
|
||||
+9
-3
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
license = "MIT"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
||||
@@ -28,7 +28,7 @@ cbc = "0.1"
|
||||
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.3.0" }
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.0" }
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
num-integer = "0.1"
|
||||
@@ -36,6 +36,12 @@ rand = "0.8"
|
||||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.22.1"
|
||||
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
||||
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
||||
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
|
||||
# attribute order, self-closing tags).
|
||||
roxmltree = "0.20"
|
||||
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2026 FreeMKV Contributors
|
||||
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, version 3 of the License.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
|
||||
# libfreemkv
|
||||
|
||||
@@ -190,4 +190,4 @@ Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/fre
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only
|
||||
MIT
|
||||
|
||||
@@ -7,7 +7,7 @@ AACS decryption requires an external `keydb.cfg` (default
|
||||
material is compiled in; DVD CSS player keys are the only compiled-in keys.
|
||||
|
||||
**Repository:** <https://github.com/freemkv/libfreemkv>
|
||||
**License:** AGPL-3.0-only
|
||||
**License:** MIT
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -84,7 +84,7 @@ pub fn ts_sync_destroyed(unit: &[u8]) -> bool {
|
||||
/// * `(buf[0] & 0xC0) == 0` → CPI clear → the unit is plaintext; pass through.
|
||||
/// * non-zero → bytes `16..6144` are AES-CBC encrypted; decrypt.
|
||||
///
|
||||
/// This is exactly libaacs' test (`if (!(buf[0] & 0xc0)) return; /* clear */`)
|
||||
/// This is exactly the spec CPI test (`buf[0] & 0xc0 == 0` means clear)
|
||||
/// and is the spec-correct replacement for the [`ts_sync_destroyed`] byte
|
||||
/// heuristic. CRITICAL: it is only meaningful when `unit` is read at the correct
|
||||
/// clip-FILE-anchored boundary — byte 0 must be the real unit start. A
|
||||
@@ -238,7 +238,7 @@ fn ts_syncs_intact(unit: &[u8]) -> bool {
|
||||
}
|
||||
|
||||
/// STRICT, standards-correct "is this a clean MPEG-TS aligned unit?" check —
|
||||
/// byte-for-byte libaacs' `_verify_ts` (`aacs.c`): EVERY one of the 32 BD source
|
||||
/// the standards-correct all-32-sync verify: EVERY one of the 32 BD source
|
||||
/// packets (192-byte stride) must carry its TS sync `0x47` at offset 4; the first
|
||||
/// miss fails. This is the authoritative gate for the POST-READ verify stage,
|
||||
/// independent of (and not coupled to) `decrypt_unit`.
|
||||
@@ -1211,7 +1211,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unit_is_clean_ts_is_strict_all_32_syncs() {
|
||||
// Standards-correct gate (libaacs `_verify_ts`): EVERY one of the 32
|
||||
// Standards-correct gate (all-32 TS syncs): EVERY one of the 32
|
||||
// packet syncs is required. A fully-synced clear unit passes.
|
||||
let clear = clear_unit();
|
||||
assert!(unit_is_clean_ts(&clear), "all-32-sync unit is clean");
|
||||
|
||||
+3
-6
@@ -10,8 +10,8 @@ use super::types::*;
|
||||
///
|
||||
/// A Processing Key is **terminal**: it is the key at its Subset-Difference
|
||||
/// node, one `AES-G` from the Media Key. So this is the fast path — each PK is
|
||||
/// tried *directly* against the MKB cvalue tables (no tree descent), matching
|
||||
/// libaacs `_calc_mk_pks` (iterate PKs × cvalues). On a large AACS 2.x UHD MKB
|
||||
/// tried *directly* against the MKB cvalue tables (no tree descent) — the
|
||||
/// direct PK × cvalue iteration. On a large AACS 2.x UHD MKB
|
||||
/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node
|
||||
/// label and walking the tree.
|
||||
///
|
||||
@@ -612,10 +612,7 @@ mod resolve_candidate_tests {
|
||||
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_is_itself() {
|
||||
let uk = UnitKey {
|
||||
idx: 2,
|
||||
key: [0x9u8; 16],
|
||||
};
|
||||
let uk = UnitKey::new(2, [0x9u8; 16]);
|
||||
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal");
|
||||
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||
assert!(r.vuk.is_none() && r.mk.is_none());
|
||||
|
||||
+4
-4
@@ -121,7 +121,7 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
return None;
|
||||
}
|
||||
|
||||
// Title → CPS unit mapping. libaacs (unit_key.c) validates each on-disc CPS
|
||||
// Title → CPS unit mapping (AACS Unit_Key_RO format): each on-disc CPS
|
||||
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
|
||||
// index to a 0-based key index. We mirror that so the stored value is a safe,
|
||||
// ready-to-use key index rather than a raw 1-based number.
|
||||
@@ -261,10 +261,10 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Content Certificate layout (matches libaacs content_cert.c):
|
||||
// Content Certificate layout (per the AACS content-cert format):
|
||||
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||||
// [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (libaacs: `p + 14`)
|
||||
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (`p + 14`)
|
||||
let version = if data[0] == 0x00 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
|
||||
+5
-6
@@ -157,7 +157,7 @@ impl AacsVersion {
|
||||
}
|
||||
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [libaacs] `mkb.c` — not in the public spec.
|
||||
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [RE] — not in the public spec (from real 2.x MKBs).
|
||||
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
|
||||
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
|
||||
@@ -198,9 +198,8 @@ pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
///
|
||||
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
|
||||
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
|
||||
/// 5-byte Subset-Difference index entries in record `0x04`. This matches
|
||||
/// libaacs, whose `mkb_cvalues()` reads `0x05` and `mkb_subdiff_records()`
|
||||
/// reads `0x04`.
|
||||
/// 5-byte Subset-Difference index entries in record `0x04` — the standard AACS
|
||||
/// MKB layout (`0x05` cvalues 1:1 with the `0x04` subset-difference index).
|
||||
///
|
||||
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
|
||||
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
|
||||
@@ -281,10 +280,10 @@ pub const MKB_TYPE_4_PRERECORDED: u32 = 0x0004_1003;
|
||||
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
|
||||
pub const MKB_TYPE_10_CLASS_II: u32 = 0x000A_1003;
|
||||
|
||||
/// `0x48141003` — AACS 2.0 Category C (UHD content). libaacs `MKB_20_CATEGORY_C`.
|
||||
/// `0x48141003` — AACS 2.0 Category C (UHD content) MKB type value.
|
||||
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
|
||||
|
||||
/// `0x48151003` — AACS 2.1 Category C (UHD content). libaacs `MKB_21_CATEGORY_C`.
|
||||
/// `0x48151003` — AACS 2.1 Category C (UHD content) MKB type value.
|
||||
pub const MKB_21_CATEGORY_C: u32 = 0x4815_1003;
|
||||
|
||||
/// The AACS MKB Type field, decoded.
|
||||
|
||||
+4
-6
@@ -22,8 +22,8 @@
|
||||
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
|
||||
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
|
||||
//! - `[BD]` — AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
|
||||
//! - `[libaacs]` — the libaacs reference implementation, cited only where the spec
|
||||
//! is silent (the `0x86` verify record and the Category-C MKBType names).
|
||||
//! - `[RE]` — reverse-engineered from real discs, cited only where the public
|
||||
//! spec is silent (the `0x86` verify record and the Category-C MKB type values).
|
||||
|
||||
pub mod content;
|
||||
pub mod crypto;
|
||||
@@ -38,6 +38,7 @@ pub mod segment_key;
|
||||
pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variant;
|
||||
pub mod variant_select;
|
||||
|
||||
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
|
||||
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
|
||||
@@ -103,10 +104,7 @@ mod tests {
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
let _ = super::derive::resolve_candidate(
|
||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey {
|
||||
idx: 0,
|
||||
key: [0u8; 16],
|
||||
}),
|
||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
|
||||
+8
-8
@@ -8,7 +8,7 @@ use super::mkb::*;
|
||||
//
|
||||
// Canonical form is `<category>1003` (low 16 bits `0x1003` is a fixed marker).
|
||||
// Types 3/4/10 are from the AACS Common Cryptographic Elements spec (0.953,
|
||||
// §3.2.5.1.1); the Category-C 2.0/2.1 values match libaacs `mkb.h` constants.
|
||||
// §3.2.5.1.1); the Category-C 2.0/2.1 values are the standard MKB type constants.
|
||||
|
||||
// ── Full VUK resolution chain ───────────────────────────────────────────────
|
||||
|
||||
@@ -790,7 +790,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validate_processing_key_round_trip_with_nonzero_uv() {
|
||||
// Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the
|
||||
// libaacs _validate_pk relation, then confirm validate_processing_key
|
||||
// AACS PK-validation relation, then confirm validate_processing_key
|
||||
// recovers mk. Catches the bugs that landed pre-fix:
|
||||
// * uv XOR step was missing → mk wrong whenever uv != 0
|
||||
// * AES-128E + 12-zero check instead of AES-128D + magic
|
||||
@@ -844,8 +844,8 @@ mod tests {
|
||||
// ── MKB cvalue-record selection (issue #259 / #281) ─────────────────
|
||||
//
|
||||
// The cvalue (Media Key Data) table is record 0x05; the
|
||||
// Subset-Difference index is record 0x04. This matches libaacs
|
||||
// (`mkb_cvalues` → 0x05, `mkb_subdiff_records` → 0x04). Record 0x07
|
||||
// Subset-Difference index is record 0x04 (the standard AACS MKB layout:
|
||||
// 0x05 = cvalues, 0x04 = subset-difference index). Record 0x07
|
||||
// (Explicit Subset-Difference Record) is NOT the cvalue table. On real
|
||||
// in-drive AACS 2.x UHD MKBs 0x07 is small (~96 entries) while the 0x05
|
||||
// table is large (181270 entries, 1:1 with 0x04). An earlier
|
||||
@@ -1264,7 +1264,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn test_content_cert_parse() {
|
||||
// AACS 1.0 cert, bus encryption OFF. Layout matches libaacs: flag in
|
||||
// AACS 1.0 cert, bus encryption OFF. Content-cert layout: flag in
|
||||
// BIT 7 of byte 1, cc_id at bytes 14..20.
|
||||
let mut data = vec![0u8; 20];
|
||||
data[0] = 0x00; // AACS 1.0
|
||||
@@ -1460,7 +1460,7 @@ mod tests {
|
||||
// [20..22] first_play, [22..24] top_menu, [24..26] num_titles, then
|
||||
// per-title 2-byte pad + 2-byte CPS unit at 26 + i*4 + 2. Each on-disc
|
||||
// 1-based CPS number in `1..=num_uk` is validated and converted to a
|
||||
// 0-based key index (libaacs unit_key.c); an out-of-range number → 0.
|
||||
// 0-based key index (per the AACS Unit_Key_RO format); an out-of-range number → 0.
|
||||
let mut data = build_unit_key_ro(4, 64); // num_uk = 4 → CPS 1..=4 valid
|
||||
data[20..22].copy_from_slice(&1u16.to_be_bytes()); // first_play CPS 1
|
||||
data[22..24].copy_from_slice(&2u16.to_be_bytes()); // top_menu CPS 2
|
||||
@@ -1580,7 +1580,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn parse_content_cert_extracts_cc_id_and_nonzero_type_is_v20() {
|
||||
// libaacs layout: [0]=type, [1] bit7=bus-enc, [14..20]=cc_id. Any
|
||||
// Content-cert layout: [0]=type, [1] bit7=bus-enc, [14..20]=cc_id. Any
|
||||
// non-0x00 type → V20.
|
||||
let mut data = vec![0u8; 20];
|
||||
data[0] = 0x10; // AACS2 type marker → V20
|
||||
@@ -1593,7 +1593,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn parse_content_cert_bus_encryption_reads_bit7() {
|
||||
// bus_encryption = (data[1] >> 7) & 1 (libaacs). Low bits set with bit7
|
||||
// bus_encryption = (data[1] >> 7) & 1. Low bits set with bit7
|
||||
// clear → false; bit7 set → true. Pins the bit, not a truthiness of the byte.
|
||||
let mut data = vec![0u8; 20];
|
||||
data[1] = 0x7F; // bits 0..6 set, bit 7 clear
|
||||
|
||||
+44
-14
@@ -17,12 +17,15 @@
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
||||
//! record[count] (16 bytes each):
|
||||
//! u32 marker (= 0x01000000) | u16 segment_number | u16 flag (= 1)
|
||||
//! u32 marker (= 0x01000000) | u16 variant | u16 flag (= 1)
|
||||
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||
//! ```
|
||||
//! Source-packet numbers are the 192-byte BDAV packet index: byte offset =
|
||||
//! `spn * 192`. Observed on one disc: 792 segments, each ~2560 packets
|
||||
//! (~480 KB), spread across the entire 54 GB feature (one roughly every 67 MB).
|
||||
//! `variant` is the 1..32 forensic-variant tag, NOT a sequential segment id:
|
||||
//! measured on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across
|
||||
//! records in file order — 24 full cycles of 32 plus a final partial cycle of
|
||||
//! 24 = 792 records. Source-packet numbers are the 192-byte BDAV packet index:
|
||||
//! byte offset = `spn * 192`. Each segment is ~2560 packets (~480 KB), spread
|
||||
//! across the entire 54 GB feature (one roughly every 67 MB).
|
||||
|
||||
/// Fixed size of one `IndividualSegment.tbl` record.
|
||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||
@@ -48,8 +51,11 @@ pub const BYPASS_FMTS_KEY: bool = true;
|
||||
/// in the FMTS clip.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Segment {
|
||||
/// 1-based segment number (table order).
|
||||
pub number: u16,
|
||||
/// Forensic variant tag, 1..=32 (field@4 of the record). Cycles across the
|
||||
/// table rather than counting up — it selects WHICH variant this range is,
|
||||
/// which is what a variant-keyed decode routes on. (`0` is not used here;
|
||||
/// the default/non-forensic content carries no segment record at all.)
|
||||
pub variant: u16,
|
||||
/// First source packet of the segment (inclusive).
|
||||
pub start_spn: u32,
|
||||
/// Last source packet of the segment (inclusive).
|
||||
@@ -141,12 +147,12 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||
let mut segments = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let o = 8 + i * record_size;
|
||||
// o+4..o+8 = segment_number (u16) + flag (u16); o+8..o+16 = start/end SPN.
|
||||
let number = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||
// o+4..o+8 = variant (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
||||
let variant = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
|
||||
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
||||
segments.push(Segment {
|
||||
number,
|
||||
variant,
|
||||
start_spn,
|
||||
end_spn,
|
||||
});
|
||||
@@ -159,7 +165,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
||||
/// records. `recs` are `(segment_number, start_spn, end_spn)`.
|
||||
/// records. `recs` are `(variant, start_spn, end_spn)`.
|
||||
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||
@@ -177,7 +183,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_real_disc_layout() {
|
||||
// First three records observed on a retail 2.1 disc: 2560-packet segments.
|
||||
// First three records observed on retail 2.1 (Zombieland): the variant
|
||||
// field counts 1,2,3,… (it wraps at 32 further into the table — see
|
||||
// `variant_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||
let tbl = build_tbl(&[
|
||||
(1, 343680, 346239),
|
||||
(2, 695616, 698175),
|
||||
@@ -185,7 +193,9 @@ mod tests {
|
||||
]);
|
||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||
assert_eq!(segs.len(), 3);
|
||||
assert_eq!(segs[0].number, 1);
|
||||
assert_eq!(segs[0].variant, 1);
|
||||
assert_eq!(segs[1].variant, 2);
|
||||
assert_eq!(segs[2].variant, 3);
|
||||
assert_eq!(segs[0].start_spn, 343680);
|
||||
assert_eq!(segs[0].end_spn, 346239);
|
||||
assert_eq!(segs[0].packet_count(), 2560);
|
||||
@@ -230,7 +240,27 @@ mod tests {
|
||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.number, 1);
|
||||
assert_eq!(hit.variant, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_field_cycles_one_to_thirty_two() {
|
||||
// Reality on Zombieland: field@4 is the variant, cycling 1..=32 in file
|
||||
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
||||
let mut recs = Vec::new();
|
||||
let mut spn = 1000u32;
|
||||
for row in 0..2 {
|
||||
for v in 1..=32u16 {
|
||||
recs.push((v, spn, spn + 2559));
|
||||
spn += 50_000; // ~one segment every ~67 MB
|
||||
}
|
||||
let _ = row;
|
||||
}
|
||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||
assert_eq!(segs.len(), 64);
|
||||
assert_eq!(segs[31].variant, 32); // end of first cycle
|
||||
assert_eq!(segs[32].variant, 1); // wraps, does not become 33
|
||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.variant)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -250,7 +280,7 @@ mod tests {
|
||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.number, 7);
|
||||
assert_eq!(hit.variant, 7);
|
||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
||||
assert!(variant_segment_for_unit(&segs, before).is_none());
|
||||
|
||||
@@ -57,6 +57,42 @@ pub struct ProcessingKey(pub [u8; 16]);
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
/// AACS 2.1 (FMTS) forensic-variant tag.
|
||||
///
|
||||
/// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0
|
||||
/// key and for the bulk of a 2.1 title. `1..=32` = a variant key that
|
||||
/// decrypts the forensic segments tagged with that same variant in
|
||||
/// `IndividualSegment.tbl`. A disc resolves to exactly one variant, so at
|
||||
/// most one non-zero value is ever in play for a given rip; the decode
|
||||
/// selects the segments matching it and drops the other variants.
|
||||
pub variant_number: u8,
|
||||
}
|
||||
|
||||
impl UnitKey {
|
||||
/// An ordinary (non-forensic) unit key: `variant_number == 0`. The value
|
||||
/// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title.
|
||||
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
variant_number: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A forensic-variant key: `variant_number` in `1..=32`, decrypting the
|
||||
/// `IndividualSegment.tbl` segments tagged with that variant.
|
||||
pub const fn variant(idx: u32, key: [u8; 16], variant_number: u8) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
variant_number,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this key decrypts ordinary (non-forensic) content.
|
||||
pub const fn is_default_variant(&self) -> bool {
|
||||
self.variant_number == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! FMTS variant selection — the pure decode-time decision for a 2.1 disc.
|
||||
//!
|
||||
//! A 2.1 disc resolves to exactly one forensic variant (1..=32) for a given
|
||||
//! rip. `IndividualSegment.tbl` tags each forensic segment with a variant (see
|
||||
//! [`super::segment`]); the decode keeps the segments matching our variant,
|
||||
//! drops the other 31, and treats everything outside a segment as ordinary
|
||||
//! (variant-0) content. This module owns that classification and nothing else —
|
||||
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
|
||||
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
||||
//!
|
||||
//! Where the resolved variant comes from is a separate concern
|
||||
//! ([`resolve_disc_variant`]): today it is read off the variant keys the key
|
||||
//! source handed us; when Processing Keys are available it will come from the
|
||||
//! VK derivation instead. Either way the disposition logic below is identical.
|
||||
|
||||
use super::segment::{Segment, variant_segment_for_unit};
|
||||
use super::types::UnitKey;
|
||||
|
||||
/// What the decode should do with one AACS aligned unit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnitDisposition {
|
||||
/// Outside every forensic segment: ordinary content, decrypt with the
|
||||
/// default (variant-0) unit key.
|
||||
Default,
|
||||
/// Inside a forensic segment tagged with OUR resolved variant: decrypt with
|
||||
/// that variant's key.
|
||||
Variant(u8),
|
||||
/// Inside a forensic segment tagged with a DIFFERENT variant: not our
|
||||
/// watermark, so it is not part of our output — drop it.
|
||||
DropForeignVariant(u8),
|
||||
/// Inside a forensic segment but no variant key is held (the disc's variant
|
||||
/// was never resolved): the segment cannot be decoded, so it is concealed
|
||||
/// as loss. Carries the segment's variant for diagnostics.
|
||||
ForensicNoKey(u8),
|
||||
}
|
||||
|
||||
/// Resolve the disc's single forensic variant from the keys we hold.
|
||||
///
|
||||
/// Scans for a variant key (`variant_number` in `1..=32`) and returns its
|
||||
/// variant. `None` when only default (variant-0) keys are held — i.e. no
|
||||
/// variant source answered, so forensic segments are not decodable. A disc has
|
||||
/// exactly one variant, so the first non-zero key decides; if several distinct
|
||||
/// variant keys were somehow supplied the lowest wins (deterministic), which is
|
||||
/// only a defensive tiebreak — the probe/derivation yields one.
|
||||
pub fn resolve_disc_variant(unit_keys: &[UnitKey]) -> Option<u8> {
|
||||
unit_keys
|
||||
.iter()
|
||||
.map(|k| k.variant_number)
|
||||
.filter(|&v| v != 0)
|
||||
.min()
|
||||
}
|
||||
|
||||
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
|
||||
/// the forensic segment map and the disc's resolved variant (`None` if no
|
||||
/// variant key is held).
|
||||
pub fn unit_disposition(
|
||||
unit_offset: u64,
|
||||
segments: &[Segment],
|
||||
disc_variant: Option<u8>,
|
||||
) -> UnitDisposition {
|
||||
match variant_segment_for_unit(segments, unit_offset) {
|
||||
// Not in any forensic segment → ordinary content.
|
||||
None => UnitDisposition::Default,
|
||||
// In a forensic segment → decide by whether it is our variant.
|
||||
Some(seg) => {
|
||||
let seg_variant = seg.variant as u8;
|
||||
match disc_variant {
|
||||
Some(v) if v == seg_variant => UnitDisposition::Variant(v),
|
||||
Some(_) => UnitDisposition::DropForeignVariant(seg_variant),
|
||||
None => UnitDisposition::ForensicNoKey(seg_variant),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
||||
|
||||
/// Build a one-record segment table (variant, start_spn, end_spn).
|
||||
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
v.extend_from_slice(&(recs.len() as u16).to_be_bytes());
|
||||
v.extend_from_slice(&16u16.to_be_bytes());
|
||||
for &(n, s, e) in recs {
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
v.extend_from_slice(&n.to_be_bytes());
|
||||
v.extend_from_slice(&1u16.to_be_bytes());
|
||||
v.extend_from_slice(&s.to_be_bytes());
|
||||
v.extend_from_slice(&e.to_be_bytes());
|
||||
}
|
||||
parse_individual_segments(&v).expect("parse")
|
||||
}
|
||||
|
||||
fn uk(idx: u32, variant: u8) -> UnitKey {
|
||||
if variant == 0 {
|
||||
UnitKey::new(idx, [0u8; 16])
|
||||
} else {
|
||||
UnitKey::variant(idx, [variant; 16], variant)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_picks_the_single_variant_key() {
|
||||
// Default keys only → no variant resolved.
|
||||
assert_eq!(resolve_disc_variant(&[uk(0, 0)]), None);
|
||||
assert_eq!(resolve_disc_variant(&[]), None);
|
||||
// One variant key among defaults → that variant.
|
||||
assert_eq!(resolve_disc_variant(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||
// Defensive: lowest of several distinct variants (deterministic).
|
||||
assert_eq!(resolve_disc_variant(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_outside_segments_is_default() {
|
||||
let segs = tbl(&[(1, 343680, 346239)]);
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN; // well before the segment
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(1)),
|
||||
UnitDisposition::Default
|
||||
);
|
||||
// With no segments at all (1.0 / 2.0), everything is Default.
|
||||
assert_eq!(
|
||||
unit_disposition(off, &[], Some(1)),
|
||||
UnitDisposition::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_our_variant_decrypts() {
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(7)),
|
||||
UnitDisposition::Variant(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_foreign_variant_drops() {
|
||||
// Segment tagged variant 7, but our disc variant is 3 → drop it.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(3)),
|
||||
UnitDisposition::DropForeignVariant(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forensic_unit_with_no_key_is_concealed() {
|
||||
// A forensic segment but we never resolved a variant → conceal as loss.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, None),
|
||||
UnitDisposition::ForensicNoKey(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straddling_unit_still_classified_as_its_segment() {
|
||||
// A unit whose 32-packet span only tails into the segment still routes
|
||||
// to the segment (matches variant_segment_for_unit's span test).
|
||||
let segs = tbl(&[(5, 100, 200)]);
|
||||
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
||||
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(5)),
|
||||
UnitDisposition::Variant(5)
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -236,7 +236,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
}
|
||||
|
||||
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
|
||||
/// language, codec sub-fields). Layout per BD spec / libbluray
|
||||
/// language, codec sub-fields). Layout per the BD CLPI spec
|
||||
/// clpi_parse.c:
|
||||
///
|
||||
/// ```text
|
||||
@@ -394,7 +394,7 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// Stream PID entry — bit-packed per BD spec (libbluray clpi_parse.c):
|
||||
// Stream PID entry — bit-packed per the BD CLPI spec:
|
||||
// stream_PID: 16 bits → ep_map[2..4]
|
||||
// reserved: 10 bits ┐
|
||||
// EP_stream_type: 4 bits │ ep_map[4..14] = 80 bits
|
||||
@@ -821,7 +821,7 @@ mod tests {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Added hardening tests. Grounded in the BD-ROM CLPI spec
|
||||
// (https://github.com/lw/BluRay/wiki/CLPI) and libbluray clpi_parse.c.
|
||||
// (https://github.com/lw/BluRay/wiki/CLPI).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a ProgramInfo section. `streams` = Vec<(pid, sci_bytes)>.
|
||||
|
||||
@@ -66,6 +66,10 @@ pub mod coding_type {
|
||||
pub const MPEG2_VIDEO: u8 = 0x02;
|
||||
/// H.264 / AVC video (ISO/IEC 13818-1 Table 2-34).
|
||||
pub const H264: u8 = 0x1B;
|
||||
/// H.264 / MVC dependent view (Blu-ray 3D right-eye substream). Carried in
|
||||
/// the SSIF interleaved stream under its own PID; the base view is [`H264`].
|
||||
/// ISO/IEC 13818-1 stream_type 0x20 (MVC video sub-bitstream).
|
||||
pub const H264_MVC: u8 = 0x20;
|
||||
/// HEVC / H.265 video (ISO/IEC 13818-1 Table 2-34, 2015 amendment).
|
||||
pub const HEVC: u8 = 0x24;
|
||||
/// SMPTE VC-1 video (BD-ROM convention, ISO user-private range).
|
||||
|
||||
+120
-133
@@ -1,57 +1,46 @@
|
||||
//! CSS cipher implementation based on the Stevenson 1999 analysis.
|
||||
//! CSS content cipher — an independent implementation of the publicly
|
||||
//! documented Content Scramble System stream cipher.
|
||||
//!
|
||||
//! The CSS cipher uses two table-driven feedback circuits:
|
||||
//! - LFSR1: 17-bit state (9-bit lo + 8-bit hi register, seeded from
|
||||
//! key[0..2]), driven by TAB2/TAB3
|
||||
//! - LFSR0: 24-bit feedback register (seeded from key[2..5] XOR seed[2..5],
|
||||
//! masked to 0xFFFFFF), driven by a feedback polynomial through TAB4
|
||||
//! The algorithm is the one recovered and published in Frank A. Stevenson's
|
||||
//! 1999 cryptanalysis ("Cryptanalysis of Contents Scrambling System") and
|
||||
//! described in the open CSS literature. It is implemented here from that public
|
||||
//! description; its constants (see [`super::tables`]) are the cipher's own
|
||||
//! defined values. Nothing in this file is copied or translated from any
|
||||
//! particular CSS software.
|
||||
//!
|
||||
//! The keystream is the bytewise sum (with carry) of both LFSR outputs.
|
||||
//! Content descrambling computes plain = TAB1[cipher] ^ keystream — a TAB1
|
||||
//! substitution of each ciphertext byte followed by an XOR with the keystream
|
||||
//! (NOT a plain XOR; the cipher is not its own inverse).
|
||||
//! The cipher uses two table-driven linear-feedback circuits:
|
||||
//! - **LFSR1** — a 17-bit register (a 9-bit and an 8-bit half seeded from
|
||||
//! `key[0..2] XOR seed[0..2]`), stepped through `TAB2`/`TAB3`/`TAB5`.
|
||||
//! - **LFSR0** — a 24-bit feedback register (seeded from `key[2..5] XOR
|
||||
//! seed[2..5]`), stepped through a feedback polynomial and `TAB4`.
|
||||
//!
|
||||
//! Algorithm: Frank A. Stevenson's divide-and-conquer attack (1999).
|
||||
//! Tables: CSS specification constants.
|
||||
//! Each output byte is the sum-with-carry of the two register outputs. A body
|
||||
//! byte is recovered as `plain = TAB1[cipher] ^ keystream` — a `TAB1`
|
||||
//! substitution of the ciphertext byte followed by an XOR with the keystream
|
||||
//! (so the cipher is deliberately not its own inverse).
|
||||
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
/// Descramble a CSS-encrypted DVD sector in place.
|
||||
///
|
||||
/// Exact port of libdvdcss `dvdcss_unscramble` (css.c). The two content
|
||||
/// LFSRs are seeded **directly** from `title_key XOR sector_seed` — there is
|
||||
/// no `decrypt_key` mangling on this path (that is the disc/title-key
|
||||
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
|
||||
/// `*p = TAB1[*p] ^ (i_t5 & 0xff)`.
|
||||
/// The two feedback registers are seeded **directly** from
|
||||
/// `title_key XOR sector_seed` (bytes `0x54..0x59`) — there is no title-key
|
||||
/// mangling on the content path (that belongs to the disc/title-key hierarchy,
|
||||
/// not the sector cipher). Only the body, bytes `0x80..0x800`, is transformed:
|
||||
/// `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`.
|
||||
///
|
||||
/// The scramble flag at byte 0x14 (bits 4-5) indicates encryption. This
|
||||
/// descrambler CLEARS that flag after unscrambling, so a descrambled sector
|
||||
/// reads as `sector[0x14] & 0x30 == 0`; callers and the tests use that to tell
|
||||
/// it from ciphertext, and re-running descramble on an already-cleared sector
|
||||
/// is a no-op (the flag guard below skips it). Clearing does not affect the
|
||||
/// recovered body.
|
||||
/// The scramble flag at byte `0x14` (bits 4-5) marks an encrypted sector. This
|
||||
/// routine CLEARS that flag after unscrambling, so a descrambled sector reads as
|
||||
/// `sector[0x14] & 0x30 == 0`; callers and tests use that to tell it from
|
||||
/// ciphertext, and re-running descramble on an already-cleared sector is a no-op
|
||||
/// (the flag guard below skips it). Clearing does not affect the recovered body.
|
||||
///
|
||||
/// No-op (returns without modifying `sector`) in two cases:
|
||||
/// - `sector.len() < 2048`: the encrypted region (0x80..0x800) is not
|
||||
/// fully present. Callers chunk by 2048, so a trailing partial chunk is
|
||||
/// left untouched. The `debug_assert!` flags this misuse in debug/test
|
||||
/// builds; a DVD sector is always exactly 2048 bytes.
|
||||
/// - `sector.len() < 2048`: the encrypted region (`0x80..0x800`) is not fully
|
||||
/// present. Callers chunk by 2048, so a trailing partial chunk is left
|
||||
/// untouched. The `debug_assert!` flags this misuse in debug/test builds; a
|
||||
/// DVD sector is always exactly 2048 bytes.
|
||||
/// - scramble flags are zero: the sector is not CSS-encrypted.
|
||||
///
|
||||
/// Design reference: libdvdcss `dvdcss_unscramble`. The combiner mirrors
|
||||
/// `css.c` line-for-line:
|
||||
/// ```text
|
||||
/// i_t1 = (key[0] ^ sec[0x54]) | 0x100;
|
||||
/// i_t2 = key[1] ^ sec[0x55];
|
||||
/// i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ (sec[0x56]|sec[0x57]<<8|sec[0x58]<<16);
|
||||
/// i_t4 = i_t3 & 7; i_t3 = i_t3*2 + 8 - i_t4;
|
||||
/// // per byte over 0x80..0x800:
|
||||
/// i_t4 = TAB2[i_t2] ^ TAB3[i_t1];
|
||||
/// i_t2 = i_t1 >> 1; i_t1 = ((i_t1 & 1) << 8) ^ i_t4; i_t4 = TAB5[i_t4];
|
||||
/// i_t6 = (((((((i_t3>>3)^i_t3)>>1)^i_t3)>>8)^i_t3)>>5) & 0xff;
|
||||
/// i_t3 = (i_t3 << 8) | i_t6; i_t6 = TAB4[i_t6];
|
||||
/// i_t5 += i_t6 + i_t4; *p = TAB1[*p] ^ (i_t5 & 0xff); i_t5 >>= 8;
|
||||
/// ```
|
||||
pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
debug_assert!(
|
||||
sector.len() >= 2048,
|
||||
@@ -61,102 +50,103 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// libdvdcss: `if( !(p_sec[0x14] & 0x30) ) return;`
|
||||
// Not scrambled (flag bits 4-5 clear) → nothing to do.
|
||||
if sector[0x14] & 0x30 == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// LFSR1: seeded directly from (key ^ seed) — NO decrypt_key.
|
||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
// LFSR1 halves, seeded from (key ^ seed) bytes 0-1. The 9-bit half carries a
|
||||
// set bit 8 (`| 0x100`) as its running marker.
|
||||
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
|
||||
// LFSR0 (i_t3): 24-bit feedback register seeded from the remaining three
|
||||
// key/seed bytes, then transformed `i_t3 = i_t3*2 + 8 - (i_t3 & 7)`.
|
||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
||||
// LFSR0 (24-bit), seeded from the remaining three key/seed bytes, then
|
||||
// pre-conditioned `r0 = r0*2 + 8 - (r0 & 7)`.
|
||||
let mut r0: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
let i_t4_seed = i_t3 & 7;
|
||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
||||
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||
|
||||
let mut i_t5: u32 = 0;
|
||||
// Keystream accumulator; the low byte is the current keystream byte and the
|
||||
// high bits carry into the next iteration.
|
||||
let mut acc: u32 = 0;
|
||||
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
// Advance LFSR1.
|
||||
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
// Step LFSR1: its output byte `o1`.
|
||||
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||
r1b = r1a >> 1;
|
||||
r1a = ((r1a & 1) << 8) ^ o1;
|
||||
o1 = TAB5[o1 as usize] as u32;
|
||||
|
||||
// Advance LFSR0 (i_t3) and fold both outputs into i_t5.
|
||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
// Step LFSR0: its output byte `o0`.
|
||||
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||
r0 = (r0 << 8) | o0;
|
||||
o0 = TAB4[o0 as usize] as u32;
|
||||
|
||||
*byte = TAB1[*byte as usize] ^ (i_t5 & 0xFF) as u8;
|
||||
i_t5 >>= 8;
|
||||
// Combine (sum with carry) and recover the plaintext byte.
|
||||
acc += o0 + o1;
|
||||
*byte = TAB1[*byte as usize] ^ (acc & 0xFF) as u8;
|
||||
acc >>= 8;
|
||||
}
|
||||
|
||||
// libdvdcss leaves byte 0x14 untouched; freemkv clears the scramble bits
|
||||
// so downstream code and tests can tell a sector was descrambled.
|
||||
// Clear the scramble bits so downstream code and tests can tell a sector was
|
||||
// descrambled; bits 6-7 of byte 0x14 are preserved.
|
||||
sector[0x14] &= 0xCF;
|
||||
}
|
||||
|
||||
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
|
||||
/// CSS ciphertext under `title_key`.
|
||||
///
|
||||
/// Descramble computes `plain = TAB1[cipher] ^ (i_t5 & 0xff)`, so the
|
||||
/// inverse is `cipher = TAB1_INV[plain ^ (i_t5 & 0xff)]` with the identical
|
||||
/// LFSR keystream. The keystream derivation is byte-for-byte the same as
|
||||
/// `descramble_sector` (libdvdcss `dvdcss_unscramble`); only the final
|
||||
/// substitution differs. Bytes 0x80..0x800 are rewritten in place; the
|
||||
/// scramble flag is set to 0x10 so a subsequent descramble runs.
|
||||
/// Descramble computes `plain = TAB1[cipher] ^ (keystream & 0xff)`, so the
|
||||
/// inverse is `cipher = TAB1_INV[plain ^ (keystream & 0xff)]` with the identical
|
||||
/// keystream. The keystream derivation is the same as [`descramble_sector`];
|
||||
/// only the final substitution differs. Bytes `0x80..0x800` are rewritten in
|
||||
/// place; the scramble flag is set to `0x10` so a subsequent descramble runs.
|
||||
///
|
||||
/// Not on any production read path — it exists so the key-recovery tests
|
||||
/// (and any caller that needs to produce a known CSS-encrypted sector) can
|
||||
/// build genuine ciphertext rather than approximating it.
|
||||
/// Not on any production read path — it exists so the key-recovery tests (and
|
||||
/// any caller that needs a known CSS-encrypted sector) can build genuine
|
||||
/// ciphertext rather than approximating it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
if sector.len() < 2048 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
let mut i_t3: u32 = (((title_key[2] as u32)
|
||||
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
let mut r0: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
let i_t4_seed = i_t3 & 7;
|
||||
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
|
||||
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||
|
||||
let mut i_t5: u32 = 0;
|
||||
let mut acc: u32 = 0;
|
||||
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||
r1b = r1a >> 1;
|
||||
r1a = ((r1a & 1) << 8) ^ o1;
|
||||
o1 = TAB5[o1 as usize] as u32;
|
||||
|
||||
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||
r0 = (r0 << 8) | o0;
|
||||
o0 = TAB4[o0 as usize] as u32;
|
||||
acc += o0 + o1;
|
||||
|
||||
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
|
||||
*byte = (*TAB1_INV)[(*byte ^ (i_t5 & 0xFF) as u8) as usize];
|
||||
i_t5 >>= 8;
|
||||
*byte = (*TAB1_INV)[(*byte ^ (acc & 0xFF) as u8) as usize];
|
||||
acc >>= 8;
|
||||
}
|
||||
|
||||
// Mark the sector scrambled so the descrambler will process it.
|
||||
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
|
||||
}
|
||||
|
||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a
|
||||
/// bijection on 0..256, so `TAB1_INV[TAB1[x]] == x`.
|
||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a bijection on
|
||||
/// `0..256`, so `TAB1_INV[TAB1[x]] == x`.
|
||||
#[cfg(test)]
|
||||
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
|
||||
let mut inv = [0u8; 256];
|
||||
@@ -180,14 +170,15 @@ mod tests {
|
||||
assert_eq!(sector, original);
|
||||
}
|
||||
|
||||
/// Cross-check `descramble_sector` against the EXACT output of libdvdcss
|
||||
/// `dvdcss_unscramble` (css.c) for a fixed sector, computed from the
|
||||
/// reference C semantics with the reference tables. Pins the content
|
||||
/// cipher to libdvdcss byte-for-byte.
|
||||
/// Regression vector: the deterministic output of the CSS content cipher for
|
||||
/// a fixed key/seed/body. The value is generated by this implementation and
|
||||
/// is self-consistent with the scramble/descramble round-trip below — any
|
||||
/// correct CSS descrambler yields the same bytes, since the cipher is
|
||||
/// deterministic. Pins the implementation against accidental change.
|
||||
///
|
||||
/// key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA.
|
||||
#[test]
|
||||
fn descramble_matches_libdvdcss_unscramble_vector() {
|
||||
fn descramble_produces_the_reference_css_vector() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
@@ -199,12 +190,12 @@ mod tests {
|
||||
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
|
||||
0xA0, 0x31
|
||||
],
|
||||
"descramble body head must match libdvdcss dvdcss_unscramble"
|
||||
"descramble body head must match the reference CSS vector"
|
||||
);
|
||||
assert_eq!(
|
||||
§or[0x7F8..0x800],
|
||||
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
|
||||
"descramble body tail must match libdvdcss dvdcss_unscramble"
|
||||
"descramble body tail must match the reference CSS vector"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,8 +231,8 @@ mod tests {
|
||||
|
||||
/// Test 2: descramble inverts scramble over the body.
|
||||
///
|
||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to
|
||||
/// the ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to the
|
||||
/// ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
|
||||
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
|
||||
/// with the same key must reproduce the original body exactly.
|
||||
#[test]
|
||||
@@ -278,9 +269,9 @@ mod tests {
|
||||
|
||||
/// css_tab1_relationship
|
||||
///
|
||||
/// Verify the structure of TAB1: it is a substitution table used in
|
||||
/// key mangling. Check that no two inputs map to the same output
|
||||
/// (TAB1 is a permutation of 0..255).
|
||||
/// Verify the structure of TAB1: it is a substitution table used in key
|
||||
/// mangling. Check that no two inputs map to the same output (TAB1 is a
|
||||
/// permutation of 0..255).
|
||||
#[test]
|
||||
fn css_tab1_is_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
@@ -331,8 +322,8 @@ mod tests {
|
||||
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
|
||||
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
|
||||
///
|
||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live
|
||||
/// in bits 4-5; the masked value 0 means not scrambled.
|
||||
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live in
|
||||
/// bits 4-5; the masked value 0 means not scrambled.
|
||||
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
|
||||
/// seen as scrambled and the body would change.
|
||||
#[test]
|
||||
@@ -351,11 +342,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each individual scramble bit (4 and 5) independently marks the sector
|
||||
/// as encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||
/// Each individual scramble bit (4 and 5) independently marks the sector as
|
||||
/// encrypted: 0x10 and 0x20 must both trigger descrambling.
|
||||
///
|
||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both
|
||||
/// nonzero.
|
||||
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both nonzero.
|
||||
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
|
||||
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
|
||||
#[test]
|
||||
@@ -380,8 +370,8 @@ mod tests {
|
||||
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
|
||||
///
|
||||
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
|
||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved
|
||||
/// high bits assert fails.
|
||||
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved high
|
||||
/// bits assert fails.
|
||||
#[test]
|
||||
fn descramble_clear_preserves_high_bits_of_0x14() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
@@ -397,11 +387,11 @@ mod tests {
|
||||
|
||||
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
|
||||
|
||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80
|
||||
/// (the header) must NOT be modified by the keystream — except byte 0x14
|
||||
/// whose flag is cleared. In particular the sector-seed bytes 0x54..0x59
|
||||
/// (which live inside the header) must survive untouched, since the
|
||||
/// descrambler reads them but never writes them.
|
||||
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80 (the
|
||||
/// header) must NOT be modified by the keystream — except byte 0x14 whose
|
||||
/// flag is cleared. In particular the sector-seed bytes 0x54..0x59 (which
|
||||
/// live inside the header) must survive untouched, since the descrambler
|
||||
/// reads them but never writes them.
|
||||
///
|
||||
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
|
||||
/// 128..2048 only.
|
||||
@@ -428,16 +418,15 @@ mod tests {
|
||||
assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive");
|
||||
}
|
||||
|
||||
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a
|
||||
/// prefix. With a constant body and constant key, the keystream is
|
||||
/// non-degenerate enough that the very last sector byte (index 2047) is
|
||||
/// altered. This guards the loop bound `.take(2048)` against an
|
||||
/// off-by-one that would leave the final byte(s) scrambled.
|
||||
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a prefix.
|
||||
/// With a constant body and constant key, the keystream is non-degenerate
|
||||
/// enough that the very last sector byte (index 2047) is altered. This guards
|
||||
/// the loop bound `.take(2048)` against an off-by-one that would leave the
|
||||
/// final byte(s) scrambled.
|
||||
///
|
||||
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
|
||||
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
|
||||
/// assert fires (keystream byte for the last position is verified nonzero
|
||||
/// below by the round-trip, and this body is all-zero so any XOR shows).
|
||||
/// assert fires (this body is all-zero so any keystream XOR shows).
|
||||
#[test]
|
||||
fn descramble_covers_final_body_byte() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
@@ -446,9 +435,7 @@ mod tests {
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
|
||||
// the keystream reaches the final byte. (If the last keystream byte
|
||||
// happened to be 0 this could be a flaky test, so assert the run-end
|
||||
// region as a whole differs from zero.)
|
||||
// the keystream reaches the final byte.
|
||||
assert_ne!(
|
||||
§or[2040..2048],
|
||||
&[0u8; 8][..],
|
||||
@@ -457,13 +444,13 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Descramble is keyed by `title_key XOR seed`: two different title keys
|
||||
/// produce two different bodies for the same scrambled input. A cipher
|
||||
/// that ignored the title key (or mixed it in wrongly) would yield
|
||||
/// identical output — silent wrong-key decryption.
|
||||
/// produce two different bodies for the same scrambled input. A cipher that
|
||||
/// ignored the title key (or mixed it in wrongly) would yield identical
|
||||
/// output — silent wrong-key decryption.
|
||||
///
|
||||
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
|
||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both
|
||||
/// keys give the same body, assert fires.
|
||||
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both keys
|
||||
/// give the same body, assert fires.
|
||||
#[test]
|
||||
fn descramble_output_depends_on_title_key() {
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
|
||||
+2
-2
@@ -46,7 +46,7 @@ pub struct CssState {
|
||||
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
|
||||
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
|
||||
///
|
||||
/// The crib comes from `AttackPattern`: a scrambled sector's cleartext region
|
||||
/// The crib comes from the periodic-run detector: a scrambled sector's cleartext region
|
||||
/// (bytes 0x00..0x80) often ends in a short-period repeating run (stuffing /
|
||||
/// constant fill); the attack assumes that run continues across the 0x80
|
||||
/// boundary into the encrypted region, giving the known plaintext the 2^16
|
||||
@@ -274,7 +274,7 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
|
||||
/// Descramble a whole CSS buffer in place, re-cracking the title key on a VOB
|
||||
/// region boundary. `title_key` is a CACHE of the last crack, not a fixed disc
|
||||
/// key: it changes per VTS/VOB region, so it is validated on every scrambled
|
||||
/// sector and re-cracked on a miss (libdvdcss's on-demand per-region rekey).
|
||||
/// sector and re-cracked on a miss (the standard on-demand per-region rekey).
|
||||
///
|
||||
/// This CSS key acquisition is intrinsic to the cipher — CSS has no external key
|
||||
/// source, the ONLY way to a title key is cracking the data — so it lives with
|
||||
|
||||
+42
-58
@@ -1,48 +1,37 @@
|
||||
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
|
||||
//! (1999), ported exactly from libdvdcss `RecoverTitleKey` + `AttackPattern`
|
||||
//! (css.c).
|
||||
//!
|
||||
//! Recovers the 5-byte CSS title key from a single scrambled DVD sector with
|
||||
//! no player keys and no disc-key crack, using only known plaintext.
|
||||
//! (1999), implemented from his published cryptanalysis ("Cryptanalysis of
|
||||
//! Contents Scrambling System"). It recovers the 5-byte CSS title key from a
|
||||
//! single scrambled DVD sector with no player keys and no disc-key crack, using
|
||||
//! only known plaintext. Implemented from that public description; nothing here
|
||||
//! is copied or translated from any particular CSS software.
|
||||
//!
|
||||
//! # The cipher this attacks
|
||||
//!
|
||||
//! The content descrambler ([`super::lfsr::descramble_sector`], = libdvdcss
|
||||
//! `dvdcss_unscramble`) seeds its two LFSRs **directly** from
|
||||
//! `key = title_key XOR sector_seed` (seed = `sector[0x54..0x59]`):
|
||||
//!
|
||||
//! ```text
|
||||
//! i_t1 = (key[0] ^ sec[0x54]) | 0x100; // LFSR1 low (9-bit)
|
||||
//! i_t2 = key[1] ^ sec[0x55]; // LFSR1 high
|
||||
//! i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ seed3; // LFSR0 (24-bit feedback)
|
||||
//! i_t3 = i_t3*2 + 8 - (i_t3 & 7);
|
||||
//! // per byte: *p = TAB1[*p] ^ (i_t5 & 0xff)
|
||||
//! ```
|
||||
//!
|
||||
//! There is NO `decrypt_key` mangling on the content path. So the recovery
|
||||
//! is a single inversion of `dvdcss_unscramble`, not the multi-stage
|
||||
//! working-key inversion the previous (non-CSS) implementation used.
|
||||
//! The content descrambler ([`super::lfsr::descramble_sector`]) seeds its two
|
||||
//! LFSRs **directly** from `key = title_key XOR sector_seed` (seed =
|
||||
//! `sector[0x54..0x59]`): LFSR1 from key/seed bytes 0-1, LFSR0 (24-bit) from
|
||||
//! bytes 2-4 with the pre-conditioning `r0 = r0*2 + 8 - (r0 & 7)`, and each body
|
||||
//! byte recovered as `plain = TAB1[cipher] ^ (keystream & 0xff)`. There is no
|
||||
//! title-key mangling on the content path, so the recovery is a single inversion
|
||||
//! of the sector cipher.
|
||||
//!
|
||||
//! # The attack
|
||||
//!
|
||||
//! 1. **Known plaintext → keystream.** Because the descramble applies TAB1
|
||||
//! to the ciphertext, the per-byte keystream is
|
||||
//! `buf[i] = TAB1[cipher[i]] ^ plain[i]` (matching libdvdcss
|
||||
//! `RecoverTitleKey`'s `p_buffer`).
|
||||
//! 1. **Known plaintext → keystream.** Because descramble applies TAB1 to the
|
||||
//! ciphertext, the per-byte keystream is `TAB1[cipher[i]] ^ plain[i]`.
|
||||
//! 2. **Brute the 16-bit LFSR1 seed.** For each of 2^16 seeds, run LFSR1
|
||||
//! forward; for the first four steps deduce the LFSR0 output bytes from
|
||||
//! the keystream (carry-tracked), reconstructing `i_t3`. For the next six
|
||||
//! steps clock LFSR0 normally and check it reproduces the keystream — a
|
||||
//! wrong LFSR1 seed fails fast.
|
||||
//! 3. **Back-clock LFSR0.** Run four backward `i_t3` steps (each a 256-way
|
||||
//! search for the byte shifted in) to reach the initial state, then undo
|
||||
//! `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
||||
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]` (plain XOR —
|
||||
//! the descramble seeds directly, so there is no inversion).
|
||||
//! forward; for the first four steps deduce the LFSR0 output bytes from the
|
||||
//! keystream (carry-tracked), reconstructing LFSR0's state. For the next six
|
||||
//! steps clock LFSR0 normally and check it reproduces the keystream — a wrong
|
||||
//! LFSR1 seed fails fast.
|
||||
//! 3. **Back-clock LFSR0.** Run four backward steps (each a 256-way search for
|
||||
//! the byte shifted in) to reach the initial state, then undo the
|
||||
//! `r0*2 + 8 - (r0 & 7)` pre-conditioning to recover key[2..5].
|
||||
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]`.
|
||||
//!
|
||||
//! `AttackPattern` finds known plaintext for step 1: the longest periodic
|
||||
//! run in the cleartext `sec[0x00..0x80]`, assumed to continue into the
|
||||
//! encrypted region at 0x80.
|
||||
//! Known plaintext for step 1 comes from the longest periodic run in the
|
||||
//! cleartext `sec[0x00..0x80]`, assumed to continue into the encrypted region at
|
||||
//! 0x80.
|
||||
|
||||
use super::lfsr::descramble_sector;
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
@@ -52,13 +41,11 @@ const ENCRYPTED_START: usize = 0x80; // byte 128
|
||||
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
||||
const FLAG_BYTE: usize = 0x14;
|
||||
|
||||
/// RecoverTitleKey: recover the title key from cipher + known plaintext.
|
||||
///
|
||||
/// Exact port of libdvdcss `RecoverTitleKey` (css.c). `crypted` is the
|
||||
/// ciphertext starting at sector byte 0x80; `decrypted` is the matching
|
||||
/// known plaintext; `seed` is `sector[0x54..0x59]`. On success returns the
|
||||
/// recovered 5-byte title key; `None` if no LFSR seed reproduces the
|
||||
/// keystream.
|
||||
/// Recover the title key from cipher + known plaintext (the core of Stevenson's
|
||||
/// attack). `crypted` is the ciphertext starting at sector byte 0x80;
|
||||
/// `decrypted` is the matching known plaintext; `seed` is `sector[0x54..0x59]`.
|
||||
/// On success returns the recovered 5-byte title key; `None` if no LFSR seed
|
||||
/// reproduces the keystream.
|
||||
///
|
||||
/// At least 10 bytes of `crypted`/`decrypted` are required (the cipher is
|
||||
/// iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).
|
||||
@@ -222,16 +209,13 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
|
||||
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
|
||||
}
|
||||
|
||||
/// AttackPattern: find a repeating pattern just before the encrypted region
|
||||
/// and assume the plaintext at 0x80 continues it.
|
||||
///
|
||||
/// Functionally-equivalent port of libdvdcss `AttackPattern` (css.c) — finds the
|
||||
/// same periodic cribs on real DVD data, though its byte-comparison anchor
|
||||
/// differs from the C on phase-misaligned runs. Scans cleartext
|
||||
/// `sec[0x00..0x80]` for the longest run that repeats with a cycle length in
|
||||
/// 2..0x2F. If the run is long enough (`plen > 3` and at least two full
|
||||
/// cycles), the known plaintext at 0x80 is taken to be the periodic run
|
||||
/// continuing forward, and [`recover_title_key_from_plain`] is applied.
|
||||
/// Find a repeating pattern just before the encrypted region and assume the
|
||||
/// plaintext at 0x80 continues it — the known-plaintext step of Stevenson's
|
||||
/// attack. Scans cleartext `sec[0x00..0x80]` for the longest run that repeats
|
||||
/// with a cycle length in 2..0x2F. If the run is long enough (`plen > 3` and at
|
||||
/// least two full cycles), the known plaintext at 0x80 is taken to be the
|
||||
/// periodic run continuing forward, and [`recover_title_key_from_plain`] is
|
||||
/// applied.
|
||||
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
if sector.len() < SECTOR_BYTES {
|
||||
return None;
|
||||
@@ -260,7 +244,7 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
result
|
||||
}
|
||||
|
||||
/// AttackPattern crib: the predicted 10-byte plaintext at byte 0x80.
|
||||
/// Crib: the predicted 10-byte plaintext at byte 0x80.
|
||||
///
|
||||
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
|
||||
/// run that repeats with a cycle length in 2..0x2F. If the run is long enough
|
||||
@@ -366,7 +350,7 @@ mod tests {
|
||||
|
||||
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
|
||||
/// in a periodic run that continues into the encrypted region — the case
|
||||
/// `AttackPattern` (crack_title_key) is designed to crack.
|
||||
/// `crack_title_key` is designed to crack.
|
||||
fn synth_periodic_sector(
|
||||
title_key: &[u8; 5],
|
||||
seed: &[u8; 5],
|
||||
@@ -379,7 +363,7 @@ mod tests {
|
||||
// (RUN_START..0x80) and continuing into the encrypted region. This
|
||||
// mirrors a real VOB: a periodic data run just before the scrambled
|
||||
// part. The run must NOT overlap the seed bytes (0x54..0x59), or the
|
||||
// AttackPattern detector would break mid-run. The phase is anchored to
|
||||
// the crib detector would break mid-run. The phase is anchored to
|
||||
// offset 0 so the run is consistent across the 0x80 boundary.
|
||||
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
|
||||
// — enough for >=2 cycles of every tested period (<=19).
|
||||
@@ -466,7 +450,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// MANDATORY (Task C.1): the AttackPattern entry point crack_title_key —
|
||||
/// MANDATORY (Task C.1): the crib-based entry point crack_title_key —
|
||||
/// no plaintext supplied — recovers a round-tripping key when the
|
||||
/// cleartext ends in a periodic run that continues into 0x80.
|
||||
#[test]
|
||||
@@ -488,7 +472,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// recover_title_key_from_plain inverts dvdcss_unscramble exactly: scramble
|
||||
/// recover_title_key_from_plain inverts descramble_sector exactly: scramble
|
||||
/// a known body, hand back the keystream-derived key, and the recovered
|
||||
/// key (XOR-back included) reproduces the plaintext.
|
||||
#[test]
|
||||
|
||||
+11
-12
@@ -24,9 +24,8 @@ pub const TAB1: [u8; 256] = [
|
||||
0xb7, 0xf7, 0xbf, 0xa2, 0xe7, 0xa7, 0xef, 0xf2, 0xba, 0xfa, 0xb2, 0xaf, 0xea, 0xaa, 0xe2, 0xff,
|
||||
];
|
||||
|
||||
/// Table 2: LFSR1 high-byte feedback permutation.
|
||||
///
|
||||
/// Byte-identical to libdvdcss `p_css_tab2` (csstables.h).
|
||||
/// Table 2: LFSR1 high-byte feedback permutation — a fixed constant of the CSS
|
||||
/// cipher (per the published algorithm).
|
||||
pub const TAB2: [u8; 256] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09, 0x08, 0x0b, 0x0a, 0x0d, 0x0c, 0x0f, 0x0e,
|
||||
0x12, 0x13, 0x10, 0x11, 0x16, 0x17, 0x14, 0x15, 0x1b, 0x1a, 0x19, 0x18, 0x1f, 0x1e, 0x1d, 0x1c,
|
||||
@@ -46,12 +45,12 @@ pub const TAB2: [u8; 256] = [
|
||||
0xed, 0xec, 0xef, 0xee, 0xe9, 0xe8, 0xeb, 0xea, 0xe4, 0xe5, 0xe6, 0xe7, 0xe0, 0xe1, 0xe2, 0xe3,
|
||||
];
|
||||
|
||||
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries).
|
||||
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries) — a fixed constant
|
||||
/// of the CSS cipher (per the published algorithm).
|
||||
///
|
||||
/// Byte-identical to libdvdcss `p_css_tab3` (csstables.h): the 8-value
|
||||
/// block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step indexes this
|
||||
/// table with the 9-bit low register (0x100..=0x1FF), but only the low 3
|
||||
/// bits select the output — the high bits are ignored, hence the constant
|
||||
/// It is the 8-value block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step
|
||||
/// indexes this table with the 9-bit low register (0x100..=0x1FF), but only the
|
||||
/// low 3 bits select the output — the high bits are ignored, hence the constant
|
||||
/// blocks. The 512-entry width simply lets the 9-bit index be used without
|
||||
/// masking.
|
||||
pub const TAB3: [u8; 512] = [
|
||||
@@ -197,12 +196,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// TAB3 is the libdvdcss `p_css_tab3`: the 8-value feedback block
|
||||
/// TAB3 is the CSS LFSR1 low-word table: the 8-value feedback block
|
||||
/// BASE = [0x00,0x24,0x49,0x6d,0x92,0xb6,0xdb,0xff]
|
||||
/// repeated 64 times — `TAB3[i] == BASE[i & 7]`. The high bits of the
|
||||
/// 9-bit index do not affect the output (libdvdcss's LFSR1 step indexes
|
||||
/// with the full 9-bit low register but only `& 7` matters). This pins
|
||||
/// all 512 entries to the published table.
|
||||
/// 9-bit index do not affect the output (the LFSR1 step indexes with the
|
||||
/// full 9-bit low register but only `& 7` matters). This pins all 512
|
||||
/// entries to the published cipher's table.
|
||||
///
|
||||
/// Mutation: flip any single byte in the TAB3 literal -> the formula
|
||||
/// check fails at that index.
|
||||
|
||||
+59
-5
@@ -72,6 +72,11 @@ impl Disc {
|
||||
// Parse each clip for size, duration, and sector extents
|
||||
let mut extents = Vec::new();
|
||||
let mut total_size: u64 = 0;
|
||||
// Set when any clip resolves to a STREAM/SSIF/<clip>.ssif — a Blu-ray 3D
|
||||
// interleaved stream carrying both the base (left) and MVC dependent
|
||||
// (right) views. Drives reading the SSIF for both eyes and adding the
|
||||
// dependent-view stream below.
|
||||
let mut is_3d = false;
|
||||
let mut clips = Vec::with_capacity(parsed.play_items.len());
|
||||
// BD playlists legally reference the same .m2ts clip_id from
|
||||
// multiple PlayItems (multi-angle, seamless splits, looped
|
||||
@@ -110,10 +115,23 @@ impl Disc {
|
||||
// use `.ssif` (see [`CLIP_STREAM_EXTS`]). A normal `.m2ts`
|
||||
// clip is unchanged — the fallback only runs when `.m2ts`
|
||||
// is absent, which is exactly when `file_extents` errors.
|
||||
let file_exts = CLIP_STREAM_EXTS.iter().find_map(|ext| {
|
||||
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
|
||||
udf_fs.file_extents(reader, &path).ok()
|
||||
});
|
||||
// 3D discs interleave the left (base) and right (MVC
|
||||
// dependent) views in STREAM/SSIF/<clip>.ssif — note the
|
||||
// SSIF/ subdir. Prefer it when present: the SSIF is one
|
||||
// transport stream carrying BOTH eyes on distinct PIDs,
|
||||
// so muxing it captures the full 3D. 2D clips fall back to
|
||||
// the base .m2ts / .fmts as before.
|
||||
let ssif = format!("/BDMV/STREAM/SSIF/{}.ssif", play_item.clip_id);
|
||||
let file_exts = match udf_fs.file_extents(reader, &ssif) {
|
||||
Ok(exts) => {
|
||||
is_3d = true;
|
||||
Some(exts)
|
||||
}
|
||||
Err(_) => CLIP_STREAM_EXTS.iter().find_map(|ext| {
|
||||
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
|
||||
udf_fs.file_extents(reader, &path).ok()
|
||||
}),
|
||||
};
|
||||
if let Some(file_exts) = file_exts {
|
||||
for (lba, sectors) in file_exts {
|
||||
if sectors > 0 && lba > 0 {
|
||||
@@ -138,7 +156,7 @@ impl Disc {
|
||||
}
|
||||
|
||||
// Build streams from STN table
|
||||
let streams: Vec<Stream> = parsed
|
||||
let mut streams: Vec<Stream> = parsed
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
@@ -232,6 +250,42 @@ impl Disc {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3D: add the MVC dependent (right-eye) video stream. The base STN table
|
||||
// lists only the left-eye video; the dependent view is a second video
|
||||
// PID (stream_type 0x20) carried in the SSIF. The on-disc PAT/PMT are
|
||||
// AACS-encrypted (unreadable pre-key) and the base STN omits the
|
||||
// dependent view (it lives in the MPLS STN_table_SS), so we use the
|
||||
// BD-3D PID convention: dependent = base-view video PID + 1
|
||||
// (e.g. 0x1011 -> 0x1012). Reading the SSIF (above) provides its packets.
|
||||
//
|
||||
// Limitation: `is_3d` latches per PLAYLIST, not per clip. A playlist that
|
||||
// mixed a 3D clip (has an SSIF) with a 2D clip (no SSIF) would tag the
|
||||
// whole title 3D; the 2D clip's frames then mux as plain Blocks (no
|
||||
// dependent PID → no BlockAdditional) under a track that still advertises
|
||||
// the mvcC mapping. That output is valid (per-frame BlockAdditional is
|
||||
// optional) but over-claims 3D for those frames. Real 3D main-feature
|
||||
// playlists are single-clip or uniformly 3D, so this is not exercised;
|
||||
// per-clip 3D would need per-clip stream sets (a larger change).
|
||||
if is_3d {
|
||||
if let Some(base) = streams.iter().find_map(|s| match s {
|
||||
Stream::Video(v) => Some(v.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
let dep_pid = base.pid.wrapping_add(1);
|
||||
let have_dep = streams
|
||||
.iter()
|
||||
.any(|s| matches!(s, Stream::Video(v) if v.pid == dep_pid));
|
||||
if !have_dep {
|
||||
streams.push(Stream::Video(VideoStream {
|
||||
pid: dep_pid,
|
||||
secondary: true,
|
||||
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
|
||||
..base
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert marks to chapters. mark_type == 1 is an entry-mark
|
||||
// (chapter); type 2 is a link point and type 0 is reserved, so
|
||||
// neither is a chapter.
|
||||
|
||||
+444
-18
@@ -9,26 +9,28 @@
|
||||
//! rule: a genuinely different format is a new enumerator, not an extension
|
||||
//! bolted into the BD path.
|
||||
//!
|
||||
//! Title composition: the `HVA*.VTI` navigation file (the DVD-IFO analogue) names
|
||||
//! every clip in authored order. Standard Content splits the main feature across
|
||||
//! clips at the layer break (`FEATURE_1`/`FEATURE_2`, or `feature`/`feature_Divide`);
|
||||
//! the scanner parses the VTI clip table and concatenates those parts into ONE
|
||||
//! [`DiscTitle`] (so the largest-title pick is the whole movie, not just part 1),
|
||||
//! emitting every other clip as its own title. Container is
|
||||
//! [`ContentFormat::MpegPs`], so the existing PS mux path handles it. Per-clip
|
||||
//! streams ARE enumerated: the clip head is demuxed through the PS demuxer and one
|
||||
//! [`Stream`] is built per distinct elementary stream (video + DD+ audio
|
||||
//! sub-streams), with the codec sniffed from the ES bytes.
|
||||
//! Title composition — authoritative, from the Advanced-Content playlist. HD-DVD
|
||||
//! ships a real player playlist at `ADV_OBJ/VPLST000.XPL` (DVD-Forum
|
||||
//! `HDDVDVideo/Playlist` XML). The scanner parses it (with a real XML parser,
|
||||
//! `roxmltree`) into one [`DiscTitle`] per `<Title>`: its `<PrimaryAudioVideoClip>`
|
||||
//! clips in playback order (each an EVO, referenced via its `.MAP` sidecar), the
|
||||
//! `titleDuration`, the `displayName`, and the `<ChapterList>`. A layer-break
|
||||
//! split (`FEATURE_1` + `FEATURE_2`, or `feature`/`feature_Divide`) is composed
|
||||
//! into ONE title with the two parts as clips, carrying each clip's title-time
|
||||
//! in/out points (45 kHz ticks) so a seamless join can be spliced onto one
|
||||
//! timeline. Container is [`ContentFormat::MpegPs`], so the existing PS mux path
|
||||
//! handles it. Per-clip streams are enumerated by demuxing the clip head and
|
||||
//! building one [`Stream`] per distinct elementary stream (video + DD+ audio),
|
||||
//! codec sniffed from the ES bytes.
|
||||
//!
|
||||
//! What is NOT parsed yet — and is honestly stubbed, not faked:
|
||||
//! * full VTI program-chain parsing (chapters, non-feature title grouping) —
|
||||
//! the feature join uses the clip table + the `feature*` naming convention,
|
||||
//! not the authoritative PGC,
|
||||
//! * `.map` timemap → real durations,
|
||||
//! * subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`).
|
||||
//! When no playlist is present (or it fails to parse), the scanner falls back to
|
||||
//! the older clip-name heuristic: parse the `HVA*.VTI` clip table, join the
|
||||
//! `feature*`-named clips into one title, and emit every other clip on its own.
|
||||
//!
|
||||
//! Extents and size ARE real (the ripper needs those to image a clip); durations
|
||||
//! and chapters are left empty rather than guessed.
|
||||
//! Not parsed yet: subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`) and per-track
|
||||
//! audio languages (the XPL carries `<Audio description=...>` but they are not yet
|
||||
//! wired onto the streams). Extents and size are real (the ripper images the
|
||||
//! clips).
|
||||
|
||||
use super::*;
|
||||
use crate::mux::ps::{PsDemuxer, dvd_audio_pid};
|
||||
@@ -316,6 +318,229 @@ fn collect_es(
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────── Advanced-Content playlist (XPL) ──────────────────
|
||||
//
|
||||
// HD-DVD Advanced Content ships an authoritative playlist at
|
||||
// `ADV_OBJ/VPLST000.XPL` (DVD-Forum `HDDVDVideo/Playlist` XML). It is the real
|
||||
// player playlist: a `<TitleSet>` of `<Title>`s, each naming its
|
||||
// `<PrimaryAudioVideoClip>` clips (an EVO, via its `.MAP` sidecar) in playback
|
||||
// order with title-time in/out points, a `titleDuration`, a `displayName`, and a
|
||||
// `<ChapterList>`. Parsing it gives authoritative title composition — clips,
|
||||
// duration, name, chapters — instead of the `feature*` clip-name heuristic, plus
|
||||
// the per-clip title-time offsets needed to splice a layer-break split
|
||||
// (`FEATURE_1` + `FEATURE_2`) onto one continuous timeline. Parsed with a real
|
||||
// XML parser (`roxmltree`), not a hand-rolled scanner — the XPL is genuine XML.
|
||||
|
||||
/// One clip reference inside an XPL `<Title>`: the resolved `.evo` name (lower
|
||||
/// case) and the clip's placement on the title timeline, in seconds.
|
||||
struct XplClip {
|
||||
evo: String,
|
||||
begin_secs: f64,
|
||||
end_secs: f64,
|
||||
}
|
||||
|
||||
/// One `<Title>` from the XPL: number, display name, total duration, its clips
|
||||
/// in playback order, and chapter start times (seconds).
|
||||
struct XplTitle {
|
||||
number: u16,
|
||||
name: String,
|
||||
duration_secs: f64,
|
||||
clips: Vec<XplClip>,
|
||||
chapters: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Parse an `HH:MM:SS:FF` (or `MM:SS:FF`) timecode at `tick_base` frames/sec into
|
||||
/// seconds. `None` on a malformed field.
|
||||
fn parse_timecode(s: &str, tick_base: u32) -> Option<f64> {
|
||||
let n: Vec<u32> = s
|
||||
.split(':')
|
||||
.map(|p| p.trim().parse::<u32>())
|
||||
.collect::<std::result::Result<Vec<u32>, _>>()
|
||||
.ok()?;
|
||||
let tb = tick_base.max(1) as f64;
|
||||
let (h, m, sec, f) = match n.as_slice() {
|
||||
[h, m, s, f] => (*h, *m, *s, *f),
|
||||
[m, s, f] => (0, *m, *s, *f),
|
||||
_ => return None,
|
||||
};
|
||||
Some(h as f64 * 3600.0 + m as f64 * 60.0 + sec as f64 + f as f64 / tb)
|
||||
}
|
||||
|
||||
/// `tickBase="60fps"` → 60. Defaults to 60 when absent/unparseable.
|
||||
fn parse_tick_base(s: &str) -> u32 {
|
||||
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
digits.parse().unwrap_or(60)
|
||||
}
|
||||
|
||||
/// `<PrimaryAudioVideoClip src="file:///.../FEATURE_1.MAP">` → `feature_1.evo`:
|
||||
/// take the basename, drop the extension, normalise to a lower-case `.evo` name
|
||||
/// (the playlist references the `.MAP` sidecar; the A/V is the same-stem `.EVO`).
|
||||
fn evo_from_src(src: &str) -> Option<String> {
|
||||
let base = src.rsplit(['/', '\\']).next().unwrap_or(src);
|
||||
let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base);
|
||||
if stem.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{}.evo", stem.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// Parse the Advanced-Content playlist into its titles. Elements are matched by
|
||||
/// LOCAL name (the document is in the `HDDVDVideo/Playlist` default namespace).
|
||||
/// Returns empty for a non-XML / non-playlist blob so the caller falls back to
|
||||
/// the clip-name heuristic.
|
||||
fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> {
|
||||
let text = String::from_utf8_lossy(xpl);
|
||||
let Ok(doc) = roxmltree::Document::parse(&text) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let local = |n: &roxmltree::Node, name: &str| n.tag_name().name() == name;
|
||||
|
||||
// tickBase lives on <TitleSet> (default 60fps).
|
||||
let tick_base = doc
|
||||
.descendants()
|
||||
.find(|n| local(n, "TitleSet"))
|
||||
.and_then(|n| n.attribute("tickBase"))
|
||||
.map(parse_tick_base)
|
||||
.unwrap_or(60);
|
||||
|
||||
let mut titles = Vec::new();
|
||||
for tnode in doc.descendants().filter(|n| local(n, "Title")) {
|
||||
let number = tnode
|
||||
.attribute("titleNumber")
|
||||
.and_then(|s| s.trim().parse::<u16>().ok())
|
||||
.unwrap_or(0);
|
||||
let name = tnode
|
||||
.attribute("displayName")
|
||||
.or_else(|| tnode.attribute("id"))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let duration_secs = tnode
|
||||
.attribute("titleDuration")
|
||||
.and_then(|s| parse_timecode(s, tick_base))
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let mut clips = Vec::new();
|
||||
for c in tnode
|
||||
.descendants()
|
||||
.filter(|n| local(n, "PrimaryAudioVideoClip"))
|
||||
{
|
||||
let Some(evo) = c.attribute("src").and_then(evo_from_src) else {
|
||||
continue;
|
||||
};
|
||||
let begin_secs = c
|
||||
.attribute("titleTimeBegin")
|
||||
.and_then(|s| parse_timecode(s, tick_base))
|
||||
.unwrap_or(0.0);
|
||||
let end_secs = c
|
||||
.attribute("titleTimeEnd")
|
||||
.and_then(|s| parse_timecode(s, tick_base))
|
||||
.unwrap_or(begin_secs);
|
||||
clips.push(XplClip {
|
||||
evo,
|
||||
begin_secs,
|
||||
end_secs,
|
||||
});
|
||||
}
|
||||
if clips.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chapters = tnode
|
||||
.descendants()
|
||||
.filter(|n| local(n, "Chapter"))
|
||||
.filter_map(|ch| {
|
||||
ch.attribute("titleTimeBegin")
|
||||
.and_then(|s| parse_timecode(s, tick_base))
|
||||
})
|
||||
.collect();
|
||||
|
||||
titles.push(XplTitle {
|
||||
number,
|
||||
name,
|
||||
duration_secs,
|
||||
clips,
|
||||
chapters,
|
||||
});
|
||||
}
|
||||
titles
|
||||
}
|
||||
|
||||
/// Read the Advanced-Content playlist `ADV_OBJ/VPLST*.XPL`, if present.
|
||||
fn read_adv_obj_xpl(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Option<Vec<u8>> {
|
||||
let dir = udf_fs.find_dir("/ADV_OBJ")?;
|
||||
let name = dir.entries.iter().find_map(|e| {
|
||||
let lower = e.name.to_ascii_lowercase();
|
||||
(!e.is_dir && lower.starts_with("vplst") && lower.ends_with(".xpl")).then(|| e.name.clone())
|
||||
})?;
|
||||
udf_fs.read_file(reader, &format!("/ADV_OBJ/{name}")).ok()
|
||||
}
|
||||
|
||||
/// Compose [`DiscTitle`]s from parsed XPL titles: resolve each title's clips to
|
||||
/// physical extents, concatenate them in playback order, carry the title-time
|
||||
/// in/out points onto each [`Clip`] (45 kHz ticks — the offset that splices a
|
||||
/// layer-break split onto one timeline), and attach the duration, name, and
|
||||
/// chapters. A title whose clips resolve to no on-disc extents is skipped.
|
||||
fn compose_xpl_titles(
|
||||
reader: &mut dyn SectorSource,
|
||||
xpl_titles: &[XplTitle],
|
||||
clip_extents: &BTreeMap<String, (String, u64, Vec<Extent>)>,
|
||||
) -> Vec<DiscTitle> {
|
||||
let mut titles = Vec::new();
|
||||
for t in xpl_titles {
|
||||
let mut extents = Vec::new();
|
||||
let mut size_bytes = 0u64;
|
||||
let mut parts = Vec::new();
|
||||
for c in &t.clips {
|
||||
let Some((orig, size, exts)) = clip_extents.get(&c.evo) else {
|
||||
continue;
|
||||
};
|
||||
extents.extend_from_slice(exts);
|
||||
size_bytes += *size;
|
||||
parts.push(Clip {
|
||||
clip_id: orig
|
||||
.rsplit_once('.')
|
||||
.map(|(b, _)| b)
|
||||
.unwrap_or(orig)
|
||||
.to_string(),
|
||||
in_time: (c.begin_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
|
||||
out_time: (c.end_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
|
||||
duration_secs: (c.end_secs - c.begin_secs).max(0.0),
|
||||
source_packets: 0,
|
||||
});
|
||||
}
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let streams = probe_evo_streams(reader, &extents);
|
||||
let chapters = t
|
||||
.chapters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &ts)| Chapter {
|
||||
time_secs: ts.max(0.0),
|
||||
name: super::chapter_name(i),
|
||||
})
|
||||
.collect();
|
||||
titles.push(DiscTitle {
|
||||
playlist: if t.name.is_empty() {
|
||||
format!("Title {}", t.number)
|
||||
} else {
|
||||
t.name.clone()
|
||||
},
|
||||
playlist_id: t.number,
|
||||
duration_secs: t.duration_secs,
|
||||
size_bytes,
|
||||
clips: parts,
|
||||
streams,
|
||||
chapters,
|
||||
extents,
|
||||
content_format: ContentFormat::MpegPs,
|
||||
codec_privates: Vec::new(),
|
||||
});
|
||||
}
|
||||
titles
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips.
|
||||
///
|
||||
@@ -375,6 +600,18 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
// Authoritative composition from the Advanced-Content playlist
|
||||
// (`ADV_OBJ/VPLST*.XPL`): real per-title clip lists, durations, names,
|
||||
// chapters, and title-time offsets. This is the primary path; the
|
||||
// clip-name heuristic below is the fallback when the playlist is absent
|
||||
// or unparseable (or resolves to no on-disc clips).
|
||||
if let Some(xpl) = read_adv_obj_xpl(reader, udf_fs) {
|
||||
let composed = compose_xpl_titles(reader, &parse_xpl_titles(&xpl), &clip_extents);
|
||||
if !composed.is_empty() {
|
||||
return composed;
|
||||
}
|
||||
}
|
||||
|
||||
// Feature clips, in authored order, that actually resolved to extents.
|
||||
let feature: Vec<String> = order
|
||||
.iter()
|
||||
@@ -966,4 +1203,193 @@ mod tests {
|
||||
"VC-1 routes to the extended-stream-id PID 0xFD55"
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────── Advanced-Content playlist (XPL) ──────────────
|
||||
|
||||
/// A minimal but faithful VPLST000.XPL: the DVD-Forum default namespace, an
|
||||
/// XML comment, a `<TitleSet tickBase="60fps">`, a MainMovie title whose main
|
||||
/// feature is a two-clip layer-break split (FEATURE_1 + FEATURE_2, seamless)
|
||||
/// with two chapters, and a separate deleted-scene title. Attribute order
|
||||
/// varies (as it does across real discs).
|
||||
const SYNTH_XPL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Playlist majorVersion="1" minorVersion="0" xmlns="http://www.dvdforum.org/2005/HDDVDVideo/Playlist">
|
||||
<!-- Authored with TOSHIBA AdvMain -->
|
||||
<TitleSet timeBase="60fps" tickBase="60fps" defaultLanguage="en">
|
||||
<Title titleNumber="2" titleDuration="01:37:20:00" id="MainMovie" displayName="Main Movie">
|
||||
<PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:48:29:50" src="file:///dvddisc/HVDVD_TS/FEATURE_1.MAP" dataSource="Disc">
|
||||
<Video track="1" mediaAttr="2"/>
|
||||
<Audio track="1" streamNumber="1" description="English DD+"/>
|
||||
</PrimaryAudioVideoClip>
|
||||
<PrimaryAudioVideoClip titleTimeBegin="00:48:29:50" clipTimeBegin="00:00:00:00" titleTimeEnd="01:37:20:00" src="file:///dvddisc/HVDVD_TS/FEATURE_2.MAP" seamless="true">
|
||||
<Video track="1" mediaAttr="2"/>
|
||||
</PrimaryAudioVideoClip>
|
||||
<ChapterList>
|
||||
<Chapter displayName="Chapter 1" titleTimeBegin="00:00:00:00" />
|
||||
<Chapter displayName="Chapter 2" titleTimeBegin="00:03:40:30" />
|
||||
</ChapterList>
|
||||
</Title>
|
||||
<Title titleNumber="7" titleDuration="00:00:44:29" id="Deleted5" displayName="Deleted Scenes - Veronica Past">
|
||||
<PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:00:44:29" src="file:///dvddisc/HVDVD_TS/DEL5_VERONICAPAST.MAP" />
|
||||
</Title>
|
||||
</TitleSet>
|
||||
</Playlist>"#;
|
||||
|
||||
#[test]
|
||||
fn parse_timecode_hhmmssff_at_60fps() {
|
||||
// 01:37:20:00 = 5840 s exactly.
|
||||
assert!((parse_timecode("01:37:20:00", 60).unwrap() - 5840.0).abs() < 1e-6);
|
||||
// 00:48:29:50 = 48m29s + 50/60 frames.
|
||||
let want = 48.0 * 60.0 + 29.0 + 50.0 / 60.0;
|
||||
assert!((parse_timecode("00:48:29:50", 60).unwrap() - want).abs() < 1e-6);
|
||||
// MM:SS:FF short form (hours omitted).
|
||||
assert!((parse_timecode("02:05:15", 60).unwrap() - (125.0 + 15.0 / 60.0)).abs() < 1e-6);
|
||||
assert_eq!(parse_timecode("garbage", 60), None);
|
||||
assert_eq!(parse_timecode("", 60), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evo_from_src_maps_map_sidecar_to_evo() {
|
||||
assert_eq!(
|
||||
evo_from_src("file:///dvddisc/HVDVD_TS/FEATURE_1.MAP").as_deref(),
|
||||
Some("feature_1.evo")
|
||||
);
|
||||
// Already an EVO, or lowercase feature — normalise to lower `.evo`.
|
||||
assert_eq!(evo_from_src("feature.EVO").as_deref(), Some("feature.evo"));
|
||||
assert_eq!(
|
||||
evo_from_src("file:///x/feature_Divide.MAP").as_deref(),
|
||||
Some("feature_divide.evo")
|
||||
);
|
||||
// Empty stem → None (defensive against a malformed src).
|
||||
assert_eq!(evo_from_src("file:///x/").as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_xpl_titles_reads_titles_clips_chapters_durations() {
|
||||
let titles = parse_xpl_titles(SYNTH_XPL.as_bytes());
|
||||
assert_eq!(titles.len(), 2, "MainMovie + one deleted-scene title");
|
||||
|
||||
let mm = &titles[0];
|
||||
assert_eq!(mm.number, 2);
|
||||
assert_eq!(mm.name, "Main Movie");
|
||||
assert!(
|
||||
(mm.duration_secs - 5840.0).abs() < 1e-6,
|
||||
"97:20 from titleDuration"
|
||||
);
|
||||
// The layer-break split is ONE title with TWO clips, contiguous timeline.
|
||||
assert_eq!(mm.clips.len(), 2);
|
||||
assert_eq!(mm.clips[0].evo, "feature_1.evo");
|
||||
assert_eq!(mm.clips[1].evo, "feature_2.evo");
|
||||
assert!(
|
||||
(mm.clips[0].end_secs - mm.clips[1].begin_secs).abs() < 1e-6,
|
||||
"FEATURE_2 begins exactly where FEATURE_1 ends (seamless join)"
|
||||
);
|
||||
assert!(
|
||||
mm.clips[1].begin_secs > 0.0,
|
||||
"second clip carries a title-time offset"
|
||||
);
|
||||
assert_eq!(mm.chapters.len(), 2);
|
||||
assert!((mm.chapters[1] - (3.0 * 60.0 + 40.0 + 30.0 / 60.0)).abs() < 1e-6);
|
||||
|
||||
let del = &titles[1];
|
||||
assert_eq!(del.name, "Deleted Scenes - Veronica Past");
|
||||
assert_eq!(del.clips.len(), 1);
|
||||
assert_eq!(del.clips[0].evo, "del5_veronicapast.evo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_xpl_titles_returns_empty_on_non_xml() {
|
||||
assert!(parse_xpl_titles(b"not xml at all").is_empty());
|
||||
assert!(parse_xpl_titles(&[0xFF, 0x00, 0x01, 0x02]).is_empty());
|
||||
assert!(parse_xpl_titles(b"<Playlist></Playlist>").is_empty());
|
||||
}
|
||||
|
||||
/// Build a UDF with `HVDVD_TS/` `.evo` clips plus an `ADV_OBJ/VPLST000.XPL`
|
||||
/// carrying `xpl`, so `scan_hddvd_titles` takes the playlist path.
|
||||
fn make_hddvd_fs_xpl(
|
||||
disc: &mut MemDisc,
|
||||
evos: &[(&str, u32, u32)],
|
||||
xpl: &[u8],
|
||||
) -> crate::udf::UdfFs {
|
||||
let mut hv_files = Vec::new();
|
||||
let mut icb = 100u32;
|
||||
for (name, sectors, data_lba) in evos {
|
||||
hv_files.push(file(name, icb, *data_lba, sectors * 2048, true));
|
||||
icb += 1;
|
||||
}
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "HVDVD_TS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: hv_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "ADV_OBJ".to_string(),
|
||||
icb_lba: 30,
|
||||
dir_data_lba: 31,
|
||||
files: vec![file_with("VPLST000.XPL", 40, 4000, xpl.to_vec(), true)],
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(disc, 10);
|
||||
lay_dir(disc, &root);
|
||||
crate::udf::read_filesystem(disc).expect("fs")
|
||||
}
|
||||
|
||||
/// The authoritative path: when a VPLST000.XPL is present, titles come from
|
||||
/// the playlist — the layer-break split (FEATURE_1 + FEATURE_2) is composed
|
||||
/// into ONE title with the real duration, name, chapters, and per-clip
|
||||
/// title-time offsets — not the clip-name heuristic.
|
||||
#[test]
|
||||
fn scan_hddvd_composes_titles_from_xpl_playlist() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_hddvd_fs_xpl(
|
||||
&mut disc,
|
||||
&[
|
||||
("FEATURE_1.EVO", 2000, 5000),
|
||||
("FEATURE_2.EVO", 1800, 9000),
|
||||
("DEL5_VERONICAPAST.EVO", 100, 12000),
|
||||
],
|
||||
SYNTH_XPL.as_bytes(),
|
||||
);
|
||||
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
|
||||
assert_eq!(
|
||||
titles.len(),
|
||||
2,
|
||||
"the two playlist titles that resolve to clips"
|
||||
);
|
||||
|
||||
let mm = titles
|
||||
.iter()
|
||||
.find(|t| t.playlist == "Main Movie")
|
||||
.expect("MainMovie composed from the playlist");
|
||||
assert_eq!(mm.playlist_id, 2);
|
||||
assert!(
|
||||
(mm.duration_secs - 5840.0).abs() < 1.0,
|
||||
"97:20 duration from titleDuration, not 0/unknown"
|
||||
);
|
||||
assert_eq!(
|
||||
mm.clips.len(),
|
||||
2,
|
||||
"layer-break split kept as ONE title, two clips"
|
||||
);
|
||||
// FEATURE_2's clip carries the 48:29 offset (45 kHz ticks) — the datum
|
||||
// that splices it onto FEATURE_1's timeline instead of restarting at 0.
|
||||
assert!(
|
||||
mm.clips[1].in_time > 100_000_000,
|
||||
"FEATURE_2 offset onto the title timeline (48:29 * 45000), got {}",
|
||||
mm.clips[1].in_time
|
||||
);
|
||||
assert_eq!(mm.chapters.len(), 2);
|
||||
assert_eq!(mm.chapters[0].name, "1", "bare ordinal chapter name");
|
||||
// Both feature halves are in ONE title's extents.
|
||||
assert!(!mm.extents.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -216,6 +216,22 @@ pub struct VideoStream {
|
||||
pub measured_cicp: Option<MeasuredCicp>,
|
||||
}
|
||||
|
||||
/// Label marking a video stream as the Blu-ray 3D **MVC dependent (right-eye)
|
||||
/// view** — the paired substream of the AVC base view. Set by the BD scan
|
||||
/// (`bluray.rs`) and recognised by the mux path (`resolve.rs` builds its parser
|
||||
/// in param-set-preserving mode; `mkvstream.rs` folds it into the base track as
|
||||
/// per-frame `BlockAdditional`). Single source of truth for the contract.
|
||||
pub const MVC_DEPENDENT_LABEL: &str = "MVC dependent view (3D right eye)";
|
||||
|
||||
impl VideoStream {
|
||||
/// Whether this video stream is the MVC dependent (right-eye) view — the
|
||||
/// 3D substream that the muxer merges into the base track as a per-frame
|
||||
/// `BlockAdditional` rather than emitting as an independent track.
|
||||
pub fn is_mvc_dependent(&self) -> bool {
|
||||
self.label == MVC_DEPENDENT_LABEL
|
||||
}
|
||||
}
|
||||
|
||||
/// Measured CICP colour signalling read directly from a video elementary stream
|
||||
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -811,7 +827,11 @@ impl Codec {
|
||||
use crate::consts::coding_type as c;
|
||||
match ct {
|
||||
c::HEVC => Codec::Hevc,
|
||||
c::H264 => Codec::H264,
|
||||
// 0x1B base-view AVC and 0x20 MVC dependent-view (Blu-ray 3D right
|
||||
// eye) are both H.264. Mapping 0x20 to video is what makes the PMT
|
||||
// scan enumerate the dependent view as a second H.264 stream (its
|
||||
// own PID in the SSIF) instead of dropping it — the basis of 3D.
|
||||
c::H264 | c::H264_MVC => Codec::H264,
|
||||
c::VC1 => Codec::Vc1,
|
||||
c::MPEG2_VIDEO => Codec::Mpeg2,
|
||||
c::TRUEHD => Codec::TrueHd,
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
//! grid (clips can start off the 6144 grid and fragment across UDF extents). So
|
||||
//! this gate BUFFERS the disc-absolute read stream and re-ALIGNS it into
|
||||
//! clip-file units, then applies the standards-correct
|
||||
//! [`crate::aacs::content::unit_is_clean_ts`] gate (libaacs `_verify_ts`, all-32 syncs).
|
||||
//! [`crate::aacs::content::unit_is_clean_ts`] gate (all-32 TS syncs).
|
||||
//!
|
||||
//! FAIL-SAFE CONTRACT (this sits in the middle of every read, so it must never
|
||||
//! break a good read): the gate can ONLY downgrade a unit it is *confident* is
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
//!
|
||||
//! Byte layout follows the DVD-Video specification (VMGI/VTSI headers,
|
||||
//! PGC/cell tables, PCI/HLI button packets); the VM command decoder is
|
||||
//! verified against libdvdnav's decoder.
|
||||
//! verified against real discs.
|
||||
//!
|
||||
//! Current contents: [`vmcmd`] — the VM command decoder (proven against the
|
||||
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
|
||||
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! An 8-byte navigation command as found in PGC command tables (pre/post/cell)
|
||||
//! and PCI button info. Decoded per the DVD-Video VM instruction set and
|
||||
//! verified against libdvdnav's command decoder.
|
||||
//! verified against real discs.
|
||||
//!
|
||||
//! Bit model: the 8 bytes are a big-endian 64-bit word. `byte0` bits 7-5 are the
|
||||
//! command **type**; for type 1, `byte0` bit 4 selects Link (0) vs Jump (1), and
|
||||
@@ -133,7 +133,7 @@ const JP_JUMP_SS: u8 = 6;
|
||||
const JP_CALL_SS: u8 = 8;
|
||||
|
||||
// Link (type 1, direct=0) sub-commands. NOTE: sub-op 0 is NOP/no-link and 1 is
|
||||
// the LinkSub form (libdvdnav `decoder.c` `eval_link_instruction`).
|
||||
// the LinkSub form (the DVD-Video VM link instruction).
|
||||
const LK_SUB: u8 = 1;
|
||||
const LK_PGCN: u8 = 4;
|
||||
const LK_PTTN: u8 = 5;
|
||||
@@ -159,7 +159,7 @@ fn be16(b: &[u8; 8], o: usize) -> u16 {
|
||||
((b[o] as u16) << 8) | b[o + 1] as u16
|
||||
}
|
||||
|
||||
// Compare-operand layouts ("if_version"s) per libdvdnav `decoder.c`. The op
|
||||
// Compare-operand layouts ("if_version"s) per the DVD-Video VM. The op
|
||||
// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The
|
||||
// operand *offsets* differ by command family.
|
||||
//
|
||||
@@ -205,7 +205,7 @@ pub fn decode(b: &[u8; 8]) -> Command {
|
||||
let cmd = b[1] & 0x0F;
|
||||
|
||||
// Compare predicate, with the operand layout for this command family
|
||||
// (libdvdnav `decoder.c` `vm_eval_command` type dispatch).
|
||||
// (the DVD-Video VM command type dispatch).
|
||||
let compare = match (typ, direct) {
|
||||
(TYPE_SPECIAL, _) => if_v1(b),
|
||||
(TYPE_LINK_JUMP, 1) => if_v2(b), // jump
|
||||
@@ -372,7 +372,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the libdvdnav cross-check: link sub-op 0 = NOP, 1 = LinkSub.
|
||||
// Regression for the link sub-op decode: 0 = NOP, 1 = LinkSub.
|
||||
#[test]
|
||||
fn link_subop_zero_is_nop_one_is_linksub() {
|
||||
assert_eq!(decode(&h("2000000000000000")).instr, Instr::Nop);
|
||||
|
||||
+8
-8
@@ -58,7 +58,7 @@ pub struct DvdTitle {
|
||||
pub struct DvdCell {
|
||||
pub first_sector: u32,
|
||||
pub last_sector: u32,
|
||||
/// Raw cell-category byte at `cell_playback + 0` (libdvdread layout).
|
||||
/// Raw cell-category byte at `cell_playback + 0` (DVD-Video IFO layout).
|
||||
/// Packs block_mode (bits 7-6), block_type (bits 5-4), seamless_play
|
||||
/// (bit 3), interleaved (bit 2), stc_discontinuity (bit 1),
|
||||
/// seamless_angle (bit 0). Carried so the extent builder can recognise
|
||||
@@ -72,7 +72,7 @@ pub struct DvdCell {
|
||||
}
|
||||
|
||||
/// Decoded view of a cell-category byte (`cell_playback + 0`), per the
|
||||
/// DVD-Video spec / libdvdread `cell_playback_t` layout. Byte-0 bitfields,
|
||||
/// DVD-Video IFO cell-playback layout. Byte-0 bitfields,
|
||||
/// MSB-first: `block_mode`(7-6), `block_type`(5-4), `seamless_play`(3),
|
||||
/// `interleaved`(2), `stc_discontinuity`(1), `seamless_angle`(0). (The real
|
||||
/// `cell_type` is a karaoke-only field in byte 1, not used here.)
|
||||
@@ -93,7 +93,7 @@ pub struct CellCategory {
|
||||
}
|
||||
|
||||
impl CellCategory {
|
||||
/// Decode the raw `cell_playback + 0` byte (libdvdread `read_cell_playback`).
|
||||
/// Decode the raw `cell_playback + 0` byte (DVD-Video IFO cell playback).
|
||||
pub fn decode(raw: u8) -> Self {
|
||||
CellCategory {
|
||||
block_mode: (raw >> 6) & 0x03,
|
||||
@@ -423,7 +423,7 @@ fn parse_vts(
|
||||
}
|
||||
|
||||
// VTSI_MAT (VTS_xx_0.IFO header) field offsets — fixed by the DVD-Video
|
||||
// spec (libdvdread `vtsi_mat_t`). The offsets are constant; the sector
|
||||
// spec (the VTSI management table). The offsets are constant; the sector
|
||||
// values they point to are per-disc.
|
||||
const VTSTT_VOBS_OFFSET: usize = 0xC4; // VTS title VOBS start sector (feature)
|
||||
const VTS_PGCIT_OFFSET: usize = 0xCC; // VTS_PGCIT sector pointer
|
||||
@@ -580,7 +580,7 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
|
||||
_ => Codec::Unknown(coding_mode),
|
||||
};
|
||||
|
||||
let sample_rate_flag = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (libdvdread audio_attr_t)
|
||||
let sample_rate_flag = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (DVD-Video audio attributes)
|
||||
let sample_rate = match sample_rate_flag {
|
||||
0 => 48000,
|
||||
1 => 96000,
|
||||
@@ -1071,7 +1071,7 @@ mod tests {
|
||||
/// reads with — a co-edit of constant + helper would silently re-introduce
|
||||
/// the PAL-as-NTSC bug and every test would still pass. This test feeds
|
||||
/// `parse_video_attr` HARDCODED bytes captured from real DVD-Video layouts
|
||||
/// (DVD spec / libdvdread `video_attr_t`: mpeg_version[7-6] video_format[5-4]
|
||||
/// (DVD-Video video attributes: mpeg_version[7-6] video_format[5-4]
|
||||
/// display_aspect[3-2] permitted_df[1-0]) — no `v_atr_byte`. If the parser's
|
||||
/// bit positions drift, these fail.
|
||||
#[test]
|
||||
@@ -1238,7 +1238,7 @@ mod tests {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Added hardening tests. Grounded in the DVD-Video IFO spec
|
||||
// (dvd_udf / libdvdread ifo_types.h; http://dvd.sourceforge.net).
|
||||
// (DVD-Video IFO format; http://dvd.sourceforge.net).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// BCD frame-rate flag: bits 7-6 of byte[3]. 0b01 = 25fps (PAL),
|
||||
@@ -1593,7 +1593,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// CellCategory decodes the libdvdread byte-0 bitfields: block_mode (7-6),
|
||||
/// CellCategory decodes the DVD-Video cell-category byte-0 bitfields: block_mode (7-6),
|
||||
/// block_type (5-4), seamless_play (3), interleaved (2),
|
||||
/// stc_discontinuity (1), seamless_angle (0).
|
||||
#[test]
|
||||
|
||||
+3
-9
@@ -346,7 +346,7 @@ pub fn key_fetch(
|
||||
///
|
||||
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
|
||||
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
|
||||
/// spec-correct signal (libaacs' `buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
|
||||
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
|
||||
/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant
|
||||
/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
|
||||
/// unit sent to a key server yields nothing to validate against — the "0
|
||||
@@ -563,10 +563,7 @@ mod tests {
|
||||
struct HasKey([u8; 16]);
|
||||
impl KeySource for HasKey {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey {
|
||||
idx: 0,
|
||||
key: self.0,
|
||||
}])
|
||||
Ok(vec![UnitKey::new(0, self.0)])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,10 +609,7 @@ mod tests {
|
||||
if let Ok(s) = ctx.samples(8) {
|
||||
self.seen.lock().unwrap().extend(s);
|
||||
}
|
||||
Ok(vec![UnitKey {
|
||||
idx: 0,
|
||||
key: self.key,
|
||||
}])
|
||||
Ok(vec![UnitKey::new(0, self.key)])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+114
-3
@@ -56,6 +56,13 @@ pub struct H264Parser {
|
||||
/// (HD-DVD EVO) path where the source stamps a PTS once per GOP. `None` on
|
||||
/// the BD/UHD transport path, which carries a per-frame PTS.
|
||||
reorder: Option<super::reorder::SparsePtsReorder>,
|
||||
/// MVC dependent-view (Blu-ray 3D right-eye) passthrough mode. When set, the
|
||||
/// parser does NOT strip SPS/PPS (nor re-assert at keyframes): every NAL —
|
||||
/// subset SPS (type 15), prefix (14), coded-slice-extension (20), PPS (8) —
|
||||
/// is length-prefixed in-band, so each emitted frame is a self-contained
|
||||
/// dependent access unit suitable for a Matroska `BlockAdditional`. The base
|
||||
/// view's avcC/param-set stripping is unchanged (separate parser instance).
|
||||
mvc_passthrough: bool,
|
||||
}
|
||||
|
||||
impl Default for H264Parser {
|
||||
@@ -73,6 +80,7 @@ impl H264Parser {
|
||||
cur_sps: None,
|
||||
cur_pps: None,
|
||||
reorder: None,
|
||||
mvc_passthrough: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +93,15 @@ impl H264Parser {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable MVC dependent-view passthrough (see the `mvc_passthrough` field):
|
||||
/// keep every parameter set in-band so each frame is a self-contained
|
||||
/// dependent access unit for a Matroska `BlockAdditional`. Used only for the
|
||||
/// Blu-ray 3D dependent (right-eye) stream.
|
||||
pub(crate) fn with_mvc_passthrough(mut self, enabled: bool) -> Self {
|
||||
self.mvc_passthrough = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Route a finished frame through the PTS reorderer when enabled, else emit
|
||||
/// it directly (unchanged transport-stream behaviour).
|
||||
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
|
||||
@@ -192,17 +209,23 @@ impl CodecParser for H264Parser {
|
||||
// frame in the mux hot path (mirrors the HEVC parser).
|
||||
let mut frame_data = Vec::with_capacity(pes.data.len() + 64);
|
||||
|
||||
// MVC dependent-view passthrough: keep ALL param sets in-band (the frame
|
||||
// is a self-contained BlockAdditional access unit), never strip/re-assert.
|
||||
let mvc = self.mvc_passthrough;
|
||||
|
||||
for nal in NalIterator::new(&pes.data) {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
|
||||
match nal_type {
|
||||
// Param sets: seed avcC, strip if unchanged vs the active set,
|
||||
// emit in-band on any change (incl. reverting to the avcC copy).
|
||||
NAL_SPS => {
|
||||
// In MVC passthrough these fall through to the default arm so the
|
||||
// subset SPS / PPS stay in-band (self-contained dependent AU).
|
||||
NAL_SPS if !mvc => {
|
||||
emitted_sps |=
|
||||
handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data)
|
||||
}
|
||||
NAL_PPS => {
|
||||
NAL_PPS if !mvc => {
|
||||
emitted_pps |=
|
||||
handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data)
|
||||
}
|
||||
@@ -250,7 +273,7 @@ impl CodecParser for H264Parser {
|
||||
// ahead of the slices (even when unchanged vs codecPrivate) so a decoder
|
||||
// that dropped the set at a reset recovers, and a stale avcC re-apply
|
||||
// can't revert it. Skipped per-type only when this AU already carried it.
|
||||
if keyframe {
|
||||
if keyframe && !mvc {
|
||||
let mut prefix = Vec::new();
|
||||
reassert_active(&mut prefix, &self.cur_sps, emitted_sps);
|
||||
reassert_active(&mut prefix, &self.cur_pps, emitted_pps);
|
||||
@@ -651,6 +674,94 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_passthrough_keeps_param_sets_inband() {
|
||||
// A dependent-view access unit: subset SPS (NAL 15) + PPS (NAL 8) +
|
||||
// coded-slice-extension (NAL 20). No IDR (type 5), so keyframe stays
|
||||
// false and there is no keyframe re-assertion.
|
||||
let au = || {
|
||||
let mut d = Vec::new();
|
||||
d.extend_from_slice(&h264_nal(0x6F, &[0x80, 0x00, 0x33, 0xAA])); // subset SPS (15)
|
||||
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
|
||||
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
|
||||
d
|
||||
};
|
||||
let nal_types =
|
||||
|f: &Frame| -> Vec<u8> { h264_nals_in(&f.data).iter().map(|n| n[0] & 0x1F).collect() };
|
||||
|
||||
// Normal parser strips the PPS from a non-keyframe AU (it is captured for
|
||||
// the avcC and, without an IDR, never re-asserted in-band).
|
||||
let mut normal = H264Parser::new();
|
||||
let f = normal.parse(&make_pes(au(), Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert!(
|
||||
!nal_types(&f[0]).contains(&8),
|
||||
"normal parser strips PPS from a non-keyframe AU: {:?}",
|
||||
nal_types(&f[0])
|
||||
);
|
||||
|
||||
// Passthrough keeps EVERY parameter set in-band, so each dependent frame
|
||||
// is a self-contained access unit for a BlockAdditional.
|
||||
let mut pt = H264Parser::new().with_mvc_passthrough(true);
|
||||
let f = pt.parse(&make_pes(au(), Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
let types = nal_types(&f[0]);
|
||||
assert!(types.contains(&15), "subset SPS kept in-band: {types:?}");
|
||||
assert!(
|
||||
types.contains(&8),
|
||||
"PPS kept in-band under passthrough: {types:?}"
|
||||
);
|
||||
assert!(types.contains(&20), "slice kept: {types:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_for_mvc_dependent_h264_is_passthrough() {
|
||||
// The dependent-view stream must get a passthrough parser: a PPS in a
|
||||
// non-keyframe AU is kept in-band, not stripped like the base parser.
|
||||
let mut p = crate::mux::codec::parser_for_mvc_dependent(crate::disc::Codec::H264, false);
|
||||
let mut d = Vec::new();
|
||||
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
|
||||
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
|
||||
let f = p.parse(&make_pes(d, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
let types: Vec<u8> = h264_nals_in(&f[0].data)
|
||||
.iter()
|
||||
.map(|n| n[0] & 0x1F)
|
||||
.collect();
|
||||
assert!(
|
||||
types.contains(&8),
|
||||
"dependent parser keeps PPS in-band (passthrough): {types:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_passthrough_with_idr_does_not_reassert_param_sets() {
|
||||
// With an IDR present (keyframe=true), passthrough must NOT re-assert the
|
||||
// param sets (the `keyframe && !mvc` guard), so SPS/PPS appear exactly
|
||||
// once — a duplicate would corrupt the dependent BlockAdditional.
|
||||
let mut p = H264Parser::new().with_mvc_passthrough(true);
|
||||
let mut d = Vec::new();
|
||||
d.extend_from_slice(&h264_nal(0x67, &[0x42, 0x00, 0x1E, 0x01])); // SPS (7)
|
||||
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
|
||||
d.extend_from_slice(&h264_nal(0x65, &[0x88, 0x00])); // IDR slice (5)
|
||||
let f = p.parse(&make_pes(d, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
let types: Vec<u8> = h264_nals_in(&f[0].data)
|
||||
.iter()
|
||||
.map(|n| n[0] & 0x1F)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
types.iter().filter(|&&t| t == 7).count(),
|
||||
1,
|
||||
"exactly one SPS, no keyframe re-assert under passthrough: {types:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
types.iter().filter(|&&t| t == 8).count(),
|
||||
1,
|
||||
"exactly one PPS, no keyframe re-assert under passthrough: {types:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_populates_measured_coding_type_and_source() {
|
||||
use super::super::coding::CodingType;
|
||||
|
||||
@@ -207,6 +207,22 @@ pub fn parser_for_codec(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the codec parser for a Blu-ray 3D **MVC dependent (right-eye)** video
|
||||
/// stream. Same codec space as the base view (H.264), but in param-set
|
||||
/// passthrough mode so each emitted frame is a self-contained dependent access
|
||||
/// unit for a Matroska `BlockAdditional`. Non-H.264 (unexpected) falls back to
|
||||
/// the ordinary parser.
|
||||
pub fn parser_for_mvc_dependent(codec: Codec, is_dvd_ps: bool) -> Box<dyn CodecParser> {
|
||||
match codec {
|
||||
Codec::H264 => Box::new(
|
||||
h264::H264Parser::new()
|
||||
.with_ps_reorder(is_dvd_ps)
|
||||
.with_mvc_passthrough(true),
|
||||
),
|
||||
_ => parser_for_codec(codec, None, is_dvd_ps),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -94,6 +94,31 @@ pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete EBML signed-integer element (two's-complement, big-endian,
|
||||
/// minimal width). Used for `ReferenceBlock` (0xFB), whose value is a signed
|
||||
/// tick offset relative to the current block's timestamp.
|
||||
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
// Minimal two's-complement width: shrink while the top byte is pure sign
|
||||
// extension of the next byte's MSB.
|
||||
let be = val.to_be_bytes();
|
||||
let mut start = 0usize;
|
||||
while start < 7 {
|
||||
let sign_ext = if be[start + 1] & 0x80 != 0 {
|
||||
0xFF
|
||||
} else {
|
||||
0x00
|
||||
};
|
||||
if be[start] != sign_ext {
|
||||
break;
|
||||
}
|
||||
start += 1;
|
||||
}
|
||||
let bytes = &be[start..];
|
||||
write_size(w, bytes.len() as u64)?;
|
||||
w.write_all(bytes)
|
||||
}
|
||||
|
||||
/// Write a complete EBML float element (8-byte double).
|
||||
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
@@ -481,6 +506,25 @@ pub const LUMINANCE_MIN: u32 = 0x55DA;
|
||||
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
|
||||
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
|
||||
pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED;
|
||||
/// BlockAddIDValue (RFC 9559) — the value a per-frame `BlockAddID` references
|
||||
/// to select this BlockAdditionMapping. Values ≥ 2 (1 is the default plain
|
||||
/// BlockAdditional). Used by the MVC (`mvcC`) mapping for Blu-ray 3D.
|
||||
pub const BLOCK_ADD_ID_VALUE: u32 = 0x41F0;
|
||||
|
||||
// Block additions carried inside a BlockGroup — per-frame side data. For
|
||||
// Blu-ray 3D (MVC) the dependent (right-eye) view NAL units for an access unit
|
||||
// ride here as a BlockAdditional under the track's `mvcC` mapping (RFC 9559
|
||||
// §5.1.4.1.4; Matroska Codec Specifications §4.1.5).
|
||||
pub const BLOCK_ADDITIONS: u32 = 0x75A1;
|
||||
pub const BLOCK_MORE: u32 = 0xA6;
|
||||
pub const BLOCK_ADDITIONAL: u32 = 0xA5;
|
||||
pub const BLOCK_ADD_ID: u32 = 0xEE;
|
||||
/// ReferenceBlock (RFC 9559 element 0xFB, child of BlockGroup) — signed
|
||||
/// timestamp (in TimestampScale ticks) of a block this one references, relative
|
||||
/// to this block's own timestamp. Its PRESENCE marks the Block as non-keyframe
|
||||
/// (a keyframe Block in a BlockGroup carries none). Written for non-keyframe
|
||||
/// video frames that must live in a BlockGroup to carry an MVC BlockAdditional.
|
||||
pub const REFERENCE_BLOCK: u32 = 0xFB;
|
||||
|
||||
// Audio
|
||||
pub const AUDIO: u32 = 0xE1;
|
||||
@@ -960,6 +1004,31 @@ mod tests {
|
||||
assert_eq!(buf, [0x42, 0x86, 0x81, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_int_minimal_two_complement_width() {
|
||||
// ReferenceBlock (0xFB) signed offsets, minimal two's-complement width.
|
||||
let enc = |v: i64| {
|
||||
let mut b = Vec::new();
|
||||
write_int(&mut b, REFERENCE_BLOCK, v).unwrap();
|
||||
b
|
||||
};
|
||||
assert_eq!(enc(0), [0xFB, 0x81, 0x00], "0 -> 1 byte 0x00");
|
||||
assert_eq!(enc(-1), [0xFB, 0x81, 0xFF], "-1 -> 1 byte 0xFF");
|
||||
assert_eq!(enc(127), [0xFB, 0x81, 0x7F], "127 -> 1 byte");
|
||||
assert_eq!(
|
||||
enc(128),
|
||||
[0xFB, 0x82, 0x00, 0x80],
|
||||
"128 needs 2 bytes (0x80 alone is -128)"
|
||||
);
|
||||
assert_eq!(enc(-128), [0xFB, 0x81, 0x80], "-128 -> 1 byte 0x80");
|
||||
assert_eq!(enc(-129), [0xFB, 0x82, 0xFF, 0x7F], "-129 needs 2 bytes");
|
||||
// i64::MIN is the widest: 8 bytes, size 0x88.
|
||||
let mn = enc(i64::MIN);
|
||||
assert_eq!(mn[0], 0xFB);
|
||||
assert_eq!(mn[1], 0x88);
|
||||
assert_eq!(&mn[2..], &i64::MIN.to_be_bytes());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// write_float — EBML floats here are always 8-byte IEEE-754 doubles,
|
||||
// big-endian (Matroska SamplingFrequency/Duration). size byte = 0x88.
|
||||
|
||||
+393
-27
@@ -68,6 +68,59 @@ const COLOUR_RANGE_LIMITED: u8 = 1;
|
||||
/// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec).
|
||||
const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343;
|
||||
|
||||
/// BlockAddIDType "mvcC" — the MVCDecoderConfigurationRecord fourcc, big-endian
|
||||
/// ASCII 'm''v''c''C'. Matroska BlockAdditionMapping/BlockAddIDType for a
|
||||
/// Blu-ray 3D MVC configuration (RFC 9559 + Matroska Codec Specifications
|
||||
/// §4.1.5; equals ISO/IEC 14496-15 MVCConfigurationBox('mvcC')). The per-frame
|
||||
/// dependent (right-eye) view rides as a BlockAdditional under this mapping.
|
||||
const BLOCK_ADD_ID_TYPE_MVCC: u64 = 0x6D76_6343;
|
||||
|
||||
/// BlockAddIDValue for the MVC mapping — the value each per-frame `BlockAddID`
|
||||
/// references (RFC 9559 requires ≥ 2; 1 is the default plain BlockAdditional).
|
||||
const BLOCK_ADD_ID_VALUE_MVC: u64 = 2;
|
||||
|
||||
/// Build an `MVCDecoderConfigurationRecord` (ISO/IEC 14496-15:2013 §7.6.2) from
|
||||
/// the dependent view's subset-SPS (NAL type 15) and PPS NAL units. This is the
|
||||
/// `BlockAddIDExtraData` for the `mvcC` BlockAdditionMapping (and, in the ISO
|
||||
/// container, the `MVCConfigurationBox('mvcC')` payload).
|
||||
///
|
||||
/// Layout mirrors the AVCDecoderConfigurationRecord except byte[4] repurposes
|
||||
/// the AVC record's `bit(6) reserved` as
|
||||
/// `complete_representation(1) | explicit_au_track(1) | reserved '1111'(4)`.
|
||||
/// `profile`/`compat`/`level` describe the WHOLE MVC stream and come from the
|
||||
/// subset SPS (bytes 1..=3). `length_size_minus_one` MUST match the base avcC
|
||||
/// (freemkv always emits 4-byte length prefixes → 3). SPSs precede subset SPSs
|
||||
/// in the array; here the array carries the dependent view's parameter sets.
|
||||
///
|
||||
/// Returns `None` if either param set is absent, too short, or exceeds the
|
||||
/// 16-bit length field (a param set > 65535 bytes is non-conforming and would
|
||||
/// mis-frame the record).
|
||||
fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
|
||||
if subset_sps.len() < 4 || subset_sps.len() > 0xFFFF || pps.is_empty() || pps.len() > 0xFFFF {
|
||||
return None;
|
||||
}
|
||||
let mut record = vec![
|
||||
1, // configurationVersion
|
||||
subset_sps[1], // AVCProfileIndication (whole MVC stream, from subset SPS)
|
||||
subset_sps[2], // profile_compatibility
|
||||
subset_sps[3], // AVCLevelIndication
|
||||
// complete_representation(1)=1 | explicit_au_track(1)=0 |
|
||||
// reserved '1111'(4) | lengthSizeMinusOne(2)=3(11) → 1_0_1111_11 = 0xBF.
|
||||
0xBF,
|
||||
// reserved '0'(1) | numOfSequenceParameterSets(7)=1 → 0_0000001 = 0x01.
|
||||
// NB: distinct from the AVC record's byte[5] (reserved(3)|numSPS(5)).
|
||||
0x01,
|
||||
(subset_sps.len() >> 8) as u8,
|
||||
subset_sps.len() as u8,
|
||||
];
|
||||
record.extend_from_slice(subset_sps);
|
||||
record.push(1); // numOfPictureParameterSets
|
||||
record.push((pps.len() >> 8) as u8);
|
||||
record.push(pps.len() as u8);
|
||||
record.extend_from_slice(pps);
|
||||
Some(record)
|
||||
}
|
||||
|
||||
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
|
||||
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
|
||||
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
|
||||
@@ -176,6 +229,16 @@ pub struct MkvTrack {
|
||||
/// stream is parsed. When `Some`, the serializer emits MasteringMetadata +
|
||||
/// MaxCLL/MaxFALL inside Colour; when `None` they are omitted entirely.
|
||||
pub hdr10: Option<crate::mux::codec::Hdr10Metadata>,
|
||||
/// Blu-ray 3D (MVC): the dependent (right-eye) view's `(subset_sps, pps)`
|
||||
/// NAL units, from which the serializer builds the `mvcC`
|
||||
/// MVCDecoderConfigurationRecord (ISO/IEC 14496-15 §7.6.2) for the track's
|
||||
/// BlockAdditionMapping. `None` for non-3D tracks. Set at muxer activation
|
||||
/// from the dependent stream's parameter sets — the same deferred path
|
||||
/// `hdr10`/FieldOrder use — never at construction. When `Some`, the per-frame
|
||||
/// dependent view rides as a `BlockAdditional`. (No `StereoMode` is written:
|
||||
/// RFC 9559 assigns no StereoMode value to MVC-in-BlockAdditional; the mvcC
|
||||
/// mapping is the 3D signal.)
|
||||
pub mvc_params: Option<(Vec<u8>, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
|
||||
@@ -380,6 +443,7 @@ impl MkvTrack {
|
||||
// coded picture's PictureInfo before the header is written (the same
|
||||
// deferred path FieldOrder uses). `None` here → omitted unless seen.
|
||||
hdr10: None,
|
||||
mvc_params: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +512,7 @@ impl MkvTrack {
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
hdr10: None,
|
||||
mvc_params: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +550,7 @@ impl MkvTrack {
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
hdr10: None,
|
||||
mvc_params: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,6 +596,12 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
/// the primary video. `None` when the title has no video track (no track
|
||||
/// drives epochs).
|
||||
primary_video_track: Option<usize>,
|
||||
/// Per-track: whether the track declared an `mvcC` BlockAdditionMapping (its
|
||||
/// `mvc_params` was set at activation). A `BlockAdditional` with BlockAddID=2
|
||||
/// is only conforming when the track carries the matching mapping, so
|
||||
/// `write_frame`'s additional is dropped for a track without it (e.g. the
|
||||
/// dependent view's parameter sets were never captured before activation).
|
||||
track_has_mvc_mapping: Vec<bool>,
|
||||
/// Cross-clip timeline-continuity corrector (clip-boundary PTS rebasing).
|
||||
continuity: TimelineContinuity,
|
||||
cues: Vec<CuePoint>,
|
||||
@@ -565,6 +637,11 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
/// Highest block timestamp (TimestampScale ticks) written across all tracks —
|
||||
/// the muxed runtime, used to back-patch the DURATION placeholder.
|
||||
max_block_ticks: i64,
|
||||
/// Timestamp (TimestampScale ticks) of the last video keyframe written on the
|
||||
/// primary video track. A non-keyframe MVC base frame lives in a BlockGroup
|
||||
/// (to carry its dependent-view BlockAdditional) and needs a `ReferenceBlock`
|
||||
/// so players don't mistake it for a keyframe; it references this keyframe.
|
||||
last_video_keyframe_ticks: Option<i64>,
|
||||
/// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble
|
||||
/// is unreliable, so the channel count written in the track header is
|
||||
/// corrected from the AC-3 bitstream `acmod` of the first frame on the
|
||||
@@ -923,6 +1000,33 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut writer, vid_pos)?;
|
||||
}
|
||||
|
||||
// Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of
|
||||
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
|
||||
// mediainfo recognise the dependent (right-eye) view that rides as a
|
||||
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
|
||||
if let Some((subset_sps, pps)) = track.mvc_params.as_ref() {
|
||||
if let Some(record) = mvc_decoder_config_record(subset_sps, pps) {
|
||||
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
||||
ebml::write_uint(
|
||||
&mut writer,
|
||||
ebml::BLOCK_ADD_ID_VALUE,
|
||||
BLOCK_ADD_ID_VALUE_MVC,
|
||||
)?;
|
||||
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?;
|
||||
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, &record)?;
|
||||
ebml::end_master(&mut writer, map_pos)?;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"MVC track: could not build MVCDecoderConfigurationRecord from the \
|
||||
dependent view's parameter sets (subset_sps={} B, pps={} B); \
|
||||
emitting no mvcC mapping — the 3D pairing will not be signalled.",
|
||||
subset_sps.len(),
|
||||
pps.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
||||
// TrackEntry (sibling of Video). Carries the dvcC so players /
|
||||
// mediainfo recognise the track as Dolby Vision.
|
||||
@@ -1013,6 +1117,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
primary_video_track: tracks
|
||||
.iter()
|
||||
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
||||
track_has_mvc_mapping: tracks.iter().map(|t| t.mvc_params.is_some()).collect(),
|
||||
continuity: TimelineContinuity::new(),
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
@@ -1027,6 +1132,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
duration_secs,
|
||||
duration_patch_pos,
|
||||
max_block_ticks: 0,
|
||||
last_video_keyframe_ticks: None,
|
||||
ac3_channel_fixups,
|
||||
opening_capture: None,
|
||||
})
|
||||
@@ -1058,6 +1164,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
/// (`None`) leaves the written value untouched: an interlaced track keeps
|
||||
/// its guess rather than being cleared via a multi-element change. The byte
|
||||
/// width is fixed (FieldOrder is 0..=14), so the in-place rewrite is valid.
|
||||
///
|
||||
/// `block_additional`, when `Some`, is attached to the frame as a Matroska
|
||||
/// `BlockAdditional` (BlockAddID=2) — Blu-ray 3D (MVC): the base view is the
|
||||
/// Block and the dependent (right-eye) access unit rides as the
|
||||
/// BlockAdditional under the track's `mvcC` mapping. Such a frame is always a
|
||||
/// `BlockGroup` (never a SimpleBlock), with a `ReferenceBlock` when it is not
|
||||
/// a keyframe. `None` for every non-3D frame.
|
||||
pub fn write_frame(
|
||||
&mut self,
|
||||
track_idx: usize,
|
||||
@@ -1065,6 +1178,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
keyframe: bool,
|
||||
data: &[u8],
|
||||
duration_ns: Option<u64>,
|
||||
block_additional: Option<&[u8]>,
|
||||
) -> io::Result<()> {
|
||||
// --log-level 3: capture the first ~100 coded frames per track to the
|
||||
// side file BEFORE any timeline mangling, with the codec parser's own
|
||||
@@ -1226,15 +1340,72 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
self.max_block_ticks = self.max_block_ticks.max(block_end_ticks);
|
||||
|
||||
let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
|
||||
match duration_ns {
|
||||
Some(dur_ns) => {
|
||||
// BlockDuration is in TimestampScale ticks, floored at 1.
|
||||
let duration_ticks = (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64;
|
||||
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, duration_ticks)?;
|
||||
let duration_ticks =
|
||||
duration_ns.map(|dur_ns| (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64);
|
||||
// A BlockAdditional with BlockAddID=2 is only conforming when the track
|
||||
// declared the matching mvcC BlockAdditionMapping. If it did not (the
|
||||
// dependent view's parameter sets were never captured before the header
|
||||
// was written), drop the additional and emit a plain block rather than a
|
||||
// non-conforming file with an orphaned BlockAddID.
|
||||
let block_additional = match block_additional {
|
||||
Some(a)
|
||||
if self
|
||||
.track_has_mvc_mapping
|
||||
.get(track_idx)
|
||||
.copied()
|
||||
.unwrap_or(false) =>
|
||||
{
|
||||
Some(a)
|
||||
}
|
||||
None => {
|
||||
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
|
||||
Some(_) => None,
|
||||
None => None,
|
||||
};
|
||||
match block_additional {
|
||||
// MVC: base view Block + dependent-view BlockAdditional, always a
|
||||
// BlockGroup. Non-keyframe base frames get a ReferenceBlock to the
|
||||
// last keyframe (a keyframe carries none), so a player never treats a
|
||||
// P/B frame as a seek point.
|
||||
Some(additional) => {
|
||||
let reference = if keyframe {
|
||||
None
|
||||
} else {
|
||||
// Offset (ticks) of the referenced keyframe relative to this
|
||||
// block. A non-keyframe MUST carry a ReferenceBlock or a reader
|
||||
// treats it as a seek point; fall back to 0 (self-relative) in
|
||||
// the pre-first-keyframe corner (unreachable in practice — such
|
||||
// frames are dropped before a cluster opens) so the marker is
|
||||
// never absent.
|
||||
Some(
|
||||
self.last_video_keyframe_ticks
|
||||
.map(|kf| kf - pts_ticks)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
};
|
||||
self.write_block_group_mvc(
|
||||
track_idx + 1,
|
||||
relative_ts,
|
||||
data,
|
||||
additional,
|
||||
reference,
|
||||
duration_ticks,
|
||||
)?;
|
||||
}
|
||||
None => match duration_ticks {
|
||||
// BlockDuration present (PGS subtitles) → BlockGroup.
|
||||
Some(dt) => {
|
||||
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, dt)?;
|
||||
}
|
||||
None => {
|
||||
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
|
||||
}
|
||||
},
|
||||
}
|
||||
// Remember the last PRIMARY-video keyframe's tick so a later non-keyframe
|
||||
// MVC base frame references a keyframe on its OWN track (see the
|
||||
// block_additional path above). Gating to the primary video track avoids a
|
||||
// secondary video track's keyframe becoming a cross-track reference target.
|
||||
if keyframe && Some(track_idx) == self.primary_video_track {
|
||||
self.last_video_keyframe_ticks = Some(pts_ticks);
|
||||
}
|
||||
self.frame_count += 1;
|
||||
|
||||
@@ -1519,6 +1690,52 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut self.writer, bg_pos)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a BlockGroup carrying the base view Block plus the MVC dependent
|
||||
/// (right-eye) access unit as a `BlockAdditional` (BlockAddID=2), per the
|
||||
/// track's `mvcC` BlockAdditionMapping. A non-keyframe frame gets a
|
||||
/// `ReferenceBlock` (`reference` = referenced keyframe offset in ticks) so it
|
||||
/// is not mistaken for a seek point; `BlockDuration` is written when known.
|
||||
fn write_block_group_mvc(
|
||||
&mut self,
|
||||
track_num: usize,
|
||||
relative_ts: i16,
|
||||
data: &[u8],
|
||||
additional: &[u8],
|
||||
reference: Option<i64>,
|
||||
duration_ticks: Option<u64>,
|
||||
) -> io::Result<()> {
|
||||
let (tv, tv_len) = track_vint(track_num);
|
||||
let track_vint = &tv[..tv_len];
|
||||
// The 0x80 Keyframe flag is SimpleBlock-only; inside a BlockGroup Block
|
||||
// it is reserved and MUST be 0 — keyframe-ness is signalled by the
|
||||
// presence/absence of ReferenceBlock.
|
||||
let flags: u8 = 0x00;
|
||||
let block_size = track_vint.len() + 2 + 1 + data.len();
|
||||
|
||||
let bg_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_GROUP)?;
|
||||
ebml::write_id(&mut self.writer, ebml::BLOCK)?;
|
||||
ebml::write_size(&mut self.writer, block_size as u64)?;
|
||||
self.writer.write_all(track_vint)?;
|
||||
self.writer.write_all(&relative_ts.to_be_bytes())?;
|
||||
self.writer.write_all(&[flags])?;
|
||||
self.writer.write_all(data)?;
|
||||
if let Some(dt) = duration_ticks {
|
||||
ebml::write_uint(&mut self.writer, ebml::BLOCK_DURATION, dt)?;
|
||||
}
|
||||
if let Some(ref_off) = reference {
|
||||
ebml::write_int(&mut self.writer, ebml::REFERENCE_BLOCK, ref_off)?;
|
||||
}
|
||||
// BlockAdditions → BlockMore { BlockAddID=2, BlockAdditional=dependent AU }.
|
||||
let adds_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_ADDITIONS)?;
|
||||
let more_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_MORE)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::BLOCK_ADD_ID, BLOCK_ADD_ID_VALUE_MVC)?;
|
||||
ebml::write_binary(&mut self.writer, ebml::BLOCK_ADDITIONAL, additional)?;
|
||||
ebml::end_master(&mut self.writer, more_pos)?;
|
||||
ebml::end_master(&mut self.writer, adds_pos)?;
|
||||
ebml::end_master(&mut self.writer, bg_pos)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -1702,6 +1919,7 @@ mod tests {
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
hdr10: None,
|
||||
mvc_params: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1731,6 +1949,7 @@ mod tests {
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
hdr10: None,
|
||||
mvc_params: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1814,7 +2033,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], None)
|
||||
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None, None)
|
||||
.unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(
|
||||
@@ -1834,7 +2053,7 @@ mod tests {
|
||||
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], None)
|
||||
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
|
||||
.unwrap();
|
||||
muxer.finish().unwrap();
|
||||
|
||||
@@ -1860,7 +2079,7 @@ mod tests {
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, Some("NoCue"), 60.0, &[]).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
|
||||
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
|
||||
.unwrap();
|
||||
// Force the zero-cue branch: drop every cue before finalizing.
|
||||
let cues_entry_pos = muxer.cues_seek_entry_pos.expect("CUES seek entry recorded");
|
||||
@@ -2111,16 +2330,16 @@ mod tests {
|
||||
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], None)
|
||||
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None)
|
||||
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None)
|
||||
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None)
|
||||
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None, None)
|
||||
.unwrap();
|
||||
// Should not panic
|
||||
let data = muxer.writer.into_inner();
|
||||
@@ -2135,11 +2354,11 @@ mod tests {
|
||||
|
||||
// Record position before first frame
|
||||
let pos_before_kf = muxer.writer.position();
|
||||
muxer.write_frame(0, 0, true, &[0xAA], None).unwrap();
|
||||
muxer.write_frame(0, 0, true, &[0xAA], None, None).unwrap();
|
||||
let pos_after_kf = muxer.writer.position();
|
||||
|
||||
muxer
|
||||
.write_frame(0, 1_000_000, false, &[0xBB], None)
|
||||
.write_frame(0, 1_000_000, false, &[0xBB], None, None)
|
||||
.unwrap();
|
||||
let pos_after_nkf = muxer.writer.position();
|
||||
|
||||
@@ -2385,7 +2604,7 @@ mod tests {
|
||||
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();
|
||||
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
|
||||
}
|
||||
let frame_count = muxer.frame_count;
|
||||
muxer.finish().unwrap();
|
||||
@@ -2747,7 +2966,7 @@ mod tests {
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
||||
for f in &frames {
|
||||
muxer
|
||||
.write_frame(0, f.pts_ns, f.keyframe, &f.data, f.duration_ns)
|
||||
.write_frame(0, f.pts_ns, f.keyframe, &f.data, f.duration_ns, None)
|
||||
.unwrap();
|
||||
}
|
||||
muxer.finish().unwrap();
|
||||
@@ -3136,12 +3355,14 @@ mod tests {
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
||||
// Audio frames (track 1) and non-keyframe video — no track-0 keyframe.
|
||||
muxer.write_frame(1, 0, true, &[0xAA; 8], None).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 10_000_000, false, &[0xBB; 8], None)
|
||||
.write_frame(1, 0, true, &[0xAA; 8], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 20_000_000, true, &[0xCC; 8], None)
|
||||
.write_frame(0, 10_000_000, false, &[0xBB; 8], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 20_000_000, true, &[0xCC; 8], None, None)
|
||||
.unwrap();
|
||||
let err = muxer.finish().unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
@@ -3216,7 +3437,7 @@ mod tests {
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
||||
for (t, pts, kf, data) in &frames_in_order {
|
||||
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
|
||||
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
|
||||
}
|
||||
muxer.finish().unwrap();
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
@@ -3286,7 +3507,7 @@ mod tests {
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, &[]).unwrap();
|
||||
for (t, pts, kf, data, dur) in frames {
|
||||
muxer.write_frame(*t, *pts, *kf, data, *dur).unwrap();
|
||||
muxer.write_frame(*t, *pts, *kf, data, *dur, None).unwrap();
|
||||
}
|
||||
muxer.finish().unwrap();
|
||||
shared.lock().unwrap().clone().into_inner()
|
||||
@@ -4135,10 +4356,10 @@ mod tests {
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 10.0, &[]).unwrap();
|
||||
// Video keyframe 1000 bytes; audio frame 500 bytes.
|
||||
muxer
|
||||
.write_frame(0, 0, true, &vec![0xABu8; 1000], None)
|
||||
.write_frame(0, 0, true, &vec![0xABu8; 1000], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 0, false, &vec![0xCDu8; 500], None)
|
||||
.write_frame(1, 0, false, &vec![0xCDu8; 500], None, None)
|
||||
.unwrap();
|
||||
muxer.finish().unwrap();
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
@@ -4188,8 +4409,10 @@ mod tests {
|
||||
// lfeon(0) = 0b0100_0000 = 0x40. acmod_channels only needs >= 8 bytes.
|
||||
let ac3 = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8 << 3, 0x40, 0x00];
|
||||
// Open a cluster with a video keyframe first (cluster invariant).
|
||||
muxer.write_frame(0, 0, true, &[0x01, 0x02], None).unwrap();
|
||||
muxer.write_frame(1, 0, false, &ac3, None).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x01, 0x02], None, None)
|
||||
.unwrap();
|
||||
muxer.write_frame(1, 0, false, &ac3, None, None).unwrap();
|
||||
muxer.finish().unwrap();
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
|
||||
@@ -4234,6 +4457,149 @@ mod tests {
|
||||
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_decoder_config_record_matches_iso_14496_15_layout() {
|
||||
// subset SPS (NAL type 15): [nal_hdr, profile, compat, level, ...].
|
||||
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
|
||||
let pps = vec![0x68, 0xEE, 0x3C];
|
||||
let rec = mvc_decoder_config_record(&subset_sps, &pps).expect("record builds");
|
||||
|
||||
// ISO/IEC 14496-15:2013 §7.6.2 byte layout, verbatim:
|
||||
let expected: Vec<u8> = [
|
||||
vec![
|
||||
1, // configurationVersion
|
||||
0x80, // AVCProfileIndication = subset_sps[1]
|
||||
0x00, // profile_compatibility = subset_sps[2]
|
||||
0x33, // AVCLevelIndication = subset_sps[3]
|
||||
0xBF, // complete_rep(1) explicit_au(0) reserved'1111' lengthSizeMinusOne=3
|
||||
0x01, // reserved'0'(1) numOfSequenceParameterSets(7)=1
|
||||
0x00, 0x06, // sequenceParameterSetLength = 6
|
||||
],
|
||||
subset_sps.clone(),
|
||||
vec![
|
||||
1, // numOfPictureParameterSets
|
||||
0x00, 0x03, // pictureParameterSetLength = 3
|
||||
],
|
||||
pps.clone(),
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(rec, expected, "MVCDecoderConfigurationRecord byte layout");
|
||||
|
||||
// Guards: too-short SPS and empty PPS both refuse (no corrupt record).
|
||||
assert!(mvc_decoder_config_record(&[0x6F, 0x80, 0x00], &pps).is_none());
|
||||
assert!(mvc_decoder_config_record(&subset_sps, &[]).is_none());
|
||||
// Over-length param sets (> 65535) would mis-frame the 16-bit length
|
||||
// field; both must refuse rather than emit a truncated record.
|
||||
let huge = vec![0u8; 0x1_0000];
|
||||
assert!(mvc_decoder_config_record(&huge, &pps).is_none());
|
||||
assert!(mvc_decoder_config_record(&subset_sps, &huge).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_frame_emits_blockgroup_additional_and_reference() {
|
||||
// A track declaring an mvcC mapping: a keyframe base frame carrying a
|
||||
// dependent AU emits BlockGroup > BlockAdditions > BlockMore
|
||||
// {BlockAddID=2, BlockAdditional=dep}; a following non-keyframe adds a
|
||||
// ReferenceBlock so it is not mistaken for a seek point.
|
||||
let mut v = make_video_track();
|
||||
v.mvc_params = Some((
|
||||
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
|
||||
vec![0x68, 0xEE, 0x3C],
|
||||
));
|
||||
let mut muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
|
||||
let dep_kf = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||
let dep_p = [0xCAu8, 0xFE];
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x65, 0x01, 0x02], None, Some(&dep_kf))
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(0, 40_000_000, false, &[0x41, 0x03], None, Some(&dep_p))
|
||||
.unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
|
||||
assert!(
|
||||
find_id(&data, ebml::BLOCK_ADDITIONS).is_some(),
|
||||
"BlockAdditions (0x75A1) present"
|
||||
);
|
||||
// BlockAddID = 2 (uint): element 0xEE, size 0x81, value 0x02.
|
||||
assert!(
|
||||
data.windows(3).any(|w| w == [0xEE, 0x81, 0x02]),
|
||||
"BlockAddID must be 2"
|
||||
);
|
||||
assert!(
|
||||
data.windows(4).any(|w| w == dep_kf),
|
||||
"keyframe dependent AU present as BlockAdditional"
|
||||
);
|
||||
assert!(
|
||||
data.windows(2).any(|w| w == dep_p),
|
||||
"non-keyframe dependent AU present"
|
||||
);
|
||||
assert!(
|
||||
find_id(&data, ebml::REFERENCE_BLOCK).is_some(),
|
||||
"non-keyframe MVC base frame must carry a ReferenceBlock"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additional_dropped_when_track_has_no_mvc_mapping() {
|
||||
// If a track did NOT declare an mvcC mapping (mvc_params None), a stray
|
||||
// block_additional must be dropped (no orphaned BlockAddID=2 / no
|
||||
// BlockAdditions) so the file stays conforming.
|
||||
let mut muxer = MkvMuxer::new(
|
||||
Cursor::new(Vec::new()),
|
||||
&[make_video_track()],
|
||||
None,
|
||||
0.0,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x65, 0x01], None, Some(&[0xDE, 0xAD]))
|
||||
.unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(
|
||||
find_id(&data, ebml::BLOCK_ADDITIONS).is_none(),
|
||||
"no BlockAdditions without a declared mvcC mapping"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_track_emits_mvcc_block_addition_mapping() {
|
||||
// A track with mvc_params must emit BlockAdditionMapping (0x41E4) with the
|
||||
// mvcC BlockAddIDType (0x6D766343) and a BlockAddIDValue (0x41F0), so
|
||||
// players / mediainfo recognise the Blu-ray 3D dependent view.
|
||||
let mut v = make_video_track();
|
||||
v.mvc_params = Some((
|
||||
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
|
||||
vec![0x68, 0xEE, 0x3C],
|
||||
));
|
||||
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(
|
||||
find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_some(),
|
||||
"MVC track must emit BlockAdditionMapping"
|
||||
);
|
||||
assert!(
|
||||
find_id(&data, ebml::BLOCK_ADD_ID_VALUE).is_some(),
|
||||
"MVC mapping must carry BlockAddIDValue"
|
||||
);
|
||||
assert!(
|
||||
data.windows(4).any(|w| w == [0x6D, 0x76, 0x63, 0x43]),
|
||||
"mvcC fourcc must be present as the BlockAddIDType value"
|
||||
);
|
||||
// Without mvc_params, no mapping.
|
||||
let muxer = MkvMuxer::new(
|
||||
Cursor::new(Vec::new()),
|
||||
&[make_video_track()],
|
||||
None,
|
||||
0.0,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
||||
}
|
||||
|
||||
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
|
||||
//
|
||||
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
|
||||
|
||||
+549
-52
@@ -120,14 +120,206 @@ struct PendingMux {
|
||||
video_track: Option<usize>,
|
||||
/// `--log-level 3` opening-capture side-file path (if any).
|
||||
opening_capture_path: Option<std::path::PathBuf>,
|
||||
/// Frames received before activation, replayed in order once built.
|
||||
buffered: Vec<crate::pes::PesFrame>,
|
||||
/// Frames received before activation, replayed in order once built. Each
|
||||
/// carries an optional MVC dependent-view `BlockAdditional` (present only
|
||||
/// for a 3D base-view frame that was already paired before activation).
|
||||
buffered: Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>,
|
||||
}
|
||||
|
||||
/// Matroska container stream.
|
||||
pub struct MkvStream {
|
||||
disc_title: DiscTitle,
|
||||
mode: Mode,
|
||||
/// Blu-ray 3D (MVC) merge state — present iff the title carries an MVC
|
||||
/// dependent (right-eye) view. Folds the dependent stream's frames into the
|
||||
/// base video track as per-frame `BlockAdditional`, paired by PTS, so the
|
||||
/// output is a single MVC track instead of two independent H.264 tracks.
|
||||
mvc: Option<MvcMerge>,
|
||||
}
|
||||
|
||||
/// Largest number of base frames held awaiting their PTS-matching dependent AU
|
||||
/// before the oldest is flushed unpaired (a plain Block). The SSIF interleaves
|
||||
/// base and dependent access units per unit, so a base's dependent normally
|
||||
/// arrives within one or two frames; this window only bounds memory/latency for
|
||||
/// a stream where the pairing drifts.
|
||||
const MVC_PAIR_WINDOW: usize = 32;
|
||||
|
||||
/// A base-view frame (track already remapped to the muxer's base track index)
|
||||
/// awaiting — or already carrying — its dependent-view `BlockAdditional`.
|
||||
struct PendingBase {
|
||||
frame: crate::pes::PesFrame,
|
||||
additional: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// State for folding the MVC dependent (right-eye) view into the base track.
|
||||
struct MvcMerge {
|
||||
/// `title.streams` index of the base (left-eye) video stream.
|
||||
base_stream_idx: usize,
|
||||
/// `title.streams` index of the dependent (right-eye) video stream.
|
||||
dep_stream_idx: usize,
|
||||
/// Muxer track index of the base view — where the dependent AU is attached
|
||||
/// as a `BlockAdditional` and where `mvc_params` (the `mvcC` mapping) lives.
|
||||
base_track_idx: usize,
|
||||
/// `title.streams` index → muxer track index. The dependent maps to `None`
|
||||
/// (it becomes a BlockAdditional, not a track); every other stream shifts
|
||||
/// down by one if it followed the dependent in stream order.
|
||||
stream_to_track: Vec<Option<usize>>,
|
||||
/// Base frames (decode order) awaiting their dependent or a window flush.
|
||||
pending_base: std::collections::VecDeque<PendingBase>,
|
||||
/// Dependent AU data keyed by PTS, waiting for the matching base.
|
||||
dep_by_pts: std::collections::HashMap<i64, Vec<u8>>,
|
||||
/// `(subset_sps, pps)` from the first dependent AU — builds the `mvcC`
|
||||
/// MVCDecoderConfigurationRecord for the base track's BlockAdditionMapping.
|
||||
captured_params: Option<(Vec<u8>, Vec<u8>)>,
|
||||
/// Count of dependent AUs dropped with no matching base (diagnostic).
|
||||
orphan_deps: u64,
|
||||
}
|
||||
|
||||
impl MvcMerge {
|
||||
/// Ingest one incoming frame; returns `(frame, additional)` pairs ready to
|
||||
/// hand to the muxer, in emit order. Base frames buffer briefly to pair with
|
||||
/// their dependent by PTS; the dependent stream produces no frames of its own
|
||||
/// (it becomes `BlockAdditional`); all other streams pass straight through
|
||||
/// with their track index remapped.
|
||||
fn ingest(
|
||||
&mut self,
|
||||
frame: &crate::pes::PesFrame,
|
||||
) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
|
||||
let mut out = Vec::new();
|
||||
if frame.track == self.dep_stream_idx {
|
||||
if self.captured_params.is_none() {
|
||||
self.captured_params = extract_mvc_params(&frame.data);
|
||||
}
|
||||
// Attach to a waiting base of the same PTS, else stash by PTS.
|
||||
if let Some(pb) = self
|
||||
.pending_base
|
||||
.iter_mut()
|
||||
.find(|pb| pb.frame.pts == frame.pts && pb.additional.is_none())
|
||||
{
|
||||
pb.additional = Some(frame.data.clone());
|
||||
} else {
|
||||
// Bound the orphan map BEFORE inserting: if dependents pile up
|
||||
// unpaired (pairing badly drifted), drop the drifted buffer so it
|
||||
// stays bounded — but keep THIS just-arrived dependent, whose base
|
||||
// frame commonly arrives next. Clearing after the insert would
|
||||
// discard it and overcount orphans by one.
|
||||
if self.dep_by_pts.len() >= MVC_PAIR_WINDOW * 4 {
|
||||
self.orphan_deps += self.dep_by_pts.len() as u64;
|
||||
self.dep_by_pts.clear();
|
||||
}
|
||||
// A duplicate-PTS dependent (e.g. a stale repeat after a stream
|
||||
// discontinuity) displaces the prior one — count it as an orphan
|
||||
// rather than losing it silently.
|
||||
if self
|
||||
.dep_by_pts
|
||||
.insert(frame.pts, frame.data.clone())
|
||||
.is_some()
|
||||
{
|
||||
self.orphan_deps += 1;
|
||||
}
|
||||
}
|
||||
} else if frame.track == self.base_stream_idx {
|
||||
let additional = self.dep_by_pts.remove(&frame.pts);
|
||||
let mut remapped = frame.clone();
|
||||
remapped.track = self.base_track_idx;
|
||||
self.pending_base.push_back(PendingBase {
|
||||
frame: remapped,
|
||||
additional,
|
||||
});
|
||||
} else {
|
||||
// Audio / subtitle / other video: remap the track index and forward.
|
||||
let mut remapped = frame.clone();
|
||||
if let Some(Some(t)) = self.stream_to_track.get(frame.track) {
|
||||
remapped.track = *t;
|
||||
out.push((remapped, None));
|
||||
}
|
||||
}
|
||||
self.drain_ready(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit base frames from the FIFO front once each has its dependent attached,
|
||||
/// or flush the oldest unpaired base as a plain Block when the window is full.
|
||||
fn drain_ready(&mut self, out: &mut Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>) {
|
||||
loop {
|
||||
let front_ready = self
|
||||
.pending_base
|
||||
.front()
|
||||
.map(|pb| pb.additional.is_some())
|
||||
.unwrap_or(false);
|
||||
if front_ready || self.pending_base.len() > MVC_PAIR_WINDOW {
|
||||
if let Some(pb) = self.pending_base.pop_front() {
|
||||
out.push((pb.frame, pb.additional));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush every remaining buffered base frame (unpaired → plain Block) at EOF.
|
||||
fn flush(&mut self) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
|
||||
let mut out = Vec::new();
|
||||
for pb in self.pending_base.drain(..) {
|
||||
out.push((pb.frame, pb.additional));
|
||||
}
|
||||
self.orphan_deps += self.dep_by_pts.len() as u64;
|
||||
self.dep_by_pts.clear();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a frame to the muxer, attaching the MVC dependent view as a
|
||||
/// `BlockAdditional` when `additional` is `Some` (a 3D base frame), else a
|
||||
/// plain block.
|
||||
fn emit_to_muxer(
|
||||
m: &mut MkvMuxer<Box<dyn WriteSeek + Send>>,
|
||||
frame: &crate::pes::PesFrame,
|
||||
additional: Option<&[u8]>,
|
||||
) -> io::Result<()> {
|
||||
m.write_frame(
|
||||
frame.track,
|
||||
frame.pts,
|
||||
frame.keyframe,
|
||||
&frame.data,
|
||||
frame.duration_ns,
|
||||
additional,
|
||||
)
|
||||
}
|
||||
|
||||
/// Scan a length-prefixed (4-byte big-endian) H.264 NAL stream for the first
|
||||
/// subset SPS (NAL type 15) and first PPS (NAL type 8) — the two parameter sets
|
||||
/// that populate the `mvcC` MVCDecoderConfigurationRecord. Returns
|
||||
/// `Some((subset_sps, pps))` only when BOTH are found; `None` otherwise (the
|
||||
/// serializer then emits no mvcC mapping and logs it).
|
||||
fn extract_mvc_params(data: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
let mut subset_sps: Option<Vec<u8>> = None;
|
||||
let mut pps: Option<Vec<u8>> = None;
|
||||
let mut i = 0usize;
|
||||
while i + 4 <= data.len() {
|
||||
let len = u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
|
||||
i += 4;
|
||||
// A zero-length NAL (a stray length prefix) is skipped, not fatal — the
|
||||
// subset SPS / PPS may still follow. A length that runs past the buffer
|
||||
// end IS unrecoverable (the NAL can't be read), so stop there.
|
||||
if len == 0 {
|
||||
continue;
|
||||
}
|
||||
if i + len > data.len() {
|
||||
break;
|
||||
}
|
||||
let nal = &data[i..i + len];
|
||||
i += len;
|
||||
match nal[0] & 0x1F {
|
||||
15 if subset_sps.is_none() => subset_sps = Some(nal.to_vec()),
|
||||
8 if pps.is_none() => pps = Some(nal.to_vec()),
|
||||
_ => {}
|
||||
}
|
||||
if subset_sps.is_some() && pps.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some((subset_sps?, pps?))
|
||||
}
|
||||
|
||||
impl MkvStream {
|
||||
@@ -146,10 +338,38 @@ impl MkvStream {
|
||||
title: &DiscTitle,
|
||||
output_path: Option<&std::path::Path>,
|
||||
) -> io::Result<Self> {
|
||||
// Blu-ray 3D (MVC): a dependent (right-eye) view stream is NOT emitted as
|
||||
// its own track — it is folded into the base track as per-frame
|
||||
// BlockAdditional. Detect it so we skip building a track for it and set up
|
||||
// the merge. `base_stream_idx` is the first video stream.
|
||||
let dep_stream_idx = title
|
||||
.streams
|
||||
.iter()
|
||||
.position(|s| matches!(s, crate::disc::Stream::Video(v) if v.is_mvc_dependent()));
|
||||
// The base is the first NON-dependent video. Excluding the dependent here
|
||||
// means a (malformed / hand-built) title whose only video IS the dependent
|
||||
// yields `base_stream_idx == None` → no merge (the dependent is muxed as an
|
||||
// ordinary track) instead of `base == dep` and a panic on the skipped slot.
|
||||
let base_stream_idx = title
|
||||
.streams
|
||||
.iter()
|
||||
.position(|s| matches!(s, crate::disc::Stream::Video(v) if !v.is_mvc_dependent()));
|
||||
// The merge is only active when BOTH a dependent and a distinct base exist;
|
||||
// only then is the dependent's track skipped/folded.
|
||||
let mvc_active = dep_stream_idx.is_some() && base_stream_idx.is_some();
|
||||
let skip_stream_idx = if mvc_active { dep_stream_idx } else { None };
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
let mut has_default_video = false;
|
||||
let mut has_default_audio = false;
|
||||
// `title.streams` index → muxer track index (`None` = the dependent view,
|
||||
// which has no track). Streams after the dependent shift down by one.
|
||||
let mut stream_to_track: Vec<Option<usize>> = Vec::with_capacity(title.streams.len());
|
||||
for (idx, s) in title.streams.iter().enumerate() {
|
||||
if Some(idx) == skip_stream_idx {
|
||||
stream_to_track.push(None);
|
||||
continue;
|
||||
}
|
||||
let mut track = match s {
|
||||
crate::disc::Stream::Video(v) => MkvTrack::video(v),
|
||||
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
|
||||
@@ -166,9 +386,31 @@ impl MkvStream {
|
||||
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
|
||||
track.codec_private = Some(cp.clone());
|
||||
}
|
||||
stream_to_track.push(Some(tracks.len()));
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
// Assemble the MVC merge only when active — i.e. a dependent AND a
|
||||
// distinct base video both exist (established above). `base_stream_idx`
|
||||
// then always has a built track, so its remap is `Some` (no panic path).
|
||||
let mvc = match (mvc_active, dep_stream_idx, base_stream_idx) {
|
||||
(true, Some(dep_stream_idx), Some(base_stream_idx)) => stream_to_track
|
||||
.get(base_stream_idx)
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(|base_track_idx| MvcMerge {
|
||||
base_stream_idx,
|
||||
dep_stream_idx,
|
||||
base_track_idx,
|
||||
stream_to_track,
|
||||
pending_base: std::collections::VecDeque::new(),
|
||||
dep_by_pts: std::collections::HashMap::new(),
|
||||
captured_params: None,
|
||||
orphan_deps: 0,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Defer muxer construction (and the TrackEntry dump) until the first
|
||||
// coded picture arrives, so the primary video track's FieldOrder is set
|
||||
// from the parser's MEASURED value before the header is written — never
|
||||
@@ -177,6 +419,7 @@ impl MkvStream {
|
||||
|
||||
Ok(Self {
|
||||
disc_title: title.clone(),
|
||||
mvc,
|
||||
mode: Mode::Write(WriteMode::Pending(Box::new(PendingMux {
|
||||
writer,
|
||||
tracks,
|
||||
@@ -209,6 +452,24 @@ impl MkvStream {
|
||||
if let Some(vt) = pending.video_track {
|
||||
apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen);
|
||||
}
|
||||
// Blu-ray 3D: set the base video track's `mvc_params` from the dependent
|
||||
// view's captured subset-SPS/PPS BEFORE the header is written, so the
|
||||
// TrackEntry carries the `mvcC` BlockAdditionMapping. Captured from the
|
||||
// first dependent AU (which arrives right after the first base AU in the
|
||||
// SSIF), so it is available by the time the first base frame activates.
|
||||
if let Some(mvc) = &self.mvc {
|
||||
if let Some(params) = &mvc.captured_params {
|
||||
if let Some(t) = pending.tracks.get_mut(mvc.base_track_idx) {
|
||||
t.mvc_params = Some(params.clone());
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"MVC: no dependent-view subset-SPS/PPS captured before activation; \
|
||||
the base track will carry no mvcC mapping (3D not signalled)."
|
||||
);
|
||||
}
|
||||
}
|
||||
// --log-level 3: dump the FINAL TrackEntry metadata (field order set).
|
||||
for (i, track) in pending.tracks.iter().enumerate() {
|
||||
crate::diag::dump_mkv_track((i + 1) as u64, track);
|
||||
@@ -223,18 +484,72 @@ impl MkvStream {
|
||||
if let Some(path) = &pending.opening_capture_path {
|
||||
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
|
||||
}
|
||||
for f in pending.buffered.drain(..) {
|
||||
muxer.write_frame(f.track, f.pts, f.keyframe, &f.data, f.duration_ns)?;
|
||||
for (f, additional) in pending.buffered.drain(..) {
|
||||
muxer.write_frame(
|
||||
f.track,
|
||||
f.pts,
|
||||
f.keyframe,
|
||||
&f.data,
|
||||
f.duration_ns,
|
||||
additional.as_deref(),
|
||||
)?;
|
||||
}
|
||||
self.mode = Mode::Write(WriteMode::Active(Box::new(muxer)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit one frame (track index already muxer-relative) with an optional MVC
|
||||
/// dependent-view `BlockAdditional`, honouring the deferred-activation
|
||||
/// machinery: the first video frame triggers muxer construction (its coding
|
||||
/// sets FieldOrder); earlier frames buffer. `additional` is `None` for every
|
||||
/// non-3D frame and for the 3D base frames that had no paired dependent.
|
||||
fn emit(&mut self, frame: &crate::pes::PesFrame, additional: Option<&[u8]>) -> io::Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
|
||||
Mode::Write(WriteMode::Active(m)) => {
|
||||
return emit_to_muxer(m, frame, additional);
|
||||
}
|
||||
Mode::Write(WriteMode::Building) => return Ok(()),
|
||||
Mode::Write(WriteMode::Pending(_)) => {}
|
||||
}
|
||||
// Pending: the first video frame (or the safety cap) triggers muxer
|
||||
// construction; that frame's coding sets the field order. Other frames
|
||||
// buffer until then.
|
||||
let (activate_now, use_coding) = match &self.mode {
|
||||
Mode::Write(WriteMode::Pending(p)) => {
|
||||
let is_video = match p.video_track {
|
||||
Some(vt) => frame.track == vt,
|
||||
// No video track: nothing to wait for — build on frame one.
|
||||
None => true,
|
||||
};
|
||||
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
|
||||
}
|
||||
_ => unreachable!("guarded above"),
|
||||
};
|
||||
if activate_now {
|
||||
// Pass the trigger frame's coding only when it IS the video frame; a
|
||||
// cap-triggered build never saw the video frame, so nothing measured
|
||||
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
|
||||
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
|
||||
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
|
||||
return emit_to_muxer(m, frame, additional);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
|
||||
p.buffered
|
||||
.push((frame.clone(), additional.map(|a| a.to_vec())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an MKV file for reading → PES frames.
|
||||
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
|
||||
let (disc_title, codec_privates, ts_scale_ns) = parse_mkv_header(&mut reader)?;
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
mvc: None,
|
||||
mode: Mode::Read(ReadState {
|
||||
reader: Box::new(reader),
|
||||
cluster_ts_ticks: 0,
|
||||
@@ -425,59 +740,40 @@ impl crate::pes::Stream for MkvStream {
|
||||
}
|
||||
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
// Fast paths.
|
||||
match &mut self.mode {
|
||||
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
|
||||
Mode::Write(WriteMode::Active(m)) => {
|
||||
return m.write_frame(
|
||||
frame.track,
|
||||
frame.pts,
|
||||
frame.keyframe,
|
||||
&frame.data,
|
||||
frame.duration_ns,
|
||||
);
|
||||
}
|
||||
Mode::Write(WriteMode::Building) => return Ok(()),
|
||||
Mode::Write(WriteMode::Pending(_)) => {}
|
||||
if matches!(self.mode, Mode::Read(_)) {
|
||||
return Err(crate::error::Error::StreamReadOnly.into());
|
||||
}
|
||||
// Pending: the first video frame (or the safety cap) triggers muxer
|
||||
// construction; that frame's coding sets the field order. Other frames
|
||||
// buffer until then.
|
||||
let (activate_now, use_coding) = match &self.mode {
|
||||
Mode::Write(WriteMode::Pending(p)) => {
|
||||
let is_video = match p.video_track {
|
||||
Some(vt) => frame.track == vt,
|
||||
// No video track: nothing to wait for — build on frame one.
|
||||
None => true,
|
||||
};
|
||||
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
|
||||
}
|
||||
_ => unreachable!("guarded above"),
|
||||
};
|
||||
if activate_now {
|
||||
// Pass the trigger frame's coding only when it IS the video frame; a
|
||||
// cap-triggered build never saw the video frame, so nothing measured
|
||||
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
|
||||
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
|
||||
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
|
||||
return m.write_frame(
|
||||
frame.track,
|
||||
frame.pts,
|
||||
frame.keyframe,
|
||||
&frame.data,
|
||||
frame.duration_ns,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
|
||||
p.buffered.push(frame.clone());
|
||||
}
|
||||
Ok(())
|
||||
// Non-3D fast path: emit the frame directly, no clone, no buffering.
|
||||
if self.mvc.is_none() {
|
||||
return self.emit(frame, None);
|
||||
}
|
||||
// Blu-ray 3D: run the frame through the MVC merge, which remaps track
|
||||
// indices, folds the dependent view into the base as BlockAdditional
|
||||
// (paired by PTS), and yields 0+ frames ready to emit. `ingest` returns
|
||||
// owned pairs so the `self.mvc` borrow is released before `emit`.
|
||||
let emits = self.mvc.as_mut().unwrap().ingest(frame);
|
||||
for (f, additional) in emits {
|
||||
self.emit(&f, additional.as_deref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
// Blu-ray 3D: flush any base frames still awaiting a dependent (emitted
|
||||
// unpaired as plain Blocks) before finalizing.
|
||||
if let Some(mvc) = self.mvc.as_mut() {
|
||||
let tail = mvc.flush();
|
||||
let orphans = mvc.orphan_deps;
|
||||
if orphans > 0 {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"MVC: {orphans} dependent-view access units had no matching base frame (dropped)"
|
||||
);
|
||||
}
|
||||
for (f, additional) in tail {
|
||||
self.emit(&f, additional.as_deref())?;
|
||||
}
|
||||
}
|
||||
// A title that produced no frames (or only buffered ones) is still
|
||||
// finalized into a valid MKV: activate now with no measured coding.
|
||||
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
|
||||
@@ -916,6 +1212,207 @@ mod tests {
|
||||
use crate::pes::Stream as _;
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Length-prefix (4-byte big-endian) each NAL, as the H.264 parser emits.
|
||||
fn lp(nals: &[&[u8]]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
for n in nals {
|
||||
v.extend_from_slice(&(n.len() as u32).to_be_bytes());
|
||||
v.extend_from_slice(n);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn mvc_frame(track: usize, pts: i64, keyframe: bool, data: Vec<u8>) -> crate::pes::PesFrame {
|
||||
crate::pes::PesFrame {
|
||||
track,
|
||||
pts,
|
||||
keyframe,
|
||||
data,
|
||||
duration_ns: None,
|
||||
source: None,
|
||||
coding: None,
|
||||
}
|
||||
}
|
||||
|
||||
// A subset SPS (NAL type 15), a PPS (type 8), and a coded-slice-extension
|
||||
// (type 20) — the shape of a dependent-view access unit.
|
||||
const SUBSET_SPS: [u8; 5] = [0x6F, 0x80, 0x00, 0x33, 0xAA]; // 0x6F & 0x1F = 15
|
||||
const DEP_PPS: [u8; 3] = [0x68, 0xEE, 0x3C]; // 0x68 & 0x1F = 8
|
||||
const DEP_SLICE: [u8; 3] = [0x74, 0x11, 0x22]; // 0x74 & 0x1F = 20
|
||||
|
||||
#[test]
|
||||
fn extract_mvc_params_finds_subset_sps_and_pps() {
|
||||
let data = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
|
||||
let (s, p) = extract_mvc_params(&data).expect("both param sets present");
|
||||
assert_eq!(s, SUBSET_SPS, "subset SPS (NAL 15) captured verbatim");
|
||||
assert_eq!(p, DEP_PPS, "PPS (NAL 8) captured verbatim");
|
||||
// Missing PPS → None (the serializer then emits no mvcC mapping).
|
||||
assert!(extract_mvc_params(&lp(&[&SUBSET_SPS, &DEP_SLICE])).is_none());
|
||||
// Missing subset SPS → None.
|
||||
assert!(extract_mvc_params(&lp(&[&DEP_PPS, &DEP_SLICE])).is_none());
|
||||
}
|
||||
|
||||
fn empty_merge() -> MvcMerge {
|
||||
MvcMerge {
|
||||
base_stream_idx: 0,
|
||||
dep_stream_idx: 2,
|
||||
base_track_idx: 0,
|
||||
stream_to_track: vec![Some(0), Some(1), None],
|
||||
pending_base: std::collections::VecDeque::new(),
|
||||
dep_by_pts: std::collections::HashMap::new(),
|
||||
captured_params: None,
|
||||
orphan_deps: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_pairs_base_and_dependent_by_pts() {
|
||||
let mut m = empty_merge();
|
||||
let dep = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
|
||||
|
||||
// Base arrives first (SSIF order): buffered, nothing emitted yet.
|
||||
let e = m.ingest(&mvc_frame(0, 100, true, lp(&[&[0x65, 1, 2]])));
|
||||
assert!(e.is_empty(), "base held until its dependent arrives");
|
||||
|
||||
// Dependent arrives → base is emitted, remapped to the base track, with
|
||||
// the dependent AU as its BlockAdditional; params are captured.
|
||||
let e = m.ingest(&mvc_frame(2, 100, false, dep.clone()));
|
||||
assert_eq!(e.len(), 1, "the paired base frame is emitted");
|
||||
assert_eq!(e[0].0.track, 0, "remapped to the base muxer track");
|
||||
assert_eq!(
|
||||
e[0].1.as_deref(),
|
||||
Some(dep.as_slice()),
|
||||
"dependent attached"
|
||||
);
|
||||
assert!(m.captured_params.is_some(), "mvcC params captured");
|
||||
|
||||
// Audio passes straight through (remapped, no additional).
|
||||
let e = m.ingest(&mvc_frame(1, 100, true, vec![0xAA]));
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].0.track, 1);
|
||||
assert!(e[0].1.is_none());
|
||||
|
||||
// Dependent-before-base (reordered) also pairs.
|
||||
let dep2 = lp(&[&DEP_SLICE]);
|
||||
assert!(m.ingest(&mvc_frame(2, 200, false, dep2.clone())).is_empty());
|
||||
let e = m.ingest(&mvc_frame(0, 200, false, lp(&[&[0x61, 3, 4]])));
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].1.as_deref(), Some(dep2.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_flushes_unpaired_base_at_eof() {
|
||||
let mut m = empty_merge();
|
||||
// Base with no dependent ever → held, then flushed unpaired at EOF.
|
||||
assert!(
|
||||
m.ingest(&mvc_frame(0, 10, true, vec![0, 0, 0, 1]))
|
||||
.is_empty()
|
||||
);
|
||||
let tail = m.flush();
|
||||
assert_eq!(tail.len(), 1, "unpaired base still emitted");
|
||||
assert!(tail[0].1.is_none(), "no BlockAdditional when unpaired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_mvc_params_no_panic_on_truncated_or_empty() {
|
||||
// Empty, sub-header, zero-length NAL, and a length prefix claiming more
|
||||
// than is present must all return None without panicking (untrusted AU).
|
||||
assert!(extract_mvc_params(&[]).is_none());
|
||||
assert!(extract_mvc_params(&[0, 0, 0]).is_none());
|
||||
assert!(
|
||||
extract_mvc_params(&[0, 0, 0, 0]).is_none(),
|
||||
"lone zero-length NAL yields no params"
|
||||
);
|
||||
assert!(
|
||||
extract_mvc_params(&[0, 0, 0, 10, 0x6F]).is_none(),
|
||||
"length prefix past end breaks, no slice panic"
|
||||
);
|
||||
// A zero-length NAL is SKIPPED, not fatal: valid param sets that follow
|
||||
// are still found (a stray length prefix must not abandon the whole AU).
|
||||
let mut d = vec![0, 0, 0, 0];
|
||||
d.extend_from_slice(&lp(&[&SUBSET_SPS, &DEP_PPS]));
|
||||
let (s, p) = extract_mvc_params(&d).expect("params found past the zero-length NAL");
|
||||
assert_eq!(s, SUBSET_SPS);
|
||||
assert_eq!(p, DEP_PPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_flushes_oldest_base_once_past_window() {
|
||||
let mut m = empty_merge();
|
||||
// Push more unpaired base frames than the window; the excess flush as
|
||||
// plain (unpaired) blocks in FIFO order once len exceeds MVC_PAIR_WINDOW.
|
||||
let n = MVC_PAIR_WINDOW + 8;
|
||||
let mut emitted = 0usize;
|
||||
for pts in 0..n {
|
||||
emitted += m
|
||||
.ingest(&mvc_frame(0, pts as i64, false, vec![0, 0, 0, 1]))
|
||||
.len();
|
||||
}
|
||||
assert_eq!(emitted, 8, "the {n} bases beyond the window flush unpaired");
|
||||
assert_eq!(m.pending_base.len(), MVC_PAIR_WINDOW, "window still held");
|
||||
assert!(m.flush().iter().all(|(_, add)| add.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_dep_overflow_drops_old_keeps_newest() {
|
||||
let mut m = empty_merge();
|
||||
// Fill dep_by_pts to the bound with unpaired dependents (unique PTS).
|
||||
for pts in 0..(MVC_PAIR_WINDOW * 4) {
|
||||
assert!(
|
||||
m.ingest(&mvc_frame(2, pts as i64, false, lp(&[&DEP_SLICE])))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
assert_eq!(m.dep_by_pts.len(), MVC_PAIR_WINDOW * 4);
|
||||
// One more overflows: the drifted buffer is cleared BUT the newest survives
|
||||
// so its (soon-to-arrive) base can still pair.
|
||||
let dep_new = lp(&[&DEP_SLICE]);
|
||||
m.ingest(&mvc_frame(2, 9_999, false, dep_new.clone()));
|
||||
assert_eq!(m.dep_by_pts.len(), 1, "old cleared, newest kept");
|
||||
assert!(m.dep_by_pts.contains_key(&9_999));
|
||||
assert_eq!(
|
||||
m.orphan_deps,
|
||||
(MVC_PAIR_WINDOW * 4) as u64,
|
||||
"old buffer counted once"
|
||||
);
|
||||
// The surviving dependent pairs with its base.
|
||||
let e = m.ingest(&mvc_frame(0, 9_999, false, vec![0x61, 1]));
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].1.as_deref(), Some(dep_new.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_does_not_panic_when_only_video_is_mvc_dependent() {
|
||||
// A (malformed / hand-built) title whose single video IS the dependent
|
||||
// must NOT panic: base_stream_idx is None, so no merge is set up and the
|
||||
// dependent is muxed as an ordinary track.
|
||||
use crate::disc::{
|
||||
Codec, ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream, VideoStream,
|
||||
};
|
||||
let dep = VideoStream {
|
||||
pid: 0x1012,
|
||||
codec: Codec::H264,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F24,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
display_aspect: None,
|
||||
secondary: true,
|
||||
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
|
||||
measured_cicp: None,
|
||||
};
|
||||
let title = DiscTitle {
|
||||
streams: vec![Stream::Video(dep)],
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &title)
|
||||
.expect("create must succeed, not panic");
|
||||
assert!(
|
||||
s.mvc.is_none(),
|
||||
"no merge when there is no distinct base view"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_coding_to_track_sets_measured_field_order_never_guesses() {
|
||||
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
|
||||
|
||||
+11
-1
@@ -575,7 +575,17 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, idx));
|
||||
let is_dvd_ps = matches!(format, ContentFormat::MpegPs);
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
|
||||
// The Blu-ray 3D MVC dependent (right-eye) view uses a param-set-
|
||||
// passthrough H.264 parser so each frame is a self-contained dependent
|
||||
// access unit for a BlockAdditional; every other stream uses the
|
||||
// ordinary parser for its codec.
|
||||
let parser = match s {
|
||||
crate::disc::Stream::Video(v) if v.is_mvc_dependent() => {
|
||||
super::codec::parser_for_mvc_dependent(codec, is_dvd_ps)
|
||||
}
|
||||
_ => super::codec::parser_for_codec(codec, None, is_dvd_ps),
|
||||
};
|
||||
parsers.push((pid, parser));
|
||||
}
|
||||
let (ts, ps) = match format {
|
||||
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),
|
||||
|
||||
+58
-5
@@ -593,10 +593,27 @@ impl UdfFs {
|
||||
match next_block {
|
||||
Some(cont_lba) => {
|
||||
read_sector(reader, self.meta_to_abs(cont_lba)?, &mut block)?;
|
||||
// A continuation block is a list of Short ADs from byte 0,
|
||||
// spanning the whole sector.
|
||||
ad_start = 0;
|
||||
ad_bytes = block.len();
|
||||
// A continuation block does NOT begin with allocation
|
||||
// descriptors — it begins with a 24-byte Allocation Extent
|
||||
// Descriptor (ECMA-167 4/14.5, tag id 258): the 16-byte
|
||||
// descriptor tag, then prev_allocation_extent_location
|
||||
// (Uint32 @16) and length_of_allocation_descriptors
|
||||
// (Uint32 @20). The real ADs (same Short/Long/Extended type
|
||||
// as the file) start at offset 24, and their total byte
|
||||
// length is that @20 field.
|
||||
//
|
||||
// Reading from offset 0 parses the AED's own tag header as
|
||||
// allocation descriptors → one garbage extent, then an
|
||||
// unknown extent_type break — silently truncating every
|
||||
// file whose ADs spill into a continuation block (a heavily
|
||||
// fragmented file, e.g. a Blu-ray 3D interleaved base-view
|
||||
// .m2ts with ~1600 fragments). A normal few-extent .m2ts
|
||||
// fits inline and never reaches here, which is why this
|
||||
// stayed hidden.
|
||||
let aed_l_ad =
|
||||
u32::from_le_bytes([block[20], block[21], block[22], block[23]]) as usize;
|
||||
ad_start = 24;
|
||||
ad_bytes = aed_l_ad.min(block.len().saturating_sub(24));
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
@@ -1535,8 +1552,15 @@ mod tests {
|
||||
|
||||
/// A continuation block: a bare list of short ADs from byte 0.
|
||||
fn build_cont_block(ads: &[(u32, u32, u32)]) -> [u8; 2048] {
|
||||
// A continuation block is an Allocation Extent Descriptor (ECMA-167
|
||||
// 4/14.5): a 16-byte descriptor tag, then prev_allocation_extent_location
|
||||
// (Uint32 @16) and length_of_allocation_descriptors (Uint32 @20). The
|
||||
// actual allocation descriptors begin at offset 24. The parser skips the
|
||||
// 24-byte header and reads `l_ad` bytes of ADs from there.
|
||||
let mut s = [0u8; 2048];
|
||||
let mut off = 0usize;
|
||||
let l_ad = (ads.len() * 8) as u32;
|
||||
s[20..24].copy_from_slice(&l_ad.to_le_bytes());
|
||||
let mut off = 24usize;
|
||||
for &(etype, dlen, dlba) in ads {
|
||||
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
|
||||
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
|
||||
@@ -1590,6 +1614,35 @@ mod tests {
|
||||
assert_eq!(extents, vec![(10, 4096), (20, 2048)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icb_extents_continuation_skips_aed_header_not_read_as_extent() {
|
||||
// Regression (Blu-ray 3D): a continuation block begins with a 24-byte
|
||||
// Allocation Extent Descriptor whose 16-byte descriptor tag holds
|
||||
// NON-zero bytes (tag id 258, CRC, location, ...). Reading allocation
|
||||
// descriptors from offset 0 parses that tag header as an AD → a garbage
|
||||
// extent, then an unknown extent_type break → every fragment past the
|
||||
// first continuation is lost. On a 3D disc that truncated the 25.8 GB
|
||||
// interleaved base-view feature to ~1.8 GB (113 of ~1600 fragments) and
|
||||
// then choked the mux on the bogus, non-unit-aligned extent. The parser
|
||||
// MUST skip the 24-byte AED header and read the real ADs from offset 24.
|
||||
let icb = build_efe(6144, &[(0, 4096, 10), (3, 2048, 50)]);
|
||||
let mut cont = build_cont_block(&[(0, 2048, 20)]);
|
||||
// Stamp a realistic AED descriptor tag (id 258) into the header so a
|
||||
// regression that reads from offset 0 mis-parses it as a bogus extent
|
||||
// instead of finding the real (20, 2048) at offset 24.
|
||||
cont[0..2].copy_from_slice(&258u16.to_le_bytes());
|
||||
let mut reader = MapReader::new();
|
||||
reader.put(5, icb);
|
||||
reader.put(50, cont);
|
||||
let fs = fs_with(0, 0, file_entry("3D", 5, 6144));
|
||||
let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
|
||||
assert_eq!(
|
||||
extents,
|
||||
vec![(10, 4096), (20, 2048)],
|
||||
"continuation ADs must be read past the 24-byte AED header, not from offset 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icb_extents_long_ad_returns_all_extents_not_just_first() {
|
||||
// Regression: BD-ROM large .m2ts files use Long ADs (16-byte
|
||||
|
||||
Reference in New Issue
Block a user