DVD vob_start absolute rebase + rc.5.3 audit fixes
- ifo.rs: rebase VTS title VOBS to absolute disc LBA (file_start_lba + vtstt_vobs); fixes DVD rips opening on the menu region instead of the movie (e.g. SOTL). Adds absolute-placement regression test. - aacs/boil.rs: add mk_from_pk primitive (PK -> MK via MKB walk). - dvdnav/: nav-VM command decoder + start-cell resolver seam, parked behind USE_NAV_RESOLVER (kept compiled, never executed). - mux: FVI src.byte within-sector per spec; Unknown colour -> CICP unspecified (2,2,2,1); demux clear PCS -> NORMAL; ts.rs feed() base reset + boundary provenance fix. - Assorted audit fixes (doc/comment/test accuracy) across the crate.
This commit is contained in:
+16
-1
@@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [1.0.0-rc.6] — UNRELEASED
|
## [1.1.0-beta.1] — UNRELEASED
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -13,6 +13,21 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **DVD rips now start on the movie, not the disc menu.** A VTS title VOB's
|
||||||
|
start sector was read from the IFO as a VTS-relative pointer but used as an
|
||||||
|
absolute disc address, so a DVD title's read extents began `ifo_lba` sectors
|
||||||
|
too early — the rip opened on the disc's menu / VMGI region and only drifted
|
||||||
|
into the feature minutes later (Silence of the Lambs, for example, showed
|
||||||
|
several minutes of the main menu before the movie). The title VOB is now
|
||||||
|
rebased to its absolute on-disc location, so the rip begins at the first frame
|
||||||
|
of the feature. Aspect ratio and chapter timing were already correct; only the
|
||||||
|
starting sector was wrong. (Covered by a new absolute-placement regression
|
||||||
|
test.)
|
||||||
|
- **Container metadata correctness.** Unknown colorimetry now emits the CICP
|
||||||
|
"unspecified" code point (2) consistently across the MKV track and the FVI
|
||||||
|
sidecar (previously 0); PGS subtitle wipes use the NORMAL composition state
|
||||||
|
rather than a full epoch reset; and FVI source-byte offsets are written
|
||||||
|
within-sector per the format spec.
|
||||||
- **Multi-extent AACS alignment in `dir://` extraction.** The decrypted
|
- **Multi-extent AACS alignment in `dir://` extraction.** The decrypted
|
||||||
file-tree extractor anchored the AACS unit grid to the first extent only, so a
|
file-tree extractor anchored the AACS unit grid to the first extent only, so a
|
||||||
file spanning multiple clip extents could mis-align and corrupt. Each extent
|
file spanning multiple clip extents could mis-align and corrupt. Each extent
|
||||||
|
|||||||
+109
-1
@@ -15,11 +15,17 @@
|
|||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! mk_from_dk(device_keys, mkb, vid) → MediaKey (Km)
|
//! mk_from_dk(device_keys, mkb, vid) → MediaKey (Km)
|
||||||
|
//! mk_from_pk(processing_keys, mkb) → MediaKey (Km)
|
||||||
//! vuk_from_mk(MediaKey, Vid) → Vuk (= AES-G(Km, VID))
|
//! vuk_from_mk(MediaKey, Vid) → Vuk (= AES-G(Km, VID))
|
||||||
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each)
|
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each)
|
||||||
//! ```
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `mk_from_dk` and `mk_from_pk` are two entry points to the SAME Media Key:
|
||||||
|
//! the device-key path walks the MKB's Media-Key-Variant chain, the
|
||||||
|
//! processing-key path walks the MKB's Subset-Difference cvalue tables. Neither
|
||||||
|
//! needs a VID (the VID enters at `vuk_from_mk`).
|
||||||
|
|
||||||
use super::keys::{decrypt_unit_key, derive_vuk};
|
use super::keys::{decrypt_unit_key, derive_media_key_from_pk, derive_vuk};
|
||||||
use super::types::DeviceKey;
|
use super::types::DeviceKey;
|
||||||
use super::variants::{KEY_CORRECTION_DATA_PLACEHOLDER, derive_media_key_variant, walk_mkb};
|
use super::variants::{KEY_CORRECTION_DATA_PLACEHOLDER, derive_media_key_variant, walk_mkb};
|
||||||
|
|
||||||
@@ -111,6 +117,31 @@ pub fn mk_from_dk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive the Media Key (Km) from one or more Processing Keys and the disc MKB.
|
||||||
|
///
|
||||||
|
/// Wraps [`derive_media_key_from_pk`] — the Subset-Difference PK→MK walk: each
|
||||||
|
/// processing key is validated (and tree-walked) against the MKB's cvalue tables
|
||||||
|
/// (records `0x04`/`0x05`) until one yields the Media Key whose verify record
|
||||||
|
/// (`0x81`/`0x86`) matches. Unlike [`mk_from_dk`] this path is reachable for
|
||||||
|
/// real discs — a leaked/precomputed AACS Processing Key in the keydb resolves
|
||||||
|
/// the Media Key directly. No VID is involved at this step; the VID enters at
|
||||||
|
/// [`vuk_from_mk`].
|
||||||
|
///
|
||||||
|
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no processing key resolves
|
||||||
|
/// the MKB — the same terminal error as [`mk_from_dk`]; no numeric distinction
|
||||||
|
/// is load-bearing at this boundary.
|
||||||
|
///
|
||||||
|
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
|
||||||
|
pub fn mk_from_pk(
|
||||||
|
processing_keys: &[[u8; 16]],
|
||||||
|
mkb: &[u8],
|
||||||
|
) -> Result<MediaKey, crate::error::Error> {
|
||||||
|
match derive_media_key_from_pk(mkb, processing_keys) {
|
||||||
|
Some(km) => Ok(MediaKey(km)),
|
||||||
|
None => Err(crate::error::Error::AacsMkUnavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -197,4 +228,81 @@ mod tests {
|
|||||||
let e2 = mk_from_dk(&[dk], &mkb, Vid([0x09; 16]));
|
let e2 = mk_from_dk(&[dk], &mkb, Vid([0x09; 16]));
|
||||||
assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable)));
|
assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a 4-byte MKB record header (type + 3-byte big-endian total length,
|
||||||
|
/// header included) and append `body`. Mirrors the MKB record framing the
|
||||||
|
/// parser expects; no crypto.
|
||||||
|
fn mkb_record(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||||
|
let total = 4 + body.len();
|
||||||
|
let mut rec = Vec::with_capacity(total);
|
||||||
|
rec.push(rec_type);
|
||||||
|
rec.push(((total >> 16) & 0xFF) as u8);
|
||||||
|
rec.push(((total >> 8) & 0xFF) as u8);
|
||||||
|
rec.push((total & 0xFF) as u8);
|
||||||
|
rec.extend_from_slice(body);
|
||||||
|
rec
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `mk_from_pk` resolves a planted Processing Key against a synthetic MKB and
|
||||||
|
/// drives the FULL boil chain PK → MK → VUK → UK. The MKB is built with the
|
||||||
|
/// same (pk, cv, mk_dv, uv) construction the production SD walk validates, so
|
||||||
|
/// this proves a PK entry yields real Unit Keys — not just an `Ok`.
|
||||||
|
#[test]
|
||||||
|
fn mk_from_pk_drives_full_chain_to_uks() {
|
||||||
|
let pk: [u8; 16] = [
|
||||||
|
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||||
|
0xFF, 0x00,
|
||||||
|
];
|
||||||
|
let mk: [u8; 16] = [
|
||||||
|
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||||
|
0xAE, 0xAF,
|
||||||
|
];
|
||||||
|
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||||
|
|
||||||
|
// cv = AES-E(pk, mk_raw), where mk_raw is mk with the last-4-bytes-uv XOR
|
||||||
|
// pre-undone, so the validate step XORs uv back in and recovers mk.
|
||||||
|
let mut mk_raw = mk;
|
||||||
|
for a in 0..4 {
|
||||||
|
mk_raw[12 + a] ^= uv[a];
|
||||||
|
}
|
||||||
|
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||||
|
|
||||||
|
// mk_dv = AES-E(mk, magic||pad): AES-D(mk, mk_dv) starts with the AACS
|
||||||
|
// verify sentinel.
|
||||||
|
let mut vd = [0x11u8; 16];
|
||||||
|
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||||
|
let mk_dv = aes_ecb_encrypt(&mk, &vd);
|
||||||
|
|
||||||
|
// Synthetic MKB: type/version (0x10), verify record (0x86 = mk_dv),
|
||||||
|
// one-entry SD index (0x04 = [u_mask_shift=0][uv]), one-entry cvalue
|
||||||
|
// table (0x05 = cv).
|
||||||
|
let mut sd = vec![0u8];
|
||||||
|
sd.extend_from_slice(&uv);
|
||||||
|
let mut mkb = Vec::new();
|
||||||
|
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||||
|
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
|
||||||
|
mkb.extend_from_slice(&mkb_record(0x04, &sd));
|
||||||
|
mkb.extend_from_slice(&mkb_record(0x05, &cv));
|
||||||
|
|
||||||
|
// PK → MK.
|
||||||
|
let got_mk = mk_from_pk(std::slice::from_ref(&pk), &mkb).expect("planted PK resolves MK");
|
||||||
|
assert_eq!(got_mk, MediaKey(mk), "mk_from_pk recovers the planted MK");
|
||||||
|
|
||||||
|
// MK → VUK → UK over an encrypted title key.
|
||||||
|
let vid = Vid([0x42u8; 16]);
|
||||||
|
let plain_uk = [0x7Eu8; 16];
|
||||||
|
let vuk = vuk_from_mk(got_mk, vid);
|
||||||
|
let enc = aes_ecb_encrypt(&vuk.0, &plain_uk);
|
||||||
|
let uks = uk_from_vuk(vuk, std::slice::from_ref(&enc));
|
||||||
|
assert_eq!(uks.len(), 1);
|
||||||
|
assert_eq!(uks[0].key, plain_uk, "PK chain recovers the title key");
|
||||||
|
|
||||||
|
// A corrupt PK resolves nothing.
|
||||||
|
let mut bad = pk;
|
||||||
|
bad[0] ^= 0xFF;
|
||||||
|
assert!(matches!(
|
||||||
|
mk_from_pk(std::slice::from_ref(&bad), &mkb),
|
||||||
|
Err(crate::error::Error::AacsMkUnavailable)
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -24,7 +24,7 @@ pub mod types;
|
|||||||
pub mod variants;
|
pub mod variants;
|
||||||
|
|
||||||
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
|
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
|
||||||
pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, uk_from_vuk, vuk_from_mk};
|
pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk};
|
||||||
// Structured, English-free resolution trace.
|
// Structured, English-free resolution trace.
|
||||||
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
|
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
|
||||||
|
|
||||||
@@ -35,6 +35,10 @@ pub use decrypt::{
|
|||||||
decrypt_unit_full, decrypt_unit_try_keys, is_aacs_scrambled, is_unit_aligned, ts_packet_total,
|
decrypt_unit_full, decrypt_unit_try_keys, is_aacs_scrambled, is_unit_aligned, ts_packet_total,
|
||||||
ts_sync_count, unit_key_validates,
|
ts_sync_count, unit_key_validates,
|
||||||
};
|
};
|
||||||
|
// `probe` is a reproduction-harness helper (see keys.rs), not part of the
|
||||||
|
// documented 1.0 surface; keep it reachable but off the rendered docs so we
|
||||||
|
// don't commit semver stability to test primitives.
|
||||||
|
#[doc(hidden)]
|
||||||
pub use keys::probe;
|
pub use keys::probe;
|
||||||
pub use keys::{
|
pub use keys::{
|
||||||
AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, MKB_TYPE_3_RECORDABLE,
|
AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, MKB_TYPE_3_RECORDABLE,
|
||||||
@@ -97,5 +101,6 @@ mod tests {
|
|||||||
let _ = mkb_content_len(&[]);
|
let _ = mkb_content_len(&[]);
|
||||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||||
|
let _ = mk_from_pk(&[[0u8; 16]], &[]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -48,8 +48,9 @@ pub const BD_SOURCE_PACKET_BYTES: usize = TS_PACKET_BYTES + BD_TIMESTAMP_PREFIX_
|
|||||||
/// This is one registry used in two places that share the same value space:
|
/// This is one registry used in two places that share the same value space:
|
||||||
/// the MPEG-TS PMT `stream_type` (ISO/IEC 13818-1 Table 2-34) and the Blu-ray
|
/// the MPEG-TS PMT `stream_type` (ISO/IEC 13818-1 Table 2-34) and the Blu-ray
|
||||||
/// STN/CLPI `stream_coding_type` (BD-ROM Part 3). The standardized video codes
|
/// STN/CLPI `stream_coding_type` (BD-ROM Part 3). The standardized video codes
|
||||||
/// (`0x02`, `0x1B`, `0x24`, `0xEA`) are ISO assignments; the `0x80..=0xA2`
|
/// (`0x02`, `0x1B`, `0x24`) are ISO assignments (ISO/IEC 13818-1 Table 2-34);
|
||||||
/// audio/graphics codes sit in the ISO user-private range and follow the
|
/// `0xEA` (VC-1) is a BD-ROM convention in the ISO user-private range. The
|
||||||
|
/// `0x80..=0xA2` audio/graphics codes also sit in the user-private range and follow the
|
||||||
/// Blu-ray Disc Association / ATSC A/52 convention. Because every consumer
|
/// Blu-ray Disc Association / ATSC A/52 convention. Because every consumer
|
||||||
/// reads or writes this single byte, the family is unprefixed — the scope is
|
/// reads or writes this single byte, the family is unprefixed — the scope is
|
||||||
/// "any elementary stream freemkv parses or muxes".
|
/// "any elementary stream freemkv parses or muxes".
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crack_unscrambled_returns_none() {
|
fn crack_unscrambled_returns_none() {
|
||||||
let sector = vec![0u8; 2048];
|
let sector = vec![0u8; SECTOR_BYTES];
|
||||||
assert!(crack_title_key(§or).is_none());
|
assert!(crack_title_key(§or).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +414,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recover_needs_min_plain() {
|
fn recover_needs_min_plain() {
|
||||||
let sector = vec![0u8; 2048];
|
let sector = vec![0u8; SECTOR_BYTES];
|
||||||
let short_plain = [0u8; 4];
|
let short_plain = [0u8; 4];
|
||||||
assert!(recover_title_key(§or, &short_plain).is_none());
|
assert!(recover_title_key(§or, &short_plain).is_none());
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -373,9 +373,16 @@ pub fn decrypt_sectors(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let crib = css::stevenson::attack_crib(chunk);
|
let crib = css::stevenson::attack_crib(chunk);
|
||||||
let original: Option<Vec<u8>> = crib.as_ref().map(|_| chunk.to_vec());
|
// Snapshot the ciphertext into a stack buffer (chunk is exactly
|
||||||
|
// 2048 here — guaranteed by the `< 2048` continue above) only
|
||||||
|
// when there's a crib to validate against, so the common
|
||||||
|
// cache-hit path costs no per-sector heap allocation.
|
||||||
|
let mut original = [0u8; 2048];
|
||||||
|
if crib.is_some() {
|
||||||
|
original.copy_from_slice(chunk);
|
||||||
|
}
|
||||||
css::lfsr::descramble_sector(title_key, chunk);
|
css::lfsr::descramble_sector(title_key, chunk);
|
||||||
if let (Some(crib), Some(original)) = (crib, original) {
|
if let Some(crib) = crib {
|
||||||
if chunk[0x80..0x80 + 10] != crib[..] {
|
if chunk[0x80..0x80 + 10] != crib[..] {
|
||||||
// Cached key is stale for this region — restore the
|
// Cached key is stale for this region — restore the
|
||||||
// ciphertext and crack this sector's own key.
|
// ciphertext and crack this sector's own key.
|
||||||
|
|||||||
+119
-24
@@ -106,7 +106,7 @@ impl Disc {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for dvd_title in &ts.titles {
|
for (vts_title_idx, dvd_title) in ts.titles.iter().enumerate() {
|
||||||
title_number += 1;
|
title_number += 1;
|
||||||
|
|
||||||
// Diagnostic dump (--log-level 3): per-cell category table +
|
// Diagnostic dump (--log-level 3): per-cell category table +
|
||||||
@@ -114,13 +114,32 @@ impl Disc {
|
|||||||
// per-cell IFO detail. No-op unless freemkv::diag is enabled.
|
// per-cell IFO detail. No-op unless freemkv::diag is enabled.
|
||||||
crate::diag::dump_dvd_cells(ts.vts_number, title_number, dvd_title);
|
crate::diag::dump_dvd_cells(ts.vts_number, title_number, dvd_title);
|
||||||
|
|
||||||
// Bug-4 leading-cell filter: drop any leading scene-index /
|
// Feature start cell. Prefer the DVD nav-VM resolver, which
|
||||||
// interleaved-angle sub-block cells so the feature starts at the
|
// PARKED (#40, menu-at-start playback). The "menu at the start"
|
||||||
// movie. Conservative — `feature_start_cell` only ever skips a
|
// symptom (e.g. SOTL) was a sector-mapping fault — the absolute
|
||||||
// prefix of secondary-block cells and never truncates a normal
|
// VOB rebase in `ifo::parse_vts` (`vob_start_sector =
|
||||||
// feature (category 0x00 on cell 0 → no-op). See
|
// file_start_lba + vtstt_vobs`) — NOT a navigation problem, so
|
||||||
// `ifo::DvdTitle::feature_start_cell`.
|
// feature-start resolution is unnecessary for correct rips. The
|
||||||
let feature_start = dvd_title.feature_start_cell();
|
// nav resolver + verified VM decoder (`dvdnav`) are kept compiled
|
||||||
|
// but deliberately bypassed; flip `USE_NAV_RESOLVER` to re-enable
|
||||||
|
// once the nav executor is finished. The fallback is the
|
||||||
|
// structural leading-cell filter (`feature_start_cell`), which
|
||||||
|
// drops leading scene-index / interleaved-angle sub-block cells
|
||||||
|
// and is a no-op for a normal feature (category 0x00 on cell 0).
|
||||||
|
// See `dvdnav::resolve_feature_start`.
|
||||||
|
const USE_NAV_RESOLVER: bool = false;
|
||||||
|
let feature_start = if USE_NAV_RESOLVER {
|
||||||
|
crate::dvdnav::resolve_feature_start(
|
||||||
|
reader,
|
||||||
|
udf_fs,
|
||||||
|
ts.vts_number as u16,
|
||||||
|
(vts_title_idx + 1) as u16,
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|| dvd_title.feature_start_cell())
|
||||||
|
} else {
|
||||||
|
dvd_title.feature_start_cell()
|
||||||
|
}
|
||||||
|
.min(dvd_title.cells.len());
|
||||||
let dropped_secs: f64 = dvd_title.cells[..feature_start]
|
let dropped_secs: f64 = dvd_title.cells[..feature_start]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| c.duration_secs)
|
.map(|c| c.duration_secs)
|
||||||
@@ -136,9 +155,9 @@ impl Disc {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build extents from cell sector ranges (absolute = vob_start + cell offset)
|
// Build extents from cell sector ranges (absolute = vob_start + cell offset),
|
||||||
let extents: Vec<Extent> = dvd_title
|
// starting at the resolved feature-start cell.
|
||||||
.feature_cells()
|
let extents: Vec<Extent> = dvd_title.cells[feature_start..]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|cell| {
|
.map(|cell| {
|
||||||
let start = ts.vob_start_sector.saturating_add(cell.first_sector);
|
let start = ts.vob_start_sector.saturating_add(cell.first_sector);
|
||||||
@@ -553,8 +572,10 @@ mod tests {
|
|||||||
assert_eq!(titles.len(), 1);
|
assert_eq!(titles.len(), 1);
|
||||||
let t = &titles[0];
|
let t = &titles[0];
|
||||||
assert_eq!(t.extents.len(), 1);
|
assert_eq!(t.extents.len(), 1);
|
||||||
// absolute start = vob_start(1000) + first_sector(10) = 1010.
|
// absolute start = ifo_lba + vtstt_vobs(1000) + first_sector(10).
|
||||||
assert_eq!(t.extents[0].start_lba, 1010);
|
// The IFO file sits at PART_START(3000) + data_lba(6000) = 9000, so
|
||||||
|
// 9000 + 1000 + 10 = 10010.
|
||||||
|
assert_eq!(t.extents[0].start_lba, 10010);
|
||||||
// inclusive: 109 - 10 + 1 = 100 sectors.
|
// inclusive: 109 - 10 + 1 = 100 sectors.
|
||||||
assert_eq!(t.extents[0].sector_count, 100);
|
assert_eq!(t.extents[0].sector_count, 100);
|
||||||
// DVD sector = 2048 bytes.
|
// DVD sector = 2048 bytes.
|
||||||
@@ -607,12 +628,85 @@ mod tests {
|
|||||||
assert_eq!(titles.len(), 1);
|
assert_eq!(titles.len(), 1);
|
||||||
let t = &titles[0];
|
let t = &titles[0];
|
||||||
assert_eq!(t.extents.len(), 1);
|
assert_eq!(t.extents.len(), 1);
|
||||||
// Title VOBS (3640) + cell first_sector (0) = 3640 — NOT the menu 44.
|
// ifo_lba(9000) + vtstt_vobs(3640) + first_sector(0) = 12640 — built
|
||||||
|
// from the Title VOBS (0xC4), NOT the menu VOBS (0xC0). The IFO file is
|
||||||
|
// at PART_START(3000) + data_lba(6000) = 9000.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
t.extents[0].start_lba, 12640,
|
||||||
|
"extent must start at ifo_lba + vtstt_vobs (0xC4), not vtsm_vobs (0xC0)"
|
||||||
|
);
|
||||||
|
// Must not resolve from the menu VOBS (would be 9000 + 44 = 9044), nor
|
||||||
|
// use the raw IFO-relative vtstt_vobs (3640) without the absolute base.
|
||||||
|
assert_ne!(t.extents[0].start_lba, 9044, "must not use the menu VOBS");
|
||||||
|
assert_ne!(
|
||||||
t.extents[0].start_lba, 3640,
|
t.extents[0].start_lba, 3640,
|
||||||
"extent must start at vtstt_vobs (0xC4), not vtsm_vobs (0xC0)"
|
"must add the IFO's absolute disc LBA, not use the raw relative value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ABSOLUTE-REBASE regression (THESILENCEOFTHELAMBS / Greenland fix):
|
||||||
|
/// `ifo::parse_vts` now sets `vob_start_sector = file_start_lba(IFO) +
|
||||||
|
/// vtstt_vobs`, so an extent's `start_lba` must equal the sum of THREE
|
||||||
|
/// independent terms — the IFO file's absolute on-disc LBA, the
|
||||||
|
/// IFO-relative `vtstt_vobs` (0xC4), and the cell's `first_sector` — none of
|
||||||
|
/// which may be dropped. The earlier code used the bare relative
|
||||||
|
/// `vtstt_vobs`, placing every extent `ifo_lba` sectors too early (the rip
|
||||||
|
/// opened in the VMGI/menu region before drifting into the movie). The
|
||||||
|
/// other tests fold two of the three terms together (zero cell offset, or
|
||||||
|
/// a single combined expectation); this one keeps all three distinct and
|
||||||
|
/// non-overlapping so a regression to ANY two-term combination is caught.
|
||||||
|
#[test]
|
||||||
|
fn scan_dvd_titles_extent_is_absolute_three_term_sum() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||||
|
// vtstt_vobs (Title VOBS, 0xC4) = 700; one cell first_sector = 33.
|
||||||
|
let vts = build_vts(700, 0x00, &[], &[], &[(33, 132)], false);
|
||||||
|
// IFO data at data_lba 6000 → absolute ifo_lba = PART_START(3000) + 6000.
|
||||||
|
let ifo_lba = PART_START + 6000; // 9000
|
||||||
|
let vtstt_vobs = 700u32;
|
||||||
|
let first_sector = 33u32;
|
||||||
|
let udf = build_video_ts_fs(
|
||||||
|
&mut disc,
|
||||||
|
&[
|
||||||
|
FileSpec {
|
||||||
|
name: "VIDEO_TS.IFO".into(),
|
||||||
|
icb_lba: 60,
|
||||||
|
data_lba: 5000,
|
||||||
|
contents: vmg,
|
||||||
|
},
|
||||||
|
FileSpec {
|
||||||
|
name: "VTS_01_0.IFO".into(),
|
||||||
|
icb_lba: 62,
|
||||||
|
data_lba: 6000,
|
||||||
|
contents: vts,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||||
|
assert_eq!(t.extents.len(), 1);
|
||||||
|
let got = t.extents[0].start_lba;
|
||||||
|
// The one correct answer: all three terms summed (9000 + 700 + 33).
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
ifo_lba + vtstt_vobs + first_sector,
|
||||||
|
"extent start must be file_start_lba(IFO) + vtstt_vobs + cell.first_sector"
|
||||||
|
);
|
||||||
|
// Each wrong two-term combination must be rejected:
|
||||||
|
assert_ne!(
|
||||||
|
got,
|
||||||
|
vtstt_vobs + first_sector,
|
||||||
|
"must not use the bare relative vtstt_vobs (missing the IFO's absolute LBA)"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
got,
|
||||||
|
ifo_lba + first_sector,
|
||||||
|
"must not drop vtstt_vobs (the Title VOBS pointer)"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
got,
|
||||||
|
ifo_lba + vtstt_vobs,
|
||||||
|
"must not drop the cell's first_sector offset"
|
||||||
);
|
);
|
||||||
assert_ne!(t.extents[0].start_lba, 44, "must not use the menu VOBS");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Multi-cell title: extents preserve cell order and each maps to its
|
/// Multi-cell title: extents preserve cell order and each maps to its
|
||||||
@@ -648,9 +742,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||||
assert_eq!(t.extents.len(), 2);
|
assert_eq!(t.extents.len(), 2);
|
||||||
assert_eq!(t.extents[0].start_lba, 500); // 500 + 0
|
assert_eq!(t.extents[0].start_lba, 9500); // ifo_lba(9000) + 500 + 0
|
||||||
assert_eq!(t.extents[0].sector_count, 100);
|
assert_eq!(t.extents[0].sector_count, 100);
|
||||||
assert_eq!(t.extents[1].start_lba, 700); // 500 + 200
|
assert_eq!(t.extents[1].start_lba, 9700); // ifo_lba(9000) + 500 + 200
|
||||||
assert_eq!(t.extents[1].sector_count, 100);
|
assert_eq!(t.extents[1].sector_count, 100);
|
||||||
assert_eq!(t.size_bytes, 200 * 2048);
|
assert_eq!(t.size_bytes, 200 * 2048);
|
||||||
}
|
}
|
||||||
@@ -1051,8 +1145,9 @@ mod tests {
|
|||||||
assert_eq!(titles[0].playlist, "VTS_01_1.VOB");
|
assert_eq!(titles[0].playlist, "VTS_01_1.VOB");
|
||||||
assert_eq!(titles[1].playlist, "VTS_02_2.VOB");
|
assert_eq!(titles[1].playlist, "VTS_02_2.VOB");
|
||||||
// Distinct vob_start → distinct extents.
|
// Distinct vob_start → distinct extents.
|
||||||
assert_eq!(titles[0].extents[0].start_lba, 100);
|
// VTS_01 IFO @ PART_START(3000)+6000=9000; VTS_02 IFO @ 3000+7000=10000.
|
||||||
assert_eq!(titles[1].extents[0].start_lba, 200);
|
assert_eq!(titles[0].extents[0].start_lba, 9100); // 9000 + 100
|
||||||
|
assert_eq!(titles[1].extents[0].start_lba, 10200); // 10000 + 200
|
||||||
}
|
}
|
||||||
|
|
||||||
/// chapter_times from the IFO become Chapter entries with ordinal
|
/// chapter_times from the IFO become Chapter entries with ordinal
|
||||||
@@ -1168,8 +1263,8 @@ mod tests {
|
|||||||
// The leading 0x90 cell is dropped: 2 feature extents, not 3.
|
// The leading 0x90 cell is dropped: 2 feature extents, not 3.
|
||||||
assert_eq!(t.extents.len(), 2, "leading angle sub-block cell dropped");
|
assert_eq!(t.extents.len(), 2, "leading angle sub-block cell dropped");
|
||||||
// First extent starts at the feature cell (vob 1000 + 100), not at 1000+0.
|
// First extent starts at the feature cell (vob 1000 + 100), not at 1000+0.
|
||||||
assert_eq!(t.extents[0].start_lba, 1000 + 100);
|
assert_eq!(t.extents[0].start_lba, 9000 + 1000 + 100); // ifo_lba + vtstt + first
|
||||||
assert_eq!(t.extents[1].start_lba, 1000 + 300);
|
assert_eq!(t.extents[1].start_lba, 9000 + 1000 + 300);
|
||||||
// Chapter times shift earlier by the dropped 5s. Program 0 was at the
|
// Chapter times shift earlier by the dropped 5s. Program 0 was at the
|
||||||
// dropped head (clamped to 0); program 1 was at cell 3 =
|
// dropped head (clamped to 0); program 1 was at cell 3 =
|
||||||
// dur(cell0)+dur(cell1) = 5 + 59 = 64s, now 59s after the 5s shift.
|
// dur(cell0)+dur(cell1) = 5 + 59 = 64s, now 59s after the 5s shift.
|
||||||
@@ -1219,8 +1314,8 @@ mod tests {
|
|||||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||||
// Nothing dropped: both cells become extents, starting at the very head.
|
// Nothing dropped: both cells become extents, starting at the very head.
|
||||||
assert_eq!(t.extents.len(), 2);
|
assert_eq!(t.extents.len(), 2);
|
||||||
assert_eq!(t.extents[0].start_lba, 1000); // 1000 + 0, head intact
|
assert_eq!(t.extents[0].start_lba, 9000 + 1000); // ifo_lba + vtstt + 0, head intact
|
||||||
assert_eq!(t.extents[1].start_lba, 1200);
|
assert_eq!(t.extents[1].start_lba, 9000 + 1200);
|
||||||
// Chapter 0 stays at 0.0 (no shift).
|
// Chapter 0 stays at 0.0 (no shift).
|
||||||
assert!((t.chapters[0].time_secs - 0.0).abs() < 0.01);
|
assert!((t.chapters[0].time_secs - 0.0).abs() < 0.01);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -234,9 +234,12 @@ impl Disc {
|
|||||||
|
|
||||||
// Acquire (rather than Relaxed) on these per-file delta loads:
|
// Acquire (rather than Relaxed) on these per-file delta loads:
|
||||||
// `extract_tree` drives `dec` single-threaded so there is no race
|
// `extract_tree` drives `dec` single-threaded so there is no race
|
||||||
// today, but Acquire costs nothing on x86 and gives a happens-
|
// today, and Acquire costs nothing on x86. Note this is only half
|
||||||
// before edge if file extraction is ever parallelised, so the
|
// the synchronisation: the paired counter store
|
||||||
// delta can never read a torn/stale counter across iterations.
|
// (sector/decrypting.rs `fetch_add`) is Relaxed, so an Acquire
|
||||||
|
// load alone does NOT yet establish a happens-before edge. Before
|
||||||
|
// file extraction is parallelised, upgrade that store to Release
|
||||||
|
// (or stronger) so the delta cannot read a stale counter.
|
||||||
let before_loss = decrypt_loss.load(Ordering::Acquire);
|
let before_loss = decrypt_loss.load(Ordering::Acquire);
|
||||||
let (mut fr, halted) =
|
let (mut fr, halted) =
|
||||||
extract_one_file(&mut dec, dest, pf, total_bytes, &mut done_bytes, opts)?;
|
extract_one_file(&mut dec, dest, pf, total_bytes, &mut done_bytes, opts)?;
|
||||||
|
|||||||
+12
-12
@@ -825,12 +825,12 @@ pub(super) fn handle_read_success<R: SectorSource + ?Sized>(
|
|||||||
state.stall_start = (state.now)();
|
state.stall_start = (state.now)();
|
||||||
state.bytes_good_last = bytes_good_now;
|
state.bytes_good_last = bytes_good_now;
|
||||||
}
|
}
|
||||||
if (state.now)().duration_since(state.stall_start) > std::time::Duration::from_secs(STALL_SECS)
|
let stall_elapsed = (state.now)().duration_since(state.stall_start);
|
||||||
{
|
if stall_elapsed > std::time::Duration::from_secs(STALL_SECS) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "patch_stall",
|
phase = "patch_stall",
|
||||||
elapsed_secs = (state.now)().duration_since(state.stall_start).as_secs(),
|
elapsed_secs = stall_elapsed.as_secs(),
|
||||||
bytes_good = bytes_good_now,
|
bytes_good = bytes_good_now,
|
||||||
bytes_good_start = state.bytes_good_start,
|
bytes_good_start = state.bytes_good_start,
|
||||||
"Patch stalled - no recovery for {}s, exiting pass",
|
"Patch stalled - no recovery for {}s, exiting pass",
|
||||||
@@ -1107,13 +1107,12 @@ pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
|
|||||||
state.stall_start = (state.now)();
|
state.stall_start = (state.now)();
|
||||||
state.bytes_good_last = bytes_good_now;
|
state.bytes_good_last = bytes_good_now;
|
||||||
}
|
}
|
||||||
if (state.now)().duration_since(state.stall_start)
|
let stall_elapsed = (state.now)().duration_since(state.stall_start);
|
||||||
> std::time::Duration::from_secs(STALL_SECS)
|
if stall_elapsed > std::time::Duration::from_secs(STALL_SECS) {
|
||||||
{
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "patch_stall",
|
phase = "patch_stall",
|
||||||
elapsed_secs = (state.now)().duration_since(state.stall_start).as_secs(),
|
elapsed_secs = stall_elapsed.as_secs(),
|
||||||
bytes_good = bytes_good_now,
|
bytes_good = bytes_good_now,
|
||||||
bytes_good_start = state.bytes_good_start,
|
bytes_good_start = state.bytes_good_start,
|
||||||
"Patch stalled (NOT_READY path) - no recovery for {}s, exiting pass",
|
"Patch stalled (NOT_READY path) - no recovery for {}s, exiting pass",
|
||||||
@@ -1193,12 +1192,12 @@ pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
|
|||||||
state.stall_start = (state.now)();
|
state.stall_start = (state.now)();
|
||||||
state.bytes_good_last = bytes_good_now;
|
state.bytes_good_last = bytes_good_now;
|
||||||
}
|
}
|
||||||
if (state.now)().duration_since(state.stall_start) > std::time::Duration::from_secs(STALL_SECS)
|
let stall_elapsed = (state.now)().duration_since(state.stall_start);
|
||||||
{
|
if stall_elapsed > std::time::Duration::from_secs(STALL_SECS) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "patch_stall",
|
phase = "patch_stall",
|
||||||
elapsed_secs = (state.now)().duration_since(state.stall_start).as_secs(),
|
elapsed_secs = stall_elapsed.as_secs(),
|
||||||
consecutive_failures = state.consecutive_failures,
|
consecutive_failures = state.consecutive_failures,
|
||||||
bytes_good = bytes_good_now,
|
bytes_good = bytes_good_now,
|
||||||
bytes_good_start = state.bytes_good_start,
|
bytes_good_start = state.bytes_good_start,
|
||||||
@@ -1472,13 +1471,14 @@ pub(super) fn check_range_watchdog(
|
|||||||
state.range_bytes_good = bytes_good_now;
|
state.range_bytes_good = bytes_good_now;
|
||||||
state.range_start = (state.now)();
|
state.range_start = (state.now)();
|
||||||
}
|
}
|
||||||
if (state.now)().duration_since(state.range_start).as_secs() >= frame.range_budget_secs {
|
let range_elapsed = (state.now)().duration_since(state.range_start);
|
||||||
|
if range_elapsed.as_secs() >= frame.range_budget_secs {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "patch_range_stall",
|
phase = "patch_range_stall",
|
||||||
range_lba = frame.range_pos / 2048,
|
range_lba = frame.range_pos / 2048,
|
||||||
range_sectors = frame.range_sectors,
|
range_sectors = frame.range_sectors,
|
||||||
elapsed_secs = (state.now)().duration_since(state.range_start).as_secs(),
|
elapsed_secs = range_elapsed.as_secs(),
|
||||||
budget_secs = frame.range_budget_secs,
|
budget_secs = frame.range_budget_secs,
|
||||||
bytes_recovered = state.range_bytes_good.saturating_sub(state.bytes_good_before),
|
bytes_recovered = state.range_bytes_good.saturating_sub(state.bytes_good_before),
|
||||||
"Range stalled - moving to next range"
|
"Range stalled - moving to next range"
|
||||||
|
|||||||
+5
-1
@@ -433,8 +433,12 @@ impl Drive {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
// No unlocker matched: not an error — fall through to OEM route.
|
// No unlocker matched, or one matched but only hit a capability
|
||||||
|
// failure (not firmware-unlockable / no OEM VID): not an error —
|
||||||
|
// fall through to the OEM host-cert route.
|
||||||
Ok(None) => Ok(()),
|
Ok(None) => Ok(()),
|
||||||
|
// A genuine transport fault during unlock (UnlockError::Scsi)
|
||||||
|
// propagates here and aborts init — the bus is dead.
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//! DVD-Video navigation — read-only resolver for the **main-feature start
|
||||||
|
//! point** (issue #40). Mirrors what a DVD player's nav VM resolves: First-Play
|
||||||
|
//! → menu "Play" → title dispatch → the first cell of the feature, so the rip
|
||||||
|
//! starts at the movie rather than at raw cell 0 (e.g. skipping a leading
|
||||||
|
//! logo/warning segment when the disc's own navigation does).
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! Current contents: [`vmcmd`] — the VM command decoder (proven against the
|
||||||
|
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
|
||||||
|
//! that resolves the start cell build on top of this.
|
||||||
|
|
||||||
|
pub mod vmcmd;
|
||||||
|
|
||||||
|
use crate::sector::SectorSource;
|
||||||
|
|
||||||
|
/// Resolve the feature title's **true start cell** (0-based index into the
|
||||||
|
/// title PGC's cell list) by following the disc's own navigation — First-Play →
|
||||||
|
/// menu "Play" → title dispatch — the way a player reaches the movie. This is
|
||||||
|
/// what lets the rip begin at the feature instead of at raw cell 0 when the
|
||||||
|
/// disc's nav enters the title past a leading logo/warning segment (e.g. a
|
||||||
|
/// disc whose "Play" resolves to a later cell than cell 0).
|
||||||
|
///
|
||||||
|
/// Returns `None` when navigation cannot be resolved, so the caller falls back
|
||||||
|
/// to the structural leading-cell filter (today's behaviour, ≈ cell 0 / 0:00).
|
||||||
|
///
|
||||||
|
/// TODO(#40): the IFO/PCI parsing + nav executor (built on [`vmcmd`]) land
|
||||||
|
/// incrementally. Until the executor is complete this returns `None`, so wiring
|
||||||
|
/// it in is behaviour-neutral; improvements to the resolver take effect here
|
||||||
|
/// without touching the call site.
|
||||||
|
pub fn resolve_feature_start(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
udf: &crate::udf::UdfFs,
|
||||||
|
vtsn: u16,
|
||||||
|
vts_ttn: u16,
|
||||||
|
) -> Option<usize> {
|
||||||
|
// `reader`/`udf` are the seam inputs the nav executor will consume to read
|
||||||
|
// VIDEO_TS.IFO + the VTS IFOs/menu VOBs. Reserved until that lands.
|
||||||
|
let _ = (reader, udf);
|
||||||
|
tracing::trace!(
|
||||||
|
target: "freemkv::dvdnav",
|
||||||
|
vtsn,
|
||||||
|
vts_ttn,
|
||||||
|
"nav start-cell resolver: unresolved — caller falls back to leading-cell filter"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
//! DVD-Video VM command decoder.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! 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
|
||||||
|
//! `byte1` bits 3-0 are the sub-command. Compare predicates live in `byte1`
|
||||||
|
//! bits 6-4 with the operands in bytes 2-5.
|
||||||
|
//!
|
||||||
|
//! This module is pure decode + a register model — no I/O, no English (numeric
|
||||||
|
//! semantics only), matching libfreemkv conventions. The navigation *executor*
|
||||||
|
//! and IFO/PCI parsing build on top of this.
|
||||||
|
|
||||||
|
/// A decoded navigation instruction. Only the variants freemkv's start-point
|
||||||
|
/// resolver needs are modelled explicitly; everything else is [`Instr::Other`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Instr {
|
||||||
|
Nop,
|
||||||
|
/// Stop executing the current command list (resume cell playback).
|
||||||
|
Break,
|
||||||
|
/// Goto command line within the same list (1-based).
|
||||||
|
Goto {
|
||||||
|
line: u8,
|
||||||
|
},
|
||||||
|
/// Leave the current domain.
|
||||||
|
Exit,
|
||||||
|
/// Jump to a VMG title (1-based TT_SRPT index).
|
||||||
|
JumpTt {
|
||||||
|
ttn: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a title within the current VTS (1-based VTS title index).
|
||||||
|
JumpVtsTt {
|
||||||
|
ttn: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a part-of-title (chapter) within a VTS title.
|
||||||
|
JumpVtsPtt {
|
||||||
|
ttn: u8,
|
||||||
|
pttn: u16,
|
||||||
|
},
|
||||||
|
/// Jump to the First-Play PGC.
|
||||||
|
JumpSsFp,
|
||||||
|
/// Jump to a Video-Manager menu (`menu` = menu id).
|
||||||
|
JumpSsVmgm {
|
||||||
|
menu: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a Video-Title-Set menu.
|
||||||
|
JumpSsVtsm {
|
||||||
|
vts: u8,
|
||||||
|
ttn: u8,
|
||||||
|
menu: u8,
|
||||||
|
},
|
||||||
|
/// Jump to a specific VMGM menu PGC.
|
||||||
|
JumpSsVmgmPgc {
|
||||||
|
pgcn: u16,
|
||||||
|
},
|
||||||
|
/// Call a sub-domain (raw retained; resume handled by the executor).
|
||||||
|
CallSs {
|
||||||
|
sub: u8,
|
||||||
|
},
|
||||||
|
/// Link to a PGC number within the current domain.
|
||||||
|
LinkPgcn {
|
||||||
|
pgcn: u16,
|
||||||
|
},
|
||||||
|
/// Link to a part-of-title within the current PGC's title.
|
||||||
|
LinkPttn {
|
||||||
|
pttn: u16,
|
||||||
|
},
|
||||||
|
/// Link to a program number within the current PGC (1-based).
|
||||||
|
LinkPgn {
|
||||||
|
pgn: u8,
|
||||||
|
},
|
||||||
|
/// Link to a cell number within the current PGC (1-based).
|
||||||
|
LinkCn {
|
||||||
|
cn: u8,
|
||||||
|
},
|
||||||
|
/// A link "subset" op (LinkTopCell/NextPG/RSM/…); `sub` is the raw code.
|
||||||
|
LinkSub {
|
||||||
|
sub: u8,
|
||||||
|
},
|
||||||
|
/// Set a GPRM. `op` is the set-op code (1=mov, 3=add, …); value is immediate
|
||||||
|
/// (`imm`) when `immediate`, else the contents of register `src`.
|
||||||
|
SetGprm {
|
||||||
|
reg: u8,
|
||||||
|
op: u8,
|
||||||
|
immediate: bool,
|
||||||
|
imm: u16,
|
||||||
|
src: u8,
|
||||||
|
},
|
||||||
|
/// Set a system parameter / unmodelled set — executor may ignore.
|
||||||
|
SetSystem,
|
||||||
|
/// Anything not individually modelled (kept as raw bytes).
|
||||||
|
Other([u8; 8]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compare predicate carried by a command (`byte1` bits 6-4). `None` = always.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Compare {
|
||||||
|
/// Compare op: 1=&,2===,3=!=,4=>=,5=>,6=<=,7=<.
|
||||||
|
pub op: u8,
|
||||||
|
/// Left register index (GPRM 0-15, SPRM 128+).
|
||||||
|
pub lhs_reg: u8,
|
||||||
|
/// Right side: immediate when `immediate`, else register `rhs_reg`.
|
||||||
|
pub immediate: bool,
|
||||||
|
pub imm: u16,
|
||||||
|
pub rhs_reg: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fully decoded command: its predicate (if any) and the instruction.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Command {
|
||||||
|
pub compare: Option<Compare>,
|
||||||
|
pub instr: Instr,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command types — `byte0` bits 7-5.
|
||||||
|
const TYPE_SPECIAL: u8 = 0;
|
||||||
|
const TYPE_LINK_JUMP: u8 = 1;
|
||||||
|
const TYPE_SET_SYSTEM: u8 = 2;
|
||||||
|
const TYPE_SET_GPRM: u8 = 3;
|
||||||
|
|
||||||
|
// Special (type 0) sub-commands — `byte1` bits 3-0.
|
||||||
|
const SP_GOTO: u8 = 1;
|
||||||
|
const SP_BREAK: u8 = 2;
|
||||||
|
|
||||||
|
// Jump/Call (type 1, direct=1) sub-commands.
|
||||||
|
const JP_EXIT: u8 = 1;
|
||||||
|
const JP_JUMP_TT: u8 = 2;
|
||||||
|
const JP_JUMP_VTS_TT: u8 = 3;
|
||||||
|
const JP_JUMP_VTS_PTT: u8 = 5;
|
||||||
|
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`).
|
||||||
|
const LK_SUB: u8 = 1;
|
||||||
|
const LK_PGCN: u8 = 4;
|
||||||
|
const LK_PTTN: u8 = 5;
|
||||||
|
const LK_PGN: u8 = 6;
|
||||||
|
const LK_CN: u8 = 7;
|
||||||
|
|
||||||
|
// JumpSS sub-domain selector — `byte5` bits 7-6.
|
||||||
|
const SS_FP: u8 = 0;
|
||||||
|
const SS_VMGM_MENU: u8 = 1;
|
||||||
|
const SS_VTSM: u8 = 2;
|
||||||
|
|
||||||
|
// Operand field widths (spec-defined bit counts).
|
||||||
|
const MASK_TTN: u8 = 0x7F; // 7-bit title number
|
||||||
|
const MASK_PGN: u8 = 0x7F; // 7-bit program number
|
||||||
|
const MASK_LINKOP: u8 = 0x1F; // 5-bit link sub-op
|
||||||
|
const MASK_REG: u8 = 0x0F; // 4-bit GPRM index
|
||||||
|
const MASK_MENU: u8 = 0x0F; // 4-bit menu id
|
||||||
|
const MASK_PTTN: u16 = 0x03FF; // 10-bit part-of-title
|
||||||
|
const MASK_PGCN: u16 = 0x7FFF; // 15-bit PGC number
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
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
|
||||||
|
// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The
|
||||||
|
// operand *offsets* differ by command family.
|
||||||
|
//
|
||||||
|
// v1 (special + link): lhs reg = b[3]; rhs imm = bytes4-5 / rhs reg = b[4].
|
||||||
|
// v2 (jump + system-set): lhs reg = b[6]; rhs reg = b[7] (registers only).
|
||||||
|
// v3 (set-GPRM): lhs reg = b[2]; rhs imm = bytes6-7 / rhs reg = b[6].
|
||||||
|
fn if_v1(b: &[u8; 8]) -> Option<Compare> {
|
||||||
|
let op = (b[1] >> 4) & 7;
|
||||||
|
(op != 0).then(|| Compare {
|
||||||
|
op,
|
||||||
|
lhs_reg: b[3],
|
||||||
|
immediate: b[1] >> 7 != 0,
|
||||||
|
imm: be16(b, 4),
|
||||||
|
rhs_reg: b[4],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn if_v2(b: &[u8; 8]) -> Option<Compare> {
|
||||||
|
let op = (b[1] >> 4) & 7;
|
||||||
|
(op != 0).then(|| Compare {
|
||||||
|
op,
|
||||||
|
lhs_reg: b[6],
|
||||||
|
immediate: false,
|
||||||
|
imm: 0,
|
||||||
|
rhs_reg: b[7],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn if_v3(b: &[u8; 8]) -> Option<Compare> {
|
||||||
|
let op = (b[1] >> 4) & 7;
|
||||||
|
(op != 0).then(|| Compare {
|
||||||
|
op,
|
||||||
|
lhs_reg: b[2],
|
||||||
|
immediate: b[1] >> 7 != 0,
|
||||||
|
imm: be16(b, 6),
|
||||||
|
rhs_reg: b[6],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode an 8-byte VM command.
|
||||||
|
pub fn decode(b: &[u8; 8]) -> Command {
|
||||||
|
let typ = b[0] >> 5;
|
||||||
|
let direct = (b[0] >> 4) & 1;
|
||||||
|
let setop = b[0] & 0x0F;
|
||||||
|
let cmd = b[1] & 0x0F;
|
||||||
|
|
||||||
|
// Compare predicate, with the operand layout for this command family
|
||||||
|
// (libdvdnav `decoder.c` `vm_eval_command` type dispatch).
|
||||||
|
let compare = match (typ, direct) {
|
||||||
|
(TYPE_SPECIAL, _) => if_v1(b),
|
||||||
|
(TYPE_LINK_JUMP, 1) => if_v2(b), // jump
|
||||||
|
(TYPE_LINK_JUMP, 0) => if_v1(b), // link
|
||||||
|
(TYPE_SET_SYSTEM, _) => if_v2(b),
|
||||||
|
(TYPE_SET_GPRM, _) => if_v3(b),
|
||||||
|
_ => None, // 4/5/6 compound — not needed by the resolver
|
||||||
|
};
|
||||||
|
|
||||||
|
// JumpSS sub-domain selector lives in byte5 bits 7-6.
|
||||||
|
let ss_sel = b[5] >> 6;
|
||||||
|
|
||||||
|
let instr = match typ {
|
||||||
|
TYPE_LINK_JUMP if direct == 1 => match cmd {
|
||||||
|
JP_EXIT => Instr::Exit,
|
||||||
|
JP_JUMP_TT => Instr::JumpTt {
|
||||||
|
ttn: b[5] & MASK_TTN,
|
||||||
|
},
|
||||||
|
JP_JUMP_VTS_TT => Instr::JumpVtsTt {
|
||||||
|
ttn: b[5] & MASK_TTN,
|
||||||
|
},
|
||||||
|
JP_JUMP_VTS_PTT => Instr::JumpVtsPtt {
|
||||||
|
ttn: b[5] & MASK_TTN,
|
||||||
|
pttn: be16(b, 2) & MASK_PTTN,
|
||||||
|
},
|
||||||
|
JP_JUMP_SS => match ss_sel {
|
||||||
|
SS_FP => Instr::JumpSsFp,
|
||||||
|
SS_VMGM_MENU => Instr::JumpSsVmgm {
|
||||||
|
menu: b[5] & MASK_MENU,
|
||||||
|
},
|
||||||
|
SS_VTSM => Instr::JumpSsVtsm {
|
||||||
|
vts: b[4],
|
||||||
|
ttn: b[3],
|
||||||
|
menu: b[5] & MASK_MENU,
|
||||||
|
},
|
||||||
|
_ => Instr::JumpSsVmgmPgc {
|
||||||
|
pgcn: be16(b, 2) & MASK_PGCN,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
JP_CALL_SS => Instr::CallSs { sub: ss_sel },
|
||||||
|
_ => Instr::Nop,
|
||||||
|
},
|
||||||
|
TYPE_LINK_JUMP => match cmd {
|
||||||
|
// direct == 0 (link). sub-op 0 = NOP/no-link.
|
||||||
|
LK_SUB => Instr::LinkSub {
|
||||||
|
sub: b[7] & MASK_LINKOP,
|
||||||
|
},
|
||||||
|
LK_PGCN => Instr::LinkPgcn {
|
||||||
|
pgcn: be16(b, 6) & MASK_PGCN,
|
||||||
|
},
|
||||||
|
LK_PTTN => Instr::LinkPttn {
|
||||||
|
pttn: be16(b, 6) & MASK_PTTN,
|
||||||
|
},
|
||||||
|
LK_PGN => Instr::LinkPgn {
|
||||||
|
pgn: b[7] & MASK_PGN,
|
||||||
|
},
|
||||||
|
LK_CN => Instr::LinkCn { cn: b[7] },
|
||||||
|
_ => Instr::Nop,
|
||||||
|
},
|
||||||
|
TYPE_SPECIAL => match cmd {
|
||||||
|
SP_GOTO => Instr::Goto { line: b[7] },
|
||||||
|
SP_BREAK => Instr::Break,
|
||||||
|
_ => Instr::Nop,
|
||||||
|
},
|
||||||
|
TYPE_SET_GPRM => Instr::SetGprm {
|
||||||
|
reg: b[3] & MASK_REG,
|
||||||
|
op: setop,
|
||||||
|
immediate: direct != 0,
|
||||||
|
imm: be16(b, 4),
|
||||||
|
src: b[5],
|
||||||
|
},
|
||||||
|
TYPE_SET_SYSTEM => Instr::SetSystem,
|
||||||
|
_ => Instr::Other(*b),
|
||||||
|
};
|
||||||
|
|
||||||
|
Command { compare, instr }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn h(s: &str) -> [u8; 8] {
|
||||||
|
let v: Vec<u8> = (0..8)
|
||||||
|
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap())
|
||||||
|
.collect();
|
||||||
|
v.try_into().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// KATs taken from the real SOTL / Greenland discs (decoded in the PoC).
|
||||||
|
#[test]
|
||||||
|
fn greenland_first_play_is_jumptt_1() {
|
||||||
|
let c = decode(&h("3002000000010000"));
|
||||||
|
assert_eq!(c.instr, Instr::JumpTt { ttn: 1 });
|
||||||
|
assert!(c.compare.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sotl_first_play_is_jumpss_vtsm_root() {
|
||||||
|
// 30 06 ... byte5=0x83 -> sub 2 (VTSM), vts=byte4=1, menu=byte5&0xF=3 (root)
|
||||||
|
let c = decode(&h("3006000101830000"));
|
||||||
|
assert_eq!(
|
||||||
|
c.instr,
|
||||||
|
Instr::JumpSsVtsm {
|
||||||
|
vts: 1,
|
||||||
|
ttn: 1,
|
||||||
|
menu: 3
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sotl_title_dispatch_is_conditional_linkpgn_2() {
|
||||||
|
// 20 a6 ... CmpLink: if GPRM0 == 2 -> LinkPGN 2 (cell 2 = the 5:02 start)
|
||||||
|
let c = decode(&h("20a6000000020002"));
|
||||||
|
assert_eq!(c.instr, Instr::LinkPgn { pgn: 2 });
|
||||||
|
let cmp = c.compare.expect("conditional");
|
||||||
|
assert_eq!(cmp.op, 2); // ==
|
||||||
|
assert_eq!(cmp.lhs_reg, 0); // GPRM0
|
||||||
|
assert!(cmp.immediate);
|
||||||
|
assert_eq!(cmp.imm, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sotl_root_button_is_linkpgcn_37() {
|
||||||
|
assert_eq!(
|
||||||
|
decode(&h("2004000000000025")).instr,
|
||||||
|
Instr::LinkPgcn { pgcn: 37 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn greenland_scene_button_is_linkpgn() {
|
||||||
|
assert_eq!(
|
||||||
|
decode(&h("2006000000001401")).instr,
|
||||||
|
Instr::LinkPgn { pgn: 1 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jumpvts_ptt_decodes_ttn_and_pttn() {
|
||||||
|
// synthetic: 30 05 | ptt(bytes2-3)=0x0002 | ttn(byte5)=1
|
||||||
|
let c = decode(&h("3005000200010000"));
|
||||||
|
assert_eq!(c.instr, Instr::JumpVtsPtt { ttn: 1, pttn: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setgprm_immediate_mov() {
|
||||||
|
// SOTL First-Play pre[0]: 71 00 | reg=byte3=6 | imm(bytes4-5)=0x03e8 -> g6 = 1000
|
||||||
|
match decode(&h("7100000603e80000")).instr {
|
||||||
|
Instr::SetGprm {
|
||||||
|
reg,
|
||||||
|
op,
|
||||||
|
immediate,
|
||||||
|
imm,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(reg, 6);
|
||||||
|
assert_eq!(op, 1); // mov
|
||||||
|
assert!(immediate);
|
||||||
|
assert_eq!(imm, 1000);
|
||||||
|
}
|
||||||
|
other => panic!("expected SetGprm, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression for the libdvdnav cross-check: link sub-op 0 = NOP, 1 = LinkSub.
|
||||||
|
#[test]
|
||||||
|
fn link_subop_zero_is_nop_one_is_linksub() {
|
||||||
|
assert_eq!(decode(&h("2000000000000000")).instr, Instr::Nop);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&h("2001000000000010")).instr,
|
||||||
|
Instr::LinkSub { sub: 0x10 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if_version_1 register compare: rhs register is byte4 (not byte5).
|
||||||
|
#[test]
|
||||||
|
fn link_register_compare_rhs_is_byte4() {
|
||||||
|
// 20 26: link, cmp=EQ(2), dircmp=0(register) ; cmd=6 LinkPGN
|
||||||
|
let c = decode(&h("2026000304000002"));
|
||||||
|
assert_eq!(c.instr, Instr::LinkPgn { pgn: 2 });
|
||||||
|
let cmp = c.compare.expect("conditional");
|
||||||
|
assert!(!cmp.immediate);
|
||||||
|
assert_eq!(cmp.lhs_reg, 3);
|
||||||
|
assert_eq!(cmp.rhs_reg, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if_version_2 jump compare: both operands are registers in byte6 / byte7.
|
||||||
|
#[test]
|
||||||
|
fn jump_compare_uses_bytes6_and_7() {
|
||||||
|
// 30 22: jump, cmp=EQ(2) ; cmd=2 JumpTT ttn=byte5=5
|
||||||
|
let c = decode(&h("3022000000050607"));
|
||||||
|
assert_eq!(c.instr, Instr::JumpTt { ttn: 5 });
|
||||||
|
let cmp = c.compare.expect("conditional");
|
||||||
|
assert!(!cmp.immediate);
|
||||||
|
assert_eq!(cmp.lhs_reg, 6);
|
||||||
|
assert_eq!(cmp.rhs_reg, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1199,6 +1199,7 @@ mod tests {
|
|||||||
E_NO_DISC_KEY,
|
E_NO_DISC_KEY,
|
||||||
E_CSS_KEY_MISSING,
|
E_CSS_KEY_MISSING,
|
||||||
E_AACS_NO_HOST_CERT,
|
E_AACS_NO_HOST_CERT,
|
||||||
|
E_AACS_BUS_KEY_UNAVAILABLE,
|
||||||
E_KEYDB_CONNECT,
|
E_KEYDB_CONNECT,
|
||||||
E_KEYDB_HTTP,
|
E_KEYDB_HTTP,
|
||||||
E_KEYDB_INVALID,
|
E_KEYDB_INVALID,
|
||||||
|
|||||||
+13
-1
@@ -440,7 +440,19 @@ fn parse_vts(
|
|||||||
// to the feature and shifted every cell extent back by
|
// to the feature and shifted every cell extent back by
|
||||||
// `vtstt_vobs - vtsm_vobs` sectors, so the rip opened on the parental
|
// `vtstt_vobs - vtsm_vobs` sectors, so the rip opened on the parental
|
||||||
// prompt instead of the movie. The title content lives at `vtstt_vobs`.
|
// prompt instead of the movie. The title content lives at `vtstt_vobs`.
|
||||||
let vob_start_sector = be_u32(&vts_data, VTSTT_VOBS_OFFSET)?;
|
//
|
||||||
|
// `vtstt_vobs` is a sector address **relative to the start of this VTS_xx_0.IFO
|
||||||
|
// file**, not an absolute disc LBA. The cell `first_sector`/`last_sector`
|
||||||
|
// values are in turn relative to `vtstt_vobs`. To turn them into the absolute
|
||||||
|
// disc LBAs the reader needs, add the IFO file's own on-disc location (from
|
||||||
|
// the UDF FS). Without this rebase every extent started `ifo_lba` sectors too
|
||||||
|
// early — for THESILENCEOFTHELAMBS the feature began at LBA 126 (the VMGI /
|
||||||
|
// VIDEO_TS.VOB main-menu region) instead of 132886 (VTS_03_1.VOB), so the
|
||||||
|
// first ~4.5 min of muxed video was the disc's main menu before the stream
|
||||||
|
// drifted into the movie.
|
||||||
|
let vtstt_vobs = be_u32(&vts_data, VTSTT_VOBS_OFFSET)?;
|
||||||
|
let ifo_lba = udf.file_start_lba(reader, &path)?;
|
||||||
|
let vob_start_sector = ifo_lba.saturating_add(vtstt_vobs);
|
||||||
|
|
||||||
// Video attributes at offset 0x200 (2 bytes)
|
// Video attributes at offset 0x200 (2 bytes)
|
||||||
let video = parse_video_attr(&vts_data)?;
|
let video = parse_video_attr(&vts_data)?;
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ pub struct DiscInputsCtx<'a> {
|
|||||||
impl<'a> DiscInputsCtx<'a> {
|
impl<'a> DiscInputsCtx<'a> {
|
||||||
/// Build a context over `inputs`, parsing the encrypted title keys at the
|
/// Build a context over `inputs`, parsing the encrypted title keys at the
|
||||||
/// stride for AACS major `version_u8` (1 = V10, else V20/V21).
|
/// stride for AACS major `version_u8` (1 = V10, else V20/V21).
|
||||||
|
///
|
||||||
|
/// A present-but-malformed `unit_key_ro` (truncated / wrong magic / wrong
|
||||||
|
/// stride) parses to an empty key set, so a later [`Self::enc_title_keys`]
|
||||||
|
/// returns `Ok(&[])` indistinguishably from a disc that legitimately has no
|
||||||
|
/// title keys — the parse failure is swallowed here, not surfaced as an
|
||||||
|
/// error.
|
||||||
pub fn new(inputs: &'a DiscInputs, version_u8: u8) -> Self {
|
pub fn new(inputs: &'a DiscInputs, version_u8: u8) -> Self {
|
||||||
use crate::aacs::{AacsVersion, parse_unit_key_ro};
|
use crate::aacs::{AacsVersion, parse_unit_key_ro};
|
||||||
let enc_keys = if inputs.unit_key_ro.is_empty() {
|
let enc_keys = if inputs.unit_key_ro.is_empty() {
|
||||||
|
|||||||
+4
-3
@@ -1124,9 +1124,10 @@ mod registry_tests {
|
|||||||
// and as a marker for "these parsers exist."
|
// and as a marker for "these parsers exist."
|
||||||
let _ = (name, detect, parse);
|
let _ = (name, detect, parse);
|
||||||
}
|
}
|
||||||
// The loop above touches every registry entry; iterating a non-empty
|
// The loop above touches every registry entry. The non-empty
|
||||||
// fixed-size array is the assertion (a `.is_empty()` check would be
|
// invariant is covered separately by `parsers_registry_order_locked`,
|
||||||
// const-folded). The test fails to compile if the tuple shape changes.
|
// whose assert_eq! on the expected order fails if PARSERS is empty.
|
||||||
|
// This test fails to compile if the tuple shape changes.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ pub mod decrypt;
|
|||||||
pub mod diag;
|
pub mod diag;
|
||||||
pub mod disc;
|
pub mod disc;
|
||||||
pub mod drive;
|
pub mod drive;
|
||||||
|
pub mod dvdnav;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod halt;
|
pub mod halt;
|
||||||
|
|||||||
+11
-4
@@ -181,8 +181,13 @@ impl PictureInfo {
|
|||||||
match self.detail {
|
match self.detail {
|
||||||
CodingDetail::Mpeg2(m) => {
|
CodingDetail::Mpeg2(m) => {
|
||||||
if !m.frame_picture {
|
if !m.frame_picture {
|
||||||
// A single field picture is inherently interlaced; the
|
// A single field picture is inherently interlaced. Which
|
||||||
// top_field_first bit names which field this picture is.
|
// field it actually codes is given by picture_structure
|
||||||
|
// (top/bottom), not by top_field_first — §6.3.10 constrains
|
||||||
|
// top_field_first to 0 for field pictures, so it is not the
|
||||||
|
// spec source here. picture_structure is not retained on
|
||||||
|
// this carrier, so top_field_first is used only as the lone
|
||||||
|
// field hint available (best-effort, not spec-derived).
|
||||||
Some(if m.top_field_first {
|
Some(if m.top_field_first {
|
||||||
FieldOrder::Tff
|
FieldOrder::Tff
|
||||||
} else {
|
} else {
|
||||||
@@ -203,8 +208,10 @@ impl PictureInfo {
|
|||||||
/// Number of field-display periods this picture occupies — the basis for
|
/// Number of field-display periods this picture occupies — the basis for
|
||||||
/// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10,
|
/// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10,
|
||||||
/// ffmpeg `nb_fields = repeat_pict + 2`): a field picture occupies 1 field,
|
/// ffmpeg `nb_fields = repeat_pict + 2`): a field picture occupies 1 field,
|
||||||
/// a normal frame 2, a `repeat_first_field` frame 3 (or 4/6 in a progressive
|
/// a normal frame 2, a `repeat_first_field` progressive-frame 3 (or 4/6 in a
|
||||||
/// sequence). Codecs without pulldown signalling report the normal 2 fields.
|
/// progressive sequence); an rff bit on a non-progressive interlaced frame is
|
||||||
|
/// spec-forbidden (§6.3.10) and is treated as 2. Codecs without pulldown
|
||||||
|
/// signalling report the normal 2 fields.
|
||||||
pub fn nb_fields(&self) -> u8 {
|
pub fn nb_fields(&self) -> u8 {
|
||||||
match self.detail {
|
match self.detail {
|
||||||
CodingDetail::Mpeg2(m) => {
|
CodingDetail::Mpeg2(m) => {
|
||||||
|
|||||||
@@ -325,7 +325,8 @@ impl CodecParser for H264Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `(chroma_format_idc, bit_depth_luma_minus8, bit_depth_chroma_minus8)` from
|
/// Parse `(chroma_format_idc, bit_depth_luma_minus8, bit_depth_chroma_minus8)` from
|
||||||
/// a High-Profile SPS NAL (profile_idc ∈ {100, 110, 122, 144}).
|
/// a High-Profile SPS NAL (profile_idc ∈ `HIGH_PROFILES` — the 14 chroma/bit-depth
|
||||||
|
/// extended profiles `codec_private` invokes this for).
|
||||||
///
|
///
|
||||||
/// SPS RBSP layout (ITU-T H.264 §7.3.2.1.1) up to the fields we need:
|
/// SPS RBSP layout (ITU-T H.264 §7.3.2.1.1) up to the fields we need:
|
||||||
/// byte 0 NAL header (already known to be type 7)
|
/// byte 0 NAL header (already known to be type 7)
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ fn hevc_num_extra_slice_header_bits(pps_nal: &[u8]) -> Option<u32> {
|
|||||||
br.read_ue()?; // pps_pic_parameter_set_id
|
br.read_ue()?; // pps_pic_parameter_set_id
|
||||||
br.read_ue()?; // pps_seq_parameter_set_id
|
br.read_ue()?; // pps_seq_parameter_set_id
|
||||||
br.skip_bits(2)?; // dependent_slice_segments_enabled_flag, output_flag_present_flag
|
br.skip_bits(2)?; // dependent_slice_segments_enabled_flag, output_flag_present_flag
|
||||||
let mut n = 0u32;
|
let n = br.read_bits(3)?;
|
||||||
for _ in 0..3 {
|
|
||||||
n = (n << 1) | br.read_bit()?;
|
|
||||||
}
|
|
||||||
Some(n)
|
Some(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -273,8 +273,8 @@ const SUP_HEADER_LEN: usize = SUP_MAGIC.len() + 4 + 4;
|
|||||||
const SEG_PCS: u8 = 0x16;
|
const SEG_PCS: u8 = 0x16;
|
||||||
/// PGS segment type: END of display set.
|
/// PGS segment type: END of display set.
|
||||||
const SEG_END: u8 = 0x80;
|
const SEG_END: u8 = 0x80;
|
||||||
/// PCS `composition_state` value: Epoch Start (a fresh display).
|
/// PCS `composition_state` value: Normal (an update to the current epoch).
|
||||||
const PCS_COMPOSITION_STATE_EPOCH_START: u8 = 0x80;
|
const PCS_COMPOSITION_STATE_NORMAL: u8 = 0x00;
|
||||||
/// PGS segment header on the wire (inside `frame.data`): type(1) + size(2 BE).
|
/// PGS segment header on the wire (inside `frame.data`): type(1) + size(2 BE).
|
||||||
const PGS_SEG_HEADER_LEN: usize = 3;
|
const PGS_SEG_HEADER_LEN: usize = 3;
|
||||||
/// Byte offset of `width`/`height` within a PCS segment (after type+size).
|
/// Byte offset of `width`/`height` within a PCS segment (after type+size).
|
||||||
@@ -346,7 +346,7 @@ impl PgsSupWriter {
|
|||||||
PCS_FRAME_RATE,
|
PCS_FRAME_RATE,
|
||||||
0x00,
|
0x00,
|
||||||
0x00, // composition_number
|
0x00, // composition_number
|
||||||
PCS_COMPOSITION_STATE_EPOCH_START,
|
PCS_COMPOSITION_STATE_NORMAL,
|
||||||
0x00, // palette_update_flag
|
0x00, // palette_update_flag
|
||||||
0x00, // palette_id
|
0x00, // palette_id
|
||||||
PCS_NO_OBJECTS,
|
PCS_NO_OBJECTS,
|
||||||
|
|||||||
+13
-3
@@ -82,8 +82,16 @@ fn write_fvi_record(w: &mut dyn Write, r: &PictureRecord) -> io::Result<()> {
|
|||||||
// `src` is REQUIRED by the record schema (Appendix A); when provenance is
|
// `src` is REQUIRED by the record schema (Appendix A); when provenance is
|
||||||
// absent the member is still emitted as null — a reader treats null as
|
// absent the member is still emitted as null — a reader treats null as
|
||||||
// "position unknown".
|
// "position unknown".
|
||||||
|
//
|
||||||
|
// Per `docs/FVI_FORMAT.md` §9, `src.byte` is the offset of the AU's first
|
||||||
|
// byte WITHIN its `sector` (not the absolute source offset). `SourcePos.byte`
|
||||||
|
// is the absolute offset, so reduce it modulo the sector size; `sector`
|
||||||
|
// already carries the whole-sector count.
|
||||||
let src = match r.source {
|
let src = match r.source {
|
||||||
Some(s) => serde_json::json!({ "sector": s.sector, "byte": s.byte }),
|
Some(s) => serde_json::json!({
|
||||||
|
"sector": s.sector,
|
||||||
|
"byte": s.byte % u64::from(FVI_SECTOR_SIZE),
|
||||||
|
}),
|
||||||
None => serde_json::Value::Null,
|
None => serde_json::Value::Null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -355,8 +363,9 @@ mod tests {
|
|||||||
let dir = tempdir();
|
let dir = tempdir();
|
||||||
let path = dir.join("movie.fvi");
|
let path = dir.join("movie.fvi");
|
||||||
let mut sink = FviSink::create(&path, &mpeg2_title(), "iso://m.iso".into(), 1).unwrap();
|
let mut sink = FviSink::create(&path, &mpeg2_title(), "iso://m.iso".into(), 1).unwrap();
|
||||||
// Video frame on track 0 → indexed.
|
// Video frame on track 0 → indexed. Offset 2148 = sector 1, byte 100
|
||||||
sink.write(&vframe(0, Some(i_pic()), Some(SourcePos::at_byte(2048))))
|
// within that sector (exercises the within-sector `src.byte`, §9).
|
||||||
|
sink.write(&vframe(0, Some(i_pic()), Some(SourcePos::at_byte(2148))))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Audio frame on a non-video track → ignored.
|
// Audio frame on a non-video track → ignored.
|
||||||
sink.write(&vframe(7, None, Some(SourcePos::at_byte(9999))))
|
sink.write(&vframe(7, None, Some(SourcePos::at_byte(9999))))
|
||||||
@@ -387,6 +396,7 @@ mod tests {
|
|||||||
assert_eq!(rec["nb_fields"], 2);
|
assert_eq!(rec["nb_fields"], 2);
|
||||||
assert_eq!(rec["pts"], 0);
|
assert_eq!(rec["pts"], 0);
|
||||||
assert_eq!(rec["src"]["sector"], 1);
|
assert_eq!(rec["src"]["sector"], 1);
|
||||||
|
assert_eq!(rec["src"]["byte"], 100); // 2148 % 2048 → within-sector (§9)
|
||||||
assert!(rec.get("dts").is_none(), "no DTS on a frame → omitted");
|
assert!(rec.get("dts").is_none(), "no DTS on a frame → omitted");
|
||||||
assert!(
|
assert!(
|
||||||
rec.get("gop").is_none(),
|
rec.get("gop").is_none(),
|
||||||
|
|||||||
@@ -254,7 +254,10 @@ impl<W: Write> M2tsMux<W> {
|
|||||||
// step — e.g. a leading audio frame ahead of the first video keyframe),
|
// step — e.g. a leading audio frame ahead of the first video keyframe),
|
||||||
// which still floors to 0 per the documented behavior.
|
// which still floors to 0 per the documented behavior.
|
||||||
let delta = raw_90k.wrapping_sub(base) & 0x1_FFFF_FFFF;
|
let delta = raw_90k.wrapping_sub(base) & 0x1_FFFF_FFFF;
|
||||||
if delta > (1 << 32) { 0 } else { delta }
|
// Signed 33-bit: the sign bit is bit 32 (value 2^32), so the entire
|
||||||
|
// upper half [2^32, 2^33) is negative (frame before base) and floors
|
||||||
|
// to 0. delta == 2^32 is the most-negative value (-2^32), hence `>=`.
|
||||||
|
if delta >= (1 << 32) { 0 } else { delta }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit one PES payload as a chain of TS packets on `pid`. If `pcr`
|
/// Emit one PES payload as a chain of TS packets on `pid`. If `pcr`
|
||||||
|
|||||||
+53
-1
@@ -28,6 +28,9 @@ const CICP_PRIMARIES_BT470BG: u8 = 5;
|
|||||||
const CICP_PRIMARIES_BT601_525: u8 = 6;
|
const CICP_PRIMARIES_BT601_525: u8 = 6;
|
||||||
/// ColourPrimaries = 9 (BT.2020 / BT.2100) — ITU-T H.273 Table 2.
|
/// ColourPrimaries = 9 (BT.2020 / BT.2100) — ITU-T H.273 Table 2.
|
||||||
const CICP_PRIMARIES_BT2020: u8 = 9;
|
const CICP_PRIMARIES_BT2020: u8 = 9;
|
||||||
|
/// ColourPrimaries = 2 ("unspecified" — colorimetry unknown) — ITU-T H.273
|
||||||
|
/// Table 2.
|
||||||
|
const CICP_PRIMARIES_UNSPECIFIED: u8 = 2;
|
||||||
|
|
||||||
/// TransferCharacteristics = 1 (BT.709) — ITU-T H.273 Table 3.
|
/// TransferCharacteristics = 1 (BT.709) — ITU-T H.273 Table 3.
|
||||||
const CICP_TRANSFER_BT709: u8 = 1;
|
const CICP_TRANSFER_BT709: u8 = 1;
|
||||||
@@ -41,6 +44,9 @@ const CICP_TRANSFER_PQ: u8 = 16;
|
|||||||
/// TransferCharacteristics = 18 (ARIB STD-B67 / Hybrid Log-Gamma) — ITU-T H.273
|
/// TransferCharacteristics = 18 (ARIB STD-B67 / Hybrid Log-Gamma) — ITU-T H.273
|
||||||
/// Table 3.
|
/// Table 3.
|
||||||
const CICP_TRANSFER_HLG: u8 = 18;
|
const CICP_TRANSFER_HLG: u8 = 18;
|
||||||
|
/// TransferCharacteristics = 2 ("unspecified" — transfer unknown) — ITU-T H.273
|
||||||
|
/// Table 3.
|
||||||
|
const CICP_TRANSFER_UNSPECIFIED: u8 = 2;
|
||||||
|
|
||||||
/// MatrixCoefficients = 1 (BT.709) — ITU-T H.273 Table 4.
|
/// MatrixCoefficients = 1 (BT.709) — ITU-T H.273 Table 4.
|
||||||
const CICP_MATRIX_BT709: u8 = 1;
|
const CICP_MATRIX_BT709: u8 = 1;
|
||||||
@@ -50,6 +56,8 @@ const CICP_MATRIX_BT470BG: u8 = 5;
|
|||||||
const CICP_MATRIX_BT601_525: u8 = 6;
|
const CICP_MATRIX_BT601_525: u8 = 6;
|
||||||
/// MatrixCoefficients = 9 (BT.2020 non-constant luminance) — ITU-T H.273 Table 4.
|
/// MatrixCoefficients = 9 (BT.2020 non-constant luminance) — ITU-T H.273 Table 4.
|
||||||
const CICP_MATRIX_BT2020NC: u8 = 9;
|
const CICP_MATRIX_BT2020NC: u8 = 9;
|
||||||
|
/// MatrixCoefficients = 2 ("unspecified" — matrix unknown) — ITU-T H.273 Table 4.
|
||||||
|
const CICP_MATRIX_UNSPECIFIED: u8 = 2;
|
||||||
|
|
||||||
/// Matroska Colour/Range = 1 (broadcast / studio-swing "limited" range). RFC
|
/// Matroska Colour/Range = 1 (broadcast / studio-swing "limited" range). RFC
|
||||||
/// 9559 Range element. (0 = unspecified, 2 = full.)
|
/// 9559 Range element. (0 = unspecified, 2 = full.)
|
||||||
@@ -104,7 +112,16 @@ pub(crate) fn cicp_for_video(v: &VideoStream) -> (u8, u8, u8, u8) {
|
|||||||
CICP_PRIMARIES_BT601_525,
|
CICP_PRIMARIES_BT601_525,
|
||||||
COLOUR_RANGE_LIMITED,
|
COLOUR_RANGE_LIMITED,
|
||||||
),
|
),
|
||||||
ColorSpace::Unknown => (0, 0, 0, 0),
|
// Unknown colorimetry → CICP "unspecified" (code point 2) for matrix,
|
||||||
|
// transfer, and primaries, with limited range (the disc norm). Both the
|
||||||
|
// MKV sink and the FVI sidecar emit 2 so the two sinks of one title
|
||||||
|
// agree (matches `Colour::from_color_space`'s Unknown mapping).
|
||||||
|
ColorSpace::Unknown => (
|
||||||
|
CICP_MATRIX_UNSPECIFIED,
|
||||||
|
CICP_TRANSFER_UNSPECIFIED,
|
||||||
|
CICP_PRIMARIES_UNSPECIFIED,
|
||||||
|
COLOUR_RANGE_LIMITED,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
// Override the transfer for HDR signalled by the HdrFormat (the coarse enum
|
// Override the transfer for HDR signalled by the HdrFormat (the coarse enum
|
||||||
// can't express PQ/HLG). Only applies on the enum fallback; a measured CICP
|
// can't express PQ/HLG). Only applies on the enum fallback; a measured CICP
|
||||||
@@ -1549,6 +1566,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unknown colorimetry with no measured CICP and no HDR must emit CICP
|
||||||
|
/// "unspecified" (code point 2) for matrix/transfer/primaries — never 0 — so
|
||||||
|
/// the MKV sink agrees with the FVI sidecar (`Colour::from_color_space`).
|
||||||
|
#[test]
|
||||||
|
fn unknown_color_space_emits_unspecified_cicp() {
|
||||||
|
let v = VideoStream {
|
||||||
|
pid: 0xE0,
|
||||||
|
codec: Codec::Hevc,
|
||||||
|
resolution: Resolution::R1080p,
|
||||||
|
frame_rate: crate::disc::FrameRate::F24,
|
||||||
|
hdr: HdrFormat::Sdr,
|
||||||
|
color_space: ColorSpace::Unknown,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
measured_cicp: None,
|
||||||
|
};
|
||||||
|
let t = MkvTrack::video(&v);
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
t.colour_matrix,
|
||||||
|
t.colour_transfer,
|
||||||
|
t.colour_primaries,
|
||||||
|
t.colour_range
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CICP_MATRIX_UNSPECIFIED,
|
||||||
|
CICP_TRANSFER_UNSPECIFIED,
|
||||||
|
CICP_PRIMARIES_UNSPECIFIED,
|
||||||
|
COLOUR_RANGE_LIMITED
|
||||||
|
),
|
||||||
|
"Unknown colorimetry must emit CICP 'unspecified' (2), not 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper: search for a 4-byte big-endian EBML ID in a byte slice.
|
/// Helper: search for a 4-byte big-endian EBML ID in a byte slice.
|
||||||
fn find_id(data: &[u8], id: u32) -> Option<usize> {
|
fn find_id(data: &[u8], id: u32) -> Option<usize> {
|
||||||
let bytes = id.to_be_bytes();
|
let bytes = id.to_be_bytes();
|
||||||
|
|||||||
+68
-24
@@ -101,8 +101,12 @@ enum WriteMode {
|
|||||||
/// Header written; muxing live. Boxed (MkvMuxer is large) to keep the enum
|
/// Header written; muxing live. Boxed (MkvMuxer is large) to keep the enum
|
||||||
/// small (clippy::large_enum_variant).
|
/// small (clippy::large_enum_variant).
|
||||||
Active(Box<MkvMuxer<Box<dyn WriteSeek + Send>>>),
|
Active(Box<MkvMuxer<Box<dyn WriteSeek + Send>>>),
|
||||||
/// Transient placeholder held only across the Pending → Active swap; never
|
/// Sentinel held in `self.mode` while the muxer is being built (across the
|
||||||
/// observed by `read` / `write` / `finish`.
|
/// Pending → Active swap). It is also the terminal state left behind after
|
||||||
|
/// `finish()` swaps the muxer out, and the degraded state left behind if
|
||||||
|
/// `activate()` fails partway (the first error still surfaces via `?`). In
|
||||||
|
/// that terminal state a subsequent `write()` no-ops (`Ok(())`) and `finish()`
|
||||||
|
/// does not re-finalize.
|
||||||
Building,
|
Building,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +192,11 @@ impl MkvStream {
|
|||||||
/// available), then write the header and replay buffered frames. A no-op if
|
/// available), then write the header and replay buffered frames. A no-op if
|
||||||
/// not pending. The muxer only ever muxes the track it is given — this routes
|
/// not pending. The muxer only ever muxes the track it is given — this routes
|
||||||
/// the parser's measured value onto that track first.
|
/// the parser's measured value onto that track first.
|
||||||
fn activate(&mut self, coding: Option<crate::mux::codec::PictureInfo>) -> io::Result<()> {
|
fn activate(
|
||||||
|
&mut self,
|
||||||
|
coding: Option<crate::mux::codec::PictureInfo>,
|
||||||
|
video_picture_seen: bool,
|
||||||
|
) -> io::Result<()> {
|
||||||
let mut pending = match std::mem::replace(&mut self.mode, Mode::Write(WriteMode::Building))
|
let mut pending = match std::mem::replace(&mut self.mode, Mode::Write(WriteMode::Building))
|
||||||
{
|
{
|
||||||
Mode::Write(WriteMode::Pending(p)) => p,
|
Mode::Write(WriteMode::Pending(p)) => p,
|
||||||
@@ -199,7 +207,7 @@ impl MkvStream {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(vt) = pending.video_track {
|
if let Some(vt) = pending.video_track {
|
||||||
apply_coding_to_track(&mut pending.tracks[vt], coding);
|
apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen);
|
||||||
}
|
}
|
||||||
// --log-level 3: dump the FINAL TrackEntry metadata (field order set).
|
// --log-level 3: dump the FINAL TrackEntry metadata (field order set).
|
||||||
for (i, track) in pending.tracks.iter().enumerate() {
|
for (i, track) in pending.tracks.iter().enumerate() {
|
||||||
@@ -240,12 +248,20 @@ impl MkvStream {
|
|||||||
/// Set a video track's `FieldOrder` from the MEASURED coding of the first coded
|
/// Set a video track's `FieldOrder` from the MEASURED coding of the first coded
|
||||||
/// picture — the parser's value, the first time, never a guess.
|
/// picture — the parser's value, the first time, never a guess.
|
||||||
///
|
///
|
||||||
/// A progressive track has no field order (left UNDETERMINED — expected). An
|
/// A progressive track — or a progressive picture on an interlaced-flagged track
|
||||||
/// INTERLACED track that reaches here with no measured field order is a
|
/// — has no field order (left UNDETERMINED — expected). An INTERLACED track that
|
||||||
|
/// reaches here WITH a video picture but no measured field order is a
|
||||||
/// parser/source gap (MPEG-2 carries `top_field_first` on every interlaced
|
/// parser/source gap (MPEG-2 carries `top_field_first` on every interlaced
|
||||||
/// picture, so it should never be missing): LOG it loudly so the source can be
|
/// picture, so it should never be missing): LOG it loudly so the source can be
|
||||||
/// debugged, and leave UNDETERMINED — a muxer never fabricates a source fact.
|
/// debugged, and leave UNDETERMINED — a muxer never fabricates a source fact.
|
||||||
fn apply_coding_to_track(track: &mut MkvTrack, coding: Option<crate::mux::codec::PictureInfo>) {
|
/// `video_picture_seen == false` (an empty title finalized with no frames, or a
|
||||||
|
/// cap-triggered build that never saw the video frame) is NOT a defect — the
|
||||||
|
/// missing coding is expected there, so log it quietly.
|
||||||
|
fn apply_coding_to_track(
|
||||||
|
track: &mut MkvTrack,
|
||||||
|
coding: Option<crate::mux::codec::PictureInfo>,
|
||||||
|
video_picture_seen: bool,
|
||||||
|
) {
|
||||||
// HDR10 static metadata measured from the bitstream (HEVC SEI). Applied for
|
// HDR10 static metadata measured from the bitstream (HEVC SEI). Applied for
|
||||||
// ANY track type that carries it (independent of interlace): the first coded
|
// ANY track type that carries it (independent of interlace): the first coded
|
||||||
// picture's PictureInfo holds it once both HDR10 SEI messages were seen.
|
// picture's PictureInfo holds it once both HDR10 SEI messages were seen.
|
||||||
@@ -260,17 +276,32 @@ fn apply_coding_to_track(track: &mut MkvTrack, coding: Option<crate::mux::codec:
|
|||||||
match coding.and_then(|c| c.field_order()) {
|
match coding.and_then(|c| c.field_order()) {
|
||||||
Some(FieldOrder::Tff) => track.field_order = ebml::FIELD_ORDER_TFF,
|
Some(FieldOrder::Tff) => track.field_order = ebml::FIELD_ORDER_TFF,
|
||||||
Some(FieldOrder::Bff) => track.field_order = ebml::FIELD_ORDER_BFF,
|
Some(FieldOrder::Bff) => track.field_order = ebml::FIELD_ORDER_BFF,
|
||||||
other => {
|
// A progressive picture on an interlaced-flagged track carries no field
|
||||||
|
// order. Leave UNDETERMINED (not a guess) — there is no parser gap here.
|
||||||
|
Some(FieldOrder::Progressive) => {
|
||||||
|
track.field_order = ebml::FIELD_ORDER_UNDETERMINED;
|
||||||
|
}
|
||||||
|
None if video_picture_seen => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
"interlaced video track reached the muxer with NO measured field order \
|
"interlaced video track had a video picture but NO usable field order \
|
||||||
(field_order={:?}, coding_present={}); writing FieldOrder=UNDETERMINED \
|
(coding_present={}); writing FieldOrder=UNDETERMINED — NOT a guess. \
|
||||||
— NOT a guess. Debug why the source/parser did not set top_field_first.",
|
Debug why the source/parser did not set top_field_first.",
|
||||||
other,
|
|
||||||
coding.is_some(),
|
coding.is_some(),
|
||||||
);
|
);
|
||||||
track.field_order = ebml::FIELD_ORDER_UNDETERMINED;
|
track.field_order = ebml::FIELD_ORDER_UNDETERMINED;
|
||||||
}
|
}
|
||||||
|
None => {
|
||||||
|
// No video picture was ever measured (empty title finalized with no
|
||||||
|
// frames, or a cap-triggered build before the first video frame).
|
||||||
|
// Coding is legitimately absent, not a parser defect — log quietly.
|
||||||
|
tracing::debug!(
|
||||||
|
target: "mux",
|
||||||
|
"interlaced video track activated with no video picture \
|
||||||
|
(empty/buffered-only title); writing FieldOrder=UNDETERMINED.",
|
||||||
|
);
|
||||||
|
track.field_order = ebml::FIELD_ORDER_UNDETERMINED;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +458,7 @@ impl crate::pes::Stream for MkvStream {
|
|||||||
// Pass the trigger frame's coding only when it IS the video frame; a
|
// 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
|
// cap-triggered build never saw the video frame, so nothing measured
|
||||||
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
|
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
|
||||||
self.activate(if use_coding { frame.coding } else { None })?;
|
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
|
||||||
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
|
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
|
||||||
return m.write_frame(
|
return m.write_frame(
|
||||||
frame.track,
|
frame.track,
|
||||||
@@ -450,7 +481,10 @@ impl crate::pes::Stream for MkvStream {
|
|||||||
// A title that produced no frames (or only buffered ones) is still
|
// A title that produced no frames (or only buffered ones) is still
|
||||||
// finalized into a valid MKV: activate now with no measured coding.
|
// finalized into a valid MKV: activate now with no measured coding.
|
||||||
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
|
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
|
||||||
self.activate(None)?;
|
// No video picture was ever measured for this title (it produced no
|
||||||
|
// frames, or only buffered non-video ones): coding is legitimately
|
||||||
|
// absent, not a parser defect — `video_picture_seen=false`.
|
||||||
|
self.activate(None, false)?;
|
||||||
}
|
}
|
||||||
if let Mode::Write(WriteMode::Active(m)) =
|
if let Mode::Write(WriteMode::Active(m)) =
|
||||||
std::mem::replace(&mut self.mode, Mode::Write(WriteMode::Building))
|
std::mem::replace(&mut self.mode, Mode::Write(WriteMode::Building))
|
||||||
@@ -923,7 +957,7 @@ mod tests {
|
|||||||
|
|
||||||
// MEASURED bottom-field-first → BFF (6). The red-flag fix.
|
// MEASURED bottom-field-first → BFF (6). The red-flag fix.
|
||||||
let mut t = interlaced_track();
|
let mut t = interlaced_track();
|
||||||
apply_coding_to_track(&mut t, Some(pic(false, false)));
|
apply_coding_to_track(&mut t, Some(pic(false, false)), true);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
t.field_order,
|
t.field_order,
|
||||||
ebml::FIELD_ORDER_BFF,
|
ebml::FIELD_ORDER_BFF,
|
||||||
@@ -932,27 +966,37 @@ mod tests {
|
|||||||
|
|
||||||
// MEASURED top-field-first → TFF (1).
|
// MEASURED top-field-first → TFF (1).
|
||||||
let mut t = interlaced_track();
|
let mut t = interlaced_track();
|
||||||
apply_coding_to_track(&mut t, Some(pic(true, false)));
|
apply_coding_to_track(&mut t, Some(pic(true, false)), true);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
t.field_order,
|
t.field_order,
|
||||||
ebml::FIELD_ORDER_TFF,
|
ebml::FIELD_ORDER_TFF,
|
||||||
"measured TFF → FieldOrder=1"
|
"measured TFF → FieldOrder=1"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Interlaced track, NO measured coding → UNDETERMINED (logged loudly,
|
// Interlaced track, a video picture but NO usable field order →
|
||||||
// never faked).
|
// UNDETERMINED (logged loudly, never faked).
|
||||||
let mut t = interlaced_track();
|
let mut t = interlaced_track();
|
||||||
apply_coding_to_track(&mut t, None);
|
apply_coding_to_track(&mut t, None, true);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
t.field_order,
|
t.field_order,
|
||||||
ebml::FIELD_ORDER_UNDETERMINED,
|
ebml::FIELD_ORDER_UNDETERMINED,
|
||||||
"no measured value → UNDETERMINED, never a guess"
|
"no measured value → UNDETERMINED, never a guess"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Interlaced track activated with NO video picture (empty/buffered-only
|
||||||
|
// title) → UNDETERMINED, logged quietly (not a parser defect).
|
||||||
|
let mut t = interlaced_track();
|
||||||
|
apply_coding_to_track(&mut t, None, false);
|
||||||
|
assert_eq!(
|
||||||
|
t.field_order,
|
||||||
|
ebml::FIELD_ORDER_UNDETERMINED,
|
||||||
|
"empty title → UNDETERMINED, never a guess"
|
||||||
|
);
|
||||||
|
|
||||||
// Progressive picture on an interlaced-flagged track → UNDETERMINED (no
|
// Progressive picture on an interlaced-flagged track → UNDETERMINED (no
|
||||||
// field order applies; not faked to TFF/BFF).
|
// field order applies; not faked to TFF/BFF).
|
||||||
let mut t = interlaced_track();
|
let mut t = interlaced_track();
|
||||||
apply_coding_to_track(&mut t, Some(pic(true, true)));
|
apply_coding_to_track(&mut t, Some(pic(true, true)), true);
|
||||||
assert_eq!(t.field_order, ebml::FIELD_ORDER_UNDETERMINED);
|
assert_eq!(t.field_order, ebml::FIELD_ORDER_UNDETERMINED);
|
||||||
|
|
||||||
// A PROGRESSIVE track is never touched — field order stays UNDETERMINED.
|
// A PROGRESSIVE track is never touched — field order stays UNDETERMINED.
|
||||||
@@ -969,7 +1013,7 @@ mod tests {
|
|||||||
measured_cicp: None,
|
measured_cicp: None,
|
||||||
});
|
});
|
||||||
assert!(!prog.interlaced);
|
assert!(!prog.interlaced);
|
||||||
apply_coding_to_track(&mut prog, Some(pic(false, false)));
|
apply_coding_to_track(&mut prog, Some(pic(false, false)), true);
|
||||||
assert_eq!(prog.field_order, ebml::FIELD_ORDER_UNDETERMINED);
|
assert_eq!(prog.field_order, ebml::FIELD_ORDER_UNDETERMINED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1011,18 +1055,18 @@ mod tests {
|
|||||||
let mut t = make();
|
let mut t = make();
|
||||||
assert!(t.hdr10.is_none(), "fresh track has no HDR10");
|
assert!(t.hdr10.is_none(), "fresh track has no HDR10");
|
||||||
let pic = PictureInfo::coding_type_only(CodingType::I).with_hdr10(Some(h));
|
let pic = PictureInfo::coding_type_only(CodingType::I).with_hdr10(Some(h));
|
||||||
apply_coding_to_track(&mut t, Some(pic));
|
apply_coding_to_track(&mut t, Some(pic), true);
|
||||||
assert_eq!(t.hdr10, Some(h), "measured HDR10 must reach the track");
|
assert_eq!(t.hdr10, Some(h), "measured HDR10 must reach the track");
|
||||||
|
|
||||||
// Picture without HDR10 → track stays None (never fabricated).
|
// Picture without HDR10 → track stays None (never fabricated).
|
||||||
let mut t = make();
|
let mut t = make();
|
||||||
let pic = PictureInfo::coding_type_only(CodingType::I);
|
let pic = PictureInfo::coding_type_only(CodingType::I);
|
||||||
apply_coding_to_track(&mut t, Some(pic));
|
apply_coding_to_track(&mut t, Some(pic), true);
|
||||||
assert!(t.hdr10.is_none(), "no measured HDR10 → track stays None");
|
assert!(t.hdr10.is_none(), "no measured HDR10 → track stays None");
|
||||||
|
|
||||||
// No coding at all → None.
|
// No coding at all → None.
|
||||||
let mut t = make();
|
let mut t = make();
|
||||||
apply_coding_to_track(&mut t, None);
|
apply_coding_to_track(&mut t, None, true);
|
||||||
assert!(t.hdr10.is_none());
|
assert!(t.hdr10.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
//! | network:// | Yes (listen) | Yes (connect) | host:port (required) |
|
//! | network:// | Yes (listen) | Yes (connect) | host:port (required) |
|
||||||
//! | stdio:// | Yes (stdin) | Yes (stdout) | empty |
|
//! | stdio:// | Yes (stdin) | Yes (stdout) | empty |
|
||||||
//! | null:// | -- | Yes | empty |
|
//! | null:// | -- | Yes | empty |
|
||||||
|
//! | demux:// | -- | Yes | directory path (required) — per-track ES demux |
|
||||||
//! | fvi:// | -- | Yes | file path (required) — per-picture video index |
|
//! | fvi:// | -- | Yes | file path (required) — per-picture video index |
|
||||||
//!
|
//!
|
||||||
//! Bare paths without a scheme are rejected.
|
//! Bare paths without a scheme are rejected.
|
||||||
@@ -920,6 +921,30 @@ mod tests {
|
|||||||
!parse_url("dir://x").is_disc_source(),
|
!parse_url("dir://x").is_disc_source(),
|
||||||
"dir:// is a sink, never a disc source"
|
"dir:// is a sink, never a disc source"
|
||||||
);
|
);
|
||||||
|
// fvi:// parses to Fvi with the raw remainder as the path, and is a
|
||||||
|
// sink (never a disc source) — parallel to the demux:// coverage above.
|
||||||
|
match parse_url("fvi://out/movie.fvi") {
|
||||||
|
StreamUrl::Fvi { path } => {
|
||||||
|
assert_eq!(path, PathBuf::from("out/movie.fvi"));
|
||||||
|
}
|
||||||
|
other => panic!("fvi:// must parse to Fvi, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(parse_url("fvi://x").scheme(), "fvi");
|
||||||
|
assert_eq!(parse_url("fvi://x/y.fvi").path_str(), "x/y.fvi");
|
||||||
|
assert!(
|
||||||
|
!parse_url("fvi://x").is_disc_source(),
|
||||||
|
"fvi:// is a sink, never a disc source"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `fvi://` is output-only: `input()` rejects it with StreamWriteOnly
|
||||||
|
/// (E9001 → Unsupported), mirroring `null://` / `demux://`.
|
||||||
|
#[test]
|
||||||
|
fn input_fvi_url_is_write_only() {
|
||||||
|
assert_eq!(
|
||||||
|
input_err_kind("fvi://out/movie.fvi"),
|
||||||
|
std::io::ErrorKind::Unsupported
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `dir://` is output-only: `input()` rejects it (StreamWriteOnly →
|
/// `dir://` is output-only: `input()` rejects it (StreamWriteOnly →
|
||||||
|
|||||||
+88
-7
@@ -229,6 +229,12 @@ impl TsDemuxer {
|
|||||||
/// `data` in place. Zero-copy on the bulk path; one 192-byte copy
|
/// `data` in place. Zero-copy on the bulk path; one 192-byte copy
|
||||||
/// on the boundary.
|
/// on the boundary.
|
||||||
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket> {
|
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket> {
|
||||||
|
// A plain feed carries no provenance. Reset any base a prior
|
||||||
|
// `feed_at` left behind so mixing the two entry points is safe:
|
||||||
|
// after this call no `SourcePos` is stamped, and the stale running
|
||||||
|
// base can't leak a wrong offset into the boundary packet.
|
||||||
|
self.feed_base = 0;
|
||||||
|
self.has_base = false;
|
||||||
self.feed_inner(data)
|
self.feed_inner(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,15 +269,20 @@ impl TsDemuxer {
|
|||||||
self.remainder.extend_from_slice(data);
|
self.remainder.extend_from_slice(data);
|
||||||
return completed;
|
return completed;
|
||||||
}
|
}
|
||||||
|
// Capture the remainder length before clearing — it's how many of
|
||||||
|
// the boundary packet's bytes lived in the PREVIOUS feed buffer,
|
||||||
|
// and `feed_base` currently points at the FIRST byte of THIS buffer.
|
||||||
|
let rem_len = self.remainder.len();
|
||||||
let mut boundary = [0u8; BD_SOURCE_PACKET_BYTES];
|
let mut boundary = [0u8; BD_SOURCE_PACKET_BYTES];
|
||||||
boundary[..self.remainder.len()].copy_from_slice(&self.remainder);
|
boundary[..rem_len].copy_from_slice(&self.remainder);
|
||||||
boundary[self.remainder.len()..].copy_from_slice(&data[..need]);
|
boundary[rem_len..].copy_from_slice(&data[..need]);
|
||||||
self.remainder.clear();
|
self.remainder.clear();
|
||||||
// The boundary packet began in the PREVIOUS feed buffer; stamp it
|
// The boundary packet's first byte sat `rem_len` bytes before the
|
||||||
// with the offset just before this buffer (its first bytes' base).
|
// current feed_base (in the previous buffer). Stamp it there — not
|
||||||
let src = self
|
// at `feed_base - 1`, which would be wrong by `rem_len - 1` bytes.
|
||||||
.has_base
|
let src = self.has_base.then(|| {
|
||||||
.then(|| crate::pes::SourcePos::at_byte(self.feed_base.saturating_sub(1)));
|
crate::pes::SourcePos::at_byte(self.feed_base.saturating_sub(rem_len as u64))
|
||||||
|
});
|
||||||
self.process_packet(&boundary, src, &mut completed);
|
self.process_packet(&boundary, src, &mut completed);
|
||||||
offset = need;
|
offset = need;
|
||||||
}
|
}
|
||||||
@@ -931,6 +942,76 @@ mod tests {
|
|||||||
assert!(result.is_empty());
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A boundary packet (one split across two feeds) must be stamped with the
|
||||||
|
/// source offset of its FIRST byte, which sat `remainder.len()` bytes before
|
||||||
|
/// the current feed's base — not at `feed_base - 1`. We feed two 192-byte
|
||||||
|
/// packets via `feed_at`, splitting mid-second-packet so the second packet
|
||||||
|
/// is reassembled at the boundary, and assert its provenance lands exactly
|
||||||
|
/// on its first byte.
|
||||||
|
#[test]
|
||||||
|
fn boundary_packet_source_is_first_byte_not_base_minus_one() {
|
||||||
|
let pid = 0x1011;
|
||||||
|
let base: u64 = 20480; // sector-aligned (10 × 2048)
|
||||||
|
let mut demux = TsDemuxer::new(&[pid]);
|
||||||
|
|
||||||
|
let pkt0 = ts_payload_packet(pid, true, 0, &pes_start(b"AAAA"));
|
||||||
|
let pkt1 = ts_payload_packet(pid, true, 1, &pes_start(b"BBBB"));
|
||||||
|
let mut full = pkt0;
|
||||||
|
full.extend_from_slice(&pkt1);
|
||||||
|
|
||||||
|
// Split mid-pkt1 → pkt1 is reassembled from a 100-byte remainder + the
|
||||||
|
// next feed's head. pkt1's first byte is at absolute offset base + 192.
|
||||||
|
let split = BD_SOURCE_PACKET_BYTES + 100;
|
||||||
|
let out1 = demux.feed_at(base, &full[..split]);
|
||||||
|
assert!(out1.is_empty(), "pkt0's PES is still open");
|
||||||
|
|
||||||
|
// Second feed carries the rest; data[0] is at absolute base + split.
|
||||||
|
let out2 = demux.feed_at(base + split as u64, &full[split..]);
|
||||||
|
// pkt1 (PUSI) flushes pkt0's "AAAA" PES, stamped at pkt0's first byte.
|
||||||
|
assert_eq!(out2.len(), 1, "pkt0's PES completes when pkt1 starts");
|
||||||
|
assert_eq!(
|
||||||
|
out2[0].source.map(|s| s.byte),
|
||||||
|
Some(base),
|
||||||
|
"AAAA PES provenance is pkt0's first byte"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flush emits pkt1's "BBBB" PES — its source is the boundary stamp.
|
||||||
|
let out3 = demux.flush();
|
||||||
|
assert_eq!(out3.len(), 1, "pkt1's PES flushes out");
|
||||||
|
assert_eq!(
|
||||||
|
out3[0].source.map(|s| s.byte),
|
||||||
|
Some(base + BD_SOURCE_PACKET_BYTES as u64),
|
||||||
|
"boundary packet provenance must be its first byte (base + 192), \
|
||||||
|
not feed_base - 1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner decision #7: a plain `feed()` must reset/ignore any base a prior
|
||||||
|
/// `feed_at()` left behind, so mixing the two is safe. After a `feed_at`
|
||||||
|
/// primes a base, the next plain `feed` must stamp `None` on PES packets it
|
||||||
|
/// begins.
|
||||||
|
#[test]
|
||||||
|
fn plain_feed_resets_prior_feed_at_base() {
|
||||||
|
let pid = 0x1011;
|
||||||
|
let mut demux = TsDemuxer::new(&[pid]);
|
||||||
|
|
||||||
|
// Prime a base via feed_at; pkt0's PES stays open.
|
||||||
|
let out1 = demux.feed_at(20480, &ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
|
||||||
|
assert!(out1.is_empty());
|
||||||
|
|
||||||
|
// Plain feed must clear the base. pkt1 (PUSI) flushes "AAAA" (which was
|
||||||
|
// stamped during feed_at) and starts "BBBB" with NO provenance.
|
||||||
|
let out2 = demux.feed(&ts_payload_packet(pid, true, 1, &pes_start(b"BBBB")));
|
||||||
|
assert_eq!(out2.len(), 1, "AAAA completes");
|
||||||
|
|
||||||
|
let out3 = demux.flush();
|
||||||
|
assert_eq!(out3.len(), 1, "BBBB flushes");
|
||||||
|
assert_eq!(
|
||||||
|
out3[0].source, None,
|
||||||
|
"PES begun by a plain feed must carry no source after a prior feed_at"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── scan_streams PMT parsing ──────────────────────────────────────────
|
// ── scan_streams PMT parsing ──────────────────────────────────────────
|
||||||
|
|
||||||
/// Wrap a 188-byte TS packet body in a 192-byte BD-TS packet
|
/// Wrap a 188-byte TS packet body in a 192-byte BD-TS packet
|
||||||
|
|||||||
@@ -551,6 +551,21 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"measured CICP must override the coarse color_space enum"
|
"measured CICP must override the coarse color_space enum"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Unknown colorimetry, SDR, no measured CICP → all code points map to
|
||||||
|
// "unspecified" (2), matching `from_color_space(Unknown)`. Both sinks of
|
||||||
|
// one title must emit 2, never 0.
|
||||||
|
let c = Colour::from_video(&mk(HdrFormat::Sdr, ColorSpace::Unknown, None));
|
||||||
|
assert_eq!(
|
||||||
|
c,
|
||||||
|
Colour {
|
||||||
|
primaries: 2,
|
||||||
|
transfer: 2,
|
||||||
|
matrix: 2,
|
||||||
|
full_range: false,
|
||||||
|
},
|
||||||
|
"Unknown colorimetry must emit CICP 'unspecified' (2), not 0"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+9
-12
@@ -107,20 +107,17 @@ impl PesFrame {
|
|||||||
/// truncated `.pes` data would be accepted as a graceful end.
|
/// truncated `.pes` data would be accepted as a graceful end.
|
||||||
pub fn deserialize(r: &mut dyn std::io::Read) -> std::io::Result<Option<Self>> {
|
pub fn deserialize(r: &mut dyn std::io::Read) -> std::io::Result<Option<Self>> {
|
||||||
// Probe one byte first to distinguish clean EOF from a truncated
|
// Probe one byte first to distinguish clean EOF from a truncated
|
||||||
// header.
|
// header. Loop on EINTR so back-to-back signals don't fail a
|
||||||
|
// recoverable read — symmetric with read_exact's internal retry
|
||||||
|
// on the rest of the header and the data below.
|
||||||
let mut first = [0u8; 1];
|
let mut first = [0u8; 1];
|
||||||
match r.read(&mut first) {
|
loop {
|
||||||
Ok(0) => return Ok(None), // clean EOF, no frame started
|
match r.read(&mut first) {
|
||||||
Ok(_) => {}
|
Ok(0) => return Ok(None), // clean EOF, no frame started
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
|
Ok(_) => break,
|
||||||
// Retry-once on EINTR before committing to the header read.
|
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||||
match r.read(&mut first) {
|
Err(e) => return Err(e),
|
||||||
Ok(0) => return Ok(None),
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut header = [0u8; 22]; // 1 + 8 + 1 + 8 + 4
|
let mut header = [0u8; 22]; // 1 + 8 + 1 + 8 + 4
|
||||||
|
|||||||
+72
-8
@@ -104,11 +104,17 @@ pub fn register_unlocker(u: Box<dyn Unlocker>) {
|
|||||||
/// * `Ok(Some((name, vid)))` — a registered unlocker matched, put the drive
|
/// * `Ok(Some((name, vid)))` — a registered unlocker matched, put the drive
|
||||||
/// into extended mode, and returned the OEM Volume ID. The caller stashes
|
/// into extended mode, and returned the OEM Volume ID. The caller stashes
|
||||||
/// the VID for the handshake phase and need not run the cert handshake.
|
/// the VID for the handshake phase and need not run the cert handshake.
|
||||||
/// * `Ok(None)` — no unlocker matched, OR the matching unlocker failed
|
/// * `Ok(None)` — no unlocker matched, OR the matching unlocker reported a
|
||||||
/// ([`UnlockError`], logged). Either way the drive is usable in stock mode
|
/// *capability* failure ([`UnlockError::FirmwareNotUnlockable`],
|
||||||
/// and the caller falls through to the in-tree cert handshake. Folding an
|
/// [`UnlockError::VidUnavailable`], or a cert-auth outcome — all logged).
|
||||||
/// unlock failure into `Ok(None)` keeps drive `init()` infallible — a drive
|
/// Either way the drive is usable in stock mode and the caller falls
|
||||||
/// that simply isn't firmware-unlockable must not fail init.
|
/// through to the in-tree cert handshake. Folding a capability failure into
|
||||||
|
/// `Ok(None)` keeps drive `init()` infallible — a drive that simply isn't
|
||||||
|
/// firmware-unlockable must not fail init.
|
||||||
|
/// * `Err(_)` — the matching unlocker hit a genuine SCSI/transport fault
|
||||||
|
/// ([`UnlockError::Scsi`]). The bus is broken, not merely unsupported, so
|
||||||
|
/// this propagates and aborts init rather than silently falling through to
|
||||||
|
/// a cert handshake that would also fail.
|
||||||
pub(crate) fn route_unlock(
|
pub(crate) fn route_unlock(
|
||||||
scsi: &mut dyn ScsiTransport,
|
scsi: &mut dyn ScsiTransport,
|
||||||
id: &DriveId,
|
id: &DriveId,
|
||||||
@@ -124,10 +130,29 @@ pub(crate) fn route_unlock(
|
|||||||
let name = u.name().to_string();
|
let name = u.name().to_string();
|
||||||
match u.unlock(scsi, id) {
|
match u.unlock(scsi, id) {
|
||||||
Ok(vid) => return Ok(Some((name, vid))),
|
Ok(vid) => return Ok(Some((name, vid))),
|
||||||
|
// A genuine SCSI/transport fault is not "this drive can't be
|
||||||
|
// unlocked" — the bus is broken. Propagate so init() aborts
|
||||||
|
// instead of falling through to a cert handshake that will
|
||||||
|
// also fail on the same dead transport. The numeric code from
|
||||||
|
// the originating error is logged; the returned error is the
|
||||||
|
// canonical transport-error variant.
|
||||||
|
Err(UnlockError::Scsi(code)) => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "freemkv::unlock",
|
||||||
|
unlocker = %name,
|
||||||
|
code,
|
||||||
|
"unlocker hit a transport fault during unlock; aborting init"
|
||||||
|
);
|
||||||
|
return Err(crate::error::Error::ScsiError {
|
||||||
|
opcode: 0,
|
||||||
|
status: 0,
|
||||||
|
sense: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A firmware unlocker that can't unlock / has no OEM VID:
|
||||||
|
// fall through to the cert handshake. Debug-only structured
|
||||||
|
// log (variant identifiers, no English prose).
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// A firmware unlocker that can't unlock / has no OEM VID:
|
|
||||||
// fall through to the cert handshake. Debug-only structured
|
|
||||||
// log (variant identifiers, no English prose).
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::unlock",
|
target: "freemkv::unlock",
|
||||||
unlocker = %name,
|
unlocker = %name,
|
||||||
@@ -228,6 +253,10 @@ mod tests {
|
|||||||
/// `None` → `unlock` yields `Err(UnlockError::VidUnavailable)` so
|
/// `None` → `unlock` yields `Err(UnlockError::VidUnavailable)` so
|
||||||
/// `route_unlock` falls through to the cert handshake.
|
/// `route_unlock` falls through to the cert handshake.
|
||||||
vid: Option<[u8; 16]>,
|
vid: Option<[u8; 16]>,
|
||||||
|
/// When `Some(code)`, `unlock` yields `Err(UnlockError::Scsi(code))`
|
||||||
|
/// (a transport fault) instead of consulting `vid`, so `route_unlock`
|
||||||
|
/// propagates an error and aborts init.
|
||||||
|
scsi_err: Option<u16>,
|
||||||
/// Records whether set_max_read_speed was invoked.
|
/// Records whether set_max_read_speed was invoked.
|
||||||
speed_ran: Arc<AtomicBool>,
|
speed_ran: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
@@ -238,6 +267,7 @@ mod tests {
|
|||||||
ran,
|
ran,
|
||||||
// Default: a successful unlock returning an all-zero VID.
|
// Default: a successful unlock returning an all-zero VID.
|
||||||
vid: Some([0u8; 16]),
|
vid: Some([0u8; 16]),
|
||||||
|
scsi_err: None,
|
||||||
speed_ran: Arc::new(AtomicBool::new(false)),
|
speed_ran: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,6 +275,10 @@ mod tests {
|
|||||||
self.vid = vid;
|
self.vid = vid;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
fn with_scsi_err(mut self, code: u16) -> Self {
|
||||||
|
self.scsi_err = Some(code);
|
||||||
|
self
|
||||||
|
}
|
||||||
fn with_speed(mut self, speed_ran: Arc<AtomicBool>) -> Self {
|
fn with_speed(mut self, speed_ran: Arc<AtomicBool>) -> Self {
|
||||||
self.speed_ran = speed_ran;
|
self.speed_ran = speed_ran;
|
||||||
self
|
self
|
||||||
@@ -263,6 +297,9 @@ mod tests {
|
|||||||
_id: &DriveId,
|
_id: &DriveId,
|
||||||
) -> std::result::Result<Vid, UnlockError> {
|
) -> std::result::Result<Vid, UnlockError> {
|
||||||
self.ran.store(true, Ordering::SeqCst);
|
self.ran.store(true, Ordering::SeqCst);
|
||||||
|
if let Some(code) = self.scsi_err {
|
||||||
|
return Err(UnlockError::Scsi(code));
|
||||||
|
}
|
||||||
match self.vid {
|
match self.vid {
|
||||||
Some(v) => Ok(Vid(v)),
|
Some(v) => Ok(Vid(v)),
|
||||||
None => Err(UnlockError::VidUnavailable),
|
None => Err(UnlockError::VidUnavailable),
|
||||||
@@ -359,6 +396,33 @@ mod tests {
|
|||||||
assert!(got.is_none(), "no match → cert fallback");
|
assert!(got.is_none(), "no match → cert fallback");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A matching unlocker that hits a genuine transport fault
|
||||||
|
/// (`UnlockError::Scsi`) makes `route_unlock` PROPAGATE an `Err` rather
|
||||||
|
/// than fold to `Ok(None)`: a dead bus must abort init, not silently fall
|
||||||
|
/// through to a cert handshake that would also fail. Capability failures
|
||||||
|
/// (`VidUnavailable` etc.) still fold to `Ok(None)` — proven by the sibling
|
||||||
|
/// routing tests; this one pins the transport-fault exception.
|
||||||
|
#[test]
|
||||||
|
fn route_unlock_propagates_scsi_transport_fault() {
|
||||||
|
let mut scsi = NoopTransport;
|
||||||
|
|
||||||
|
register_unlocker(Box::new(
|
||||||
|
FakeUnlocker::new("SCSIVNDR", Arc::new(AtomicBool::new(false)))
|
||||||
|
.with_scsi_err(crate::error::E_SCSI_ERROR),
|
||||||
|
));
|
||||||
|
|
||||||
|
let got = route_unlock(&mut scsi, &fake_id("SCSIVNDR"));
|
||||||
|
assert!(
|
||||||
|
got.is_err(),
|
||||||
|
"a transport fault during unlock aborts init (propagates Err)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
got.unwrap_err().code(),
|
||||||
|
crate::error::E_SCSI_ERROR,
|
||||||
|
"propagated error is the canonical transport-error code"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `unlocker_set_max_read_speed` consults the FIRST matching unlocker's
|
/// `unlocker_set_max_read_speed` consults the FIRST matching unlocker's
|
||||||
/// `set_max_read_speed`. A matching unlocker is invoked; a non-match is a
|
/// `set_max_read_speed`. A matching unlocker is invoked; a non-match is a
|
||||||
/// safe no-op (nothing invoked, `Ok(())`).
|
/// safe no-op (nothing invoked, `Ok(())`).
|
||||||
|
|||||||
+16
-8
@@ -160,7 +160,7 @@ fn two_gop_image() -> Vec<u8> {
|
|||||||
data[..a.len()].copy_from_slice(&a);
|
data[..a.len()].copy_from_slice(&a);
|
||||||
|
|
||||||
let mut b = ps_pack_header();
|
let mut b = ps_pack_header();
|
||||||
b.extend_from_slice(&video_pes(&gop_es(), 3003)); // ~0.1s later
|
b.extend_from_slice(&video_pes(&gop_es(), 3003)); // one frame (~33 ms) later at 29.97 fps (3003/90000 s)
|
||||||
let off = 3 * 2048;
|
let off = 3 * 2048;
|
||||||
data[off..off + b.len()].copy_from_slice(&b);
|
data[off..off + b.len()].copy_from_slice(&b);
|
||||||
|
|
||||||
@@ -276,13 +276,21 @@ fn fvi_sink_indexes_real_mpeg2_pipeline_output() {
|
|||||||
vec![0, 3],
|
vec![0, 3],
|
||||||
"stamped src sectors must reach the .fvi in arrival order; got {src_sectors:?}"
|
"stamped src sectors must reach the .fvi in arrival order; got {src_sectors:?}"
|
||||||
);
|
);
|
||||||
// For each stamped record, sector == byte / 2048 (SourcePos::at_byte). The
|
// Per FVI_FORMAT.md §9, `src.byte` is the offset of the AU's first byte
|
||||||
// byte offset is exact (here 14 into each region, just past the pack
|
// WITHIN its 2048-byte `src.sector`, so it is always < 2048. The two
|
||||||
// header), so it is NOT sector-aligned — provenance is byte-exact.
|
// stamped sources sit 14 bytes into their sector (just past the pack
|
||||||
|
// header), so the within-sector byte is exact (14) — provenance is
|
||||||
|
// byte-exact, carried unchanged from demux.
|
||||||
for r in &records {
|
for r in &records {
|
||||||
if let (Some(sector), Some(byte)) = (r["src"]["sector"].as_u64(), r["src"]["byte"].as_u64())
|
if let Some(byte) = r["src"]["byte"].as_u64() {
|
||||||
{
|
assert!(
|
||||||
assert_eq!(sector, byte / 2048, "src.sector must equal src.byte / 2048");
|
byte < 2048,
|
||||||
|
"src.byte is a within-sector offset (§9), must be < 2048; got {byte}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
byte, 14,
|
||||||
|
"stamped src.byte is 14 (just past the pack header)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +346,7 @@ fn fvi_sink_indexes_non_mpeg2_frames_codec_agnostically() {
|
|||||||
assert_eq!(recs[0]["type"], "I");
|
assert_eq!(recs[0]["type"], "I");
|
||||||
assert_eq!(recs[0]["pts"], 1234);
|
assert_eq!(recs[0]["pts"], 1234);
|
||||||
assert_eq!(recs[0]["src"]["sector"], 4); // 8192 / 2048
|
assert_eq!(recs[0]["src"]["sector"], 4); // 8192 / 2048
|
||||||
assert_eq!(recs[0]["src"]["byte"], 8192);
|
assert_eq!(recs[0]["src"]["byte"], 0); // 8192 is sector-aligned → within-sector offset 0 (§9)
|
||||||
assert!(
|
assert!(
|
||||||
recs[0].get("field_order").is_none() && recs[0].get("nb_fields").is_none(),
|
recs[0].get("field_order").is_none() && recs[0].get("nb_fields").is_none(),
|
||||||
"coding-absent record omits field_order/nb_fields"
|
"coding-absent record omits field_order/nb_fields"
|
||||||
|
|||||||
Reference in New Issue
Block a user