diff --git a/src/css/mod.rs b/src/css/mod.rs index 31f88ad..be279ad 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -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)>, } 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 { + const RUN_START: usize = 0x59; + const SEED_OFFSET: usize = 0x54; + let mut plaintext = vec![0u8; 2048]; + plaintext[0x14] = 0x10; // scramble flag + let pat: Vec = (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" + ); + } } diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 4b211cd..3a23cc8 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -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!["en", "fr", "de"] + ); + // PIDs are 0x20 + ordinal, all distinct. + let pids: Vec = 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 diff --git a/src/ifo.rs b/src/ifo.rs index 295f901..5ab8462 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -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]; diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs index adc6446..b5c4ad9 100644 --- a/src/mux/ebml.rs +++ b/src/mux/ebml.rs @@ -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 diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 60ac060..17af228 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -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 { + 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.