libfreemkv: rc.5.2 DVD test coverage — depth-aware mux, colour codes, CSS scan
Implements the rc.5.2 quick-units list from the DVD coverage audit and corrects the "passes-but-encodes-the-bug" tests that could not distinguish correct from wrong behaviour. New tests (each with the bug it guards): mux/mkv.rs - field_duration_is_direct_trackentry_child_not_in_video: depth-aware check that DefaultDecodedFieldDuration (and DefaultDuration) are direct TrackEntry children, NOT nested in the Video master. Replaces the flat find_id byte-scan that passed either way. Adds master_children / first_track_entry depth-walking helpers. - pal_576i_emits_bt470bg_colour_codes / ntsc_480i_emits_smpte170m_colour_codes: assert the actual CICP tuples written into the MKV Colour master — PAL (5,5,5,1) vs NTSC (6,6,6,1) — not just stream-layer ColorSpace. - ntsc_480i_field_order_is_tff_and_encoded: pins NTSC 480i hardcoded TFF and its ~33.37ms/16.68ms frame/field durations, asserting the encoded FlagInterlaced/FieldOrder bytes (480i was never exercised before). ifo.rs - video_attr_absolute_bytes_pin_real_layout: drives parse_video_attr with HARDCODED real DVD-Video bytes (PAL/NTSC x 4:3/16:9, plus mpeg_version in bits 7-6) instead of v_atr_byte, so a co-edit of the shift constants can't re-seed the PAL-as-NTSC bug. Anchors that permitted_df bits (1-0) are not read as the TV system. disc/dvd.rs - scan_dvd_titles_lpcm_routes_to_a0_pid_range: LPCM (coding 4) → sub-id 0xA0 → PID 0xBDA0, disjoint from the AC-3 0xBD8x space, channels kept. - scan_dvd_titles_multiple_vobsub_tracks_distinct_pids: three VobSub tracks → distinct 0x20+ordinal PIDs, per-language, shared palette. css/mod.rs - crack_outcome_reaches_cracked_with_span: drives the full crack scan to CrackOutcome::Cracked via a Stevenson-crackable synthetic sector and asserts crack_span recording (the Cracked branch was never exercised). - recrack_succeeds_on_other_vts_extents: per-VTS re-crack SUCCESS path. - all_locked_synthetic_iso_yields_css_key_missing_signal: all-locked multi-extent ISO → ScrambledUncracked, the signal the scan converts to css_error = Some(CssKeyMissing). Corrected fixtures (passes-but-encodes-the-bug): - scan_dvd_titles_mixed_audio_codecs_distinct_pids: real channel nibbles (AC-3 5.1 = 6ch, DTS 2.0 = 2ch) replacing the 1ch placeholders; asserts channel counts and exact canonical PIDs (0xBD80 / 0xBD88). - ebml.rs FieldOrder comment: drop the stale "PAL DVD (576i) is bottom-field-first" line that contradicted the TFF-for-all code.
This commit is contained in:
+152
-2
@@ -349,6 +349,11 @@ mod tests {
|
||||
/// Every read fails with CSS-locked sense `05/6F/03` (drive refusing
|
||||
/// scrambled reads because the bus-auth gate isn't open).
|
||||
lock_all: bool,
|
||||
/// When set, the sector at `crackable.0` is served as a full
|
||||
/// Stevenson-crackable scrambled sector (`crackable.1`, 2048 bytes)
|
||||
/// instead of the uniform `flag_byte` fill. Lets the scan actually
|
||||
/// reach `CrackOutcome::Cracked` from a synthetic ISO.
|
||||
crackable: Option<(u32, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl MockSource {
|
||||
@@ -358,10 +363,33 @@ mod tests {
|
||||
flag_byte,
|
||||
fail_all: false,
|
||||
lock_all: false,
|
||||
crackable: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Stevenson-crackable scrambled sector for `(title_key, seed)`:
|
||||
/// the cleartext header (0x59..0x80) carries a periodic run that continues
|
||||
/// across the 0x80 boundary into the encrypted region — the crib
|
||||
/// `stevenson::crack_title_key` recovers a key from. Mirrors the
|
||||
/// `synth_periodic_sector` fixture in the stevenson tests but built here
|
||||
/// from the crate-internal `scramble_sector`.
|
||||
fn crackable_sector(title_key: &[u8; 5], seed: &[u8; 5], period: usize) -> Vec<u8> {
|
||||
const RUN_START: usize = 0x59;
|
||||
const SEED_OFFSET: usize = 0x54;
|
||||
let mut plaintext = vec![0u8; 2048];
|
||||
plaintext[0x14] = 0x10; // scramble flag
|
||||
let pat: Vec<u8> = (0..period)
|
||||
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
|
||||
.collect();
|
||||
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
|
||||
*b = pat[i % period];
|
||||
}
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
lfsr::scramble_sector(title_key, &mut plaintext);
|
||||
plaintext
|
||||
}
|
||||
|
||||
impl SectorSource for MockSource {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
@@ -390,8 +418,22 @@ mod tests {
|
||||
for b in buf[..end].iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
if buf.len() > 0x14 {
|
||||
buf[0x14] = self.flag_byte;
|
||||
// Fill each sector in the batch with the uniform flag byte, EXCEPT a
|
||||
// designated crackable LBA which gets the full synthetic sector.
|
||||
for s in 0..count as u32 {
|
||||
let sect_lba = lba + s;
|
||||
let base = s as usize * 2048;
|
||||
if base + 2048 > end {
|
||||
break;
|
||||
}
|
||||
match &self.crackable {
|
||||
Some((clba, sector)) if *clba == sect_lba => {
|
||||
buf[base..base + 2048].copy_from_slice(sector);
|
||||
}
|
||||
_ => {
|
||||
buf[base + 0x14] = self.flag_byte;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
@@ -652,4 +694,112 @@ mod tests {
|
||||
assert!(res.is_none());
|
||||
assert_eq!(src.reads.borrow().len(), 0);
|
||||
}
|
||||
|
||||
// ── Scan-level Cracked branch + per-VTS re-crack success (audit §2 / §5 #8) ─
|
||||
|
||||
/// SCAN-LEVEL CRACKED (audit gap "MockSource never yields a crackable
|
||||
/// sector"): drive the full `crack_key_scan` over a synthetic ISO whose
|
||||
/// scan hits a Stevenson-crackable scrambled sector. The outcome must be
|
||||
/// `CrackOutcome::Cracked` with a key that round-trips the sector, AND the
|
||||
/// `crack_span` must be recorded as the half-open extent span (the per-VTS
|
||||
/// routing key the mux path needs). Previously only the leaf crack and the
|
||||
/// Uncracked/Unencrypted branches were tested — the Cracked branch and
|
||||
/// `crack_span` recording were never exercised end-to-end.
|
||||
#[test]
|
||||
fn crack_outcome_reaches_cracked_with_span() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||
let crackable = crackable_sector(&title_key, &seed, 8);
|
||||
// The crackable sector sits a few sectors into the extent.
|
||||
let mut src = MockSource::new(0x00); // surrounding sectors: clear
|
||||
src.crackable = Some((1003, crackable.clone()));
|
||||
let extents = [Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 50,
|
||||
}];
|
||||
let outcome = crack_key_outcome(&mut src, &extents, 4, None);
|
||||
let state = match outcome {
|
||||
CrackOutcome::Cracked(s) => s,
|
||||
other => panic!("expected Cracked, got {other:?}"),
|
||||
};
|
||||
// The recovered key descrambles the crackable sector body.
|
||||
let mut test = crackable.clone();
|
||||
descramble_sector(&state, &mut test);
|
||||
let mut plain = crackable;
|
||||
lfsr::descramble_sector(&title_key, &mut plain);
|
||||
assert_eq!(
|
||||
&test[0x80..],
|
||||
&plain[0x80..],
|
||||
"recovered key must round-trip the scrambled sector body"
|
||||
);
|
||||
// crack_span = half-open [start, start+count) of the scanned extent.
|
||||
assert_eq!(
|
||||
state.crack_span,
|
||||
Some((1000, 1050)),
|
||||
"crack_span must record the extent LBA span for per-VTS routing"
|
||||
);
|
||||
}
|
||||
|
||||
/// CSS_ERROR WIRING (audit §2 / §5 #7): an all-locked synthetic ISO (every
|
||||
/// VOB read returns CSS-locked sense `05/6F/03` across MULTIPLE extents, as a
|
||||
/// real encrypted-but-unauthenticated disc image does) must produce the exact
|
||||
/// outcome the scan converts into `disc.css_error = Some(Error::CssKeyMissing)`
|
||||
/// — i.e. `CrackOutcome::ScrambledUncracked` / `is_scrambled_uncracked()`,
|
||||
/// NOT `Unencrypted`. disc/mod.rs's `crack_key_outcome → ScrambledUncracked`
|
||||
/// arm (where it stamps css_error) is driven by exactly this signal, so this
|
||||
/// pins the css-layer contract that arm depends on without touching the
|
||||
/// scan plumbing.
|
||||
#[test]
|
||||
fn all_locked_synthetic_iso_yields_css_key_missing_signal() {
|
||||
let mut src = MockSource::new(0x30);
|
||||
src.lock_all = true; // every read → 05/6F/03 across the whole "ISO"
|
||||
let extents = [
|
||||
Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 30,
|
||||
},
|
||||
Extent {
|
||||
start_lba: 5_000,
|
||||
sector_count: 30,
|
||||
},
|
||||
];
|
||||
let outcome = crack_key_outcome(&mut src, &extents, 16, None);
|
||||
assert!(
|
||||
outcome.is_scrambled_uncracked(),
|
||||
"all-locked ISO → ScrambledUncracked (the css_error=CssKeyMissing \
|
||||
signal), got {outcome:?}"
|
||||
);
|
||||
// The legacy Option wrapper still collapses it to None — callers that
|
||||
// surface the hard error must use crack_key_outcome, which this proves.
|
||||
let mut src2 = MockSource::new(0x30);
|
||||
src2.lock_all = true;
|
||||
assert!(crack_key(&mut src2, &extents, 16).is_none());
|
||||
}
|
||||
|
||||
/// PER-VTS RE-CRACK SUCCESS (audit gap "success path missing"): the prior
|
||||
/// re-crack test only covered the locked→None path. Here a re-crack
|
||||
/// (`crack_key`, `fail_on_locked == false`) over a DIFFERENT VTS's extents
|
||||
/// finds that VTS's own crackable sector and returns a `CssState` whose
|
||||
/// `crack_span` matches the new extents — proving a key cracked for one VTS
|
||||
/// is genuinely re-derived (not reused) for another.
|
||||
#[test]
|
||||
fn recrack_succeeds_on_other_vts_extents() {
|
||||
let title_key = [0xFE, 0xDC, 0xBA, 0x98, 0x76];
|
||||
let seed = [0x00, 0xFF, 0x80, 0x7F, 0x01];
|
||||
let crackable = crackable_sector(&title_key, &seed, 5);
|
||||
let mut src = MockSource::new(0x00);
|
||||
// The second VTS lives at a disjoint LBA range; its crackable sector is
|
||||
// the first one in the extent.
|
||||
src.crackable = Some((9000, crackable));
|
||||
let other_vts = [Extent {
|
||||
start_lba: 9000,
|
||||
sector_count: 20,
|
||||
}];
|
||||
let state = crack_key(&mut src, &other_vts, 4).expect("re-crack must recover a key");
|
||||
assert_eq!(
|
||||
state.crack_span,
|
||||
Some((9000, 9020)),
|
||||
"re-crack span must reflect the OTHER VTS extents, not a reused span"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+144
-6
@@ -745,11 +745,15 @@ mod tests {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||
// audio b0: coding_mode is (b0 >> 5) & 7. AC-3 = 0 → b0=0x00.
|
||||
// DTS = 6 → b0 = 6<<5 = 0xC0. b1 channels nibble high.
|
||||
// DTS = 6 → b0 = 6<<5 = 0xC0. b1 channels nibble = (channels-1) in bits
|
||||
// 2-0: a REAL 5.1 layout is 5 (0x05), a REAL 2.0 is 1 (0x01). The old
|
||||
// fixture used 0x10/0x50 (both decode to 1 channel) — a placeholder that
|
||||
// would pass even against code mishandling channel counts. Pin real
|
||||
// layouts: AC-3 5.1 eng, DTS 2.0 fra.
|
||||
let vts = build_vts(
|
||||
0,
|
||||
0x00,
|
||||
&[(0x00, 0x10, *b"en"), (0xC0, 0x50, *b"fr")], // AC-3 eng, DTS fra
|
||||
&[(0x00, 0x05, *b"en"), (0xC0, 0x01, *b"fr")], // AC-3 5.1 eng, DTS 2.0 fra
|
||||
&[],
|
||||
&[(0, 9)],
|
||||
false,
|
||||
@@ -784,11 +788,145 @@ mod tests {
|
||||
assert_eq!(audios[0].codec, Codec::Ac3);
|
||||
assert_eq!(audios[0].language, "en");
|
||||
assert_eq!(audios[1].codec, Codec::Dts);
|
||||
// PIDs must differ (no 0xBD00 collision).
|
||||
assert_ne!(
|
||||
audios[0].pid, audios[1].pid,
|
||||
"mixed-codec audio must route to distinct PIDs"
|
||||
// Real channel layouts survive the scan (not a 1ch placeholder): the
|
||||
// AC-3 is 5.1 (6ch), the DTS is 2.0 (2ch).
|
||||
assert_eq!(
|
||||
audios[0].channels.count(),
|
||||
6,
|
||||
"AC-3 5.1 nibble must decode to 6 channels"
|
||||
);
|
||||
assert_eq!(
|
||||
audios[1].channels.count(),
|
||||
2,
|
||||
"DTS 2.0 nibble must decode to 2 channels"
|
||||
);
|
||||
// PIDs route via the per-codec sub-id table: AC-3 #0 → 0x80 → 0xBD80,
|
||||
// DTS #0 → 0x88 → 0xBD88. Distinct (no 0xBD00 collision) AND the exact
|
||||
// canonical PIDs.
|
||||
assert_eq!(audios[0].pid, 0xBD80, "AC-3 #0 → 0xBD80");
|
||||
assert_eq!(audios[1].pid, 0xBD88, "DTS #0 → 0xBD88");
|
||||
assert_ne!(audios[0].pid, audios[1].pid);
|
||||
}
|
||||
|
||||
/// LPCM SCAN ROUTING (audit §2 / §5 #6): the 0xA0..=0xA7 PID range was never
|
||||
/// exercised in the dvd.rs scan. An LPCM stream (coding_mode 4) must get
|
||||
/// sub_stream_id 0xA0 → PID 0xBDA0 via `dvd_audio_pid`, distinct from the
|
||||
/// AC-3 0xBD80 space, with its real channel count preserved.
|
||||
#[test]
|
||||
fn scan_dvd_titles_lpcm_routes_to_a0_pid_range() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||
// b0 coding_mode = (b0 >> 5) & 7. LPCM = 4 → b0 = 4<<5 = 0x80.
|
||||
// b1 channels nibble: 2.0 stereo LPCM → (2-1)=1 → 0x01. Plus an AC-3 5.1
|
||||
// so we prove the two land in disjoint PID spaces (0xBD8x vs 0xBDAx).
|
||||
let vts = build_vts(
|
||||
0,
|
||||
0x00,
|
||||
&[(0x00, 0x05, *b"en"), (0x80, 0x01, *b"fr")], // AC-3 5.1 eng, LPCM 2.0 fra
|
||||
&[],
|
||||
&[(0, 9)],
|
||||
false,
|
||||
);
|
||||
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];
|
||||
let audios: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Audio(a) => Some(a),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(audios.len(), 2);
|
||||
assert_eq!(audios[0].codec, Codec::Ac3);
|
||||
assert_eq!(audios[1].codec, Codec::Lpcm, "coding_mode 4 → LPCM");
|
||||
assert_eq!(audios[0].pid, 0xBD80, "AC-3 #0 → 0xBD80");
|
||||
assert_eq!(
|
||||
audios[1].pid, 0xBDA0,
|
||||
"LPCM #0 → 0xBDA0 (the 0xA0 sub-id range), NOT the AC-3 space"
|
||||
);
|
||||
assert_eq!(
|
||||
audios[1].channels.count(),
|
||||
2,
|
||||
"LPCM 2.0 nibble must decode to 2 channels"
|
||||
);
|
||||
}
|
||||
|
||||
/// MULTI-VOBSUB SCAN (audit §2 / §5 #6): the single-subtitle test covered one
|
||||
/// track; a multi-subtitle VTS must emit one Stream::Subtitle per entry with
|
||||
/// distinct PIDs (0x20 + ordinal) and per-language tags, all sharing the PGC
|
||||
/// palette codec_data.
|
||||
#[test]
|
||||
fn scan_dvd_titles_multiple_vobsub_tracks_distinct_pids() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||
let vts = build_vts(
|
||||
0,
|
||||
0x00,
|
||||
&[],
|
||||
&[*b"en", *b"fr", *b"de"], // three VobSub tracks
|
||||
&[(0, 9)],
|
||||
true, // non-zero palette → codec_data on every track
|
||||
);
|
||||
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];
|
||||
let subs: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Subtitle(s) => Some(s),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(subs.len(), 3, "three VobSub tracks must all surface");
|
||||
// Languages preserved in order.
|
||||
assert_eq!(
|
||||
subs.iter().map(|s| s.language.as_str()).collect::<Vec<_>>(),
|
||||
vec!["en", "fr", "de"]
|
||||
);
|
||||
// PIDs are 0x20 + ordinal, all distinct.
|
||||
let pids: Vec<u16> = subs.iter().map(|s| s.pid).collect();
|
||||
assert_eq!(pids, vec![0x20, 0x21, 0x22], "VobSub PID = 0x20 + ordinal");
|
||||
// Every track carries the palette codec_data.
|
||||
for s in &subs {
|
||||
assert_eq!(s.codec, Codec::DvdSub);
|
||||
assert!(
|
||||
s.codec_data.is_some(),
|
||||
"each VobSub track shares the PGC palette codec_data"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subtitle streams map to Codec::DvdSub with palette codec_data when a
|
||||
|
||||
+48
@@ -1056,6 +1056,54 @@ mod tests {
|
||||
assert_eq!(attr.resolution, Resolution::R576i);
|
||||
}
|
||||
|
||||
/// ABSOLUTE-BYTE pin (audit §3 #2): the existing video-attr tests build the
|
||||
/// byte via `v_atr_byte(...)`, which uses the SAME shift constants the parser
|
||||
/// reads with — a co-edit of constant + helper would silently re-introduce
|
||||
/// the PAL-as-NTSC bug and every test would still pass. This test feeds
|
||||
/// `parse_video_attr` HARDCODED bytes captured from real DVD-Video layouts
|
||||
/// (DVD spec / libdvdread `video_attr_t`: mpeg_version[7-6] video_format[5-4]
|
||||
/// display_aspect[3-2] permitted_df[1-0]) — no `v_atr_byte`. If the parser's
|
||||
/// bit positions drift, these fail.
|
||||
#[test]
|
||||
fn video_attr_absolute_bytes_pin_real_layout() {
|
||||
// (byte @0x200, expected standard, expected aspect, expected resolution).
|
||||
// PAL 16:9 anamorphic = mpeg(00) format(01=PAL) aspect(11=16:9) df(00)
|
||||
// = 0b0001_1100 = 0x1C (e.g. a PAL 16:9 R2 feature disc).
|
||||
// PAL 4:3 = 0b0001_0000 = 0x10.
|
||||
// NTSC 16:9 = 0b0000_1100 = 0x0C.
|
||||
// NTSC 4:3 = 0b0000_0000 = 0x00.
|
||||
// A real disc also sets mpeg_version=01 (MPEG-2) in bits 7-6, which the
|
||||
// parser must IGNORE; OR it in (|0x40) to prove it doesn't leak into the
|
||||
// video_format read.
|
||||
let cases: &[(u8, TvSystem, DvdAspect, Resolution)] = &[
|
||||
(0x1C, TvSystem::Pal, DvdAspect::R16x9, Resolution::R576i),
|
||||
(0x10, TvSystem::Pal, DvdAspect::R4x3, Resolution::R576i),
|
||||
(0x0C, TvSystem::Ntsc, DvdAspect::R16x9, Resolution::R480i),
|
||||
(0x00, TvSystem::Ntsc, DvdAspect::R4x3, Resolution::R480i),
|
||||
// mpeg_version=2 (MPEG-2) in bits 7-6 must not perturb the read.
|
||||
(0x5C, TvSystem::Pal, DvdAspect::R16x9, Resolution::R576i),
|
||||
];
|
||||
for &(b0, std, aspect, res) in cases {
|
||||
let mut data = vec![0u8; 0x204];
|
||||
data[0x200] = b0;
|
||||
let attr = parse_video_attr(&data).unwrap();
|
||||
assert_eq!(attr.standard, std, "byte {b0:#04x} → standard");
|
||||
assert_eq!(attr.aspect, aspect, "byte {b0:#04x} → aspect");
|
||||
assert_eq!(attr.resolution, res, "byte {b0:#04x} → resolution");
|
||||
}
|
||||
// Anti-bug anchor: the original bug read the TV system from bits 1-0
|
||||
// (permitted_df). A PAL byte whose low 2 bits are 0 (0x1C) must NOT be
|
||||
// misread as NTSC — and a byte with low bits set but format=NTSC
|
||||
// (0x03 = NTSC, df=11) must stay NTSC, proving the low bits are ignored.
|
||||
let mut df = vec![0u8; 0x204];
|
||||
df[0x200] = 0x03; // format=NTSC(00), df=11
|
||||
assert_eq!(
|
||||
parse_video_attr(&df).unwrap().standard,
|
||||
TvSystem::Ntsc,
|
||||
"permitted_df bits (1-0) must NOT be read as the TV system"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_attr_parsing() {
|
||||
let mut data = vec![0u8; 16];
|
||||
|
||||
+4
-2
@@ -436,8 +436,10 @@ pub const FIELD_ORDER: u32 = 0x9D;
|
||||
pub const INTERLACED_INTERLACED: u64 = 1;
|
||||
pub const INTERLACED_PROGRESSIVE: u64 = 2;
|
||||
// FieldOrder values (Matroska): 0/2 = top-field-first, 1/9 = bottom-field-first.
|
||||
// NTSC DVD (480i) and HD (1080i) are top-field-first; PAL DVD (576i) is
|
||||
// bottom-field-first. 0xFF is our sentinel for "undetermined / omit".
|
||||
// NTSC DVD (480i), PAL DVD (576i) and HD (1080i) are all emitted top-field-first
|
||||
// — the muxer hardcodes TFF for every interlaced DVD/HD source (DV is the only
|
||||
// common BFF source and freemkv does not produce it). 0xFF is our sentinel for
|
||||
// "undetermined / omit".
|
||||
pub const FIELD_ORDER_TFF: u8 = 2;
|
||||
// Bottom-field-first. Retained for completeness/round-trip tests; the muxer
|
||||
// emits TFF for all DVD/HD interlaced content (DV is the only common BFF
|
||||
|
||||
+295
@@ -2236,6 +2236,45 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
/// Walk the direct children of a master element body. Returns
|
||||
/// `Vec<(id, body_start_abs, body_size)>` (absolute offsets into `data`).
|
||||
/// `master_body` is an absolute-offset slice range `[start, start+size)`.
|
||||
/// Unlike `find_id`'s flat byte-scan, this respects EBML nesting: a child
|
||||
/// id buried inside a deeper master is NOT reported at this level.
|
||||
fn master_children(data: &[u8], body_start: usize, body_size: usize) -> Vec<(u32, usize, u64)> {
|
||||
let mut out = Vec::new();
|
||||
let body = &data[body_start..body_start + body_size];
|
||||
let mut cursor = Cursor::new(body);
|
||||
while (cursor.position() as usize) < body.len() {
|
||||
let pos_before = cursor.position();
|
||||
let (id, size, hdr_len) = match ebml::read_element_header(&mut cursor) {
|
||||
Ok(v) => v,
|
||||
Err(_) => break,
|
||||
};
|
||||
let child_abs = body_start + pos_before as usize + hdr_len;
|
||||
out.push((id, child_abs, size));
|
||||
cursor
|
||||
.seek(io::SeekFrom::Current(size as i64))
|
||||
.expect("seek past child body");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Locate the first `TrackEntry` master and return the offset/size of its
|
||||
/// body. Walks Segment → Tracks → TrackEntry, never a flat byte-scan, so the
|
||||
/// returned range is the genuine TrackEntry body.
|
||||
fn first_track_entry(data: &[u8]) -> (usize, usize) {
|
||||
let (tracks_start, tracks_size) = segment_children(data)
|
||||
.into_iter()
|
||||
.find_map(|(id, off, sz)| (id == ebml::TRACKS).then_some((off, sz as usize)))
|
||||
.expect("Tracks element present");
|
||||
let (_, te_start, te_size) = master_children(data, tracks_start, tracks_size)
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| *id == ebml::TRACK_ENTRY)
|
||||
.expect("TrackEntry present");
|
||||
(te_start, te_size as usize)
|
||||
}
|
||||
|
||||
/// Find every Cluster: returns Vec<(cluster_data_start_abs, cluster_data_size, cluster_timestamp_ms)>.
|
||||
fn find_clusters(data: &[u8]) -> Vec<(usize, u64, u64)> {
|
||||
let mut out = Vec::new();
|
||||
@@ -3268,6 +3307,262 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_duration_is_direct_trackentry_child_not_in_video() {
|
||||
// GUARDS THE REAL BUG (audit §3 #1): DefaultDecodedFieldDuration
|
||||
// (0x234E7A) is, per the Matroska schema, a DIRECT child of TrackEntry —
|
||||
// NOT nested inside the Video master (which only holds FlagInterlaced /
|
||||
// FieldOrder). The pre-fix writer emitted it between start_master(VIDEO)
|
||||
// and end_master(vid_pos), burying it inside Video. The old test used the
|
||||
// flat `find_id` byte-scan, which passes regardless of nesting. This is a
|
||||
// depth-aware check: the element MUST appear among TrackEntry's direct
|
||||
// children and MUST NOT appear among Video's direct children.
|
||||
let v = VideoStream {
|
||||
pid: 0xE0,
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: Resolution::R576i,
|
||||
frame_rate: crate::disc::FrameRate::F25,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt470bg,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
};
|
||||
let t = MkvTrack::video(&v);
|
||||
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
|
||||
let (te_start, te_size) = first_track_entry(&data);
|
||||
let te_children = master_children(&data, te_start, te_size);
|
||||
|
||||
// Direct child of TrackEntry — present.
|
||||
assert!(
|
||||
te_children
|
||||
.iter()
|
||||
.any(|(id, _, _)| *id == ebml::DEFAULT_DECODED_FIELD_DURATION),
|
||||
"DefaultDecodedFieldDuration must be a DIRECT child of TrackEntry"
|
||||
);
|
||||
|
||||
// Locate the Video master (a direct child of TrackEntry) and confirm the
|
||||
// field-duration element is NOT inside it.
|
||||
let (_, vid_start, vid_size) = te_children
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|(id, _, _)| *id == ebml::VIDEO)
|
||||
.expect("Video master present");
|
||||
let vid_children = master_children(&data, vid_start, vid_size as usize);
|
||||
assert!(
|
||||
!vid_children
|
||||
.iter()
|
||||
.any(|(id, _, _)| *id == ebml::DEFAULT_DECODED_FIELD_DURATION),
|
||||
"DefaultDecodedFieldDuration must NOT be nested inside the Video master"
|
||||
);
|
||||
// And, for completeness, DefaultDuration is also a TrackEntry child (not
|
||||
// in Video) — pins the pair together so a future edit can't move either.
|
||||
assert!(
|
||||
te_children
|
||||
.iter()
|
||||
.any(|(id, _, _)| *id == ebml::DEFAULT_DURATION),
|
||||
"DefaultDuration must be a direct child of TrackEntry"
|
||||
);
|
||||
assert!(
|
||||
!vid_children
|
||||
.iter()
|
||||
.any(|(id, _, _)| *id == ebml::DEFAULT_DURATION),
|
||||
"DefaultDuration must NOT be nested inside the Video master"
|
||||
);
|
||||
// FlagInterlaced / FieldOrder ARE Video children (the spec's split).
|
||||
assert!(
|
||||
vid_children
|
||||
.iter()
|
||||
.any(|(id, _, _)| *id == ebml::FLAG_INTERLACED),
|
||||
"FlagInterlaced is a Video child"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: read the 1-byte value of a `[id][size=0x81][value]` uint element
|
||||
/// among the direct children of the Video master of the first TrackEntry.
|
||||
fn video_child_u8(data: &[u8], id: u32) -> Option<u8> {
|
||||
let (te_start, te_size) = first_track_entry(data);
|
||||
let (_, vid_start, vid_size) = master_children(data, te_start, te_size)
|
||||
.into_iter()
|
||||
.find(|(c, _, _)| *c == ebml::VIDEO)?;
|
||||
let (_, child_start, child_size) = master_children(data, vid_start, vid_size as usize)
|
||||
.into_iter()
|
||||
.find(|(c, _, _)| *c == id)?;
|
||||
// 1-byte uint value sits at the child body start.
|
||||
(child_size == 1).then(|| data[child_start])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pal_576i_emits_bt470bg_colour_codes() {
|
||||
// GUARDS audit §2 colour-code gap: the dvd.rs tests assert at the stream
|
||||
// layer (ColorSpace::Bt470bg); nothing asserted the actual CICP tuple
|
||||
// emitted in the MKV. PAL SD must emit matrix/transfer/primaries =
|
||||
// (5,5,5) with range=1 (BT.470BG). A swap with NTSC's (6,6,6) goes
|
||||
// uncaught by the stream-layer tests alone.
|
||||
let v = VideoStream {
|
||||
pid: 0xE0,
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: Resolution::R576i,
|
||||
frame_rate: crate::disc::FrameRate::F25,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt470bg,
|
||||
display_aspect: Some((16, 9)),
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
};
|
||||
let t = MkvTrack::video(&v);
|
||||
assert_eq!(
|
||||
(
|
||||
t.colour_matrix,
|
||||
t.colour_transfer,
|
||||
t.colour_primaries,
|
||||
t.colour_range
|
||||
),
|
||||
(5, 5, 5, 1),
|
||||
"PAL SD must map to BT.470BG (5,5,5,1)"
|
||||
);
|
||||
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
// Depth-scoped: Colour master inside Video, with the exact CICP codes.
|
||||
let (te_start, te_size) = first_track_entry(&data);
|
||||
let (_, vid_start, vid_size) = master_children(&data, te_start, te_size)
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| *id == ebml::VIDEO)
|
||||
.expect("Video master");
|
||||
let (_, col_start, col_size) = master_children(&data, vid_start, vid_size as usize)
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| *id == ebml::COLOUR)
|
||||
.expect("Colour master present for PAL SD");
|
||||
let col = master_children(&data, col_start, col_size as usize);
|
||||
let val = |id: u32| -> u8 {
|
||||
let (_, off, sz) = col.iter().copied().find(|(c, _, _)| *c == id).unwrap();
|
||||
assert_eq!(sz, 1, "single-byte CICP value");
|
||||
data[off]
|
||||
};
|
||||
assert_eq!(
|
||||
val(ebml::MATRIX_COEFFICIENTS),
|
||||
5,
|
||||
"PAL matrix = BT.470BG (5)"
|
||||
);
|
||||
assert_eq!(
|
||||
val(ebml::TRANSFER_CHARACTERISTICS),
|
||||
5,
|
||||
"PAL transfer = BT.470BG (5)"
|
||||
);
|
||||
assert_eq!(val(ebml::PRIMARIES), 5, "PAL primaries = BT.470BG (5)");
|
||||
assert_eq!(val(ebml::RANGE), 1, "PAL range = limited (1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntsc_480i_emits_smpte170m_colour_codes() {
|
||||
// Mirror of the PAL test: NTSC SD must emit (6,6,6,1) — SMPTE-170M /
|
||||
// BT.601-525 — not BT.470BG's (5,5,5). Together the two tests pin the
|
||||
// PAL/NTSC colour split at the emitted-byte layer.
|
||||
let v = VideoStream {
|
||||
pid: 0xE0,
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: Resolution::R480i,
|
||||
frame_rate: crate::disc::FrameRate::F29_97,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Smpte170m,
|
||||
display_aspect: Some((4, 3)),
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
};
|
||||
let t = MkvTrack::video(&v);
|
||||
assert_eq!(
|
||||
(
|
||||
t.colour_matrix,
|
||||
t.colour_transfer,
|
||||
t.colour_primaries,
|
||||
t.colour_range
|
||||
),
|
||||
(6, 6, 6, 1),
|
||||
"NTSC SD must map to SMPTE-170M (6,6,6,1)"
|
||||
);
|
||||
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
let (te_start, te_size) = first_track_entry(&data);
|
||||
let (_, vid_start, vid_size) = master_children(&data, te_start, te_size)
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| *id == ebml::VIDEO)
|
||||
.expect("Video master");
|
||||
let (_, col_start, col_size) = master_children(&data, vid_start, vid_size as usize)
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| *id == ebml::COLOUR)
|
||||
.expect("Colour master present for NTSC SD");
|
||||
let col = master_children(&data, col_start, col_size as usize);
|
||||
let val = |id: u32| -> u8 {
|
||||
let (_, off, sz) = col.iter().copied().find(|(c, _, _)| *c == id).unwrap();
|
||||
assert_eq!(sz, 1, "single-byte CICP value");
|
||||
data[off]
|
||||
};
|
||||
assert_eq!(
|
||||
val(ebml::MATRIX_COEFFICIENTS),
|
||||
6,
|
||||
"NTSC matrix = SMPTE-170M (6)"
|
||||
);
|
||||
assert_eq!(
|
||||
val(ebml::TRANSFER_CHARACTERISTICS),
|
||||
6,
|
||||
"NTSC transfer = SMPTE-170M (6)"
|
||||
);
|
||||
assert_eq!(val(ebml::PRIMARIES), 6, "NTSC primaries = SMPTE-170M (6)");
|
||||
assert_eq!(val(ebml::RANGE), 1, "NTSC range = limited (1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ntsc_480i_field_order_is_tff_and_encoded() {
|
||||
// 480i FIELD-ORDER HONESTY (audit §2 / §5 #5): NTSC 480i is hardcoded TFF
|
||||
// (mkv.rs field_order). Document & encode that reality so a future edit
|
||||
// can't silently flip it. The old field-order test covered 576i only;
|
||||
// NTSC was never exercised. Assert both the struct value AND the byte
|
||||
// actually written into the Video master.
|
||||
let v = VideoStream {
|
||||
pid: 0xE0,
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: Resolution::R480i,
|
||||
frame_rate: crate::disc::FrameRate::F29_97,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Smpte170m,
|
||||
display_aspect: Some((4, 3)),
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
};
|
||||
let t = MkvTrack::video(&v);
|
||||
assert!(t.interlaced, "480i is interlaced");
|
||||
assert_eq!(
|
||||
t.field_order,
|
||||
ebml::FIELD_ORDER_TFF,
|
||||
"NTSC 480i is hardcoded top-field-first"
|
||||
);
|
||||
// 480i @ 29.97: frame = 1001/30000 s = 33_366_666 ns; field = half.
|
||||
assert_eq!(
|
||||
t.default_duration_ns, 33_366_666,
|
||||
"480i frame duration is ~33.37 ms (29.97 fps, not halved)"
|
||||
);
|
||||
assert_eq!(
|
||||
t.field_duration_ns, 16_683_333,
|
||||
"480i field duration is half the frame (~16.68 ms)"
|
||||
);
|
||||
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
// FlagInterlaced and FieldOrder are Video children; assert the encoded
|
||||
// bytes (depth-scoped, not a flat scan).
|
||||
assert_eq!(
|
||||
video_child_u8(&data, ebml::FLAG_INTERLACED),
|
||||
Some(ebml::INTERLACED_INTERLACED as u8),
|
||||
"480i must encode FlagInterlaced = 1"
|
||||
);
|
||||
assert_eq!(
|
||||
video_child_u8(&data, ebml::FIELD_ORDER),
|
||||
Some(ebml::FIELD_ORDER_TFF),
|
||||
"480i must encode FieldOrder = TFF (2)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalize_emits_per_track_bps_tags() {
|
||||
// At finalize a Tags master with a per-track BPS SimpleTag is written.
|
||||
|
||||
Reference in New Issue
Block a user