diff --git a/src/aacs/content.rs b/src/aacs/content.rs index e3565385..08b2af86 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -890,6 +890,7 @@ mod tests { let mut ps = vec![0u8; ALIGNED_UNIT_LEN]; for off in [0usize, 2048, 4096] { ps[off..off + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + ps[off + 4] = 0x44; // '01': a 13818-1 pack } assert!( is_clean(&ps, ContentFormat::MpegPs), @@ -931,6 +932,7 @@ mod tests { let mut clear = vec![0u8; ALIGNED_UNIT_LEN]; for off in [0usize, 2048, 4096] { clear[off..off + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + clear[off + 4] = 0x44; // '01': a 13818-1 pack } // PES scrambling_control (byte 20, bits 5-4) == 0 → not encrypted. assert!( diff --git a/src/css/keyless.rs b/src/css/keyless.rs index 49ad926b..7dc563da 100644 --- a/src/css/keyless.rs +++ b/src/css/keyless.rs @@ -289,6 +289,7 @@ mod tests { fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec, Vec) { let mut plaintext = vec![0u8; SECTOR_BYTES]; plaintext[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + plaintext[4] = 0x44; // '01': a 13818-1 pack plaintext[FLAG_BYTE] = 0x10; plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed); plaintext[ENCRYPTED_START..ENCRYPTED_START + plain.len()].copy_from_slice(plain); diff --git a/src/css/mod.rs b/src/css/mod.rs index ec75421a..b87b6a2e 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -176,6 +176,80 @@ pub(crate) fn resolve_dvd_title_key( Ok(()) } +/// Where a 13818-1 pack's first PES header flags byte sits, `Some` only when that PES is +/// scrambled: the `mpg://` file's test (m1, B-1). Leading packets with no PES header (a +/// system header, a map, padding) are walked by their length; only the first PES is judged. +pub(crate) fn ps_scrambled_at(sector: &[u8]) -> Option { + if sector.len() < 2048 || sector[..4] != PACK_START || sector[4] >> 6 != 0b01 { + return None; + } + let mut p = 0x0E + usize::from(sector[0x0D] & 0x07); + loop { + let h = sector.get(p..p + 7)?; + if h[..3] != [0, 0, 1] { + return None; + } + match h[3] { + // MS-31: "if (stream_id != program_stream_map && stream_id != padding_stream && + // stream_id != private_stream_2 && stream_id != ECM && stream_id != EMM && …" + // (0xBB, the system header, is no PES packet at all). + 0xBB | 0xBC | 0xBE | 0xBF | 0xF0 | 0xF1 | 0xF2 | 0xF8 | 0xFF => { + p += 6 + usize::from(u16::from_be_bytes([h[4], h[5]])); + } + 0xBD..=0xFE => { + let at = p + 6; + // CSS leaves bytes before 0x80 clear; a header past them cannot be read. + return (at < 0x80 && h[6] >> 6 == 0b10 && (h[6] >> 4) & 0x03 != 0).then_some(at); + } + _ => return None, + } + } +} + +/// [`ps_scrambled_at`] as a test. +pub(crate) fn is_scrambled_ps_pack(sector: &[u8]) -> bool { + ps_scrambled_at(sector).is_some() +} + +// Run `f` on `sector` with its flags byte `at` presented at 0x14, where the LFSR and the +// keyless crack read it; both bytes are put back after (a stuffed pack, m1/m2). +fn with_flags_at_0x14(sector: &mut [u8], at: usize, f: impl FnOnce(&mut [u8]) -> T) -> T { + if at == 0x14 { + return f(sector); + } + let b14 = sector[0x14]; + sector[0x14] = sector[at]; + let out = f(sector); + sector[at] = sector[0x14]; + sector[0x14] = b14; + out +} + +/// The `mpg://` file's crack (design §4 step 2.1, D3): raw = false, judging each pack by +/// [`ps_scrambled_at`]; the resolved keys, `DecryptKeys::None` when the file is clear. +pub(crate) fn resolve_ps_file_title_key( + reader: &mut dyn SectorSource, + extents: &[Extent], + batch_sectors: u16, + halt: Option<&crate::halt::Halt>, +) -> std::io::Result { + match crack_key_scan_with(reader, extents, batch_sectors, halt, ps_scrambled_at) { + CrackOutcome::Cracked(state) => Ok(crate::decrypt::DecryptKeys::Css { + title_key: state.title_key, + }), + CrackOutcome::Unencrypted => Ok(crate::decrypt::DecryptKeys::None), + CrackOutcome::ScrambledUncracked => Err(crate::error::Error::CssKeyMissing.into()), + CrackOutcome::Unreadable(e) => Err(e.into()), + CrackOutcome::Halted => Err(crate::error::Error::Halted.into()), + } +} + +#[cfg(test)] +thread_local! { + /// Crack scans run on this thread (test-only: the mpg:// path must scan once). + pub(crate) static CRACK_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + // The crack scan, returning the full CrackOutcome. Tracks `saw_scrambled` so // a scrambled-but-uncracked disc is distinguished from a genuinely // unencrypted one (crack_key's Option wrapper collapses both to None). @@ -185,6 +259,22 @@ fn crack_key_scan( batch_sectors: u16, halt: Option<&crate::halt::Halt>, ) -> CrackOutcome { + crack_key_scan_with(reader, extents, batch_sectors, halt, |s| { + is_scrambled_pack(s).then_some(0x14) + }) +} + +// The crack scan with its test: `scrambled_at` gives a scrambled pack's flags offset (always +// 0x14 on the disc path; `ps_scrambled_at` for mpg:// files). +fn crack_key_scan_with( + reader: &mut dyn SectorSource, + extents: &[Extent], + batch_sectors: u16, + halt: Option<&crate::halt::Halt>, + scrambled_at: fn(&[u8]) -> Option, +) -> CrackOutcome { + #[cfg(test)] + CRACK_SCANS.with(|n| n.set(n.get() + 1)); // Batch the reads: a live drive at 1 sector/read is glacial. `batch_sectors` // MUST be sized to the source — a drive rejects a READ(10) larger than its // per-command max, and `Drive::read` does not chunk an over-large batch. @@ -266,9 +356,16 @@ fn crack_key_scan( // HARDENED pack-gated check: a clear stub sector with // stray bits at 0x14 must NOT count as scramble evidence, // or an unencrypted title falsely reports E7023. - if is_scrambled_pack(sect) { + if let Some(at) = scrambled_at(sect) { saw_scrambled = true; - if let Some(key) = keyless::crack_title_key(sect) { + // An mpg:// pack's flags may sit past 0x14 (m1); the disc's never do. + let key = if at == 0x14 { + keyless::crack_title_key(sect) + } else { + let mut copy = sect.to_vec(); + with_flags_at_0x14(&mut copy, at, |c| keyless::crack_title_key(c)) + }; + if let Some(key) = key { return CrackOutcome::Cracked(CssState { title_key: key, crack_span, @@ -341,17 +438,42 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) { const RECRACK_RETRY_EVERY: u32 = 16; pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::error::Result { + // `is_scrambled_pack`, NOT the looser `is_scrambled`: this sees arbitrary + // regions (IFO/UDF/ISO 9660) where raw byte 0x14 isn't a reliable flag. + // Measured: an IFO misread this way was destroyed, dropping titles 38→10. + descramble_region_with(buf, title_key, |c| is_scrambled_pack(c).then_some(0x14)) +} + +/// [`descramble_region`] for an `mpg://` file: each pack is judged, and its flags cleared, +/// where its own pack stuffing puts them ([`ps_scrambled_at`], m2). +pub(crate) fn descramble_ps_region(buf: &mut [u8], title_key: &mut [u8; 5]) { + let _ = descramble_region_with(buf, title_key, ps_scrambled_at); +} + +// `scrambled_at(chunk)`: the offset of a scrambled pack's flags byte, else `None`. +fn descramble_region_with( + buf: &mut [u8], + title_key: &mut [u8; 5], + scrambled_at: fn(&[u8]) -> Option, +) -> crate::error::Result { // Consecutive crib mismatches since the last re-crack attempt (0 = none // pending). Reset by a validated cache hit; a fresh run always attempts // on its first mismatch, then at most once every RECRACK_RETRY_EVERY. let mut mismatches_since_attempt: u32 = 0; for chunk in buf.chunks_mut(2048) { - // `is_scrambled_pack`, NOT the looser `is_scrambled`: this sees arbitrary - // regions (IFO/UDF/ISO 9660) where raw byte 0x14 isn't a reliable flag. - // Measured: an IFO misread this way was destroyed, dropping titles 38→10. - if chunk.len() < 2048 || !is_scrambled_pack(chunk) { + let Some(at) = (chunk.len() >= 2048).then(|| scrambled_at(chunk)).flatten() else { continue; - } + }; + with_flags_at_0x14(chunk, at, |chunk| { + descramble_one(chunk, title_key, &mut mismatches_since_attempt) + }); + } + Ok(0) +} + +// One scrambled pack (flags at 0x14), validated against its crib and re-cracked on a miss. +fn descramble_one(chunk: &mut [u8], title_key: &mut [u8; 5], mismatches_since_attempt: &mut u32) { + { let crib = keyless::attack_crib(chunk); // Snapshot the ciphertext (chunk is exactly 2048 here) only when there is // a crib to validate against, so the common cache-hit path costs no @@ -361,16 +483,16 @@ pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::erro original.copy_from_slice(chunk); } lfsr::descramble_sector(title_key, chunk); - let Some(crib) = crib else { continue }; + let Some(crib) = crib else { return }; if chunk[0x80..0x80 + 10] == crib[..] { - mismatches_since_attempt = 0; // validated: cached key still matches - continue; + *mismatches_since_attempt = 0; // validated: cached key still matches + return; } - mismatches_since_attempt += 1; - if mismatches_since_attempt % RECRACK_RETRY_EVERY != 1 { + *mismatches_since_attempt += 1; + if *mismatches_since_attempt % RECRACK_RETRY_EVERY != 1 { // Within a suppressed run, not yet due for its periodic retry — // keep the cached key (already applied above). - continue; + return; } // Due for an attempt: either the first mismatch of a run, or a // periodic retry into an ongoing one. Restore the ciphertext and @@ -382,7 +504,7 @@ pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::erro Some(fresh) => { *title_key = fresh; lfsr::descramble_sector(title_key, chunk); - mismatches_since_attempt = 0; + *mismatches_since_attempt = 0; } None => { // Re-crack found nothing; descramble with the CACHED key @@ -392,7 +514,6 @@ pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::erro } } } - Ok(0) } /// Whether bits 4-5 of the sub-header byte 0x14 are set. NOTHING MORE. @@ -419,12 +540,14 @@ pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA]; /// Requires BOTH the MPEG-PS pack-start code AND the 0x14 scramble bits /// ([`has_scramble_flag_bits`] alone is not enough), and excludes structural/nav `stream_id`s /// at 0x11 (`0xBB`/`0xBE`/`0xBF`) that CSS never scrambles so a decrypted HD-DVD's RDI nav -/// packs can't falsely trip it. 0x11 holds the `stream_id` only for zero-stuffing headers -/// (always true for DVD-Video VOB packs); a stuffed pack reads 0xFF there, harmless. +/// packs can't falsely trip it. An 11172-1 pack cannot be CSS; pack stuffing is not checked +/// here (libdvdcss reads 0x14 regardless), only by the `mpg://` file scan. pub fn is_scrambled_pack(sector: &[u8]) -> bool { use crate::consts::pes_stream_id::{PADDING_STREAM, PRIVATE_STREAM_2, SYSTEM_HEADER}; + // B2: a 13818-1 pack ('01'); an 11172-1 pack's 0x11/0x14 are a packet length and PTS. sector.len() >= 2048 && sector[0x00..0x04] == PACK_START + && sector[0x04] >> 6 == 0b01 && !matches!( sector[0x11], SYSTEM_HEADER | PADDING_STREAM | PRIVATE_STREAM_2 @@ -494,6 +617,7 @@ mod tests { fn false_positive_scrambled_pack() -> [u8; 2048] { let mut sector = [0u8; 2048]; sector[0x00..0x04].copy_from_slice(&PACK_START); + sector[4] = 0x44; // '01': a 13818-1 pack sector[0x11] = crate::consts::pes_stream_id::PRIVATE_STREAM_1; sector[0x14] = 0x30; for (i, b) in sector.iter_mut().enumerate().take(0x80).skip(0x20) { @@ -655,6 +779,87 @@ mod tests { ); } + // B2: an 11172-1 pack (12 bytes, '0010') puts a packet's length and PTS at 0x11/0x14, + // and a stuffed 13818-1 pack header moves the PES there: neither is scramble evidence. + #[test] + fn is_scrambled_pack_only_reads_an_unstuffed_13818_pack() { + // FFmpeg's MPEG-1 packet: STD buffer '01' at 0x12, then '0010' + PTS at 0x14. + let mut s = vec![0u8; 2048]; + s[..12].copy_from_slice(&[0, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0x1B, 0x83]); + s[12..16].copy_from_slice(&[0, 0, 1, 0xE0]); + s[16..18].copy_from_slice(&2030u16.to_be_bytes()); + s[18..20].copy_from_slice(&[0x60, 46]); + s[20] = 0x21; + assert!(!is_scrambled_pack(&s), "an 11172-1 pack"); + // A 13818-1 pack with pack_stuffing_length 3 (0x14 = stream_id) and 1 (0x14 = + // PES_packet_length low byte). + for stuffing in [3usize, 1] { + let mut s = vec![0xFFu8; 2048]; + s[..4].copy_from_slice(&PACK_START); + s[4] = 0x44; + s[13] = 0xF8 | stuffing as u8; + let at = 14 + stuffing; + s[at..at + 4].copy_from_slice(&[0, 0, 1, 0xE0]); + s[at + 4..at + 6].copy_from_slice(&0x07EBu16.to_be_bytes()); + s[at + 6] = 0x81; + assert!(!is_scrambled_ps_pack(&s), "pack_stuffing_length {stuffing}"); + } + } + + // A pack of `lead` (a packet with no PES header) then one video PES whose flags byte is + // `flags`: the offset of that byte, and the pack. + fn pack_after(lead: &[u8], flags: u8) -> (usize, Vec) { + let mut s = vec![0u8; 2048]; + s[..14].copy_from_slice(&[0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 0, 0, 3, 0xF8]); + s[14..14 + lead.len()].copy_from_slice(lead); + let pes = 14 + lead.len(); + s[pes..pes + 4].copy_from_slice(&[0, 0, 1, 0xE0]); + let len = (2048 - pes - 6) as u16; + s[pes + 4..pes + 6].copy_from_slice(&len.to_be_bytes()); + s[pes + 6] = flags; + (pes + 6, s) + } + + // B-1: a pack that begins with a program stream map (no PES header, MS-31) is judged by + // the PES after it: clear stays clear, scrambled is found at its own flags byte. + #[test] + fn a_map_first_pack_is_judged_by_the_pes_after_it() { + // A PSM whose byte at 0x14 (current_next_indicator, version) reads as "scrambled". + let psm = [0, 0, 1, 0xBC, 0, 10, 0xE0, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0]; + let (_, clear) = pack_after(&psm, 0x81); + assert_eq!(ps_scrambled_at(&clear), None, "clear"); + let (at, scrambled) = pack_after(&psm, 0x91); + assert_eq!(ps_scrambled_at(&scrambled), Some(at), "scrambled"); + // Every stream_id with no PES header (MS-31), and the system header, is walked past. + for id in [0xBB, 0xBC, 0xBE, 0xBF, 0xF0, 0xF1, 0xF2, 0xF8, 0xFF] { + let lead = [0, 0, 1, id, 0, 4, 0xB0, 0xB0, 0xB0, 0xB0]; + assert_eq!(ps_scrambled_at(&pack_after(&lead, 0x81).1), None, "{id:#x}"); + } + } + + // D3: on the disc/ISO path a scrambled 13818-1 pack is detected and descrambled whatever + // its pack_stuffing_length (libdvdcss reads 0x14 regardless); only mpg:// files are strict. + #[test] + fn a_stuffed_scrambled_pack_is_still_descrambled_on_the_disc_path() { + let mut pack = vec![0u8; 2048]; + for (i, b) in pack.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(29).wrapping_add(3); + } + pack[0x00..0x04].copy_from_slice(&PACK_START); + pack[4] = 0x44; // '01': a 13818-1 pack + pack[0x0D] = 0xF8 | 1; // pack_stuffing_length 1 + pack[0x14] = 0x30; + assert!(is_scrambled_pack(&pack), "stuffed but scrambled"); + let before = pack.clone(); + let mut key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; + descramble_region(&mut pack, &mut key).expect("region descramble"); + assert_ne!( + pack[0x80..], + before[0x80..], + "descrambled, not passed through" + ); + } + // Fix 3 hardening: `is_scrambled_pack` requires BOTH the pack-start code // AND the 0x14 bits, so a stub with stray 0x14 bits but no pack-start // isn't scramble evidence (else a clear title reports E7023). @@ -679,6 +884,7 @@ mod tests { ); // The real signature flips it to a scrambled pack. s[0x00..0x04].copy_from_slice(&PACK_START); + s[4] = 0x44; // '01': a 13818-1 pack assert!( is_scrambled_pack(&s), "valid pack-start + 0x14 bits → scrambled pack" @@ -695,6 +901,7 @@ mod tests { // A pack-start pack with 0x14 scramble bits set, varying only 0x11. let mut s = vec![0u8; 2048]; s[0x00..0x04].copy_from_slice(&PACK_START); + s[4] = 0x44; // '01': a 13818-1 pack s[0x14] = 0x30; for excluded in [SYSTEM_HEADER, PADDING_STREAM, PRIVATE_STREAM_2] { s[0x11] = excluded; @@ -762,6 +969,7 @@ mod tests { const SEED_OFFSET: usize = 0x54; let mut plaintext = vec![0u8; 2048]; plaintext[0x00..0x04].copy_from_slice(&PACK_START); // valid DVD pack header + plaintext[4] = 0x44; // '01': a 13818-1 pack plaintext[0x14] = 0x10; // scramble flag let pat: Vec = (0..period) .map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A) @@ -824,6 +1032,7 @@ mod tests { // before trusting 0x14; `stream_id` defaults to 0x00 // (scramblable) vs. an RDI nav pack's excluded 0xBF. buf[base..base + 4].copy_from_slice(&PACK_START); + buf[base + 4] = 0x44; // '01': a 13818-1 pack buf[base + 0x11] = self.stream_id; buf[base + 0x14] = self.flag_byte; } @@ -1021,6 +1230,8 @@ mod tests { *b = (i as u8).wrapping_mul(29).wrapping_add(3); } pack[0x00..0x04].copy_from_slice(&PACK_START); + pack[4] = 0x44; // '01': a 13818-1 pack + pack[0x0D] = 0xF8; // pack_stuffing_length 0 pack[0x14] = 0x30; assert!(is_scrambled_pack(&pack)); diff --git a/src/decrypt.rs b/src/decrypt.rs index fd36def4..8e81775c 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -703,6 +703,7 @@ mod tests { let mut plaintext = vec![0u8; 2048]; plaintext[0x00..0x04].copy_from_slice(&css::PACK_START); + plaintext[4] = 0x44; // '01': a 13818-1 pack plaintext[0x14] = 0x10; // CSS scramble flag (DVD-Video sector header) let pat: Vec = (0..PERIOD) .map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A) @@ -769,6 +770,7 @@ mod tests { const PERIOD: usize = 8; let mut plaintext = vec![0u8; 2048]; plaintext[0x00..0x04].copy_from_slice(&css::PACK_START); + plaintext[4] = 0x44; // '01': a 13818-1 pack plaintext[0x14] = 0x10; // scramble flag let pat: Vec = (0..PERIOD) .map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A) @@ -911,6 +913,8 @@ mod tests { // start code; the descrambler requires it before trusting byte 0x14. Without // it the fixture is a shape that can't occur on disc, masking a rejecting gate. sector[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + sector[4] = 0x44; // '01': a 13818-1 pack + sector[0x0D] = 0xF8; // pack_stuffing_length 0 sector[0x14] = 0x30; // scramble flag (bits 4-5) sector[0x54..0x59].copy_from_slice(seed); let plaintext = sector.clone(); @@ -978,6 +982,7 @@ mod tests { // Real scrambled DVD sectors are MPEG-2 PS packs; the scramble policy // requires the pack start code as well as the flag bits. plaintext[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + plaintext[4] = 0x44; // '01': a 13818-1 pack plaintext[0x14] = 0x10; // scramble flag // Periodic run from 0x59 (just above the seed) through 0x80 and on into // the encrypted region; phase anchored to offset 0 so it is continuous diff --git a/src/disc/extract.rs b/src/disc/extract.rs index aea8e4f4..e624b1d9 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -1583,6 +1583,7 @@ mod tests { // Pack start code: a real scrambled sector is an MPEG-2 PS pack, and // the descrambler requires it before trusting byte 0x14. plain[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + plain[4] = 0x44; // '01': a 13818-1 pack plain[0x14] = 0x10; // scramble flag let pat: Vec = (0..8) .map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A) @@ -2140,6 +2141,7 @@ mod tests { // signature (real scrambled sector = MPEG-2 PS pack) before cracking; without it // `resolve_vts_key` falls back to `base_keys` for both groups, masking the regression. plain[0x00..0x04].copy_from_slice(&crate::css::PACK_START); + plain[4] = 0x44; // '01': a 13818-1 pack plain[0x14] = 0x10; // scramble flag let pat: Vec = (0..8) .map(|k| (0xA0u8.wrapping_add(k as u8) ^ marker) ^ 0x5A) @@ -2214,6 +2216,7 @@ mod tests { let seed = [0x11u8, 0x22, 0x33, 0x44, 0x01]; let mut plain = vec![0u8; 2048]; plain[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + plain[4] = 0x44; // '01': a 13818-1 pack plain[0x14] = 0x10; let pat: Vec = (0..8) .map(|k| (0xA0u8.wrapping_add(k as u8) ^ 0x01) ^ 0x5A) @@ -2231,6 +2234,7 @@ mod tests { let uncrackable = { let mut sect = vec![0u8; 2048]; sect[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + sect[4] = 0x44; // '01': a 13818-1 pack sect[0x14] = 0x10; for (i, b) in sect.iter_mut().enumerate().skip(0x59) { // Non-repeating, so no run of any period survives to 0x80. @@ -2927,6 +2931,7 @@ mod tests { // Uncrackable ciphertext over >1 crack batch (64), so the cancel lands mid-scan. let mut sect = vec![0u8; 2048]; sect[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + sect[4] = 0x44; // '01': a 13818-1 pack sect[0x14] = 0x10; for (i, b) in sect.iter_mut().enumerate().skip(0x59) { *b = (i as u8).wrapping_mul(37).wrapping_add(11); @@ -3025,6 +3030,7 @@ mod tests { } let mut sect = vec![0u8; 2048]; sect[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + sect[4] = 0x44; // '01': a 13818-1 pack sect[0x14] = 0x10; for (i, b) in sect.iter_mut().enumerate().skip(0x59) { *b = (i as u8).wrapping_mul(37).wrapping_add(11); diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 492a83bd..e7616e5e 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -7914,6 +7914,7 @@ mod tests { const PERIOD: usize = 8; let mut sec = [0u8; 2048]; sec[0x00..0x04].copy_from_slice(&crate::css::PACK_START); + sec[4] = 0x44; // '01': a 13818-1 pack sec[0x14] = 0x10; // scramble flag for (i, b) in sec.iter_mut().enumerate().skip(RUN_START) { *b = (0xA0u8.wrapping_add((i % PERIOD) as u8)) ^ 0x5A; diff --git a/src/io/file_sector_source/mod.rs b/src/io/file_sector_source/mod.rs index 07daeed1..7e695f01 100644 --- a/src/io/file_sector_source/mod.rs +++ b/src/io/file_sector_source/mod.rs @@ -75,6 +75,9 @@ pub struct FileSectorSource { drop_window: (u64, u64), /// Cached drop chunk size (resolved from env once at open). drop_chunk_bytes: u64, + /// `Some(file length)` for [`open_padded`](Self::open_padded): a partial tail sector is + /// read, zero-filled to 2048 bytes. + padded_len: Option, } impl FileSectorSource { @@ -110,8 +113,30 @@ impl FileSectorSource { capacity, drop_window: (0, 0), drop_chunk_bytes: read_drop_chunk_bytes(), + padded_len: None, }) } + + /// Like [`open`](Self::open), but a file whose size is not a multiple of 2048 keeps its + /// tail: the last sector is zero-padded, synthetic and not counted as loss (an `mpg://` + /// source, mpg-output-design v5 §4 step 2, J13). + pub(crate) fn open_padded(path: &Path) -> Result { + let mut s = Self::open(path)?; + let len = s + .file + .metadata() + .map_err(|e| Error::IoError { source: e })? + .len(); + let sectors = len.div_ceil(SECTOR_BYTES_U64); + if sectors > u32::MAX as u64 { + return Err(Error::IsoTooLarge { + path: path.to_string_lossy().into_owned(), + }); + } + s.capacity = sectors as u32; + s.padded_len = Some(len); + Ok(s) + } } impl SectorSource for FileSectorSource { @@ -145,9 +170,16 @@ impl SectorSource for FileSectorSource { self.file .seek(SeekFrom::Start(offset)) .map_err(|e| Error::IoError { source: e })?; - if let Err(e) = self.file.read_exact(&mut out[..bytes]) { + // A padded source reads what the file holds and zero-fills the rest of the sector. + let real = match self.padded_len { + // Clamp in u64 first: on a 32-bit target `as usize` would truncate the distance. + Some(len) => len.saturating_sub(offset).min(bytes as u64) as usize, + None => bytes, + }; + if let Err(e) = self.file.read_exact(&mut out[..real]) { return Err(self.read_error(e, lba, offset + bytes as u64)); } + out[real..bytes].fill(0); // Queue the next batch's read before the caller processes what we returned. // readahead() is non-blocking; the kernel pulls pages into cache while the @@ -609,4 +641,27 @@ mod tests { // And the bound itself keeps the multiply inside u64. assert!((READ_DROP_CHUNK_MIB_MAX as u128) * 1024 * 1024 <= u64::MAX as u128); } + + // mpg-output-design v5 §4 step 2 (J13): a size that is not a multiple of 2048 "has its + // tail padded with zeros to the sector boundary"; `open` still drops it. + #[test] + fn open_padded_keeps_a_partial_tail_sector_zero_filled() { + let path = std::env::temp_dir().join(format!("fmkv-padded-{}", std::process::id())); + let mut data = vec![7u8; 2048 + 100]; + data[2048] = 9; + std::fs::write(&path, &data).unwrap(); + assert_eq!(FileSectorSource::open(&path).unwrap().capacity_sectors(), 1); + let mut s = FileSectorSource::open_padded(&path).unwrap(); + assert_eq!(s.capacity_sectors(), 2); + let mut buf = vec![0xAAu8; 4096]; + assert_eq!(s.read_sectors(0, 2, &mut buf, false).unwrap(), 4096); + assert_eq!(&buf[..2048], &data[..2048]); + assert_eq!(buf[2048], 9); + assert_eq!(&buf[2049..2148], &[7u8; 99][..]); + assert!( + buf[2148..].iter().all(|&b| b == 0), + "the synthetic pad is zeros" + ); + let _ = std::fs::remove_file(&path); + } } diff --git a/src/lib.rs b/src/lib.rs index 97a06e7e..5cc62c42 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -228,6 +228,8 @@ pub use mux::StdioStream; #[cfg(feature = "rip")] pub use mux::WriteSeek; #[cfg(feature = "rip")] +pub use mux::{FitReport, SkipReason, fit_report}; +#[cfg(feature = "rip")] pub use mux::{InputOptions, StreamUrl, input, output, parse_url}; #[cfg(feature = "rip")] pub use mux::{Medium, SourceInfo}; diff --git a/src/mux/au_assembly.rs b/src/mux/au_assembly.rs index 44b2c902..3aed3cc0 100644 --- a/src/mux/au_assembly.rs +++ b/src/mux/au_assembly.rs @@ -32,6 +32,65 @@ pub(crate) struct AssembledAu { pub discontinuity: bool, } +/// Design §2.3 "Reader side (L3)" (MPG2-7): an H.264 stream with one AUD per field splits a +/// field pair into two assembled AUs, the second without a PES PTS. It is merged back into +/// its first field (same `frame_num`, opposite parity), so the parser sees the IR frame. +#[derive(Default)] +pub(crate) struct SecondFieldMerge { + sps: Option, + /// The last AU, held until the next shows whether it is its second field, and its + /// first slice's field info (`None` once paired or when not a field picture). + held: Option<(crate::mux::ts::PesPacket, Option<(u32, bool)>)>, +} + +impl SecondFieldMerge { + // `(frame_num, bottom_field_flag)` of the AU's first slice when it codes a field; + // an SPS in the AU is taken first. + fn field_of(&mut self, data: &[u8]) -> Option<(u32, bool)> { + use crate::mux::codec::h264::{parse_slice_field_info, parse_sps_dts_info}; + use crate::mux::codec::startcode::find_start_code; + let mut at = find_start_code(data, 0); + while let Some(p) = at { + let start = p + 3; + let next = find_start_code(data, start); + let nal = &data[start..next.unwrap_or(data.len())]; + match nal.first().map(|h| h & 0x1F) { + Some(7) => self.sps = parse_sps_dts_info(nal).or(self.sps.take()), + Some(1 | 5) => { + let info = parse_slice_field_info(nal, self.sps.as_ref()?)?; + return info.field.map(|bottom| (info.frame_num, bottom)); + } + _ => {} + } + at = next; + } + None + } + + /// Feed one assembled AU; returns the AUs now complete, in order. + pub(crate) fn push(&mut self, au: crate::mux::ts::PesPacket) -> Vec { + let field = self.field_of(&au.data); + if au.pts.is_none() + && let (Some((frame, bottom)), Some((held, held_field))) = (field, self.held.as_mut()) + && held_field.is_some_and(|(f, b)| f == frame && b != bottom) + { + held.data.extend_from_slice(&au.data); + *held_field = None; + return Vec::new(); + } + self.held + .replace((au, field)) + .map(|(held, _)| held) + .into_iter() + .collect() + } + + /// The AU still held at the end of the stream. + pub(crate) fn flush(&mut self) -> Option { + self.held.take().map(|(au, _)| au) + } +} + /// VC-1 (SMPTE 421M Annex E) BDU start-code suffixes, `00 00 01 `. const VC1_FRAME: u8 = 0x0D; // coded picture const VC1_ENTRY: u8 = 0x0E; // entry-point header diff --git a/src/mux/codec/lpcm.rs b/src/mux/codec/lpcm.rs index 7eb58975..d164fc77 100644 --- a/src/mux/codec/lpcm.rs +++ b/src/mux/codec/lpcm.rs @@ -423,6 +423,80 @@ pub(crate) fn bd_payloads(pcm: &[u8], header: [u8; 2]) -> Vec<(i64, Vec)> { out } +// ── DVD LPCM re-pack (the `mpg://` sink; mpg-output-design v5 §1.1 G8) ───────── +// The exact inverse of `Format::Dvd::convert`, so DVD → IR → DVD is byte-identical. + +/// The smallest DVD LPCM quantization (16, 20 or 24 bits) that holds every sample of the +/// 24-bit IR PCM `ir` exactly: the IR keeps no source depth, and a 16-bit source's IR has +/// every low byte zero. +pub(crate) fn dvd_bits_needed(ir: &[u8]) -> u8 { + let low = ir.as_chunks::<3>().0.iter().fold(0u8, |acc, s| acc | s[2]); + match low { + 0 => 16, + l if l & 0x0F == 0 => 20, + _ => 24, + } +} + +/// Sample frames in one DVD LPCM packing unit (a 20/24-bit block, or one 16-bit frame). +pub(crate) fn dvd_unit_frames(channels: usize, bits: u8) -> usize { + if bits == 16 { 1 } else { dvd_block(channels).2 } +} + +/// DVD LPCM bytes for 24-bit IR PCM `ir` (a whole number of units) at `bits`. +pub(crate) fn dvd_pack(ir: &[u8], channels: usize, bits: u8, out: &mut Vec) { + let samples = ir.as_chunks::<3>().0; + if bits == 16 { + for s in samples { + out.extend_from_slice(&s[..2]); + } + return; + } + // A group is `g` MSB16 words, then their low bits (a byte each at 24-bit, a nibble each + // at 20-bit), as `Format::Dvd::convert` reads it. + let (_, g, _) = dvd_block(channels); + for grp in samples.chunks_exact(g) { + for s in grp { + out.extend_from_slice(&s[..2]); + } + if bits == 24 { + out.extend(grp.iter().map(|s| s[2])); + } else { + out.extend( + grp.as_chunks::<2>() + .0 + .iter() + .map(|p| (p[0][2] & 0xF0) | (p[1][2] >> 4)), + ); + } + } +} + +/// The 3-byte DVD LPCM audio header (frame number, quantization|rate|channels−1, +/// dynamic range), or `None` for a rate/channel count it cannot state. +pub(crate) fn dvd_header(channels: usize, rate: u32, bits: u8) -> Option<[u8; 3]> { + let rate_code = match rate { + 48_000 => 0, + 96_000 => 1, + _ => return None, + }; + let quant = match bits { + 16 => 0, + 20 => 1, + 24 => 2, + _ => return None, + }; + if !(1..=8).contains(&channels) { + return None; + } + // MS-30 (FFmpeg pcm_dvd): header[0] 0x0c, header[2] 0x80 (no dynamic range control). + Some([ + 0x0C, + (quant << 6) | (rate_code << 4) | (channels as u8 - 1), + 0x80, + ]) +} + #[cfg(test)] mod tests { use super::*; @@ -835,4 +909,61 @@ mod tests { assert!(!f.is_empty(), "the frame is emitted"); assert_eq!(f[0].source.map(|s| s.byte), Some(7_777)); } + + // G8 (mpg-output-design v5 §1.1): "DVD LPCM re-pack (mirror of bd_payloads)". The + // re-pack must invert this parser exactly; per design, do not change without a citation. + #[test] + fn dvd_pack_inverts_the_dvd_parser_for_every_layout() { + let mut seed = 0x1234_5678u32; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed as u8 + }; + for channels in 1..=8usize { + for bits in [16u8, 20, 24] { + let frames = dvd_unit_frames(channels, bits) * 7; + let mut ir = Vec::with_capacity(frames * channels * 3); + for _ in 0..frames * channels { + let lo = match bits { + 16 => 0, + 20 => next() & 0xF0, + _ => next(), + }; + ir.extend_from_slice(&[next(), next(), lo]); + } + assert!(dvd_bits_needed(&ir) <= bits); + let hdr = dvd_header(channels, 48_000, bits).expect("48 kHz, 1-8 ch"); + let mut es = hdr.to_vec(); + dvd_pack(&ir, channels, bits, &mut es); + let mut p = LpcmParser::new_dvd(); + let f = p.parse(&make_pes(es, Some(0))); + assert_eq!(f.len(), 1, "{channels} ch {bits} bit"); + assert_eq!(f[0].data, ir, "{channels} ch {bits} bit round trip"); + } + } + } + + #[test] + fn dvd_bits_needed_is_the_smallest_lossless_depth() { + assert_eq!(dvd_bits_needed(&[1, 2, 0, 3, 4, 0]), 16); + assert_eq!(dvd_bits_needed(&[1, 2, 0x50, 3, 4, 0]), 20); + assert_eq!(dvd_bits_needed(&[1, 2, 0x51, 3, 4, 0]), 24); + } + + // MS-30 (FFmpeg pcm_dvd): "stream->lpcm_header[0] = 0x0c; … | st->codecpar->ch_layout. + // nb_channels - 1; stream->lpcm_header[2] = 0x80;" — and the parser's own decode. + #[test] + fn dvd_header_matches_the_parser_and_ffmpeg() { + assert_eq!(dvd_header(2, 48_000, 16), Some([0x0C, 0x01, 0x80])); + assert_eq!(dvd_header(6, 96_000, 24), Some([0x0C, 0x95, 0x80])); + assert_eq!( + dvd_header(2, 44_100, 16), + None, + "mpg carries 48/96 kHz only" + ); + assert_eq!(dvd_header(9, 48_000, 16), None); + assert_eq!(dvd_header(0, 48_000, 16), None); + } } diff --git a/src/mux/codec/mod.rs b/src/mux/codec/mod.rs index 894782d0..6082f06a 100644 --- a/src/mux/codec/mod.rs +++ b/src/mux/codec/mod.rs @@ -200,7 +200,9 @@ pub fn parser_for_codec( // a display-order PTS per frame there; on BD/UHD (per-frame PTS) they don't. Codec::H264 => Box::new(h264::H264Parser::new().with_ps_reorder(is_dvd_ps)), Codec::Hevc => Box::new(hevc::HevcParser::new().with_ps_reorder(is_dvd_ps)), - Codec::Mpeg2 => Box::new(mpeg2::Mpeg2Parser::new()), + // 11172-2 is the 13818-2 syntax without extension start codes (mpg-output-design v5 + // §4 step 1), so MPEG-1 video is framed per picture by the same parser. + Codec::Mpeg2 | Codec::Mpeg1 => Box::new(mpeg2::Mpeg2Parser::new()), Codec::Vc1 => Box::new(vc1::Vc1Parser::new().with_ps_reorder(is_dvd_ps)), Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()), Codec::Flac => Box::new(flac::FlacParser::new()), @@ -212,10 +214,10 @@ pub fn parser_for_codec( Codec::Lpcm if is_dvd_ps => Box::new(lpcm::LpcmParser::new_dvd()), Codec::Lpcm => Box::new(lpcm::LpcmParser::new()), Codec::DvdSub => Box::new(dvdsub::DvdSubParser::new(codec_data)), - // Video codecs with no dedicated parser (Mpeg1/Av1 are real, just unparsed): - // multi-AU PES becomes one oversized block. Use non-keyframe passthrough, - // not all-keyframe (would explode Cues density); warn framing is approximate. - Codec::Mpeg1 | Codec::Av1 => { + // Video with no dedicated parser (AV1 is real, just unparsed): a multi-AU PES + // becomes one oversized block. Non-keyframe passthrough, not all-keyframe (that + // would explode Cues density); warn that framing is approximate. + Codec::Av1 => { tracing::warn!( target: "mux", "no dedicated parser for video codec {:?}; using non-keyframe passthrough (frame boundaries/keyframes not detected)", @@ -259,6 +261,32 @@ pub fn parser_for_mvc_dependent(codec: Codec, is_dvd_ps: bool) -> Box Option { + pub(crate) fn frame_bytes(&self) -> Option { let kbps = self.bitrate_kbps? as usize; Some(144_000 * kbps / self.sampling_hz as usize + usize::from(self.padding)) } @@ -762,9 +762,13 @@ impl ChannelTracker { self.last = Some(why); self.run = None; } - // Skipped: a failed frame CRC covers the header too, so its nch is not trusted. - Frame::Damaged { why, .. } => self.last = Some(why), - Frame::NoHeader => {} + // A failed frame CRC covers the header too, so its nch is not trusted; like a + // header-less frame it breaks the run, which must be consecutive. + Frame::Damaged { why, .. } => { + self.last = Some(why); + self.run = None; + } + Frame::NoHeader => self.run = None, } if self.frames >= MAX_FRAMES && self.run.is_none() { self.settled = true; diff --git a/src/mux/codec/mp2_channels_tests.rs b/src/mux/codec/mp2_channels_tests.rs index ad5a71b0..9ed4ea65 100644 --- a/src/mux/codec/mp2_channels_tests.rs +++ b/src/mux/codec/mp2_channels_tests.rs @@ -1309,10 +1309,10 @@ fn real_ext_zero_5_1_commits_after_three_frames() { assert_eq!(t.observe(&good), Some(6)); } -/// A failed mc_crc_check in the middle of a run resets it; a damaged or header-less frame is -/// skipped and does not. +/// A failed mc_crc_check in the middle of a run resets it, and so does a damaged or +/// header-less frame: the three CRC-valid frames must be consecutive. #[test] -fn an_mc_crc_failure_mid_run_resets_it_and_damage_does_not() { +fn an_mc_crc_failure_or_damage_mid_run_resets_it() { let good = frame(with_mc(STEREO_256, MC_3_2_LFE)); let mut t = ChannelTracker::default(); assert_eq!(t.observe(&good), None); @@ -1328,8 +1328,12 @@ fn an_mc_crc_failure_mid_run_resets_it_and_damage_does_not() { }); let mut t = ChannelTracker::default(); assert_eq!(t.observe(&good), None); - assert_eq!(t.observe(&damaged), None, "skipped"); - assert_eq!(t.observe(&[0u8; 16]), None, "skipped"); + assert_eq!(t.observe(&good), None); + assert_eq!(t.observe(&damaged), None, "reset"); + assert_eq!(t.observe(&good), None); + assert_eq!(t.observe(&good), None); + assert_eq!(t.observe(&[0u8; 16]), None, "reset"); + assert_eq!(t.observe(&good), None); assert_eq!(t.observe(&good), None); assert_eq!(t.observe(&good), Some(6)); } diff --git a/src/mux/driver.rs b/src/mux/driver.rs index d9e60f22..0c9dd2e8 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -34,7 +34,10 @@ fn url_medium(parsed: &StreamUrl) -> Medium { match parsed { StreamUrl::Disc { .. } => Medium::Disc, StreamUrl::Iso { .. } => Medium::Iso, - StreamUrl::Mkv { .. } | StreamUrl::M2ts { .. } | StreamUrl::Mp4 { .. } => Medium::File, + StreamUrl::Mkv { .. } + | StreamUrl::M2ts { .. } + | StreamUrl::Mp4 { .. } + | StreamUrl::Mpg { .. } => Medium::File, StreamUrl::Network { .. } | StreamUrl::Stdio => Medium::Stream, _ => Medium::File, } diff --git a/src/mux/fit.rs b/src/mux/fit.rs new file mode 100644 index 00000000..177da0e9 --- /dev/null +++ b/src/mux/fit.rs @@ -0,0 +1,295 @@ +//! The container-neutral pre-mux fit plan: which `title.streams` a destination +//! scheme carries and which it leaves out, with a reason, so a lossy mux is never +//! silent (mpg-output-design v5 §1.1 G3, L1). +//! +//! The plan is a PREDICTION made before any frame moves. Exclusions that only the +//! mux can see (a DVD MPEG-2 multichannel extension track whose packets arrive, an +//! mp4 track with no sample) are reported post-mux through +//! [`Stream::undelivered_streams`](crate::pes::Stream::undelivered_streams) and +//! [`Mp4Sink::final_report`](super::mp4::Mp4Sink::final_report), never here: a +//! declared-only extension track is never planned out (J23). + +use super::mp4::Mp4SkipReason; +use super::resolve::StreamUrl; +use crate::disc::{Codec, DiscTitle, Stream}; + +/// Why a stream is left out of a mux, for the never-silent note. +/// +/// `#[non_exhaustive]`: reasons are added as sinks learn to tell more cases apart. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SkipReason { + /// A bitmap subtitle (PGS/VobSub) the container has no mapping for. + BitmapSubtitle, + /// A text subtitle (SRT/SSA) the container has no mapping for. + UnmappableSubtitle, + /// An audio codec (or LPCM layout/rate) the container has no mapping for. + UnmappableAudio, + /// A secondary/dependent video view, or a second primary video. + SecondaryVideo, + /// A primary video track whose codec the container cannot carry. + UnmappableVideo, + /// A DVD MPEG-2 multichannel extension track. Post-mux only: reported once its + /// `0xD0|n` packets arrive, never from a declaration alone (J23). + Mp2Extension, + /// The container has no stream number left in this track's id range. + NoStreamId, + /// Planned, but the stream delivered no sample at all (post-mux). + NoSamples, + /// Planned, but no frame yielded a describable audio sample entry (post-mux). + UndescribableAudio, +} + +impl From for SkipReason { + fn from(r: Mp4SkipReason) -> Self { + match r { + Mp4SkipReason::BitmapSubtitle => SkipReason::BitmapSubtitle, + Mp4SkipReason::UnmappableAudio => SkipReason::UnmappableAudio, + Mp4SkipReason::SecondaryVideo => SkipReason::SecondaryVideo, + Mp4SkipReason::UnmappableVideo => SkipReason::UnmappableVideo, + Mp4SkipReason::Mp2Extension => SkipReason::Mp2Extension, + Mp4SkipReason::NoSamples => SkipReason::NoSamples, + Mp4SkipReason::UndescribableAudio => SkipReason::UndescribableAudio, + } + } +} + +/// The pre-mux plan for muxing `title` to one destination. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FitReport { + /// `title.streams` indices the destination will carry. + pub included: Vec, + /// Excluded `(stream index, reason)`, in stream order. + pub skipped: Vec<(usize, SkipReason)>, +} + +/// The pre-mux fit plan of `title` for `dest`. Every stream is either included or +/// skipped with a reason, except a declared-only MPEG-2 extension track, which is +/// in neither: whether it is lost is known only once its packets arrive (J23). +/// Destinations that carry everything (`mkv://`, the FMKV wire, …) skip nothing. +pub fn fit_report(dest: &StreamUrl, title: &DiscTitle) -> FitReport { + if let StreamUrl::Mpg { .. } = dest { + return super::mpg::plan(title).report; + } + if let StreamUrl::Mp4 { .. } = dest { + let r = super::mp4::fit_report(title); + return FitReport { + included: r.included, + skipped: r.skipped.into_iter().map(|(i, s)| (i, s.into())).collect(), + }; + } + let mut report = FitReport { + included: Vec::new(), + skipped: Vec::new(), + }; + for (i, s) in title.streams.iter().enumerate() { + if matches!(s, Stream::Audio(a) if a.is_mp2_extension()) { + continue; + } + let class_kept = match dest { + StreamUrl::Video { .. } => matches!(s, Stream::Video(_)), + StreamUrl::Audio { .. } => matches!(s, Stream::Audio(_)), + StreamUrl::Sub { .. } => matches!(s, Stream::Subtitle(_)), + _ => true, + }; + if !class_kept { + continue; + } + let refused = match (dest, s) { + (StreamUrl::M2ts { .. }, Stream::Audio(a)) if a.codec == Codec::Lpcm => { + let cp = title.codec_privates.get(i).and_then(|c| c.as_deref()); + super::m2ts::lpcm_bd_header(a, cp) + .is_none() + .then_some(SkipReason::UnmappableAudio) + } + _ => None, + }; + match refused { + Some(r) => report.skipped.push((i, r)), + None => report.included.push(i), + } + } + report +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::disc::{ + AudioChannels, AudioStream, ColorSpace, FrameRate, HdrFormat, LabelPurpose, LabelQualifier, + Resolution, SampleRate, SubtitleStream, VideoStream, + }; + use crate::mux::resolve::parse_url; + + fn video(codec: Codec) -> Stream { + Stream::Video(VideoStream { + pid: 0x1011, + codec, + resolution: Resolution::R1080p, + frame_rate: FrameRate::F23_976, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + }) + } + fn audio(pid: u16, codec: Codec, rate: SampleRate, label: &str) -> Stream { + Stream::Audio(AudioStream { + pid, + codec, + channels: AudioChannels::Stereo, + language: "eng".into(), + sample_rate: rate, + secondary: false, + purpose: LabelPurpose::Normal, + label: label.into(), + }) + } + fn pgs() -> Stream { + Stream::Subtitle(SubtitleStream { + pid: 0x1200, + codec: Codec::Pgs, + language: "eng".into(), + forced: false, + qualifier: LabelQualifier::None, + codec_data: None, + }) + } + fn title(streams: Vec) -> DiscTitle { + DiscTitle { + codec_privates: vec![None; streams.len()], + streams, + ..DiscTitle::empty() + } + } + fn ext() -> Stream { + audio( + 0x00D0, + Codec::Mp2, + SampleRate::S48, + crate::disc::MP2_EXTENSION_LABEL, + ) + } + + /// Every scheme `output()` can open, as a destination URL. + const OUTPUTS: &[&str] = &[ + "mkv:///o/x.mkv", + "mp4:///o/x.mp4", + "m2ts:///o/x.m2ts", + "demux:///o/", + "video:///o/", + "audio:///o/", + "sub:///o/", + "network://127.0.0.1:9", + "stdio://", + "null://", + "json:///o/x.json", + "chapters:///o/x.xml", + "fvi:///o/x.fvi", + ]; + + // J23: "the generic pre-mux fit report leaves out declared-only extension tracks". + // Per design; do not change without a design citation proving otherwise. + #[test] + fn a_declared_only_mp2_extension_is_never_planned_out() { + let t = title(vec![ + video(Codec::Mpeg2), + audio(0x00C0, Codec::Mp2, SampleRate::S48, ""), + ext(), + ]); + for url in OUTPUTS { + let r = fit_report(&parse_url(url), &t); + assert!( + r.skipped.iter().all(|&(i, _)| i != 2) && !r.included.contains(&2), + "{url}: a declared-only extension is in neither half: {r:?}" + ); + } + } + + #[test] + fn every_other_stream_is_either_included_or_skipped_exactly_once() { + let t = title(vec![ + video(Codec::Hevc), + audio(0x1100, Codec::TrueHd, SampleRate::S48, ""), + audio(0x1101, Codec::Ac3, SampleRate::S48, ""), + pgs(), + ]); + let class_sinks = ["video:///o/", "audio:///o/", "sub:///o/"]; + for url in OUTPUTS.iter().filter(|u| !class_sinks.contains(u)) { + let r = fit_report(&parse_url(url), &t); + let mut seen: Vec = r.included.clone(); + seen.extend(r.skipped.iter().map(|&(i, _)| i)); + seen.sort_unstable(); + assert_eq!(seen, vec![0, 1, 2, 3], "{url}: {r:?}"); + } + } + + #[test] + fn mp4_is_the_mp4_plan_through_the_wrapper() { + let t = title(vec![ + video(Codec::Hevc), + audio(0x1100, Codec::TrueHd, SampleRate::S48, ""), + audio(0x1101, Codec::Ac3, SampleRate::S48, ""), + pgs(), + ]); + let mp4 = super::super::mp4::fit_report(&t); + let r = fit_report(&parse_url("mp4:///o/x.mp4"), &t); + assert_eq!(r.included, mp4.included); + let want: Vec<(usize, SkipReason)> = + mp4.skipped.iter().map(|&(i, s)| (i, s.into())).collect(); + assert_eq!(r.skipped, want); + assert_eq!( + r.skipped, + vec![ + (1, SkipReason::UnmappableAudio), + (3, SkipReason::BitmapSubtitle) + ] + ); + } + + // m2ts refuses LPCM that BD LPCM cannot carry at create (`M2tsStream::create`); the + // plan predicts it with the same predicate. + #[test] + fn m2ts_plans_out_lpcm_bd_lpcm_cannot_carry() { + let t = title(vec![ + video(Codec::H264), + audio(0x1100, Codec::Lpcm, SampleRate::S44_1, ""), + audio(0x1101, Codec::Lpcm, SampleRate::S48, ""), + ]); + let r = fit_report(&parse_url("m2ts:///o/x.m2ts"), &t); + assert_eq!(r.included, vec![0, 2]); + assert_eq!(r.skipped, vec![(1, SkipReason::UnmappableAudio)]); + } + + // A class sink writes only its class by the user's choice: the rest is not a loss, + // so it is neither included nor skipped. + #[test] + fn class_sinks_include_only_their_class_and_skip_nothing() { + let t = title(vec![ + video(Codec::Hevc), + audio(0x1100, Codec::TrueHd, SampleRate::S48, ""), + pgs(), + ]); + for (url, want) in [("video:///o/", 0), ("audio:///o/", 1), ("sub:///o/", 2)] { + let r = fit_report(&parse_url(url), &t); + assert_eq!(r.included, vec![want], "{url}"); + assert!(r.skipped.is_empty(), "{url}"); + } + } + + #[test] + fn mkv_and_the_wire_carry_everything() { + let t = title(vec![ + video(Codec::Vc1), + audio(0x1100, Codec::Lpcm, SampleRate::S44_1, ""), + pgs(), + ]); + for url in ["mkv:///o/x.mkv", "network://127.0.0.1:9", "stdio://"] { + let r = fit_report(&parse_url(url), &t); + assert_eq!(r.included, vec![0, 1, 2], "{url}"); + assert!(r.skipped.is_empty(), "{url}"); + } + } +} diff --git a/src/mux/m2ts.rs b/src/mux/m2ts.rs index b1875617..c0c4aad2 100644 --- a/src/mux/m2ts.rs +++ b/src/mux/m2ts.rs @@ -11,6 +11,14 @@ use super::meta; use crate::disc::{DiscTitle, Stream as DiscStream}; use std::io::{self, Write}; +/// The BD LPCM header an `m2ts://` sink re-packs an LPCM track with, or `None` when BD +/// LPCM cannot carry its layout/rate and `create` leaves the track out. Shared with the +/// pre-mux plan ([`super::fit::fit_report`]) so the two cannot disagree. +pub(crate) fn lpcm_bd_header(a: &crate::disc::AudioStream, cp: Option<&[u8]>) -> Option<[u8; 2]> { + let src = cp.and_then(super::codec::lpcm::layout_byte); + super::codec::lpcm::bd_header(a.channels.count(), a.sample_rate.hz() as u32, src) +} + /// BD transport stream write sink with embedded FMKV metadata /// header. pub struct M2tsStream { @@ -43,12 +51,7 @@ impl M2tsStream { } let lpcm = match s { DiscStream::Audio(a) if a.codec == crate::disc::Codec::Lpcm => { - let src = cp.as_deref().and_then(super::codec::lpcm::layout_byte); - let h = super::codec::lpcm::bd_header( - a.channels.count(), - a.sample_rate.hz() as u32, - src, - ); + let h = lpcm_bd_header(a, cp.as_deref()); if h.is_none() { tracing::warn!( target: "mux", diff --git a/src/mux/mod.rs b/src/mux/mod.rs index 18552ea9..b721155d 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -53,6 +53,7 @@ pub(crate) mod meta_sink; // ── Sequential-sink muxers ── write-only PES → `SequentialSink`. `pub(crate)`+ // `allow(dead_code)`: `fmp4` is a STUB (pub would lock a half-built type into // v1.0); `m2ts_mux`/`hevc` are sink-split scaffolding (production: `tsmux`/`mkv`). +pub(crate) mod fit; #[allow(dead_code)] pub(crate) mod fmp4; #[allow(dead_code)] @@ -62,6 +63,7 @@ pub(crate) mod m2ts_mux; pub(crate) mod mkv; pub(crate) mod mkvstream; pub(crate) mod mp4; +pub(crate) mod mpg; pub(crate) mod network; pub(crate) mod null; pub(crate) mod ps; @@ -85,6 +87,7 @@ pub use disc::DiscStream; pub use driver::{ MuxEvents, MuxInput, MuxOptions, MuxOutcome, MuxSource, mux_stream, mux_with_keys, }; +pub use fit::{FitReport, SkipReason, fit_report}; pub use m2ts::M2tsStream; pub use mkvstream::{ MkvProbe, MkvProbeTrack, MkvStream, MkvTrackKind, parse_freemkv_version, probe_mkv, @@ -95,6 +98,7 @@ pub use videomap::{Medium, SourceInfo}; // the finished file contains — the pre-mux `mp4_fit_report` is only a prediction, // and two of its inclusions can still be dropped at `finish()`. pub use mp4::{Mp4FitReport, Mp4Sink, Mp4SkipReason, fit_report as mp4_fit_report}; +pub use mpg::MpgSink; pub use network::NetworkStream; pub use null::NullStream; pub use pipelined_stream::PipelinedPesStream; diff --git a/src/mux/mpg/fit.rs b/src/mux/mpg/fit.rs new file mode 100644 index 00000000..916cf598 --- /dev/null +++ b/src/mux/mpg/fit.rs @@ -0,0 +1,384 @@ +//! What an `mpg://` program stream carries, and on which stream id (mpg-output-design v5 +//! §2.1-§2.2 as narrowed by J24 to the DVD core the 2000 edition of H.222.0 covers). + +use crate::disc::{Codec, DiscTitle, Stream}; +use crate::mux::fit::{FitReport, SkipReason}; + +/// How one carried track is packetized. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Carriage { + /// `0xE0`; `mpeg1` selects stream_type 0x01 over 0x02 (MS-22). + Video { mpeg1: bool }, + /// `0xC0|n` (MS-27 "110x xxxx … audio stream number"), n in 0-7 only (design §2.2). + MpegAudio { stream_id: u8 }, + /// `0xD0|n`, the 13818-3 extension of the base `0xC0|n` (design §3). + Mp2Extension { stream_id: u8, base: usize }, + /// private_stream_1 sub-stream (MS-14: "user definable"; DVD convention, MS-29). + Private { sub_id: u8, kind: PrivateKind }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PrivateKind { + Ac3, + Dts, + Lpcm { channels: usize, rate: u32 }, + Subpicture, +} + +impl PrivateKind { + /// The DVD sub-id range (design §2.2 table). + fn range(self) -> std::ops::RangeInclusive { + match self { + PrivateKind::Ac3 => 0x80..=0x87, + PrivateKind::Dts => 0x88..=0x8F, + PrivateKind::Lpcm { .. } => 0xA0..=0xA7, + PrivateKind::Subpicture => 0x20..=0x3F, + } + } +} + +/// The fit plan and the carriage of every included track. +#[derive(Debug, Clone)] +pub(crate) struct Plan { + pub report: FitReport, + /// Per `title.streams` index; `None` for a skipped (or declared-only extension) track. + pub carriage: Vec>, +} + +enum Want { + Video { mpeg1: bool }, + MpegAudio, + Extension, + Private(PrivateKind), +} + +fn want(s: &Stream, have_video: bool) -> Result { + // MS-22 stream types 0x01-0x04 and 0x06; MS-27 stream ids; J24 the DVD core only. + match s { + Stream::Video(v) if v.is_mvc_dependent() || have_video => Err(SkipReason::SecondaryVideo), + // J24: H.264, HEVC and VC-1 await the 2013+ text (F8); AV1 has no PS carriage. + Stream::Video(v) => match v.codec { + Codec::Mpeg2 => Ok(Want::Video { mpeg1: false }), + Codec::Mpeg1 => Ok(Want::Video { mpeg1: true }), + _ => Err(SkipReason::UnmappableVideo), + }, + Stream::Audio(a) if a.is_mp2_extension() => Ok(Want::Extension), + Stream::Audio(a) => match a.codec { + Codec::Mp2 | Codec::Mp3 => Ok(Want::MpegAudio), + Codec::Ac3 => Ok(Want::Private(PrivateKind::Ac3)), + Codec::Dts => Ok(Want::Private(PrivateKind::Dts)), + Codec::Lpcm => { + let channels = usize::from(a.channels.count()); + let rate = a.sample_rate.hz() as u32; + crate::mux::codec::lpcm::dvd_header(channels, rate, 16) + .map(|_| Want::Private(PrivateKind::Lpcm { channels, rate })) + .ok_or(SkipReason::UnmappableAudio) + } + // J24/F8: E-AC-3; F1: TrueHD, DTS-HD, AAC, FLAC, Opus. + _ => Err(SkipReason::UnmappableAudio), + }, + Stream::Subtitle(s) => match s.codec { + Codec::DvdSub => Ok(Want::Private(PrivateKind::Subpicture)), + Codec::Pgs => Err(SkipReason::BitmapSubtitle), + _ => Err(SkipReason::UnmappableSubtitle), + }, + } +} + +fn pid(s: &Stream) -> u16 { + match s { + Stream::Video(v) => v.pid, + Stream::Audio(a) => a.pid, + Stream::Subtitle(s) => s.pid, + } +} + +/// Plan `title` for `mpg://`. DVD sources keep their source ids (the IR PID is the id); +/// other tracks take the lowest free id of their range, in track order; a full range is +/// [`SkipReason::NoStreamId`]. An extension track follows its base's `n` when the base is +/// carried, and is otherwise in neither half of the report (J23: reported only once seen). +pub(crate) fn plan(title: &DiscTitle) -> Plan { + let n = title.streams.len(); + let mut wants: Vec> = Vec::with_capacity(n); + let mut have_video = false; + for s in &title.streams { + let w = want(s, have_video); + have_video |= matches!(w, Ok(Want::Video { .. })); + wants.push(w); + } + let mut carriage: Vec> = vec![None; n]; + let mut skipped: Vec<(usize, SkipReason)> = Vec::new(); + let mut used = [false; 256]; + let mut used_private = [false; 256]; + + // Pass 1: DVD source ids, kept where the IR PID is already the id. + for (i, w) in wants.iter().enumerate() { + let p = pid(&title.streams[i]); + match w { + Ok(Want::MpegAudio) if (0xC0..=0xC7).contains(&p) && !used[p as usize] => { + used[p as usize] = true; + carriage[i] = Some(Carriage::MpegAudio { stream_id: p as u8 }); + } + Ok(Want::Private(k)) => { + let sub = if p >> 8 == 0xBD { p & 0xFF } else { p }; + if sub <= 0xFF && k.range().contains(&(sub as u8)) && !used_private[sub as usize] { + used_private[sub as usize] = true; + carriage[i] = Some(Carriage::Private { + sub_id: sub as u8, + kind: *k, + }); + } + } + _ => {} + } + } + // Pass 2: everything else, lowest free id in track order. + for (i, w) in wants.iter().enumerate() { + if carriage[i].is_some() { + continue; + } + match w { + Ok(Want::Video { mpeg1 }) => carriage[i] = Some(Carriage::Video { mpeg1: *mpeg1 }), + Ok(Want::MpegAudio) => match (0xC0..=0xC7u8).find(|&id| !used[id as usize]) { + Some(id) => { + used[id as usize] = true; + carriage[i] = Some(Carriage::MpegAudio { stream_id: id }); + } + None => skipped.push((i, SkipReason::NoStreamId)), + }, + Ok(Want::Private(k)) => match k.range().find(|&id| !used_private[id as usize]) { + Some(id) => { + used_private[id as usize] = true; + carriage[i] = Some(Carriage::Private { + sub_id: id, + kind: *k, + }); + } + None => skipped.push((i, SkipReason::NoStreamId)), + }, + Ok(Want::Extension) => {} + Err(r) => skipped.push((i, *r)), + } + } + // Pass 3: extensions bind to their carried base `0xC0|n` (same n, design §3). + for (i, w) in wants.iter().enumerate() { + if !matches!(w, Ok(Want::Extension)) { + continue; + } + let base_pid = 0x00C0 | (pid(&title.streams[i]) & 0x07); + let base = title.streams.iter().position( + |s| matches!(s, Stream::Audio(a) if !a.is_mp2_extension() && a.pid == base_pid), + ); + if let Some((b, Some(Carriage::MpegAudio { stream_id }))) = base.map(|b| (b, carriage[b])) { + carriage[i] = Some(Carriage::Mp2Extension { + stream_id: 0xD0 | (stream_id & 0x07), + base: b, + }); + } + } + skipped.sort_unstable_by_key(|&(i, _)| i); + let included = (0..n).filter(|&i| carriage[i].is_some()).collect(); + Plan { + report: FitReport { included, skipped }, + carriage, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::disc::{ + AudioChannels, AudioStream, ColorSpace, FrameRate, HdrFormat, LabelPurpose, LabelQualifier, + Resolution, SampleRate, SubtitleStream, VideoStream, + }; + + fn video(codec: Codec) -> Stream { + Stream::Video(VideoStream { + pid: 0xE0, + codec, + resolution: Resolution::R480i, + frame_rate: FrameRate::F29_97, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + }) + } + fn audio(pid: u16, codec: Codec, rate: SampleRate, label: &str) -> Stream { + Stream::Audio(AudioStream { + pid, + codec, + channels: AudioChannels::Stereo, + language: "eng".into(), + sample_rate: rate, + secondary: false, + purpose: LabelPurpose::Normal, + label: label.into(), + }) + } + fn sub(pid: u16, codec: Codec) -> Stream { + Stream::Subtitle(SubtitleStream { + pid, + codec, + language: "eng".into(), + forced: false, + qualifier: LabelQualifier::None, + codec_data: None, + }) + } + fn title(streams: Vec) -> DiscTitle { + DiscTitle { + streams, + ..DiscTitle::empty() + } + } + + #[test] + fn a_dvd_title_keeps_its_source_ids() { + let t = title(vec![ + video(Codec::Mpeg2), + audio(0x00C1, Codec::Mp2, SampleRate::S48, ""), + audio( + 0x00D1, + Codec::Mp2, + SampleRate::S48, + crate::disc::MP2_EXTENSION_LABEL, + ), + audio(0xBD81, Codec::Ac3, SampleRate::S48, ""), + audio(0xBD89, Codec::Dts, SampleRate::S48, ""), + audio(0xBDA2, Codec::Lpcm, SampleRate::S48, ""), + sub(0x0025, Codec::DvdSub), + ]); + let p = plan(&t); + assert_eq!(p.report.included, vec![0, 1, 2, 3, 4, 5, 6]); + assert!(p.report.skipped.is_empty()); + assert_eq!(p.carriage[1], Some(Carriage::MpegAudio { stream_id: 0xC1 })); + assert_eq!( + p.carriage[2], + Some(Carriage::Mp2Extension { + stream_id: 0xD1, + base: 1 + }) + ); + assert!(matches!( + p.carriage[3], + Some(Carriage::Private { + sub_id: 0x81, + kind: PrivateKind::Ac3 + }) + )); + assert!(matches!( + p.carriage[4], + Some(Carriage::Private { + sub_id: 0x89, + kind: PrivateKind::Dts + }) + )); + assert!(matches!( + p.carriage[5], + Some(Carriage::Private { + sub_id: 0xA2, + kind: PrivateKind::Lpcm { + channels: 2, + rate: 48_000 + } + }) + )); + assert!(matches!( + p.carriage[6], + Some(Carriage::Private { + sub_id: 0x25, + kind: PrivateKind::Subpicture + }) + )); + } + + // J24 (coordinator JUDGEMENT): H.264/HEVC/VC-1 go through the excluded-track note until + // the 2013+ H.222.0 text is sourced (F8). Per design; do not change without a citation. + #[test] + fn j24_newer_video_and_e_ac3_are_excluded_with_a_reason() { + for codec in [Codec::H264, Codec::Hevc, Codec::Vc1, Codec::Av1] { + let p = plan(&title(vec![video(codec)])); + assert_eq!( + p.report.skipped, + vec![(0, SkipReason::UnmappableVideo)], + "{codec:?}" + ); + } + let t = title(vec![ + video(Codec::Mpeg2), + audio(0x1100, Codec::Ac3Plus, SampleRate::S48, ""), + audio(0x1101, Codec::TrueHd, SampleRate::S48, ""), + audio(0x1102, Codec::Lpcm, SampleRate::S192, ""), + sub(0x1200, Codec::Pgs), + sub(0x1201, Codec::Srt), + video(Codec::Mpeg2), + ]); + assert_eq!( + plan(&t).report.skipped, + vec![ + (1, SkipReason::UnmappableAudio), + (2, SkipReason::UnmappableAudio), + (3, SkipReason::UnmappableAudio), + (4, SkipReason::BitmapSubtitle), + (5, SkipReason::UnmappableSubtitle), + (6, SkipReason::SecondaryVideo), + ] + ); + } + + // Design §2.2: "Overflow → NoStreamId"; MPEG audio "0xC0–0xC7 … 8", AC-3 "8 each". + #[test] + fn a_full_range_is_no_stream_id() { + let mut s = vec![video(Codec::Mpeg2)]; + s.extend((0..9).map(|i| audio(0x1100 + i, Codec::Ac3, SampleRate::S48, ""))); + s.extend((0..9).map(|i| audio(0x1200 + i, Codec::Mp2, SampleRate::S48, ""))); + let p = plan(&title(s)); + assert_eq!( + p.report.skipped, + vec![(9, SkipReason::NoStreamId), (18, SkipReason::NoStreamId)] + ); + assert_eq!( + p.carriage[1], + Some(Carriage::Private { + sub_id: 0x80, + kind: PrivateKind::Ac3 + }) + ); + assert_eq!( + p.carriage[10], + Some(Carriage::MpegAudio { stream_id: 0xC0 }) + ); + } + + // J23: a declared extension whose base is not carried is in neither half (seen-only). + #[test] + fn an_extension_without_a_carried_base_is_not_planned_out() { + let t = title(vec![ + video(Codec::Mpeg2), + audio( + 0x00D3, + Codec::Mp2, + SampleRate::S48, + crate::disc::MP2_EXTENSION_LABEL, + ), + ]); + let p = plan(&t); + assert_eq!(p.report.included, vec![0]); + assert!(p.report.skipped.is_empty()); + } + + #[test] + fn allocated_ids_avoid_kept_dvd_ids() { + let t = title(vec![ + video(Codec::Mpeg2), + audio(0x1100, Codec::Mp2, SampleRate::S48, ""), + audio(0x00C0, Codec::Mp2, SampleRate::S48, ""), + ]); + let p = plan(&t); + assert_eq!(p.carriage[1], Some(Carriage::MpegAudio { stream_id: 0xC1 })); + assert_eq!(p.carriage[2], Some(Carriage::MpegAudio { stream_id: 0xC0 })); + } +} diff --git a/src/mux/mpg/mod.rs b/src/mux/mpg/mod.rs new file mode 100644 index 00000000..56115ef1 --- /dev/null +++ b/src/mux/mpg/mod.rs @@ -0,0 +1,742 @@ +//! `mpg://`: an ISO/IEC 13818-1 program stream written from the PES IR (mpg-output-design +//! v5 §2, L2), one more `pes::Stream` sink. +//! +//! The pack layer is regenerated: packs, SCR, mux rate, system header, PSM, PES headers +//! and DTS. ES bytes are kept (DVD LPCM is re-packed losslessly, G8); PTS is kept exactly, +//! modulo one integer-tick origin (J16). J24: the carriage is the DVD core the 2000 edition +//! of H.222.0 covers (MPEG-1/2 video, MPEG audio and its 13818-3 extension, AC-3, DTS, +//! LPCM, subpictures); everything else is excluded with a reason, never silently. + +mod fit; +pub(crate) mod pack; +mod pstd; +#[cfg(test)] +mod replay; +pub(crate) mod scan; +#[cfg(test)] +mod tests; + +pub(crate) use fit::plan; +use fit::{Carriage, PrivateKind}; +use pack::{Bound, PsmEntry, SubStreamInfo}; +use pstd::{Au, BufferSpec, Mux, Payload, PstdCounters, StreamSpec}; + +use crate::disc::{Codec, DiscTitle, Stream as DiscStream}; +use crate::mux::codec::ns_to_ticks; +use crate::mux::decode_ts::{DtsCounters, DtsDeriver}; +use crate::pes::{PesFrame, Stream}; +use std::collections::VecDeque; +use std::io::{self, Write}; + +/// Design §2.3 "every output tick is `input tick − lowest + 135 000` (1.5 s)". +const ORIGIN_TICKS: i64 = 135_000; +/// Design §2.3: "The sink queues ≥ 1 s of IR before its first pack". +const WINDOW_TICKS: i64 = 90_000; +/// The start-up hold cap (design §2.3 "1 s of IR timeline or 64 MiB"). +const HOLD_CAP_TICKS: i64 = 90_000; +const HOLD_CAP_BYTES: usize = 64 * 1024 * 1024; +/// Arrival-domain bias: ticks are held as `tick − first tick + BIAS`, positive for any +/// source that starts within ~13 h of its first frame, so the deriver never clamps. +const BIAS: i64 = 1 << 32; +/// R0 (design §2.4 step 5): 10.08 Mbit/s for SD, 128 Mbit/s otherwise, in 50 B/s units. +const R0_SD: u32 = 10_080_000 / 8 / 50; +const R0_HD: u32 = 128_000_000 / 8 / 50; +/// MPEG audio and extension buffers: 16 KiB, scale 0 (design §2.4 table). +const AUDIO_BOUND: u16 = 16 * 1024 / 128; +/// `0xBD`: the largest 13-bit bound, scale 1 (J11). +const PRIVATE_BOUND: u16 = 8191; +/// Video buffer when no `vbv_buffer_size` parses: the same 13-bit maximum. +const VIDEO_BOUND_MAX: u64 = 8191 * 1024; + +/// Anomalies an `mpg://` mux resolved and counted (design §2.3-§2.4). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MpgCounters { + pub dts: DtsCounters, + pub pstd: PstdCounters, + /// A track first seen after the origin window with a lower timestamp: saturated to 0. + pub origin_saturated: u64, +} + +// One output stream's static facts. +#[derive(Debug, Clone)] +struct Out { + track: usize, + spec: StreamSpec, + kind: OutKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutKind { + Video { mpeg1: bool }, + MpegAudio { has_ext: bool }, + Extension { base_out: usize }, + Private(PrivateKind), +} + +// An AU in the arrival domain (ticks + BIAS), before the origin. +struct RelAu { + pts: i64, + dts: Option, + mark: usize, + data: Vec, + lpcm_bits: u8, +} + +#[derive(Default)] +struct LpcmState { + carry: Vec, + carry_pts: i64, + bits: u8, +} + +/// The `mpg://` sink. +pub struct MpgSink { + title: DiscTitle, + route: Vec>, + outs: Vec, + buffers: Vec, + video_out: usize, + deriver: DtsDeriver, + anchor: Option, + armed: bool, + params_prepended: bool, + pending_video: VecDeque<(i64, usize, Vec)>, + window: Vec<(usize, RelAu)>, + window_bytes: usize, + span: Option<(i64, i64)>, + offset: Option, + lpcm: Vec, + writer: Option, + mux: Option>, + excluded: super::ps::UnstoredExtensions, + origin_saturated: u64, + frames: u64, + finished: bool, +} + +// The offset of the first picture start code (`00 00 01 00`): where a video AU commences +// for MS-15/MS-19. 0 when the AU has none. +fn picture_start(data: &[u8]) -> usize { + data.windows(4).position(|w| w == [0, 0, 1, 0]).unwrap_or(0) +} + +// `vbv_buffer_size` (with its sequence-extension high bits) in bytes: 13818-2 counts it in +// 16 384-bit units. +fn vbv_bytes(es: &[u8]) -> Option { + let seq = es.windows(4).position(|w| w == [0, 0, 1, 0xB3])? + 4; + let h = es.get(seq..seq + 8)?; + // 12 + 12 + 4 + 4 + 18 + 1 = 51 bits precede the 10-bit vbv_buffer_size_value. + let bits = u64::from_be_bytes(h.try_into().ok()?); + let low = (bits >> (64 - 61)) & 0x3FF; + let mut high = 0; + let mut p = seq; + while let Some(off) = es[p..].windows(4).position(|w| w == [0, 0, 1, 0xB5]) { + let e = p + off + 4; + if es.get(e).is_some_and(|b| b >> 4 == 1) { + high = u64::from(*es.get(e + 4)?); + break; + } + p = e; + } + Some(((high << 10) | low) * 2048) +} + +// The 16 RGB entries of a VobSub `.idx` `palette:` line (the DvdSub codec_data). +fn idx_palette(codec_data: &[u8]) -> Option<[[u8; 3]; 16]> { + let text = std::str::from_utf8(codec_data).ok()?; + let line = text + .lines() + .find_map(|l| l.trim().strip_prefix("palette:"))?; + let mut out = [[0u8; 3]; 16]; + let mut n = 0; + for hex in line.split(',').map(str::trim) { + let v = u32::from_str_radix(hex, 16).ok()?; + *out.get_mut(n)? = [(v >> 16) as u8, (v >> 8) as u8, v as u8]; + n += 1; + } + (n == 16).then_some(out) +} + +fn lang3(lang: &str) -> [u8; 3] { + match lang.as_bytes() { + [a, b, c] if lang.bytes().all(|x| x.is_ascii_alphabetic()) => [*a, *b, *c], + _ => *b"und", + } +} + +impl MpgSink { + /// Plan `title`; `MpgNoVideoTrack` (E9074) when no carriable video is left (design §0). + pub fn create(writer: W, title: &DiscTitle) -> io::Result { + let plan = plan(title); + let Some(video_track) = plan + .carriage + .iter() + .position(|c| matches!(c, Some(Carriage::Video { .. }))) + else { + return Err(crate::error::Error::MpgNoVideoTrack.into()); + }; + let mut outs: Vec = Vec::new(); + let mut buffers: Vec = Vec::new(); + let mut route = vec![None; title.streams.len()]; + let mut private_buffer = None; + // Video first, then every other carried track in track order. + let order = std::iter::once(video_track) + .chain((0..title.streams.len()).filter(|&i| i != video_track)); + for i in order { + let Some(c) = plan.carriage[i] else { continue }; + let (stream_id, payload, kind, sparse) = match c { + Carriage::Video { mpeg1 } => ( + pack::VIDEO_ID, + Payload::Plain, + OutKind::Video { mpeg1 }, + false, + ), + Carriage::MpegAudio { stream_id } => { + let has_ext = plan.carriage.iter().any( + |c| matches!(c, Some(Carriage::Mp2Extension { base, .. }) if *base == i), + ); + ( + stream_id, + Payload::Plain, + OutKind::MpegAudio { has_ext }, + false, + ) + } + Carriage::Mp2Extension { stream_id, base } => { + let base_out = route[base].unwrap_or(0); + ( + stream_id, + Payload::WholeAu, + OutKind::Extension { base_out }, + false, + ) + } + Carriage::Private { sub_id, kind } => { + let payload = match kind { + PrivateKind::Ac3 | PrivateKind::Dts => Payload::Frames { sub_id }, + PrivateKind::Lpcm { channels, rate } => Payload::Lpcm { + sub_id, + channels, + rate, + }, + PrivateKind::Subpicture => Payload::SubId { sub_id }, + }; + ( + pack::PRIVATE_STREAM_1, + payload, + OutKind::Private(kind), + kind == PrivateKind::Subpicture, + ) + } + }; + let buffer = match kind { + OutKind::Private(_) => *private_buffer.get_or_insert_with(|| { + buffers.push(BufferSpec { + stream_id: pack::PRIVATE_STREAM_1, + scale_1024: true, + size: PRIVATE_BOUND, + }); + buffers.len() - 1 + }), + OutKind::Video { .. } => { + buffers.push(BufferSpec { + stream_id, + scale_1024: true, + size: 8191, + }); + buffers.len() - 1 + } + _ => { + buffers.push(BufferSpec { + stream_id, + scale_1024: false, + size: AUDIO_BOUND, + }); + buffers.len() - 1 + } + }; + route[i] = Some(outs.len()); + let av = !sparse; + outs.push(Out { + track: i, + spec: StreamSpec { + stream_id, + payload, + buffer, + sparse, + av, + }, + kind, + }); + } + let codec = match &title.streams[video_track] { + DiscStream::Video(v) => v.codec, + _ => Codec::Mpeg2, + }; + let cp = title + .codec_privates + .get(video_track) + .and_then(|c| c.as_deref()); + let mut excluded = super::ps::UnstoredExtensions::new(title, "MPG"); + excluded.retain(|i| route[i].is_none()); + Ok(Self { + title: title.clone(), + lpcm: (0..outs.len()) + .map(|_| LpcmState { + bits: 16, + ..Default::default() + }) + .collect(), + route, + outs, + buffers, + video_out: 0, + deriver: DtsDeriver::for_codec(codec, cp), + anchor: None, + armed: false, + params_prepended: false, + pending_video: VecDeque::new(), + window: Vec::new(), + window_bytes: 0, + span: None, + offset: None, + writer: Some(writer), + mux: None, + excluded, + origin_saturated: 0, + frames: 0, + finished: false, + }) + } + + /// Anomalies counted so far. + pub(crate) fn counters(&self) -> MpgCounters { + MpgCounters { + dts: self.deriver.counters(), + pstd: self.mux.as_ref().map(Mux::counters).unwrap_or_default(), + origin_saturated: self.origin_saturated, + } + } + + fn rel(&mut self, pts_ns: i64) -> i64 { + let ticks = ns_to_ticks(pts_ns); + let anchor = *self.anchor.get_or_insert(ticks); + ticks.saturating_sub(anchor).saturating_add(BIAS) + } + + // An AU resolved in the arrival domain: into the origin window, or on to the mux. + fn accept(&mut self, out: usize, au: RelAu) -> io::Result<()> { + let Some(offset) = self.offset else { + self.window_bytes += au.data.len(); + self.window.push((out, au)); + return Ok(()); + }; + let pts = au.pts + offset; + let dts = au.dts.map(|d| d + offset); + if pts < 0 || dts.is_some_and(|d| d < 0) { + self.origin_saturated += 1; + } + let pts = pts.max(0) as u64; + let dts = dts.map(|d| d.max(0) as u64).filter(|&d| d != pts); + let mux = self + .mux + .as_mut() + .expect("the mux exists once the origin is set"); + mux.push( + out, + Au { + pts, + dts, + mark: au.mark, + data: au.data, + lpcm_bits: au.lpcm_bits, + }, + ); + Ok(()) + } + + fn drain_video(&mut self) -> io::Result<()> { + while let Some(d) = self.deriver.pop() { + let Some((pts, mark, data)) = self.pending_video.pop_front() else { + break; + }; + let au = RelAu { + pts, + dts: d, + mark, + data, + lpcm_bits: 0, + }; + self.accept(self.video_out, au)?; + } + Ok(()) + } + + // LPCM AUs are whole packing units at a sticky lossless depth (G8, design §2.3 MPG4-6). + fn lpcm_aus(&mut self, out: usize, rel: i64, data: &[u8], flush: bool) -> Vec { + let Payload::Lpcm { channels, rate, .. } = self.outs[out].spec.payload else { + return Vec::new(); + }; + let st = &mut self.lpcm[out]; + let frame = channels * 3; + if !data.is_empty() { + // Re-anchor on every frame: the carried samples end where this frame starts. + let carried = (st.carry.len() / frame) as i64; + st.carry_pts = rel - (carried * 90_000 + i64::from(rate / 2)) / i64::from(rate); + } + st.carry.extend_from_slice(data); + st.bits = st + .bits + .max(crate::mux::codec::lpcm::dvd_bits_needed(&st.carry)); + let unit = crate::mux::codec::lpcm::dvd_unit_frames(channels, st.bits) * frame; + if flush && !st.carry.len().is_multiple_of(unit) { + // EOF: pad the last partial unit (< 4 sample frames) with silence. + let pad = unit - st.carry.len() % unit; + st.carry.resize(st.carry.len() + pad, 0); + } + let whole = st.carry.len() - st.carry.len() % unit; + if whole == 0 { + return Vec::new(); + } + let mut es = Vec::with_capacity(whole); + crate::mux::codec::lpcm::dvd_pack(&st.carry[..whole], channels, st.bits, &mut es); + let au = RelAu { + pts: st.carry_pts, + dts: None, + mark: 0, + data: es, + lpcm_bits: st.bits, + }; + st.carry.drain(..whole); + // The carried samples start after the AU just cut. + let frames = (whole / frame) as i64; + st.carry_pts += (frames * 90_000 + i64::from(rate / 2)) / i64::from(rate); + vec![au] + } + + // Design §2.3 "Cap: 1 s of IR timeline or 64 MiB. Past the cap it releases in arrival + // order": the held video's window resolves over the units held, counted. + fn release_over_cap(&mut self) -> io::Result<()> { + let Some(&(oldest, ..)) = self.pending_video.front() else { + return Ok(()); + }; + if !self.deriver.pending() { + return Ok(()); + } + let newest = self.span.map_or(oldest, |(_, hi)| hi); + let held: usize = self.pending_video.iter().map(|p| p.2.len()).sum::() + + if self.offset.is_none() { + self.window_bytes + } else { + 0 + }; + if newest - oldest > HOLD_CAP_TICKS || held > HOLD_CAP_BYTES { + self.deriver.release_cap(); + self.drain_video()?; + } + Ok(()) + } + + fn maybe_set_origin(&mut self, eof: bool) -> io::Result<()> { + if self.offset.is_some() { + return Ok(()); + } + let spanned = self.span.is_some_and(|(lo, hi)| hi - lo >= WINDOW_TICKS); + if !eof && !(spanned && !self.deriver.pending()) { + return Ok(()); + } + // Design §2.3 (MPG3-8): the lowest first DTS/PTS over ALL tracks maps to 1.5 s. + let Some(lowest) = self + .window + .iter() + .map(|(_, a)| a.dts.unwrap_or(a.pts).min(a.pts)) + .min() + else { + return Ok(()); + }; + self.offset = Some(ORIGIN_TICKS - lowest); + let first = self.first_pack_prefix(); + let r0 = match &self.title.streams[self.outs[self.video_out].track] { + DiscStream::Video(v) if v.resolution.pixels().is_some_and(|(_, h)| h <= 576) => R0_SD, + _ => R0_HD, + }; + let specs = self.outs.iter().map(|o| o.spec.clone()).collect(); + let writer = self.writer.take().expect("the writer is handed over once"); + self.mux = Some(Mux::new(writer, specs, self.buffers.clone(), first, r0)); + for (out, au) in std::mem::take(&mut self.window) { + self.accept(out, au)?; + } + Ok(()) + } + + // The system header and program stream map of the first pack (MS-21 §2.7.8). + fn first_pack_prefix(&mut self) -> Vec { + // J23: an extension with no packets in the origin window is not described; its + // packets, should they come later, are left out and reported like an orphan's. + let unseen: Vec = (0..self.outs.len()) + .filter(|&o| matches!(self.outs[o].kind, OutKind::Extension { .. })) + .filter(|&o| self.window.iter().all(|(w, _)| *w != o)) + .collect(); + for &o in &unseen { + let track = self.outs[o].track; + self.route[track] = None; + if let DiscStream::Audio(a) = &self.title.streams[track] { + self.excluded.add(track, a.pid); + } + } + let unseen_buffers: Vec = unseen.iter().map(|&o| self.outs[o].spec.buffer).collect(); + let ext_of = |base: usize| { + (0..self.outs.len()).any(|o| { + matches!(self.outs[o].kind, OutKind::Extension { base_out } if base_out == base) + && !unseen.contains(&o) + }) + }; + let first_au = |out: usize| self.window.iter().find(|(o, _)| *o == out).map(|(_, a)| a); + // MS-7/MS-13: the video bound from vbv_buffer_size + 8 KiB (design §2.4 table). + let video_track = self.outs[self.video_out].track; + let cp = self + .title + .codec_privates + .get(video_track) + .and_then(|c| c.as_deref()); + let vbv = first_au(self.video_out) + .and_then(|a| vbv_bytes(&a.data)) + .or_else(|| cp.and_then(vbv_bytes)); + let bs = vbv.map_or(VIDEO_BOUND_MAX, |b| (b + 8192).min(VIDEO_BOUND_MAX)); + let vb = self.outs[self.video_out].spec.buffer; + self.buffers[vb].size = bs.div_ceil(1024) as u16; + let mut entries: Vec = Vec::new(); + let mut subs: Vec = Vec::new(); + let mut palette = None; + let mut private = false; + let mut audio_bound = 0u8; + for (oi, o) in self.outs.iter().enumerate() { + let s = &self.title.streams[o.track]; + match o.kind { + OutKind::Video { mpeg1 } => entries.push(PsmEntry { + // MS-22: 0x01 ISO/IEC 11172 Video, 0x02 H.262 | 13818-2 Video. + stream_type: if mpeg1 { 0x01 } else { 0x02 }, + stream_id: o.spec.stream_id, + descriptors: Vec::new(), + }), + OutKind::MpegAudio { has_ext } => { + let has_ext = has_ext && ext_of(oi); + audio_bound += 1; + // MS-22: 0x03 11172 Audio (ID bit 1), 0x04 13818-3 Audio (LSF, or a base + // whose multichannel extension is carried). + let mpeg1 = + first_au(oi).is_none_or(|a| a.data.get(1).is_none_or(|b| b & 0x08 != 0)); + let mut d = Vec::new(); + if let DiscStream::Audio(a) = s + && let Some(l) = pack::iso639_descriptor(&a.language) + { + d.extend_from_slice(&l); + } + let n = o.spec.stream_id & 0x07; + if has_ext { + // MS-23: the base layer, hierarchy_type 15. + d.extend_from_slice(&pack::hierarchy_descriptor( + pack::HIERARCHY_BASE, + 2 * n, + 0, + )); + } + let stream_type = if has_ext || !mpeg1 { 0x04 } else { 0x03 }; + entries.push(PsmEntry { + stream_type, + stream_id: o.spec.stream_id, + descriptors: d, + }); + } + OutKind::Extension { .. } if unseen.contains(&oi) => {} + OutKind::Extension { .. } => { + audio_bound += 1; + let n = o.spec.stream_id & 0x07; + // MS-23: type 5 "ISO/IEC 13818-3 Extension bitstream", embedded = its base. + let d = pack::hierarchy_descriptor(pack::HIERARCHY_EXTENSION, 2 * n + 1, 2 * n) + .to_vec(); + entries.push(PsmEntry { + stream_type: 0x04, + stream_id: o.spec.stream_id, + descriptors: d, + }); + } + OutKind::Private(kind) => { + private = true; + let sub_id = match o.spec.payload { + Payload::Frames { sub_id } + | Payload::Lpcm { sub_id, .. } + | Payload::SubId { sub_id } => sub_id, + _ => 0, + }; + let (lang, forced) = match s { + DiscStream::Audio(a) => (lang3(&a.language), false), + DiscStream::Subtitle(t) => { + if palette.is_none() { + palette = t.codec_data.as_deref().and_then(idx_palette); + } + (lang3(&t.language), t.forced) + } + DiscStream::Video(_) => (*b"und", false), + }; + if kind != PrivateKind::Subpicture { + audio_bound += 1; + } + subs.push(SubStreamInfo { + sub_id, + lang, + forced, + }); + } + } + } + if private { + // MS-22: 0x06 "PES packets containing private data"; the map cannot name sub-streams. + entries.push(PsmEntry { + stream_type: 0x06, + stream_id: pack::PRIVATE_STREAM_1, + descriptors: Vec::new(), + }); + } + let bounds: Vec = (self.buffers.iter().enumerate()) + .filter(|(i, _)| !unseen_buffers.contains(i)) + .map(|(_, b)| Bound { + stream_id: b.stream_id, + scale_1024: b.scale_1024, + size: b.size, + }) + .collect(); + let mut prefix = pack::system_header(audio_bound, 1, &bounds); + let info = pack::fmkv_descriptors(&subs, palette.as_ref()); + // Design §2.2: the worst case is ~420 B, under the 1018-byte cap (MS-8); a map that + // cannot fit drops the FMKV table rather than the stream map itself. + let map = pack::psm(&info, &entries).or_else(|| pack::psm(&[], &entries)); + if let Some(m) = map { + prefix.extend_from_slice(&m); + } + prefix + } +} + +impl Stream for MpgSink { + fn read(&mut self) -> io::Result> { + Err(crate::error::Error::StreamWriteOnly.into()) + } + + fn write(&mut self, frame: &PesFrame) -> io::Result<()> { + if self.finished { + return Err(crate::error::Error::StreamClosed.into()); + } + let Some(out) = self.route.get(frame.track).copied().flatten() else { + self.excluded.drop_frame(frame.track); + return Ok(()); + }; + // B1: an empty frame (a Matroska empty Block) carries no access unit. + if frame.data.is_empty() { + return Ok(()); + } + let is_video = out == self.video_out; + // Drop video before the first keyframe: nothing decodes without it (tsmux's guard). + if is_video && !frame.keyframe && !self.armed { + return Ok(()); + } + let rel = self.rel(frame.pts); + self.span = Some( + self.span + .map_or((rel, rel), |(lo, hi)| (lo.min(rel), hi.max(rel))), + ); + self.frames += 1; + if is_video { + self.armed = true; + let mut data = frame.data.clone(); + if !self.params_prepended { + self.params_prepended = true; + // 13818-2 decoding starts at a sequence header; a source that moved it to + // codec_private (Matroska) gets it back ahead of the first picture. + let pic = picture_start(&data); + let has_seq = data[..pic].windows(4).any(|w| w == [0, 0, 1, 0xB3]); + let cp = self + .title + .codec_privates + .get(frame.track) + .and_then(|c| c.clone()); + if let (false, Some(cp)) = (has_seq, cp) + && cp.windows(4).any(|w| w == [0, 0, 1, 0xB3]) + { + data.splice(0..0, cp); + } + } + self.deriver.push(rel, &data); + let mark = picture_start(&data); + self.pending_video.push_back((rel, mark, data)); + self.drain_video()?; + } else if matches!(self.outs[out].spec.payload, Payload::Lpcm { .. }) { + for au in self.lpcm_aus(out, rel, &frame.data, false) { + self.accept(out, au)?; + } + } else { + let au = RelAu { + pts: rel, + dts: None, + mark: 0, + data: frame.data.clone(), + lpcm_bits: 0, + }; + self.accept(out, au)?; + } + self.release_over_cap()?; + self.maybe_set_origin(false)?; + if let Some(m) = self.mux.as_mut() { + m.pump(false)?; + } + Ok(()) + } + + fn finish(&mut self) -> io::Result<()> { + if self.finished { + return Ok(()); + } + self.finished = true; + self.deriver.finish(); + self.drain_video()?; + for out in 0..self.outs.len() { + let rel = self.lpcm[out].carry_pts; + if !self.lpcm[out].carry.is_empty() { + for au in self.lpcm_aus(out, rel, &[], true) { + self.accept(out, au)?; + } + } + } + // Design §2.3 EOF: a short input is written, not failed; zero frames is MuxEmpty. + if self.frames == 0 { + return Err(crate::error::Error::MuxEmpty.into()); + } + self.maybe_set_origin(true)?; + let c = self.counters(); + if c != MpgCounters::default() { + tracing::warn!( + target: "mux", + dts_order_violations = c.dts.order_violations, + dts_hold_overflow = c.dts.hold_overflow, + pstd_late_aus = c.pstd.late_aus, + pts_gap_over_0_7s = c.pstd.pts_gaps, + interleave_cap = c.pstd.interleave_cap, + origin_saturated = c.origin_saturated, + "mpg: program stream needed corrections (counted, not refused)" + ); + } + match self.mux.as_mut() { + Some(m) => m.finish(), + None => Err(crate::error::Error::MuxEmpty.into()), + } + } + + fn info(&self) -> &DiscTitle { + &self.title + } + + fn undelivered_streams(&self) -> Vec { + // An extension whose base is not carried, once its packets arrived (J23). + self.excluded.seen() + } +} diff --git a/src/mux/mpg/pack.rs b/src/mux/mpg/pack.rs new file mode 100644 index 00000000..de884d87 --- /dev/null +++ b/src/mux/mpg/pack.rs @@ -0,0 +1,502 @@ +//! Byte-level builders of the program stream layer: pack header, system header, program +//! stream map (with its descriptors and CRC_32), PES headers and padding. Every field +//! layout is the H.222.0 (2000) syntax quoted in [`crate::spec::mpg`]. + +/// Every pack is this long (mpg-output-design v5 §2.3 "Fixed **2048-byte packs**", J9). +pub(crate) const PACK_BYTES: usize = 2048; +/// Pack header without stuffing (MS-2: 32 + 2 + 3 + 1 + 15 + 1 + 15 + 1 + 9 + 1 + 22 + 1 + 1 +/// + 5 + 3 bits). +pub(crate) const PACK_HEADER_BYTES: usize = 14; +/// Offset in a pack of the byte holding the last bit of `system_clock_reference_base` +/// (MS-4: the byte whose arrival time the SCR encodes). +pub(crate) const SCR_BASE_LAST_BYTE: usize = 8; +/// Shortest padding PES: start code, stream_id and `PES_packet_length`. +pub(crate) const MIN_PADDING_PES: usize = 6; +/// Most stuffing bytes one pack header may carry (MS-3). +pub(crate) const MAX_PACK_STUFFING: usize = 7; +/// `rate_bound`: the largest 22-bit value, legal for any per-pack rate (design §2.4). +pub(crate) const RATE_BOUND: u32 = 0x3F_FFFF; + +pub(crate) const PSM_ID: u8 = 0xBC; +pub(crate) const PRIVATE_STREAM_1: u8 = 0xBD; +pub(crate) const PADDING_STREAM: u8 = 0xBE; +pub(crate) const VIDEO_ID: u8 = 0xE0; + +/// `pack_header()` (MS-2) at SCR `scr27` (27 MHz), `mux_rate` in 50 B/s units, then +/// `stuffing` 0xFF bytes (MS-3: at most 7). +pub(crate) fn pack_header(scr27: u64, mux_rate: u32, stuffing: usize) -> Vec { + // MS-2 field layout; MS-3 at most 7 stuffing bytes, rate ≠ 0; + // MS-4: "SCR(i) = SCR_base(i) × 300 + SCR_ext(i)". + debug_assert!(stuffing <= MAX_PACK_STUFFING && mux_rate > 0 && mux_rate <= RATE_BOUND); + let base = (scr27 / 300) & 0x1_FFFF_FFFF; + let ext = scr27 % 300; + let mut h = vec![0x00, 0x00, 0x01, 0xBA]; + h.push(0x40 | (((base >> 30) & 0x07) << 3) as u8 | 0x04 | ((base >> 28) & 0x03) as u8); + h.push((base >> 20) as u8); + h.push((((base >> 15) & 0x1F) << 3) as u8 | 0x04 | ((base >> 13) & 0x03) as u8); + h.push((base >> 5) as u8); + h.push(((base & 0x1F) << 3) as u8 | 0x04 | ((ext >> 7) & 0x03) as u8); + h.push((((ext & 0x7F) << 1) | 1) as u8); + h.push((mux_rate >> 14) as u8); + h.push((mux_rate >> 6) as u8); + h.push((((mux_rate & 0x3F) << 2) | 0x03) as u8); + // Two marker bits are part of the byte above; reserved '11111', pack_stuffing_length. + h.push(0xF8 | stuffing as u8); + h.extend(std::iter::repeat_n(0xFF, stuffing)); + h +} + +/// One system-header bound: `stream_id`, `P-STD_buffer_bound_scale`, `..._size_bound`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Bound { + pub stream_id: u8, + pub scale_1024: bool, + pub size: u16, +} + +/// `system_header()` (MS-5): every flag 0 (design §2.3, MPG2-9), `reserved_bits` all ones +/// (MS-6), then each stream's bound exactly once (MS-7). +pub(crate) fn system_header(audio_bound: u8, video_bound: u8, bounds: &[Bound]) -> Vec { + // MS-5 syntax, MS-6 bounds and reserved_bits, MS-7 one bound per stream. + let mut h = vec![0x00, 0x00, 0x01, 0xBB, 0, 0]; + h.push(0x80 | (RATE_BOUND >> 15) as u8); + h.push((RATE_BOUND >> 7) as u8); + h.push((((RATE_BOUND & 0x7F) << 1) | 1) as u8); + // audio_bound (6) | fixed_flag 0 | CSPS_flag 0. + h.push(audio_bound.min(32) << 2); + // system_audio_lock_flag 0 | system_video_lock_flag 0 | marker | video_bound (5). + h.push(0x20 | video_bound.min(16)); + // packet_rate_restriction_flag 0 | reserved_bits '111 1111'. + h.push(0x7F); + for b in bounds { + h.push(b.stream_id); + h.push(0xC0 | (u8::from(b.scale_1024) << 5) | ((b.size >> 8) & 0x1F) as u8); + h.push(b.size as u8); + } + let len = (h.len() - 6) as u16; + h[4..6].copy_from_slice(&len.to_be_bytes()); + h +} + +/// The CRC_32 of H.222.0 Annex A (MS-9, MS-26): MSB-first, register preset to all ones, no +/// final inversion, so a map followed by its CRC leaves the decoder registers at zero. +pub(crate) fn crc32(data: &[u8]) -> u32 { + // MS-26: x32 + x26 + x23 + x22 + x16 + x12 + x11 + x10 + x8 + x7 + x5 + x4 + x2 + x + 1. + let mut crc = 0xFFFF_FFFFu32; + for &b in data { + crc ^= u32::from(b) << 24; + for _ in 0..8 { + crc = if crc & 0x8000_0000 != 0 { + (crc << 1) ^ 0x04C1_1DB7 + } else { + crc << 1 + }; + } + } + crc +} + +/// One elementary stream entry of the program stream map. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PsmEntry { + pub stream_type: u8, + pub stream_id: u8, + pub descriptors: Vec, +} + +/// Largest `program_stream_map_length` (MS-8). +pub(crate) const MAX_PSM_LENGTH: usize = 1018; + +/// `program_stream_map()` (MS-8), `current_next_indicator` 1 and version 0, ending in its +/// CRC_32 (MS-9). `None` when it would exceed the 1018-byte length cap. +pub(crate) fn psm(info: &[u8], entries: &[PsmEntry]) -> Option> { + let mut es = Vec::new(); + for e in entries { + es.push(e.stream_type); + es.push(e.stream_id); + es.extend_from_slice(&u16::try_from(e.descriptors.len()).ok()?.to_be_bytes()); + es.extend_from_slice(&e.descriptors); + } + // MS-8: the length counts everything after it, CRC_32 (MS-9) included; max 1018. + let length = 2 + 2 + info.len() + 2 + es.len() + 4; + if length > MAX_PSM_LENGTH { + return None; + } + let mut m = vec![0x00, 0x00, 0x01, PSM_ID]; + m.extend_from_slice(&(length as u16).to_be_bytes()); + // current_next_indicator '1', reserved '11', version 0; reserved '1111111', marker '1'. + m.push(0xE0); + m.push(0xFF); + m.extend_from_slice(&(info.len() as u16).to_be_bytes()); + m.extend_from_slice(info); + m.extend_from_slice(&(es.len() as u16).to_be_bytes()); + m.extend_from_slice(&es); + let crc = crc32(&m); + m.extend_from_slice(&crc.to_be_bytes()); + Some(m) +} + +/// `hierarchy_type` 5, "ISO/IEC 13818-3 Extension bitstream" (MS-23). +pub(crate) const HIERARCHY_EXTENSION: u8 = 5; +/// `hierarchy_type` 15, "Base layer" (MS-23). +pub(crate) const HIERARCHY_BASE: u8 = 15; + +/// `hierarchy_descriptor()` (tag 4, MS-23, MS-25). `embedded` is written only for a +/// non-base layer: "This field is undefined if the hierarchy_type value is 15" (all ones). +pub(crate) fn hierarchy_descriptor(kind: u8, layer: u8, embedded: u8) -> [u8; 6] { + // MS-23: reserved bits are ones; the base's embedded index is undefined. + let embedded = if kind == HIERARCHY_BASE { + 0x3F + } else { + embedded + }; + [4, 4, 0xF0 | kind, 0xC0 | layer, 0xC0 | embedded, 0xC0] +} + +/// `ISO_639_language_descriptor()` (tag 10, MS-24) with `audio_type` 0 (undefined), or +/// `None` when `lang` is not three ISO 8859-1 letters. +pub(crate) fn iso639_descriptor(lang: &str) -> Option<[u8; 6]> { + // MS-24: one 24-bit ISO_639_language_code and an 8-bit audio_type. + let b = lang.as_bytes(); + (b.len() == 3 && b.iter().all(u8::is_ascii_alphabetic)).then(|| { + [ + 10, + 4, + b[0].to_ascii_lowercase(), + b[1].to_ascii_lowercase(), + b[2].to_ascii_lowercase(), + 0, + ] + }) +} + +/// The FMKV user-private descriptor tag (design §2.2, J1; MS-25 "64-255 … User Private"). +pub(crate) const FMKV_TAG: u8 = 0xFA; +pub(crate) const FMKV_MAGIC: &[u8; 4] = b"FMKV"; +pub(crate) const FMKV_VERSION: u8 = 1; +/// Type 1: a sub-stream table of `(sub_id, lang[3], flags)`; flags bit 0 = forced. +pub(crate) const FMKV_SUBSTREAMS: u8 = 1; +/// Type 2: the VobSub palette, 16 × RGB. +pub(crate) const FMKV_PALETTE: u8 = 2; + +/// One private_stream_1 sub-stream row of the FMKV table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SubStreamInfo { + pub sub_id: u8, + pub lang: [u8; 3], + pub forced: bool, +} + +/// The FMKV descriptors for `program_stream_info` (MS-28: private descriptors "shall +/// commence with descriptor_tag and descriptor_length fields"), the table split so no +/// `descriptor_length` exceeds 255. +pub(crate) fn fmkv_descriptors(subs: &[SubStreamInfo], palette: Option<&[[u8; 3]; 16]>) -> Vec { + // MS-28: a private descriptor starts with descriptor_tag and descriptor_length. + const HEAD: usize = 6; // magic, version, type + const ROW: usize = 5; + let mut out = Vec::new(); + for rows in subs.chunks((255 - HEAD) / ROW) { + out.extend_from_slice(&[FMKV_TAG, (HEAD + rows.len() * ROW) as u8]); + out.extend_from_slice(FMKV_MAGIC); + out.extend_from_slice(&[FMKV_VERSION, FMKV_SUBSTREAMS]); + for r in rows { + out.push(r.sub_id); + out.extend_from_slice(&r.lang); + out.push(u8::from(r.forced)); + } + } + if let Some(p) = palette { + out.extend_from_slice(&[FMKV_TAG, (HEAD + 48) as u8]); + out.extend_from_slice(FMKV_MAGIC); + out.extend_from_slice(&[FMKV_VERSION, FMKV_PALETTE]); + out.extend(p.iter().flatten()); + } + out +} + +/// The optional fields of one PES packet header. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct PesFields { + /// PTS in 90 kHz ticks. + pub pts: Option, + /// DTS, only where it differs from the PTS (MS-19). + pub dts: Option, + /// `(P-STD_buffer_scale, P-STD_buffer_size)` (MS-11, MS-13, MS-21). + pub pstd: Option<(bool, u16)>, + pub data_alignment: bool, +} + +// A 33-bit timestamp with its 4-bit prefix and marker bits (MS-10). +fn push_timestamp(h: &mut Vec, prefix: u8, ts: u64) { + let ts = ts & 0x1_FFFF_FFFF; + h.push((prefix << 4) | 0x01 | (((ts >> 29) & 0x0E) as u8)); + h.push((ts >> 22) as u8); + h.push(0x01 | (((ts >> 14) & 0xFE) as u8)); + h.push((ts >> 7) as u8); + h.push(0x01 | (((ts << 1) & 0xFE) as u8)); +} + +/// Header bytes a PES with `fields` takes before its payload. +#[cfg(test)] +pub(crate) fn pes_header_len(fields: &PesFields) -> usize { + 9 + ts_bytes(fields) + if fields.pstd.is_some() { 3 } else { 0 } +} + +fn ts_bytes(f: &PesFields) -> usize { + match (f.pts, f.dts) { + (None, _) => 0, + (Some(_), None) => 5, + (Some(_), Some(_)) => 10, + } +} + +/// A PES packet header (MS-10, MS-11) for `payload_len` bytes, with a bounded +/// `PES_packet_length` (MS-12: 0 is for Transport Stream video only). +pub(crate) fn pes_header(stream_id: u8, fields: &PesFields, payload_len: usize) -> Vec { + // MS-10 header and timestamps, MS-11 extension, MS-12 a bounded length. + let ext = if fields.pstd.is_some() { 3 } else { 0 }; + let hdl = ts_bytes(fields) + ext; + let length = 3 + hdl + payload_len; + debug_assert!( + length <= usize::from(u16::MAX), + "a PES always fits one pack" + ); + let mut h = vec![0x00, 0x00, 0x01, stream_id]; + h.extend_from_slice(&(length as u16).to_be_bytes()); + // '10', scrambling 00, priority 0, data_alignment_indicator, copyright 0, original 1. + h.push(0x81 | (u8::from(fields.data_alignment) << 2)); + let pts_dts = match (fields.pts, fields.dts) { + (None, _) => 0x00, + (Some(_), None) => 0x80, + (Some(_), Some(_)) => 0xC0, + }; + h.push(pts_dts | if ext > 0 { 0x01 } else { 0x00 }); + h.push(hdl as u8); + match (fields.pts, fields.dts) { + (Some(p), None) => push_timestamp(&mut h, 0b0010, p), + (Some(p), Some(d)) => { + push_timestamp(&mut h, 0b0011, p); + push_timestamp(&mut h, 0b0001, d); + } + (None, _) => {} + } + if let Some((scale, size)) = fields.pstd { + // PES_extension: only P-STD_buffer_flag set; reserved '111'. + h.push(0x1E); + h.push(0x40 | (u8::from(scale) << 5) | ((size >> 8) & 0x1F) as u8); + h.push(size as u8); + } + h +} + +/// Grow the PES header at `body[at..]` by `n` stuffing bytes (MS-10: the stuffing loop after +/// the optional fields; MS-13: at most 32 per header), fixing both length fields. +pub(crate) fn stuff_pes_header(body: &mut Vec, at: usize, n: usize) { + debug_assert!(n <= 32); + let hdl = usize::from(body[at + 8]); + let len = usize::from(u16::from_be_bytes([body[at + 4], body[at + 5]])) + n; + body[at + 4..at + 6].copy_from_slice(&(len as u16).to_be_bytes()); + body[at + 8] = (hdl + n) as u8; + let end = at + 9 + hdl; + body.splice(end..end, std::iter::repeat_n(0xFF, n)); +} + +/// A padding PES (MS-11 "padding_byte") that is exactly `total` bytes long. +pub(crate) fn padding_pes(total: usize) -> Vec { + debug_assert!(total >= MIN_PADDING_PES); + let mut p = vec![0x00, 0x00, 0x01, PADDING_STREAM]; + p.extend_from_slice(&((total - MIN_PADDING_PES) as u16).to_be_bytes()); + p.resize(total, 0xFF); + p +} + +/// `MPEG_program_end_code`. +pub(crate) const PROGRAM_END: [u8; 4] = [0x00, 0x00, 0x01, 0xB9]; + +#[cfg(test)] +mod tests { + use super::*; + + // Decode an SCR from a pack header, per MS-2's bit layout. + fn scr_of(h: &[u8]) -> (u64, u64) { + let b = |i: usize| u64::from(h[i]); + let base = ((b(4) >> 3) & 7) << 30 + | (b(4) & 3) << 28 + | b(5) << 20 + | (b(6) >> 3) << 15 + | (b(6) & 3) << 13 + | b(7) << 5 + | b(8) >> 3; + let ext = (b(8) & 3) << 7 | b(9) >> 1; + (base, ext) + } + + // MS-2/MS-3/MS-4 guard: layout, markers and SCR = base × 300 + ext. Per spec; do not + // change without a spec citation proving otherwise. + #[test] + fn pack_header_layout_markers_and_scr() { + let scr = 8_589_934_591 * 300 + 299; // the largest SCR + let h = pack_header(scr, 25_200, 3); + assert_eq!(h.len(), PACK_HEADER_BYTES + 3); + assert_eq!(&h[..4], &[0, 0, 1, 0xBA]); + assert_eq!(h[4] >> 6, 0b01, "'01'"); + assert_eq!(h[4] & 0x04, 0x04); + assert_eq!(h[6] & 0x04, 0x04); + assert_eq!(h[8] & 0x04, 0x04); + assert_eq!(h[9] & 0x01, 0x01); + assert_eq!(h[12] & 0x03, 0x03, "two marker bits after program_mux_rate"); + assert_eq!(h[13], 0xF8 | 3, "reserved 11111 + pack_stuffing_length"); + assert_eq!(&h[14..], &[0xFF; 3]); + assert_eq!(scr_of(&h), (8_589_934_591, 299)); + let rate = u32::from(h[10]) << 14 | u32::from(h[11]) << 6 | u32::from(h[12]) >> 2; + assert_eq!(rate, 25_200); + assert_eq!(scr_of(&pack_header(12_345 * 300 + 7, 1, 0)), (12_345, 7)); + } + + // MS-5/MS-6/MS-7 guard: every flag 0, reserved_bits '111 1111', one bound per stream. + #[test] + fn system_header_fields_and_bounds() { + let b = [ + Bound { + stream_id: 0xE0, + scale_1024: true, + size: 232, + }, + Bound { + stream_id: 0xC0, + scale_1024: false, + size: 128, + }, + Bound { + stream_id: 0xBD, + scale_1024: true, + size: 8191, + }, + ]; + let h = system_header(3, 1, &b); + assert_eq!(&h[..4], &[0, 0, 1, 0xBB]); + assert_eq!(usize::from(u16::from_be_bytes([h[4], h[5]])), h.len() - 6); + let rate_bound = + (u32::from(h[6]) & 0x7F) << 15 | u32::from(h[7]) << 7 | u32::from(h[8]) >> 1; + assert_eq!(rate_bound, RATE_BOUND); + assert_eq!(h[6] & 0x80, 0x80); + assert_eq!(h[8] & 1, 1); + assert_eq!(h[9], 3 << 2, "audio_bound 3, fixed_flag 0, CSPS_flag 0"); + assert_eq!(h[10], 0x21, "lock flags 0, marker, video_bound 1"); + assert_eq!( + h[11], 0x7F, + "packet_rate_restriction_flag 0, reserved_bits '111 1111'" + ); + assert_eq!(&h[12..15], &[0xE0, 0xE0, 232]); + assert_eq!(&h[15..18], &[0xC0, 0xC0, 128]); + assert_eq!(&h[18..21], &[0xBD, 0xE0 | (8191 >> 8) as u8, 0xFF]); + } + + // MS-9/MS-26 guard: a map followed by its CRC_32 leaves the Annex A decoder at zero. + #[test] + fn psm_crc_gives_a_zero_decoder_output_and_the_length_is_capped() { + let e = [PsmEntry { + stream_type: 0x02, + stream_id: 0xE0, + descriptors: vec![], + }]; + let m = psm(&[], &e).unwrap(); + assert_eq!(crc32(&m), 0, "zero residue over map + CRC"); + assert_eq!(&m[..4], &[0, 0, 1, 0xBC]); + assert_eq!(usize::from(u16::from_be_bytes([m[4], m[5]])), m.len() - 6); + assert_eq!(m[6], 0xE0, "current_next_indicator 1, version 0"); + assert_eq!( + crc32(b"123456789"), + 0x0376_E6E7, + "CRC-32/MPEG-2 check value" + ); + let big = [PsmEntry { + stream_type: 0x06, + stream_id: 0xBD, + descriptors: vec![0; 1010], + }]; + assert!( + psm(&[], &big).is_none(), + "program_stream_map_length ≤ 1018 (MS-8)" + ); + } + + // MS-23 guard: Table 2-44 values 5 and 15, the base's embedded index undefined. + #[test] + fn hierarchy_descriptor_values() { + assert_eq!( + hierarchy_descriptor(HIERARCHY_BASE, 0, 9), + [4, 4, 0xFF, 0xC0, 0xFF, 0xC0] + ); + assert_eq!( + hierarchy_descriptor(HIERARCHY_EXTENSION, 1, 0), + [4, 4, 0xF5, 0xC1, 0xC0, 0xC0] + ); + assert_eq!(iso639_descriptor("ENG"), Some([10, 4, b'e', b'n', b'g', 0])); + assert_eq!(iso639_descriptor(""), None); + assert_eq!(iso639_descriptor("en"), None); + } + + // MS-10/MS-11/MS-12 guard: flags, header length, bounded length, timestamp prefixes. + #[test] + fn pes_header_fields() { + let f = PesFields { + pts: Some(0x1_2345_6789), + dts: Some(90_000), + pstd: Some((true, 232)), + data_alignment: true, + }; + let h = pes_header(0xE0, &f, 100); + assert_eq!(h.len(), pes_header_len(&f)); + assert_eq!(&h[..4], &[0, 0, 1, 0xE0]); + assert_eq!( + usize::from(u16::from_be_bytes([h[4], h[5]])), + h.len() - 6 + 100 + ); + assert_eq!( + h[6], 0x85, + "'10', data_alignment_indicator, original_or_copy" + ); + assert_eq!(h[7], 0xC1, "PTS_DTS_flags '11', PES_extension_flag"); + assert_eq!(h[8], 13); + assert_eq!(h[9] >> 4, 0b0011); + assert_eq!(h[14] >> 4, 0b0001); + assert_eq!(h[19], 0x1E, "only P-STD_buffer_flag, reserved '111'"); + assert_eq!(h[20] >> 6, 0b01); + assert_eq!(u16::from(h[20] & 0x1F) << 8 | u16::from(h[21]), 232); + assert_eq!(h[20] & 0x20, 0x20, "scale 1"); + let bare = pes_header(0xC0, &PesFields::default(), 10); + assert_eq!(&bare[6..], &[0x81, 0x00, 0x00]); + assert_eq!(padding_pes(6), vec![0, 0, 1, 0xBE, 0, 0]); + assert_eq!(padding_pes(9), vec![0, 0, 1, 0xBE, 0, 3, 0xFF, 0xFF, 0xFF]); + } + + #[test] + fn fmkv_table_splits_under_255_bytes() { + let subs: Vec = (0..64) + .map(|i| SubStreamInfo { + sub_id: 0x20 + (i % 32) as u8, + lang: *b"eng", + forced: i == 3, + }) + .collect(); + let d = fmkv_descriptors(&subs, Some(&[[1, 2, 3]; 16])); + let mut at = 0; + let mut rows = 0; + let mut palette = false; + while at < d.len() { + assert_eq!(d[at], FMKV_TAG); + let len = usize::from(d[at + 1]); + assert_eq!(&d[at + 2..at + 6], FMKV_MAGIC); + match d[at + 7] { + FMKV_SUBSTREAMS => rows += (len - 6) / 5, + FMKV_PALETTE => palette = true, + t => panic!("type {t}"), + } + at += 2 + len; + } + assert_eq!((rows, palette), (64, true)); + // Worst case (64 sub-streams + palette) fits the map with room to spare (§2.2). + assert!(psm(&d, &[]).is_some_and(|m| m.len() < 500)); + } +} diff --git a/src/mux/mpg/pstd.rs b/src/mux/mpg/pstd.rs new file mode 100644 index 00000000..a803f603 --- /dev/null +++ b/src/mux/mpg/pstd.rs @@ -0,0 +1,907 @@ +//! The pack scheduler (mpg-output-design v5 §2.4): earliest-deadline-first over a P-STD +//! model the sink keeps, one buffer per stream_id (`0xBD` shared by its sub-streams), +//! per-pack `program_mux_rate`, SCR spacing ≤ 0.7 s, and whole PES packets inside +//! fixed 2048-byte packs. +//! +//! Times are 27 MHz (`…27`); PTS/DTS are 90 kHz ticks. An access unit's decoding time is +//! its DTS, else its PTS (MS-16). + +use super::pack::{self, PesFields}; +use std::collections::VecDeque; +use std::io::{self, Write}; + +/// 27 MHz ticks per second. +pub(crate) const HZ27: u64 = 27_000_000; +/// A chunk that commences an AU may go when that AU decodes within 0.95 s (design §2.4 +/// step 3), under MS-16's "less than or equal to one second" delay bound. +const LEAD27: u64 = HZ27 * 95 / 100; +/// MS-17: SCR fields in successive packs ≤ 0.7 s apart. +pub(crate) const MAX_SCR_GAP27: u64 = HZ27 * 7 / 10; +/// Per-track lookahead before the clock may pass `t` (design §2.4 step 1). +const LOOKAHEAD27: u64 = HZ27; +/// Max-interleave cap: a track this far behind the others is treated as sparse. +const INTERLEAVE_CAP27: u64 = 10 * HZ27; +const INTERLEAVE_CAP_BYTES: usize = 256 * 1024 * 1024; +/// ES bytes assumed per pack when sizing the rate (2048 less pack and PES headers). +const EST_PAYLOAD: u64 = 1_990; +/// 27 MHz ticks per byte at one `program_mux_rate` unit (50 B/s). +const TICKS_PER_BYTE_UNIT: u128 = (HZ27 / 50) as u128; + +/// `ceil(bytes / (units × 50 B/s))` in 27 MHz ticks. +pub(crate) fn dur27(bytes: u64, units: u32) -> u64 { + let n = u128::from(bytes) * TICKS_PER_BYTE_UNIT; + n.div_ceil(u128::from(units)) as u64 +} + +/// How a stream's PES payload starts (the private_stream_1 sub-stream header, MS-29). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Payload { + /// Video or MPEG audio: the ES alone. + Plain, + /// The MPEG-2 audio extension: one AU per PES, whole (design §2.3, MPG4-7). + WholeAu, + /// AC-3 / DTS: sub-id, number_of_frame_headers, first_access_unit_pointer. + Frames { sub_id: u8 }, + /// DVD LPCM: the AC-3 style three bytes, then the 3-byte LPCM header. + Lpcm { + sub_id: u8, + channels: usize, + rate: u32, + }, + /// Subpicture: the sub-id only. + SubId { sub_id: u8 }, +} + +impl Payload { + fn header_len(self) -> usize { + match self { + Payload::Plain | Payload::WholeAu => 0, + Payload::Frames { .. } => 4, + Payload::Lpcm { .. } => 7, + Payload::SubId { .. } => 1, + } + } +} + +/// One output stream (a stream_id, or a private_stream_1 sub-stream). +#[derive(Debug, Clone)] +pub(crate) struct StreamSpec { + pub stream_id: u8, + pub payload: Payload, + /// Index into the buffer table. + pub buffer: usize, + /// Subpictures: not held to the lookahead (design §2.4 step 1). + pub sparse: bool, + /// Video or audio: its PTS spacing is §2.7.4's concern (MS-18). + pub av: bool, +} + +/// One P-STD buffer `Bn` and its bound (MS-7, MS-13). +#[derive(Debug, Clone, Copy)] +pub(crate) struct BufferSpec { + pub stream_id: u8, + pub scale_1024: bool, + pub size: u16, +} + +impl BufferSpec { + pub(crate) fn bytes(self) -> u64 { + u64::from(self.size) * if self.scale_1024 { 1024 } else { 128 } + } +} + +/// An access unit ready to packetize. +#[derive(Debug, Clone)] +pub(crate) struct Au { + pub pts: u64, + /// Coded DTS, present only when it differs from the PTS (MS-19). + pub dts: Option, + /// Offset of the byte whose presence lets a PES carry this AU's PTS: its first + /// byte, or for video the first picture start code (MS-15, MS-19). + pub mark: usize, + pub data: Vec, + /// DVD LPCM quantization of `data` (16/20/24); 0 otherwise. + pub lpcm_bits: u8, +} + +struct QAu { + au: Au, + id: u64, + dec27: u64, + sent: usize, +} + +struct Stream { + spec: StreamSpec, + queue: VecDeque, + first_pes_done: bool, + active: bool, + last_dec27: u64, + last_pts: Option, + lagging_counted: bool, +} + +struct Entry { + stream: usize, + id: u64, + dec27: u64, + bytes: u64, + complete: bool, +} + +/// Anomalies the scheduler resolved rather than refusing the stream (design §2.4). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PstdCounters { + /// AUs whose last byte arrived after their decoding time (`pstd_late_aus`). + pub late_aus: u64, + /// Consecutive coded PTS more than 0.7 s apart (`pts_gap_over_0_7s`, MPG2-11). + pub pts_gaps: u64, + /// Tracks the interleave cap treated as sparse. + pub interleave_cap: u64, + /// Padding-only packs written to keep SCR spacing ≤ 0.7 s. + pub padding_packs: u64, + /// EOF passes that lifted the lead and whole-frame waits to drain what was stuck. + pub forced_eof: u64, +} + +/// The pack writer. +pub(crate) struct Mux { + writer: W, + streams: Vec, + buffers: Vec, + entries: Vec, + /// System header + PSM for the first pack (MS-21 §2.7.8); `None` once written. + first: Option>, + /// Lowest `program_mux_rate` (design §2.4 step 5, R0). + r0: u32, + t: Option, + last_scr: Option, + next_id: u64, + queued_bytes: usize, + counters: PstdCounters, + /// EOF with nothing schedulable: the lead and whole-frame waits are lifted (B1). + forcing: bool, +} + +// What one PES will carry. +struct PesPlan { + /// ES bytes (after any sub-stream header). + len: usize, + /// `Some((queue index, offset of its first byte in the payload))` when an AU commences. + start: Option<(usize, usize)>, +} + +impl Mux { + pub(crate) fn new( + writer: W, + streams: Vec, + buffers: Vec, + first: Vec, + r0: u32, + ) -> Self { + Self { + writer, + streams: streams + .into_iter() + .map(|spec| Stream { + // Every carried track holds the lookahead from the start, so one that + // lags in the source interleave is waited for (design §2.4 step 1); the + // extension, which IFO coding mode 3 may only declare, from its first AU. + active: !spec.sparse && spec.payload != Payload::WholeAu, + spec, + queue: VecDeque::new(), + first_pes_done: false, + last_dec27: 0, + last_pts: None, + lagging_counted: false, + }) + .collect(), + buffers, + entries: Vec::new(), + first: Some(first), + r0, + t: None, + last_scr: None, + next_id: 0, + queued_bytes: 0, + counters: PstdCounters::default(), + forcing: false, + } + } + + pub(crate) fn counters(&self) -> PstdCounters { + self.counters + } + + #[cfg(test)] + pub(crate) fn into_writer(self) -> W { + self.writer + } + + /// Queue one AU of stream `si`, in that stream's decode order. + pub(crate) fn push(&mut self, si: usize, au: Au) { + let s = &mut self.streams[si]; + let dec27 = au.dts.unwrap_or(au.pts) * 300; + // MS-18 / MPG2-11: a gap over 0.7 s is the content's own (a still or slideshow); count it. + if s.spec.av + && let Some(prev) = s.last_pts + && au.pts.abs_diff(prev) > 63_000 + { + self.counters.pts_gaps += 1; + } + s.last_pts = Some(au.pts); + s.active = true; + s.last_dec27 = s.last_dec27.max(dec27); + self.queued_bytes += au.data.len(); + let id = self.next_id; + self.next_id += 1; + s.queue.push_back(QAu { + au, + id, + dec27, + sent: 0, + }); + } + + // Design §2.4 step 1: every active non-sparse track has data decoding at or past + // `t + 1 s`, or the interleave cap lets the clock pass a lagging one. + fn ready(&mut self, t: u64, eof: bool) -> bool { + if eof || self.queued_bytes > INTERLEAVE_CAP_BYTES { + return true; + } + let lead = self.streams.iter().map(|s| s.last_dec27).max().unwrap_or(0); + let mut ok = true; + for s in &mut self.streams { + if !s.active || s.spec.sparse || s.last_dec27 >= t + LOOKAHEAD27 { + continue; + } + if s.last_dec27 + INTERLEAVE_CAP27 < lead { + if !s.lagging_counted { + s.lagging_counted = true; + self.counters.interleave_cap += 1; + } + continue; + } + ok = false; + } + ok + } + + // Bytes that may still enter buffer `b` at `t`, after removing every complete AU whose + // decoding time has come (MS-16). + fn room(&self, b: usize, t: u64) -> u64 { + // MS-16: "0 ≤ Fn(t) ≤ BSn"; an AU leaves Bn at its decoding time. + let fill: u64 = self + .entries + .iter() + .filter(|e| self.streams[e.stream].spec.buffer == b && !(e.complete && e.dec27 <= t)) + .map(|e| e.bytes) + .sum(); + self.buffers[b].bytes().saturating_sub(fill) + } + + fn remove_decoded(&mut self, t: u64) { + self.entries.retain(|e| !(e.complete && e.dec27 <= t)); + } + + // Distance from the chunk start to the first byte of queue item `k` (k ≥ 1). + fn first_byte(s: &Stream, k: usize) -> Option { + let head = s.queue.front()?; + s.queue.get(k)?; + (k >= 1).then(|| { + head.au.data.len() - head.sent + + s.queue + .iter() + .skip(1) + .take(k - 1) + .map(|q| q.au.data.len()) + .sum::() + }) + } + + // Distance from the chunk start to the commencement byte of queue item `k`. + fn commencement(s: &Stream, k: usize) -> Option { + let head = s.queue.front()?; + let q = s.queue.get(k)?; + let before: usize = if k == 0 { + 0 + } else { + head.au.data.len() - head.sent + + s.queue + .iter() + .skip(1) + .take(k - 1) + .map(|q| q.au.data.len()) + .sum::() + }; + let own = if k == 0 { + q.au.mark.checked_sub(head.sent)? + } else { + q.au.mark + }; + Some(before + own) + } + + // The next AU to commence in stream `s`'s chunk: queue index and distance. + fn next_commencement(s: &Stream) -> Option<(usize, usize)> { + let head = s.queue.front()?; + if head.sent <= head.au.mark { + return Some((0, head.au.mark - head.sent)); + } + Self::commencement(s, 1).map(|d| (1, d)) + } + + // Plan one PES of stream `si` in `avail` pack bytes at `t` (design §2.3 "PES packing"). + fn plan_pes(&self, si: usize, avail: usize, t: u64) -> Option { + let s = &self.streams[si]; + let head = s.queue.front()?; + let hdr = s.spec.payload.header_len(); + let pstd = if s.first_pes_done { 0 } else { 3 }; + let buffer_bytes = self.buffers[s.spec.buffer].bytes(); + let mut room = self.room(s.spec.buffer, t); + let unit = |q: &QAu| lpcm_unit(s.spec.payload, q.au.lpcm_bits); + // Design §2.4 (MPG3-7): an AU larger than its buffer can never pass the overflow + // test, so it takes the late path rather than deadlocking. + if head.au.data.len() as u64 + hdr as u64 > buffer_bytes { + room = u64::MAX; + } + let room = usize::try_from(room.saturating_sub(hdr as u64)).unwrap_or(usize::MAX); + // Audio AUs go whole into one PES when a pack can hold them: PS readers time a + // frame by the PES its first byte is in, some only once it completes there (the + // extension additionally by MPG4-7). + let whole = match s.spec.payload { + Payload::WholeAu | Payload::Frames { .. } => true, + Payload::Plain => (0xC0..=0xDF).contains(&s.spec.stream_id), + _ => false, + }; + // Never more than the bytes queued: the header states the payload length. + let queued = s.queue.iter().map(|q| q.au.data.len()).sum::() - head.sent; + let room = room.min(queued); + if let Some((k, dist)) = Self::next_commencement(s) { + let q = &s.queue[k]; + let ts = if q.au.dts.is_some() { 10 } else { 5 }; + // A PES that carries a PTS starts at its AU's first byte, as a DVD encoder + // writes it: PS readers give a PES's PTS to the AU holding its first byte. + let at_au = k == 0 && head.sent <= head.au.mark; + let lead_ok = self.forcing || q.dec27.saturating_sub(t) <= LEAD27; + let fresh = pack::PACK_BYTES - pack::PACK_HEADER_BYTES - (9 + pstd + ts + hdr); + if at_au && lead_ok && dist >= fresh { + // B1: no PES reaches the commencement byte (MS-15), so the bytes before it + // go first without a PTS; the PES holding it carries the PTS. + let cap = avail.checked_sub(9 + pstd + hdr)?; + let len = cap.min(dist).min(room); + return (len > 0).then_some(PesPlan { len, start: None }); + } + if let Some(cap) = avail.checked_sub(9 + pstd + ts + hdr) + && at_au + && lead_ok + && dist < cap + { + // At most one AU commences per PES: stop before the next AU's first byte. + let until_next = Self::first_byte(s, k + 1).unwrap_or(usize::MAX); + let mut len = cap.min(until_next).min(room); + if whole && k == 0 && head.sent == 0 { + // Wait for a fresh pack when one could hold the whole AU; a larger AU + // (BD AC-3 at 640 kbit/s) spans PES. + if q.au.data.len() <= cap && q.au.data.len() <= room { + len = q.au.data.len(); + } else if q.au.data.len() <= fresh && !self.forcing { + len = 0; + } + } + len = round_down(len, unit(q).max(1)); + if len > dist { + let first_byte = if k == 0 { 0 } else { dist - q.au.mark }; + return Some(PesPlan { + len, + start: Some((k, first_byte)), + }); + } + } + } + // A tail with no commencement (for the extension, only an AU too big for one pack). + if head.sent > 0 { + let cap = avail.checked_sub(9 + pstd + hdr)?; + let mut len = cap.min(head.au.data.len() - head.sent).min(room); + if let Some(next) = Self::first_byte(s, 1) { + len = len.min(next); + } + // D1: bytes before the commencement byte go without a PTS; the PES holding + // the mark carries the PTS (MS-15). + if head.sent <= head.au.mark { + len = len.min(head.au.mark - head.sent); + } + len = round_down(len, unit(head).max(1)); + if len > 0 { + return Some(PesPlan { len, start: None }); + } + } + None + } + + // Build one PES of stream `si` from `plan`; update queue and buffer model. Each AU it + // completes is returned as `(decoding time, offset of its last byte, oversize)`. + fn emit_pes(&mut self, si: usize, plan: PesPlan) -> (Vec, Vec<(u64, usize, bool)>) { + let buffer_bytes = self.buffers[self.streams[si].spec.buffer].bytes(); + let mut done_aus = Vec::new(); + let s = &self.streams[si]; + let mut fields = PesFields::default(); + if let Some((k, _)) = plan.start { + // MS-15/MS-19: the PTS names the AU commencing here; DTS only where it differs. + fields.pts = Some(s.queue[k].au.pts); + fields.dts = s.queue[k].au.dts; + } + if !s.first_pes_done { + // MS-21 §2.7.7: P-STD fields in the first PES of each stream (and sub-stream). + let b = self.buffers[s.spec.buffer]; + fields.pstd = Some((b.scale_1024, b.size)); + } + let head_sent = s.queue.front().map_or(0, |q| q.sent); + fields.data_alignment = + matches!(s.spec.payload, Payload::Plain | Payload::WholeAu) && head_sent == 0; + let hdr_len = s.spec.payload.header_len(); + let mut out = pack::pes_header(s.spec.stream_id, &fields, hdr_len + plan.len); + // The private_stream_1 sub-stream header (MS-14 "user definable"; MS-29 FFmpeg). + let frames = u8::from(plan.start.is_some()); + let pointer = plan.start.map_or(0u16, |(_, off)| off as u16 + 1); + match s.spec.payload { + Payload::Plain | Payload::WholeAu => {} + Payload::Frames { sub_id } => { + out.push(sub_id); + out.push(frames); + out.extend_from_slice(&pointer.to_be_bytes()); + } + Payload::Lpcm { + sub_id, + channels, + rate, + } => { + let bits = s.queue.front().map_or(16, |q| q.au.lpcm_bits); + let lpcm = crate::mux::codec::lpcm::dvd_header(channels, rate, bits) + .unwrap_or([0x0C, 0, 0x80]); + out.push(sub_id); + out.push(frames); + // The pointer counts the 3 LPCM header bytes too (MS-29: `avio_wb16(ctx->pb, 4)`). + let pointer = plan.start.map_or(0u16, |(_, off)| off as u16 + 4); + out.extend_from_slice(&pointer.to_be_bytes()); + out.extend_from_slice(&lpcm); + } + Payload::SubId { sub_id } => out.push(sub_id), + } + // Consume `plan.len` ES bytes, attributing them (and the sub-stream header) to AUs. + let mut left = plan.len; + let mut hdr_bytes = hdr_len as u64; + while left > 0 { + let s = &mut self.streams[si]; + let Some(q) = s.queue.front_mut() else { break }; + let take = left.min(q.au.data.len() - q.sent); + out.extend_from_slice(&q.au.data[q.sent..q.sent + take]); + q.sent += take; + left -= take; + let (id, dec27, done) = (q.id, q.dec27, q.sent == q.au.data.len()); + let bytes = take as u64 + std::mem::take(&mut hdr_bytes); + match self + .entries + .iter_mut() + .find(|e| e.stream == si && e.id == id) + { + Some(e) => { + e.bytes += bytes; + e.complete = done; + } + None => self.entries.push(Entry { + stream: si, + id, + dec27, + bytes, + complete: done, + }), + } + if done { + // Design §2.4 (MPG3-7): the AU plan_pes let past a buffer it cannot fit. + let oversize = (q.au.data.len() + hdr_len) as u64 > buffer_bytes; + done_aus.push((dec27, out.len() - 1, oversize)); + self.queued_bytes -= q.au.data.len(); + s.queue.pop_front(); + } + } + self.streams[si].first_pes_done = true; + (out, done_aus) + } + + // Design §2.4 step 5: the lowest rate, rounded up to 50 B/s units, that delivers every + // queued AU by its decoding time; `RATE_BOUND` when one is already due. + fn rate(&self, t: u64) -> u32 { + let mut due: Vec<(u64, u64)> = self + .streams + .iter() + .flat_map(|s| s.queue.iter()) + .filter(|q| q.dec27 <= t + LOOKAHEAD27) + .map(|q| (q.dec27, (q.au.data.len() - q.sent) as u64)) + .collect(); + due.sort_unstable(); + let mut units = u128::from(self.r0); + let mut bytes = 0u64; + for (dec, b) in due { + bytes += b; + if dec <= t { + return pack::RATE_BOUND; + } + let packs = bytes.div_ceil(EST_PAYLOAD) + 1; + let need = + (u128::from(packs) * 2048 * TICKS_PER_BYTE_UNIT).div_ceil(u128::from(dec - t)); + units = units.max(need); + } + units.min(u128::from(pack::RATE_BOUND)) as u32 + } + + // Build and write the pack at SCR `t`; `false` when it carried nothing (and was not + // the first, which must carry the system header and map, MS-21 §2.7.8). + fn write_pack(&mut self, t: u64, padding_only: bool) -> io::Result { + self.remove_decoded(t); + let rate = if padding_only { self.r0 } else { self.rate(t) }; + let prefix = self.first.take(); + let mut avail = + pack::PACK_BYTES - pack::PACK_HEADER_BYTES - prefix.as_ref().map_or(0, Vec::len); + let mut body: Vec = prefix.clone().unwrap_or_default(); + let mut any = false; + let mut last_pes = None; + // AUs completed in this pack: (decoding time, offset in `body`, oversize, PES start). + let mut done_aus: Vec<(u64, usize, bool, usize)> = Vec::new(); + while !padding_only && avail >= 10 { + let mut best: Option<(u64, usize, PesPlan)> = None; + for si in 0..self.streams.len() { + let Some(dec) = self.streams[si].queue.front().map(|q| q.dec27) else { + continue; + }; + if best.as_ref().is_some_and(|(d, _, _)| *d <= dec) { + continue; + } + if let Some(p) = self.plan_pes(si, avail, t) { + best = Some((dec, si, p)); + } + } + let Some((_, si, plan)) = best else { break }; + let (pes, done) = self.emit_pes(si, plan); + avail -= pes.len(); + let at = body.len(); + done_aus.extend(done.into_iter().map(|(d, o, big)| (d, at + o, big, at))); + last_pes = Some(at); + body.extend_from_slice(&pes); + any = true; + } + if !any && prefix.is_none() && !padding_only { + return Ok(false); + } + // Design §2.3 pack fill: a padding PES for ≥ 6 bytes; 1-5 bytes become PES-header + // stuffing on the last PES (MS-13 "No more than 32"), keeping the pack header + // unstuffed like a DVD VOB pack; pack stuffing (MS-3 ≤ 7) only with no PES at all. + let mut stuffed = None; + let stuffing = match (avail, last_pes) { + (0, _) => 0, + (a, _) if a >= pack::MIN_PADDING_PES => { + body.extend_from_slice(&pack::padding_pes(a)); + 0 + } + (a, Some(at)) => { + pack::stuff_pes_header(&mut body, at, a); + stuffed = Some((at, a)); + 0 + } + (a, None) => a, + }; + // MS-16: complete "at the decoding time"; later is counted, not refused. A byte's + // arrival follows MS-4 eq. 2-21 from this pack's SCR and rate. + for (dec27, off, oversize, pes_at) in done_aus { + let shift = stuffed + .filter(|(at, _)| *at == pes_at) + .map_or(0, |(_, n)| n); + let pos = pack::PACK_HEADER_BYTES + stuffing + off + shift; + let arrival = t + + ((pos - pack::SCR_BASE_LAST_BYTE) as u128 * TICKS_PER_BYTE_UNIT + / u128::from(rate)) as u64; + if arrival > dec27 || oversize { + self.counters.late_aus += 1; + } + } + let mut p = pack::pack_header(t, rate, stuffing); + p.extend_from_slice(&body); + debug_assert_eq!(p.len(), pack::PACK_BYTES); + self.writer.write_all(&p)?; + self.last_scr = Some(t); + // MS-4: every byte of this pack enters before any byte of the next; the next + // pack's 8 bytes before its SCR byte arrive at no less than R0. + self.t = Some(t + dur27(pack::PACK_BYTES as u64, rate) + dur27(8, self.r0)); + Ok(true) + } + + // When stream `si`'s head can next go, if blocked only by time or buffer room. + fn wake_time(&self, si: usize, t: u64) -> Option { + let s = &self.streams[si]; + let head = s.queue.front()?; + let removal = self + .entries + .iter() + .filter(|e| { + self.streams[e.stream].spec.buffer == s.spec.buffer && e.complete && e.dec27 > t + }) + .map(|e| e.dec27) + .min(); + // Not yet within the 0.95 s lead: wait for it; else (a tail, or an AU waiting for + // room) only a removal can unblock it. + let lead = Self::next_commencement(s).map(|(k, _)| s.queue[k].dec27.saturating_sub(LEAD27)); + let w = match lead { + Some(l) if head.sent <= head.au.mark && l > t => Some(l), + _ => removal, + }; + w.map(|w| w.max(t + 1)) + } + + /// Write every pack the lookahead allows; at `eof`, everything. + pub(crate) fn pump(&mut self, eof: bool) -> io::Result<()> { + loop { + let pending = self.streams.iter().any(|s| !s.queue.is_empty()); + if !pending && self.first.is_none() { + return Ok(()); + } + let t = match self.t { + Some(t) => t, + None => { + // Design §2.4 step 8: initial SCR = the lowest decoding time − 1 s. + let lo = self + .streams + .iter() + .filter_map(|s| s.queue.front()) + .map(|q| q.dec27) + .min(); + lo.unwrap_or(0).saturating_sub(HZ27) + } + }; + if !self.ready(t, eof) { + self.t = Some(t); + return Ok(()); + } + if self.write_pack(t, false)? { + continue; + } + // Nothing eligible: advance to the next removal or eligibility time, keeping + // SCR spacing ≤ 0.7 s with padding packs (MS-17). + let next = (0..self.streams.len()) + .filter_map(|si| self.wake_time(si, t)) + .min(); + let Some(next) = next else { + if !eof { + return Ok(()); + } + // B1: at EOF nothing is left behind. Lift the waits and drop empty AUs once; + // what still cannot go is an error, never a silent Ok. + if self.forcing { + let left: usize = self.streams.iter().map(|s| s.queue.len()).sum(); + return Err(io::Error::other(format!( + "mpg: {left} access unit(s) could not be packetized" + ))); + } + self.forcing = true; + self.counters.forced_eof += 1; + let left: usize = self.streams.iter().map(|s| s.queue.len()).sum(); + tracing::warn!( + target: "mux", + access_units = left, + "mpg: end of input with access units no pack could take; the 0.95 s lead \ + and whole-frame waits are lifted to write them" + ); + for s in &mut self.streams { + while s.queue.front().is_some_and(|q| q.au.data.is_empty()) { + s.queue.pop_front(); + } + } + continue; + }; + match self.last_scr { + Some(last) if next > last + MAX_SCR_GAP27 => { + let at = (last + MAX_SCR_GAP27).max(t); + self.counters.padding_packs += 1; + self.write_pack(at, true)?; + } + _ => self.t = Some(next), + } + } + } + + /// Drain everything and write `MPEG_program_end_code`. + pub(crate) fn finish(&mut self) -> io::Result<()> { + self.pump(true)?; + self.writer.write_all(&pack::PROGRAM_END)?; + self.writer.flush() + } +} + +/// ES bytes of one DVD LPCM packing unit at `bits`; 1 for every other payload. +fn lpcm_unit(payload: Payload, bits: u8) -> usize { + match payload { + Payload::Lpcm { channels, .. } => { + let samples = crate::mux::codec::lpcm::dvd_unit_frames(channels, bits) * channels; + samples * 2 + + match bits { + 24 => samples, + 20 => samples / 2, + _ => 0, + } + } + _ => 1, + } +} + +#[cfg(test)] +pub(super) fn lpcm_unit_for_test(channels: usize, bits: u8) -> usize { + lpcm_unit( + Payload::Lpcm { + sub_id: 0, + channels, + rate: 48_000, + }, + bits, + ) +} + +fn round_down(n: usize, unit: usize) -> usize { + n - n % unit +} + +#[cfg(test)] +mod tests { + use super::*; + + fn video_mux() -> Mux> { + let spec = StreamSpec { + stream_id: 0xE0, + payload: Payload::Plain, + buffer: 0, + sparse: false, + av: true, + }; + let buf = BufferSpec { + stream_id: 0xE0, + scale_1024: true, + size: 232, + }; + Mux::new(Vec::new(), vec![spec], vec![buf], Vec::new(), 25_200) + } + + fn au(pts: u64, len: usize, mark: usize) -> Au { + Au { + pts, + dts: None, + mark, + data: vec![0x55; len], + lpcm_bits: 0, + } + } + + // Nit (r2): the forced EOF pass that lifts the 0.95 s lead is counted, not silent. + #[test] + fn a_forced_eof_drain_is_counted() { + let mut m = video_mux(); + m.push(0, au(9_000, 0, 0)); + m.push(0, au(12_600, 100, 0)); + m.finish().unwrap(); + assert_eq!(m.counters().forced_eof, 1); + let mut clean = video_mux(); + clean.push(0, au(9_000, 100, 0)); + clean.finish().unwrap(); + assert_eq!(clean.counters().forced_eof, 0); + } + + // B1: at EOF nothing is left behind. A zero-length AU used to wedge its stream and + // every AU after it; `finish` returned Ok with them queued. + #[test] + fn eof_never_leaves_aus_behind() { + let mut m = video_mux(); + m.push(0, au(9_000, 0, 0)); + m.push(0, au(12_600, 100, 0)); + m.finish().unwrap(); + assert!( + m.streams.iter().all(|s| s.queue.is_empty()), + "AUs left queued" + ); + } + + // B1: a picture start code no PES of a fresh pack can reach (a long sequence header or + // user data ahead of it): the bytes before it go first, without a PTS. + #[test] + fn a_commencement_past_a_packs_reach_still_goes() { + let mut m = video_mux(); + m.push(0, au(9_000, 5_000, 3_000)); + m.push(0, au(12_600, 100, 0)); + m.finish().unwrap(); + assert!( + m.streams.iter().all(|s| s.queue.is_empty()), + "AUs left queued" + ); + } + + // D1: a tail that ends short of the picture start never crosses it without a PTS: with + // 494 bytes left and the mark 484 away, the commencing PES cannot fit (cap 480) and the + // tail stops at the mark. + #[test] + fn a_tail_never_crosses_the_picture_start() { + let mut m = video_mux(); + m.push(0, au(9_000, 5_000, 3_000)); + m.streams[0].first_pes_done = true; + m.streams[0].queue[0].sent = 3_000 - 484; + let p = m.plan_pes(0, 494, 0); + assert!( + p.as_ref().is_none_or(|p| p.start.is_none() && p.len <= 484), + "{:?}", + p.map(|p| (p.len, p.start)) + ); + } + + // An AU's first byte and its commencement byte share one PES: a PES ends before the next + // AU's first byte, never between its sequence header and its picture start code (MS-15). + #[test] + fn a_pes_never_splits_an_au_from_its_picture_start() { + let mut m = video_mux(); + m.push(0, au(9_000, 100, 0)); + m.push(0, au(12_600, 1_000, 20)); + let p = m.plan_pes(0, 200, 0).unwrap(); + assert_eq!(p.start.map(|s| s.0), Some(0)); + assert_eq!( + p.len, 100, + "stops at the next AU's first byte, not its picture start" + ); + m.streams[0].queue[0].sent = 90; + let tail = m.plan_pes(0, 30, 0).unwrap(); + assert_eq!((tail.len, tail.start.map(|s| s.0)), (10, None)); + } + + // A PES that carries a PTS begins at its AU's first byte, as a DVD encoder writes it: PS + // readers (our AuAssembler) give a PES's PTS to the AU holding its first byte, so a tail + // of the previous AU in front would take the PTS. + #[test] + fn a_pts_pes_begins_at_its_au() { + let mut m = video_mux(); + m.push(0, au(9_000, 100, 0)); + m.push(0, au(12_600, 1_000, 20)); + m.streams[0].queue[0].sent = 90; + let p = m.plan_pes(0, 1_000, 0).unwrap(); + assert_eq!((p.len, p.start), (10, None), "the tail goes alone"); + } + + // An audio frame that fits one PES goes whole: PS readers time a frame by the PES its + // first byte is in, and some (our Ac3Parser) only when it completes there. + #[test] + fn an_audio_frame_that_fits_goes_whole() { + let spec = StreamSpec { + stream_id: 0xBD, + payload: Payload::Frames { sub_id: 0x80 }, + buffer: 0, + sparse: false, + av: true, + }; + let buf = BufferSpec { + stream_id: 0xBD, + scale_1024: true, + size: 8191, + }; + let mut m = Mux::new(Vec::new(), vec![spec], vec![buf], Vec::new(), 25_200); + m.push(0, au(9_000, 1_792, 0)); + assert!( + m.plan_pes(0, 500, 0).is_none(), + "no 480-byte start: wait for a pack" + ); + assert_eq!(m.plan_pes(0, 2_034, 0).map(|p| p.len), Some(1_792)); + let mut big = Mux::new( + Vec::new(), + vec![m.streams[0].spec.clone()], + vec![buf], + Vec::new(), + 25_200, + ); + big.push(0, au(9_000, 2_560, 0)); + assert!( + big.plan_pes(0, 2_034, 0).is_some_and(|p| p.len < 2_560), + "a frame no PES holds spans" + ); + } +} diff --git a/src/mux/mpg/replay.rs b/src/mux/mpg/replay.rs new file mode 100644 index 00000000..eeacf3b6 --- /dev/null +++ b/src/mux/mpg/replay.rs @@ -0,0 +1,493 @@ +//! Test-only: an independent parser and P-STD replayer for `mpg://` output (design §7). +//! It re-derives every byte's arrival from SCR and `program_mux_rate` (MS-4 eq. 2-21) and +//! checks the normative constraints, allowing exactly the anomalies the sink counted. + +use super::pack; +use std::collections::BTreeMap; + +pub(super) type Key = (u8, Option); + +#[derive(Debug, Clone)] +pub(super) struct Pack { + pub off: usize, + pub scr: u64, + pub rate: u32, +} + +#[derive(Debug, Clone)] +pub(super) struct Pes { + pub pack: usize, + pub key: Key, + pub pts: Option, + pub dts: Option, + pub pstd: Option<(bool, u16)>, + /// Absolute offset of the first `PES_packet_data_byte` and of the ES after any + /// sub-stream header. + pub data_off: usize, + pub es_off: usize, + pub sub_hdr: Vec, + pub es: Vec, + pub end: usize, +} + +#[derive(Debug, Default)] +pub(super) struct Parsed { + pub packs: Vec, + pub system_headers: Vec<(usize, Vec)>, + pub psms: Vec<(usize, Vec)>, + pub pes: Vec, + pub end_code: bool, +} + +// A 33-bit PTS/DTS field with its 4-bit prefix and three marker bits (MS-10). +fn ts(b: &[u8], prefix: u8) -> Result { + if b[0] >> 4 != prefix || b[0] & 1 == 0 || b[2] & 1 == 0 || b[4] & 1 == 0 { + return Err(format!("timestamp prefix or marker bits: {:02x?}", &b[..5])); + } + Ok((u64::from(b[0] >> 1) & 7) << 30 + | u64::from(b[1]) << 22 + | u64::from(b[2] >> 1) << 15 + | u64::from(b[3]) << 7 + | u64::from(b[4] >> 1)) +} + +/// CRC_32 of Annex A (MSB first, polynomial 0x04C11DB7, register preset to ones, no final +/// XOR), bit by bit: independent of the sink's table so a shared bug cannot hide. +pub(super) fn crc32(data: &[u8]) -> u32 { + let mut c = 0xFFFF_FFFFu32; + for &b in data { + c ^= u32::from(b) << 24; + for _ in 0..8 { + c = if c & 0x8000_0000 != 0 { + (c << 1) ^ 0x04C1_1DB7 + } else { + c << 1 + }; + } + } + c +} + +/// The offset of the first `picture_start_code` (`00 00 01 00`) in a video AU, 0 if none. +pub(super) fn picture_start(es: &[u8]) -> usize { + let mut i = 0; + while i + 4 <= es.len() { + if es[i] == 0 && es[i + 1] == 0 && es[i + 2] == 1 && es[i + 3] == 0 { + return i; + } + i += 1; + } + 0 +} + +/// Parse `data` as 2048-byte packs (MS-1: every PES lies inside its pack). +pub(super) fn parse(data: &[u8]) -> Result { + let mut p = Parsed::default(); + let mut off = 0; + while off < data.len() { + if data[off..].starts_with(&pack::PROGRAM_END) { + if off + 4 != data.len() { + return Err("bytes after MPEG_program_end_code".into()); + } + p.end_code = true; + break; + } + let pk = data + .get(off..off + pack::PACK_BYTES) + .ok_or("a short pack")?; + if pk[..4] != [0, 0, 1, 0xBA] || pk[4] >> 6 != 1 { + return Err(format!("no MPEG-2 pack header at {off}")); + } + // MS-2: marker bits around the SCR and after program_mux_rate; reserved '11111'. + if pk[4] & 0x04 == 0 + || pk[6] & 0x04 == 0 + || pk[8] & 0x04 == 0 + || pk[9] & 0x01 == 0 + || pk[12] & 0x03 != 0x03 + || pk[13] & 0xF8 != 0xF8 + { + return Err(format!("pack header marker bits at {off} (MS-2)")); + } + let b = |i: usize| u64::from(pk[i]); + let base = ((b(4) >> 3) & 7) << 30 + | (b(4) & 3) << 28 + | b(5) << 20 + | (b(6) >> 3) << 15 + | (b(6) & 3) << 13 + | b(7) << 5 + | b(8) >> 3; + let ext = (b(8) & 3) << 7 | b(9) >> 1; + let rate = (u32::from(pk[10]) << 14) | (u32::from(pk[11]) << 6) | (u32::from(pk[12]) >> 2); + if rate == 0 { + return Err("program_mux_rate 0 is forbidden (MS-3)".into()); + } + let stuffing = usize::from(pk[13] & 7); + let pi = p.packs.len(); + p.packs.push(Pack { + off, + scr: base * 300 + ext, + rate, + }); + let mut i = pack::PACK_HEADER_BYTES + stuffing; + while i < pack::PACK_BYTES { + let h = &pk[i..]; + if h.len() < 6 || h[..3] != [0, 0, 1] { + return Err(format!("no start code at {}", off + i)); + } + let len = usize::from(u16::from_be_bytes([h[4], h[5]])); + let end = i + 6 + len; + if end > pack::PACK_BYTES { + return Err(format!("a packet crosses its pack at {} (MS-1)", off + i)); + } + match h[3] { + 0xBB => p.system_headers.push((pi, pk[i..end].to_vec())), + 0xBC => p.psms.push((pi, pk[i..end].to_vec())), + 0xBE => {} + sid => { + let at = off + i; + let hdl = usize::from(h[8]); + let flags = h[7]; + // MS-10: '10', and PTS_DTS_flags '01' is forbidden; the sink writes no + // ESCR, ES_rate, trick mode, copy info or CRC. + if h[6] >> 6 != 0b10 || flags & 0xC0 == 0x40 || flags & 0x3E != 0 { + return Err(format!( + "PES header flags at {at}: {:02x} {flags:02x}", + h[6] + )); + } + let mut q = 9; + let (mut pts, mut dts) = (None, None); + if flags & 0x80 != 0 { + let prefix = if flags & 0x40 != 0 { 0b0011 } else { 0b0010 }; + pts = Some(ts(&h[q..], prefix)?); + q += 5; + } + if flags & 0xC0 == 0xC0 { + dts = Some(ts(&h[q..], 0b0001)?); + q += 5; + } + let mut pstd = None; + if flags & 0x01 != 0 { + // MS-11: only the P-STD flag, reserved '111', no extension 2. + if h[q] & 0xEF != 0x0E { + return Err(format!("PES_extension flags at {at}: {:02x}", h[q])); + } + if h[q] & 0x10 != 0 { + if h[q + 1] >> 6 != 0b01 { + return Err(format!("P-STD '01' at {at}")); + } + pstd = Some(( + h[q + 1] & 0x20 != 0, + u16::from(h[q + 1] & 0x1F) << 8 | u16::from(h[q + 2]), + )); + q += 2; + } + q += 1; + } + // MS-13: PES_header_data_length covers the fields and at most 32 + // stuffing bytes of 0xFF. + let stuffing = (9 + hdl) + .checked_sub(q) + .ok_or(format!("PES_header_data_length {hdl} short at {at}"))?; + if stuffing > 32 || h[q..9 + hdl].iter().any(|&b| b != 0xFF) { + return Err(format!("PES header stuffing at {at}: {stuffing} bytes")); + } + let data_off = off + i + 9 + hdl; + let payload = &pk[i + 9 + hdl..end]; + let (key, hdr) = if sid == pack::PRIVATE_STREAM_1 { + let sub = payload[0]; + let n = match sub { + 0x80..=0x8F => 4, + 0xA0..=0xA7 => 7, + _ => 1, + }; + ((sid, Some(sub)), n) + } else { + ((sid, None), 0) + }; + p.pes.push(Pes { + pack: pi, + key, + pts, + dts, + pstd, + data_off, + es_off: data_off + hdr, + sub_hdr: payload[..hdr].to_vec(), + es: payload[hdr..].to_vec(), + end: off + end, + }); + } + } + i = end; + } + off += pack::PACK_BYTES; + } + Ok(p) +} + +/// Arrival time (27 MHz, floor) of absolute byte `x` of pack `k` (MS-4 eq. 2-21). +pub(super) fn arrival(p: &Parsed, k: usize, x: usize) -> i128 { + let pk = &p.packs[k]; + let i0 = (pk.off + pack::SCR_BASE_LAST_BYTE) as i128; + let d = x as i128 - i0; + i128::from(pk.scr) + (d * 540_000).div_euclid(i128::from(pk.rate)) +} + +/// One stream's access units, as the fixture knows them: `(size, mark)`. +pub(super) type Aus = BTreeMap>; + +/// What the replay found beyond hard failures. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct Found { + pub late_aus: u64, + pub pts_gaps: u64, +} + +/// Replay `p` through the P-STD. Hard errors are structural and normative failures that no +/// counter excuses; `Found` holds what the sink must have counted. +pub(super) fn replay(p: &Parsed, aus: &Aus) -> Result { + // MS-21 §2.7.8 / MS-7: one system header, in the first pack, each stream bound once. + let [(0, sh)] = &p.system_headers[..] else { + return Err(format!( + "system headers: {:?}", + p.system_headers.iter().map(|s| s.0).collect::>() + )); + }; + let mut bounds: BTreeMap = BTreeMap::new(); + for e in sh[12..].chunks(3) { + if bounds + .insert( + e[0], + ( + e[1] & 0x20 != 0, + u16::from(e[1] & 0x1F) << 8 | u16::from(e[2]), + ), + ) + .is_some() + { + return Err(format!("stream {:#04x} bound twice (MS-7)", e[0])); + } + } + let rate_bound = + (u32::from(sh[6]) & 0x7F) << 15 | u32::from(sh[7]) << 7 | u32::from(sh[8]) >> 1; + // MS-8/MS-9: one map in the first pack, CRC_32 zero residue, length ≤ 1018. + let [(0, psm)] = &p.psms[..] else { + return Err("the program stream map is not (only) in the first pack".into()); + }; + if crc32(psm) != 0 || psm.len() - 6 > pack::MAX_PSM_LENGTH { + return Err("PSM CRC_32 or length".into()); + } + if !p.end_code { + return Err("no MPEG_program_end_code".into()); + } + let mut found = Found::default(); + // MS-17, MS-4: SCR spacing ≤ 0.7 s; packs never overlap. + for w in p.packs.windows(2) { + if w[1].scr.saturating_sub(w[0].scr) > super::pstd::MAX_SCR_GAP27 { + return Err(format!("SCR gap {} (MS-17)", w[1].scr - w[0].scr)); + } + if w[0].rate > rate_bound || w[1].rate > rate_bound { + return Err("program_mux_rate above rate_bound (MS-6)".into()); + } + } + for k in 0..p.packs.len().saturating_sub(1) { + let last = arrival(p, k, p.packs[k].off + pack::PACK_BYTES - 1); + let first = arrival(p, k + 1, p.packs[k + 1].off); + if last > first { + return Err(format!("pack {k} overlaps the next (MS-4)")); + } + } + // Per stream: P-STD fields, timestamps and AU bytes. + let keys: std::collections::BTreeSet = p.pes.iter().map(|x| x.key).collect(); + // Buffer events: (time, buffer stream_id, +bytes / −bytes). + let mut events: Vec<(i128, u8, i64, i128)> = Vec::new(); + // Design §2.4 (MPG3-7): an AU larger than its buffer can never fit; it is late by + // definition and overflows Bn while resident, `(stream_id, first byte, decode)`. + let mut oversize: Vec<(u8, i128, i128)> = Vec::new(); + for key in &keys { + let pes: Vec<&Pes> = p.pes.iter().filter(|x| x.key == *key).collect(); + let bound = bounds + .get(&key.0) + .ok_or(format!("{key:?} has no system-header bound (MS-7)"))?; + match pes[0].pstd { + // MS-21 §2.7.7, MS-13: the first PES states the buffer; scale 0 audio, 1 video. + Some(f) if f == *bound => {} + other => { + return Err(format!( + "{key:?} first PES P-STD fields {other:?} vs bound {bound:?}" + )); + } + } + let audio = (0xC0..=0xDF).contains(&key.0); + if (audio && bound.0) || (key.0 >= 0xE0 && !bound.0) { + return Err(format!("{key:?} P-STD scale (MS-13)")); + } + let mut last_pts: Option = None; + let mut last_dts: Option = None; + for x in &pes { + if let Some(d) = x.dts { + // MS-19: DTS only with a PTS and only where it differs; monotone, ≤ PTS. + let pts = x.pts.ok_or("DTS without PTS")?; + if d >= pts || last_dts.is_some_and(|l| d <= l) { + return Err(format!( + "{key:?} DTS {d} vs PTS {pts} / previous {last_dts:?}" + )); + } + last_dts = Some(d); + } else if let Some(pts) = x.pts { + last_dts = Some(last_dts.map_or(pts, |l| l.max(pts))); + } + if let Some(pts) = x.pts { + if key.1.is_none_or(|s| !(0x20..=0x3F).contains(&s)) + && last_pts.is_some_and(|l| pts.abs_diff(l) > 63_000) + { + found.pts_gaps += 1; + } + last_pts = Some(pts); + } + } + // AU boundaries: the fixture's sizes, or the first_access_unit_pointer (MS-29). + let es_total: usize = pes.iter().map(|x| x.es.len()).sum(); + let sizes: Vec<(usize, usize)> = match aus.get(key) { + Some(s) => s.clone(), + None => { + let mut pos = 0usize; + let mut abs = Vec::new(); + for x in &pes { + if x.sub_hdr.len() >= 4 && x.sub_hdr[1] > 0 { + let ptr = usize::from(u16::from_be_bytes([x.sub_hdr[2], x.sub_hdr[3]])); + let skip = if key.1.is_some_and(|s| (0xA0..=0xA7).contains(&s)) { + 4 + } else { + 1 + }; + abs.push(pos + ptr - skip); + } else if x.sub_hdr.len() == 1 && x.pts.is_some() { + abs.push(pos); + } + pos += x.es.len(); + } + abs.push(es_total); + abs.windows(2).map(|w| (w[1] - w[0], 0)).collect() + } + }; + if sizes.iter().map(|s| s.0).sum::() != es_total { + return Err(format!( + "{key:?}: ES bytes {es_total} vs AU sizes {}", + sizes.iter().map(|s| s.0).sum::() + )); + } + // Walk bytes: byte j of the stream's ES lives in some PES at a known file offset. + let mut idx = Vec::with_capacity(pes.len()); // (es start, pes) + let mut acc = 0; + for x in &pes { + idx.push(acc); + acc += x.es.len(); + } + let locate = |j: usize| { + let pi = idx.partition_point(|&s| s <= j) - 1; + (pi, pes[pi].es_off + (j - idx[pi])) + }; + let pts_pes: Vec = (0..pes.len()).filter(|&i| pes[i].pts.is_some()).collect(); + if pts_pes.len() != sizes.len() { + return Err(format!( + "{key:?}: {} PTS-bearing PES for {} AUs (MS-19 one PTS per AU)", + pts_pes.len(), + sizes.len() + )); + } + // Each PES's sub-stream header leaves Bn with the AU holding its first ES byte. + let mut au_of_pes_hdr = vec![0i64; sizes.len()]; + { + let mut ends = Vec::with_capacity(sizes.len()); + let mut e = 0; + for s in &sizes { + e += s.0; + ends.push(e); + } + for (pi, x) in pes.iter().enumerate() { + let a = ends + .partition_point(|&end| end <= idx[pi]) + .min(sizes.len() - 1); + au_of_pes_hdr[a] += x.sub_hdr.len() as i64; + } + } + let mut j = 0; + for (a, &(size, mark)) in sizes.iter().enumerate() { + let x = pes[pts_pes[a]]; + // MS-15: the PTS names the AU whose commencement byte is in this PES. + let (cpi, _) = locate(j + mark); + if cpi != pts_pes[a] { + return Err(format!( + "{key:?} AU {a}: PTS in PES {} but it commences in PES {cpi} (MS-15)", + pts_pes[a] + )); + } + // The AU's first byte (a sequence header ahead of the picture) shares that PES, as + // a DVD encoder writes it and PS readers expect; unless no PES of a fresh pack + // reaches the commencement byte from the first (B1). + let fresh = pack::PACK_BYTES + - pack::PACK_HEADER_BYTES + - 9 + - if x.pstd.is_some() { 3 } else { 0 } + - if x.dts.is_some() { 10 } else { 5 } + - x.sub_hdr.len(); + if locate(j).0 != cpi && mark < fresh { + return Err(format!( + "{key:?} AU {a}: first byte in PES {} but PTS in PES {cpi}", + locate(j).0 + )); + } + let dec = i128::from(x.dts.or(x.pts).unwrap()) * 300; + let (fpi, first_off) = locate(j); + let (lpi, last_off) = locate(j + size - 1); + let t_first = arrival(p, pes[fpi].pack, first_off); + let t_last = arrival(p, pes[lpi].pack, last_off); + // MS-16: "less than or equal to one second" delay; complete by tdn(j). + if dec - t_first > 27_000_000 { + return Err(format!( + "{key:?} AU {a}: buffered {} s (MS-16)", + (dec - t_first) as f64 / 27e6 + )); + } + let bs = i64::from(bound.1) * if bound.0 { 1024 } else { 128 }; + let big = (size + x.sub_hdr.len()) as i64 > bs; + if big { + oversize.push((key.0, t_first, dec)); + } + if t_last > dec || big { + found.late_aus += 1; + } + events.push((dec, key.0, -(size as i64) - au_of_pes_hdr[a], 0)); + j += size; + } + for x in &pes { + // Payload bytes (sub-stream header included) enter Bn; checked at the PES's + // first byte against removals up to then (MS-16). + let bytes = (x.end - x.data_off) as i64; + events.push(( + arrival(p, x.pack, x.data_off), + key.0, + bytes, + arrival(p, x.pack, x.end - 1), + )); + } + } + // MS-16: 0 ≤ Fn(t) ≤ BSn, one buffer per stream_id (0xBD shared). + events.sort_by_key(|e| (e.0, e.2 > 0)); + let mut fill: BTreeMap = BTreeMap::new(); + for (t, sid, delta, _) in &events { + let f = fill.entry(*sid).or_default(); + *f += delta; + let (scale, size) = bounds[sid]; + let bs = i64::from(size) * if scale { 1024 } else { 128 }; + let excused = oversize + .iter() + .any(|(s, from, to)| s == sid && from <= t && t <= to); + if *f > bs && !excused { + return Err(format!("B({sid:#04x}) overflows: {f} > {bs} (MS-16)")); + } + } + Ok(found) +} diff --git a/src/mux/mpg/scan.rs b/src/mux/mpg/scan.rs new file mode 100644 index 00000000..983740f6 --- /dev/null +++ b/src/mux/mpg/scan.rs @@ -0,0 +1,488 @@ +//! The `mpg://` source scan (mpg-output-design v5 §4 step 3): the program stream map when +//! present (track order, stream types, ISO 639, the 13818-3 hierarchy, the FMKV tag-0xFA +//! table and palette), and in-band probes in every case. + +use super::pack; +use crate::disc::{ + AudioChannels, AudioStream, Codec, ColorSpace, FrameRate, HdrFormat, LabelPurpose, + LabelQualifier, Resolution, SampleRate, Stream, SubtitleStream, VideoStream, +}; +use crate::mux::ps::{PsDemuxer, PsPacket}; +use std::collections::BTreeMap; + +/// Bytes of each stream's first packets kept for the probes. +const PROBE_BYTES: usize = 64 * 1024; + +type Key = (u8, Option); + +/// What one program stream map says. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Map { + /// `(stream_type, elementary_stream_id, descriptors)` in map order. + pub entries: Vec<(u8, u8, Vec)>, + pub subs: Vec, + pub palette: Option<[[u8; 3]; 16]>, +} + +/// Parse a program stream map (MS-8), `None` unless its CRC_32 checks (MS-9). +pub(crate) fn parse_map(m: &[u8]) -> Option { + // MS-8 syntax (lengths bound every walk below); MS-9 the CRC_32 residue must be zero. + if m.len() < 16 || m[..4] != [0, 0, 1, pack::PSM_ID] || pack::crc32(m) != 0 { + return None; + } + let u16_at = |i: usize| { + m.get(i..i + 2) + .map(|b| usize::from(u16::from_be_bytes([b[0], b[1]]))) + }; + let info_len = u16_at(8)?; + let info = m.get(10..10 + info_len)?; + let mut map = Map::default(); + let mut i = 0; + while i + 2 <= info.len() { + let (tag, len) = (info[i], usize::from(info[i + 1])); + let body = info.get(i + 2..i + 2 + len)?; + // Design §2.2: tag 0xFA, magic FMKV, version, type (1 table, 2 palette). + if tag == pack::FMKV_TAG && body.len() >= 6 && &body[..4] == pack::FMKV_MAGIC { + match body[5] { + pack::FMKV_SUBSTREAMS => { + for r in body[6..].as_chunks::<5>().0 { + map.subs.push(pack::SubStreamInfo { + sub_id: r[0], + lang: [r[1], r[2], r[3]], + forced: r[4] & 1 != 0, + }); + } + } + pack::FMKV_PALETTE if body.len() == 6 + 48 => { + let mut p = [[0u8; 3]; 16]; + for (k, c) in body[6..].as_chunks::<3>().0.iter().enumerate() { + p[k] = [c[0], c[1], c[2]]; + } + map.palette = Some(p); + } + _ => {} + } + } + i += 2 + len; + } + let es_len = u16_at(10 + info_len)?; + let es = m.get(12 + info_len..12 + info_len + es_len)?; + let mut j = 0; + while j + 4 <= es.len() { + let n = usize::from(u16::from_be_bytes([es[j + 2], es[j + 3]])); + map.entries + .push((es[j], es[j + 1], es.get(j + 4..j + 4 + n)?.to_vec())); + j += 4 + n; + } + Some(map) +} + +// Walk descriptors: `(tag, body)`. +fn descriptors(d: &[u8]) -> impl Iterator { + let mut i = 0; + std::iter::from_fn(move || { + let (tag, len) = (*d.get(i)?, usize::from(*d.get(i + 1)?)); + let body = d.get(i + 2..i + 2 + len)?; + i += 2 + len; + Some((tag, body)) + }) +} + +fn lang_of(bytes: [u8; 3]) -> String { + match &bytes { + b"und" => String::new(), + b if b.iter().all(u8::is_ascii_alphabetic) => { + String::from_utf8_lossy(b).to_ascii_lowercase() + } + _ => String::new(), + } +} + +// MPEG-2 (or MPEG-1) video from its sequence header: codec, resolution, frame rate. +fn probe_video(es: &[u8], map_type: Option) -> Option<(Codec, Resolution, FrameRate)> { + let seq = es.windows(4).position(|w| w == [0, 0, 1, 0xB3]); + let ext = es + .windows(5) + .position(|w| w[..4] == [0, 0, 1, 0xB5] && w[4] >> 4 == 1); + let codec = match map_type { + Some(0x01) => Codec::Mpeg1, + Some(0x02) => Codec::Mpeg2, + // J24: other video stream types await the 2013+ text (F8). + Some(_) => return None, + None if seq.is_some() && ext.is_some() => Codec::Mpeg2, + None if seq.is_some() => Codec::Mpeg1, + None => return None, + }; + let (mut res, mut rate) = (Resolution::Unknown, FrameRate::Unknown); + if let Some(h) = seq.and_then(|s| es.get(s + 4..s + 8)) { + let height = (u32::from(h[1] & 0x0F) << 8) | u32::from(h[2]); + let progressive = ext + .and_then(|e| es.get(e + 5)) + .is_none_or(|b| b & 0x08 != 0); + res = match (height, progressive) { + (480, false) => Resolution::R480i, + (576, false) => Resolution::R576i, + (1080, false) => Resolution::R1080i, + (h, _) => Resolution::from_height(h), + }; + rate = match h[3] & 0x0F { + 1 => FrameRate::F23_976, + 2 => FrameRate::F24, + 3 => FrameRate::F25, + 4 => FrameRate::F29_97, + 5 => FrameRate::F30, + 6 => FrameRate::F50, + 7 => FrameRate::F59_94, + 8 => FrameRate::F60, + _ => FrameRate::Unknown, + }; + } + Some((codec, res, rate)) +} + +// Design §4 step 3 "MPEG audio | header + mc_header": Layer II frames from `at` go through +// the M1 channel tracker (a CRC-verified 13818-3 mc_header run), else `None`. +fn mc_channels(es: &[u8], at: usize) -> Option { + use crate::mux::codec::mp2_channels::{ChannelTracker, Header}; + let mut t = ChannelTracker::default(); + let mut pos = at; + while let Some(n) = es + .get(pos..) + .and_then(Header::parse) + .and_then(|h| h.frame_bytes()) + .filter(|&n| n > 4 && pos + n <= es.len()) + { + if let Some(c) = t.observe(&es[pos..pos + n]) { + return Some(AudioChannels::from_count(c)); + } + pos += n; + } + t.finish().map(AudioChannels::from_count) +} + +// 11172-3 / 13818-3 frame header: codec, channels, sample rate. +fn probe_mpeg_audio(es: &[u8]) -> (Codec, AudioChannels, SampleRate) { + let Some(at) = es + .windows(2) + .position(|w| w[0] == 0xFF && w[1] & 0xE0 == 0xE0) + else { + return (Codec::Mp2, AudioChannels::Unknown, SampleRate::Unknown); + }; + let h = &es[at..(at + 4).min(es.len())]; + let codec = if h[1] >> 1 & 3 == 1 { + Codec::Mp3 + } else { + Codec::Mp2 + }; + let (channels, hz) = match h { + [_, b1, b2, b3] => { + let rates: [u32; 3] = if b1 & 0x08 != 0 { + [44_100, 48_000, 32_000] + } else { + [22_050, 24_000, 16_000] + }; + let hz = rates.get(usize::from(b2 >> 2 & 3)).copied().unwrap_or(0); + let ch = if b3 >> 6 == 3 { + AudioChannels::Mono + } else { + AudioChannels::Stereo + }; + (ch, hz) + } + _ => (AudioChannels::Unknown, 0), + }; + let channels = match codec { + Codec::Mp2 => mc_channels(es, at).unwrap_or(channels), + _ => channels, + }; + (codec, channels, SampleRate::from_hz(hz)) +} + +// The 3-byte DVD LPCM header (after the private header the demuxer strips). +fn probe_lpcm(es: &[u8]) -> (AudioChannels, SampleRate) { + match es { + [_, b, ..] => { + let hz = [48_000, 96_000, 44_100, 32_000][usize::from(b >> 4 & 3)]; + ( + AudioChannels::from_count((b & 7) + 1), + SampleRate::from_hz(hz), + ) + } + _ => (AudioChannels::Unknown, SampleRate::Unknown), + } +} + +// The VobSub `.idx` text the DvdSub parser takes (as `format_palette` writes it), from RGB. +fn idx_text(palette: &[[u8; 3]; 16], res: Resolution) -> Vec { + let mut out = String::new(); + if let Some((w, h)) = res.pixels() { + out.push_str(&format!("size: {w}x{h}\n")); + } + let parts: Vec = palette + .iter() + .map(|[r, g, b]| format!("{r:02x}{g:02x}{b:02x}")) + .collect(); + out.push_str(&format!("palette: {}\n", parts.join(", "))); + out.into_bytes() +} + +fn audio( + pid: u16, + codec: Codec, + channels: AudioChannels, + rate: SampleRate, + language: String, + label: String, +) -> Stream { + Stream::Audio(AudioStream { + pid, + codec, + channels, + language, + sample_rate: rate, + secondary: false, + purpose: LabelPurpose::Normal, + label, + }) +} + +/// [`scan`]'s streams alone. +#[cfg(test)] +pub(crate) fn scan_streams(head: &[u8]) -> Option> { + scan(head).map(|s| s.streams) +} + +/// What the scan found: the streams, and the one video `stream_id` carried. +pub(crate) struct Scan { + pub streams: Vec, + /// Only this video `stream_id` is routed; others are left out (one video track). + pub video_id: Option, +} + +// `(hierarchy_type, hierarchy_layer_index, hierarchy_embedded_layer_index)` (MS-23). +fn hierarchy(desc: &[u8]) -> Option<(u8, u8, u8)> { + descriptors(desc) + .find(|(t, b)| *t == 4 && b.len() >= 4) + .map(|(_, b)| (b[0] & 0x0F, b[1] & 0x3F, b[2] & 0x3F)) +} + +/// Streams of the program stream in `head`, in map order (else id order), with the +/// `pid`s the DVD demux routes (`PsPacket::dvd_pid`), and the chosen video `stream_id`. +/// `None` when `head` holds no pack. +pub(crate) fn scan(head: &[u8]) -> Option { + if !head.windows(4).any(|w| w == [0, 0, 1, 0xBA]) { + return None; + } + let mut d = PsDemuxer::new(); + let mut seen: BTreeMap> = BTreeMap::new(); + let packets: Vec = d.feed(head).into_iter().chain(d.flush()).collect(); + for p in packets.iter().filter(|p| p.dvd_pid().is_some()) { + let e = seen.entry((p.stream_id, p.sub_stream_id)).or_default(); + if e.len() < PROBE_BYTES { + e.extend_from_slice(&p.data); + } + } + let map = d.psm().and_then(parse_map); + if d.psm().is_some() && map.is_none() { + tracing::warn!(target: "mux", "mpg: program stream map fails its CRC_32; using in-band probes only"); + } + // Track order: the map's entries (0xBD expanded by the FMKV table), then anything seen + // that it does not name, in id order (design §4 step 3). + let mut order: Vec = Vec::new(); + let mut map_type: BTreeMap)> = BTreeMap::new(); + if let Some(m) = &map { + for (ty, id, desc) in &m.entries { + map_type.insert(*id, (*ty, desc.clone())); + if *id == pack::PRIVATE_STREAM_1 { + order.extend( + m.subs + .iter() + .map(|s| (pack::PRIVATE_STREAM_1, Some(s.sub_id))), + ); + } else { + order.push((*id, None)); + } + } + } + let mut rest: Vec = seen + .keys() + .filter(|k| !order.contains(k)) + .copied() + .collect(); + // Without a map: video, MPEG audio, then private sub-streams (audio before subpictures). + rest.sort_by_key(|&(id, sub)| match (id, sub) { + (0xE0..=0xEF, _) => (0, id, 0), + (0xC0..=0xDF, _) => (1, id, 0), + (_, Some(s @ 0x20..=0x3F)) => (3, id, s), + (_, s) => (2, id, s.unwrap_or(0)), + }); + order.extend(rest); + + let fmkv = |sub: u8| { + map.as_ref() + .and_then(|m| m.subs.iter().find(|s| s.sub_id == sub).copied()) + }; + let empty = Vec::new(); + let mut streams: Vec = Vec::new(); + let mut video_res = Resolution::Unknown; + let mut video_id = None; + // Extensions resolved against their base once every base is known: `(index, base id)`. + let mut exts: Vec<(usize, u8)> = Vec::new(); + for key in order { + let es = seen.get(&key).unwrap_or(&empty); + let (ty, desc) = map_type + .get(&key.0) + .map_or((None, &empty), |(t, d)| (Some(*t), d)); + let lang = descriptors(desc) + .find(|(t, b)| *t == 10 && b.len() >= 3) + .map(|(_, b)| lang_of([b[0], b[1], b[2]])) + .unwrap_or_default(); + match key { + (0xE0..=0xEF, _) => { + if video_id.is_some() { + tracing::warn!(target: "mux", stream_id = key.0, "mpg: a second video stream; left out"); + continue; + } + let Some((codec, res, rate)) = probe_video(es, ty) else { + tracing::warn!(target: "mux", stream_id = key.0, "mpg: video stream with no MPEG-1/2 sequence header in the head; left out"); + continue; + }; + video_res = res; + video_id = Some(key.0); + streams.push(Stream::Video(VideoStream { + pid: crate::mux::ps::DVD_VIDEO_PID, + codec, + resolution: res, + frame_rate: rate, + hdr: HdrFormat::Sdr, + color_space: if res.pixels().is_some_and(|(_, h)| h == 576) { + ColorSpace::Bt470bg + } else { + ColorSpace::Bt709 + }, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + })); + } + (id @ 0xD0..=0xD7, None) => { + // With a map, the hierarchy descriptor decides (type 5, MS-23) and names the + // base by hierarchy_embedded_layer_index; without one, the first sync word: + // 13818-3 ext_syncword 0x7FF is an extension (base 0xC0|n), 0xFFF audio. + let base = match &map { + Some(m) => hierarchy(desc) + .filter(|h| h.0 == pack::HIERARCHY_EXTENSION) + .map(|(_, _, embedded)| { + m.entries + .iter() + .find(|(_, _, d)| hierarchy(d).is_some_and(|h| h.1 == embedded)) + .map(|e| e.1) + }), + None => es + .windows(2) + .find(|w| (w[0] == 0x7F || w[0] == 0xFF) && w[1] & 0xF0 == 0xF0) + .filter(|w| w[0] == 0x7F) + .map(|_| Some(0xC0 | (id & 7))), + }; + if let Some(base) = base { + // Paired in a second pass, when every base is known. + exts.push((streams.len(), base.unwrap_or(0))); + streams.push(audio( + u16::from(id), + Codec::Mp2, + AudioChannels::Unknown, + SampleRate::Unknown, + String::new(), + crate::disc::MP2_EXTENSION_LABEL.into(), + )); + } else { + let (codec, ch, rate) = probe_mpeg_audio(es); + streams.push(audio(u16::from(id), codec, ch, rate, lang, String::new())); + } + } + (id @ 0xC0..=0xC7, None) => { + let (codec, ch, rate) = probe_mpeg_audio(es); + streams.push(audio(u16::from(id), codec, ch, rate, lang, String::new())); + } + (pack::PRIVATE_STREAM_1, Some(sub)) => { + let info = fmkv(sub); + let lang = info.map(|i| lang_of(i.lang)).unwrap_or_default(); + let pid = 0xBD00 | u16::from(sub); + let s = match sub { + 0x80..=0x87 => audio( + pid, + Codec::Ac3, + AudioChannels::Unknown, + SampleRate::S48, + lang, + String::new(), + ), + 0x88..=0x8F => audio( + pid, + Codec::Dts, + AudioChannels::Unknown, + SampleRate::S48, + lang, + String::new(), + ), + 0xA0..=0xA7 => { + let (ch, rate) = probe_lpcm(es); + audio(pid, Codec::Lpcm, ch, rate, lang, String::new()) + } + 0xC0..=0xCF => audio( + pid, + Codec::Ac3Plus, + AudioChannels::Unknown, + SampleRate::S48, + lang, + String::new(), + ), + 0x20..=0x3F => Stream::Subtitle(SubtitleStream { + pid: u16::from(sub), + codec: Codec::DvdSub, + language: lang, + forced: info.is_some_and(|i| i.forced), + qualifier: LabelQualifier::None, + codec_data: map + .as_ref() + .and_then(|m| m.palette.as_ref()) + .map(|p| idx_text(p, video_res)), + }), + _ => continue, + }; + streams.push(s); + } + _ => {} + } + } + // The IR pairs an extension 0xD0|n with base 0xC0|n (M1); a map pairing anything else, + // or a base not carried, leaves the extension out. + let mut drop = Vec::new(); + for (i, base) in exts { + let Stream::Audio(ext) = &streams[i] else { + continue; + }; + let id = ext.pid as u8; + let b = streams.iter().find_map(|s| match s { + Stream::Audio(a) if a.pid == u16::from(base) && !a.is_mp2_extension() => Some(a), + _ => None, + }); + match b { + Some(b) if base == 0xC0 | (id & 7) => { + let (channels, rate, lang) = (b.channels, b.sample_rate, b.language.clone()); + if let Stream::Audio(e) = &mut streams[i] { + (e.channels, e.sample_rate, e.language) = (channels, rate, lang); + } + } + _ => { + tracing::warn!(target: "mux", stream_id = id, "mpg: 13818-3 extension with no base stream it can pair with; left out"); + drop.push(i); + } + } + } + for i in drop.into_iter().rev() { + streams.remove(i); + } + (!streams.is_empty()).then_some(Scan { streams, video_id }) +} diff --git a/src/mux/mpg/tests.rs b/src/mux/mpg/tests.rs new file mode 100644 index 00000000..3c56d7a6 --- /dev/null +++ b/src/mux/mpg/tests.rs @@ -0,0 +1,2084 @@ +//! `mpg://` sink tests (mpg-output-design v5 §7): synthetic DVD-like titles through the +//! sink, then an independent parse and P-STD replay, ES and PTS round trips, and a read +//! back through the crate's own program stream demuxer. + +use super::replay::{self, Aus, Key, Parsed}; +use super::*; +use crate::disc::{ + AudioChannels, AudioStream, ColorSpace, FrameRate, HdrFormat, LabelPurpose, LabelQualifier, + Resolution, SampleRate, SubtitleStream, VideoStream, +}; +use crate::mux::codec::CodecParser; +use crate::mux::decode_ts::test_es; +use std::collections::BTreeMap; + +const MS: i64 = 1_000_000; + +// A 13818-2 sequence header (4:3, bit_rate 0x3FFFF) with its sequence_extension (MP@ML). +fn seq_header(w: u16, h: u16, frc: u8, vbv: u16, low_delay: bool) -> Vec { + let bits: u64 = (u64::from(w) << 52) + | (u64::from(h) << 40) + | (2 << 36) + | (u64::from(frc) << 32) + | (0x3FFFF << 14) + | (1 << 13) + | (u64::from(vbv & 0x3FF) << 3); + let mut v = vec![0, 0, 1, 0xB3]; + v.extend_from_slice(&bits.to_be_bytes()); + // extension id 1, profile_and_level 0x48, progressive 0, 4:2:0, no size/rate + // extensions, marker, vbv extension 0, low_delay, frame rate extensions 0. + v.extend_from_slice(&[ + 0, + 0, + 1, + 0xB5, + 0x14, + 0x82, + 0x00, + 0x01, + 0x00, + u8::from(low_delay) << 7, + ]); + v +} + +// group_of_pictures_header: time code 0 (marker set), closed_gop 1 (no leading B pictures). +fn gop_header() -> Vec { + vec![0, 0, 1, 0xB8, 0x00, 0x08, 0x00, 0x40] +} + +#[derive(Clone)] +struct Opts { + secs: i64, + audio_lead_ms: i64, + /// Display milliseconds with no video (a damage gap), `(from, len)`. + gap: Option<(i64, i64)>, + i_size: usize, + spu_tracks: usize, + lpcm: bool, +} + +impl Default for Opts { + fn default() -> Self { + Self { + secs: 6, + audio_lead_ms: 0, + gap: None, + i_size: 40_000, + spu_tracks: 1, + lpcm: true, + } + } +} + +struct Fx { + title: DiscTitle, + frames: Vec, + /// Input ES per track, frame by frame. + input: BTreeMap)>>, + aus: Aus, +} + +const VIDEO_START_NS: i64 = 500 * MS; + +fn fixture(o: &Opts) -> Fx { + let mut streams = vec![DiscStream::Video(VideoStream { + pid: 0xE0, + codec: Codec::Mpeg2, + resolution: Resolution::R576i, + frame_rate: FrameRate::F25, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt470bg, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + })]; + let audio = |pid: u16, codec: Codec, lang: &str, label: &str| { + DiscStream::Audio(AudioStream { + pid, + codec, + channels: AudioChannels::Stereo, + language: lang.into(), + sample_rate: SampleRate::S48, + secondary: false, + purpose: LabelPurpose::Normal, + label: label.into(), + }) + }; + streams.push(audio(0x00C0, Codec::Mp2, "eng", "")); + streams.push(audio( + 0x00D0, + Codec::Mp2, + "eng", + crate::disc::MP2_EXTENSION_LABEL, + )); + streams.push(audio(0xBD80, Codec::Ac3, "fra", "")); + streams.push(audio(0xBD88, Codec::Dts, "deu", "")); + if o.lpcm { + streams.push(audio(0xBDA0, Codec::Lpcm, "spa", "")); + streams.push(audio(0xBDA1, Codec::Lpcm, "ita", "")); + } + let palette = crate::mux::codec::dvdsub::format_palette(&[[0, 0x80, 0x80, 0x80]; 16], 720, 576); + for k in 0..o.spu_tracks { + streams.push(DiscStream::Subtitle(SubtitleStream { + pid: 0x20 + k as u16, + codec: Codec::DvdSub, + language: if k == 0 { "eng".into() } else { "nld".into() }, + forced: k == 0, + qualifier: LabelQualifier::None, + codec_data: Some(palette.clone()), + })); + } + let title = DiscTitle { + codec_privates: vec![None; streams.len()], + streams, + ..DiscTitle::empty() + }; + let mut ev: Vec<(i64, usize, PesFrame)> = Vec::new(); + let mut input: BTreeMap)>> = BTreeMap::new(); + let mut aus: Aus = BTreeMap::new(); + let mut add = |ev: &mut Vec<(i64, usize, PesFrame)>, + key_ns: i64, + track: usize, + pts: i64, + key: bool, + data: Vec, + k: Option<(Key, usize)>| { + input.entry(track).or_default().push((pts, data.clone())); + if let Some((kk, mark)) = k { + aus.entry(kk).or_default().push((data.len(), mark)); + } + let seq = ev.len(); + let _ = seq; + ev.push(( + key_ns, + track, + PesFrame { + track, + pts, + keyframe: key, + data, + duration_ns: None, + discard_padding_ns: 0, + source: None, + coding: None, + }, + )); + }; + // Video: closed GOPs of 10, decode I0 P3 B1 B2 P6 B4 B5 P9 B7 B8, 25 fps. + let order: [(usize, u8); 10] = [ + (0, 1), + (3, 2), + (1, 3), + (2, 3), + (6, 2), + (4, 3), + (5, 3), + (9, 2), + (7, 3), + (8, 3), + ]; + let gops = o.secs * 25 / 10; + for g in 0..gops { + let display0 = g * 10; + let t0 = display0 * 40; + if o.gap + .is_some_and(|(from, len)| t0 + 400 > from && t0 < from + len) + { + continue; + } + for (k, &(d, coding)) in order.iter().enumerate() { + let mut data = Vec::new(); + if k == 0 { + data.extend(seq_header(720, 576, 3, 112, false)); + data.extend(gop_header()); + } + let mark = data.len(); + let mut pic = test_es::mpeg2_pic(coding, 3); + // temporal_reference (10 bits): the picture's display index in its GOP (13818-2). + pic[4] = (d >> 2) as u8; + pic[5] = ((d as u8 & 3) << 6) | (coding << 3); + data.extend(pic); + let size = match coding { + 1 => o.i_size, + 2 => 15_000, + _ => 6_000, + }; + data.resize(size.max(data.len()), 0x55); + let pts = VIDEO_START_NS + (display0 + d as i64) * 40 * MS; + let arrive = VIDEO_START_NS + (display0 + k as i64 - 1) * 40 * MS; + add( + &mut ev, + arrive, + 0, + pts, + coding == 1, + data, + Some(((0xE0, None), mark)), + ); + } + } + let a0 = VIDEO_START_NS - o.audio_lead_ms * MS; + let end = VIDEO_START_NS + o.secs * 1_000 * MS; + // MPEG-1 Layer II, 24 ms, and its 13818-3 extension frame with the same PTS (MS-20). + let mut t = a0; + while t < end { + // MPEG-1 Layer II, 256 kbit/s, 48 kHz, stereo, no CRC: 144·256000/48000 = 768 B. + let mut f = vec![0xFF, 0xFD, 0xC4, 0x00]; + f.resize(768, 0x33); + add(&mut ev, t, 1, t, true, f, Some(((0xC0, None), 0))); + let mut x = vec![0x7F, 0xF0, 0x12, 0x34]; + x.resize(256, 0x44); + add(&mut ev, t, 2, t, true, x, Some(((0xD0, None), 0))); + t += 24 * MS; + } + // AC-3 32 ms / 1792 B; DTS 512 samples / 2012 B. + let mut k = 0i64; + while a0 + k * 32 * MS < end { + // A decodable AC-3 syncframe: 48 kHz, frmsizecod 30 (448 kbit/s, 1792 B), bsid 8, CRC. + let mut f = vec![0x0B, 0x77, 0, 0, 0x1E, 0x40]; + f.resize(1_792, 0x66); + let c = crate::mux::codec::crc::crc16_ansi(&f[2..1_790]); + f[1_790..].copy_from_slice(&c.to_be_bytes()); + let t = a0 + k * 32 * MS; + add(&mut ev, t, 3, t, true, f, Some(((0xBD, Some(0x80)), 0))); + k += 1; + } + let mut k = 0i64; + while a0 + k * 512 * 1_000_000_000 / 48_000 < end { + // A DTS core frame: normal, 512 samples (NBLKS 15), 48 kHz, FSIZE 2011. + let mut f = vec![0u8; 2_012]; + f[..4].copy_from_slice(&[0x7F, 0xFE, 0x80, 0x01]); + let fsize = 2_011usize; + f[4] = 0x80 | (31 << 2); + f[5] = (15 << 2) | ((fsize >> 12) & 3) as u8; + f[6] = (fsize >> 4) as u8; + f[7] = ((fsize & 0x0F) << 4) as u8; + f[8] = 13 << 2; + let t = a0 + k * 512 * 1_000_000_000 / 48_000; + add(&mut ev, t, 4, t, true, f, Some(((0xBD, Some(0x88)), 0))); + k += 1; + } + let mut next = 5; + if o.lpcm { + // LPCM IR: 10 ms of 24-bit stereo; track 5 from a 16-bit source, 6 truly 24-bit. + let mut k = 0i64; + while a0 + k * 10 * MS < end { + let t = a0 + k * 10 * MS; + for (track, low) in [(5, 0u8), (6, 0x21u8)] { + let mut f = Vec::with_capacity(480 * 6); + for s in 0..960u32 { + f.extend_from_slice(&[(s >> 3) as u8, (s * 7) as u8, low]); + } + add(&mut ev, t, track, t, true, f, None); + } + k += 1; + } + next = 7; + } + for sp in 0..o.spu_tracks { + let mut t = VIDEO_START_NS + 200 * MS + sp as i64 * 7 * MS; + while t < end { + let mut f = vec![0x0B, 0xB8]; + f.resize(3_000, 0x11); + add( + &mut ev, + t, + next + sp, + t, + true, + f, + Some(((0xBD, Some(0x20 + sp as u8)), 0)), + ); + t += 2_000 * MS; + } + } + ev.sort_by_key(|e| (e.0, e.1)); + Fx { + title, + frames: ev.into_iter().map(|e| e.2).collect(), + input, + aus, + } +} + +struct Run { + out: Vec, + counters: MpgCounters, + parsed: Parsed, +} + +fn run(fx: &Fx) -> Run { + let mut sink = MpgSink::create(Vec::new(), &fx.title).expect("create"); + for f in &fx.frames { + sink.write(f).expect("write"); + } + sink.finish().expect("finish"); + let counters = sink.counters(); + let out = sink.mux.take().expect("mux").into_writer(); + let parsed = replay::parse(&out).expect("parses as 2048-byte packs"); + Run { + out, + counters, + parsed, + } +} + +fn es_of(p: &Parsed, key: Key) -> Vec { + p.pes + .iter() + .filter(|x| x.key == key) + .flat_map(|x| x.es.iter().copied()) + .collect() +} + +// The replay finds exactly what the sink counted (design §7). +fn assert_replays(r: &Run, fx: &Fx) { + let found = replay::replay(&r.parsed, &fx.aus).unwrap_or_else(|e| panic!("replay: {e}")); + assert_eq!( + (found.late_aus, found.pts_gaps), + (r.counters.pstd.late_aus, r.counters.pstd.pts_gaps), + "replayed {found:?} vs counted {:?}", + r.counters + ); +} + +// B1: an empty frame (a Matroska empty Block) is no AU: it never wedges its stream, and +// every frame after it is written. +#[test] +fn an_empty_frame_never_wedges_its_stream() { + let mut fx = fixture(&Opts { + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + // An empty AC-3 frame, and an empty video frame after the first keyframe. + for (track, key) in [(3, true), (0, false)] { + let at = fx + .frames + .iter() + .position(|f| f.track == track && f.keyframe == key) + .unwrap(); + let mut e = fx.frames[at].clone(); + e.data.clear(); + fx.frames.insert(at + 1, e); + } + let r = run(&fx); + for (track, key) in [(0usize, (0xE0u8, None)), (3, (0xBD, Some(0x80u8)))] { + let want: Vec = fx.input[&track] + .iter() + .flat_map(|f| f.1.iter().copied()) + .collect(); + assert_eq!(es_of(&r.parsed, key).len(), want.len(), "{key:?} ES bytes"); + } + assert_replays(&r, &fx); +} + +// B1: user data ahead of the picture start code puts the commencement byte past any PES of +// a fresh pack; the AU is still written, its PTS in the PES that holds the picture start. +#[test] +fn a_picture_start_past_one_pack_is_written() { + let mut fx = fixture(&Opts { + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + for f in fx.frames.iter_mut().filter(|f| f.track == 0 && f.keyframe) { + let at = replay::picture_start(&f.data); + let mut user = vec![0, 0, 1, 0xB2]; + user.resize(3_000, 0x5A); + f.data.splice(at..at, user); + } + let fx = rebuild_aus(fx); + let r = run(&fx); + let want: Vec = fx.input[&0] + .iter() + .flat_map(|f| f.1.iter().copied()) + .collect(); + assert_eq!(es_of(&r.parsed, (0xE0, None)), want, "video ES bytes"); + assert_replays(&r, &fx); +} + +// Design §2.3 pack fill: 1-5 spare bytes go as PES-header stuffing on the last PES (MS-13 +// "No more than 32 stuffing bytes"), never pack stuffing: a stuffed pack header moves byte +// 0x14 off the first PES's flags, where CSS scrambling detection reads a VOB pack. +#[test] +fn no_pack_header_carries_stuffing() { + for o in [ + Opts::default(), + Opts { + spu_tracks: 12, + ..Opts::default() + }, + Opts { + i_size: 180_000, + ..Opts::default() + }, + ] { + let fx = fixture(&o); + let r = run(&fx); + assert_replays(&r, &fx); + for pk in r.out.as_chunks::<{ pack::PACK_BYTES }>().0 { + assert_eq!(pk[13] & 7, 0, "pack_stuffing_length"); + assert!( + !crate::css::is_scrambled_pack(pk), + "a clear pack reads as scrambled" + ); + } + } +} + +#[test] +fn a_dvd_like_title_replays_clean_with_nothing_counted() { + let fx = fixture(&Opts::default()); + let r = run(&fx); + assert_replays(&r, &fx); + assert_eq!( + r.counters, + MpgCounters::default(), + "a clean title counts nothing" + ); +} + +// Design §2.4 (MPG3-7): an AU larger than its buffer takes the late path rather than +// deadlocking; it is counted in pstd_late_aus, and the replay allows exactly that count. +#[test] +fn an_au_larger_than_its_buffer_is_counted_late() { + let fx = fixture(&Opts { + i_size: 300_000, + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + let r = run(&fx); + assert_replays(&r, &fx); + assert!(r.counters.pstd.late_aus >= 15, "{:?}", r.counters); +} + +// J16/§2.3: ES bytes kept, PTS exact modulo one integer-tick origin. +#[test] +fn es_bytes_and_pts_round_trip_exactly() { + let fx = fixture(&Opts::default()); + let r = run(&fx); + let keys: [(usize, Key); 6] = [ + (0, (0xE0, None)), + (1, (0xC0, None)), + (2, (0xD0, None)), + (3, (0xBD, Some(0x80))), + (4, (0xBD, Some(0x88))), + (7, (0xBD, Some(0x20))), + ]; + let mut offset = None; + for (track, key) in keys { + let want: Vec = fx.input[&track] + .iter() + .flat_map(|f| f.1.iter().copied()) + .collect(); + assert_eq!(es_of(&r.parsed, key), want, "{key:?} ES bytes"); + let pts: Vec = r + .parsed + .pes + .iter() + .filter(|x| x.key == key) + .filter_map(|x| x.pts) + .collect(); + let mut inp: Vec = fx.input[&track].iter().map(|f| ns_to_ticks(f.0)).collect(); + if track == 0 { + // Video PTS arrive in decode order, as written. + assert_eq!(pts.len(), inp.len()); + } + inp.truncate(pts.len()); + for (o, i) in pts.iter().zip(&inp) { + let d = *o as i64 - i; + assert_eq!( + *offset.get_or_insert(d), + d, + "{key:?}: one constant tick offset" + ); + } + } + // LPCM: the DVD re-pack decodes back to the IR exactly (G8). + for (track, sub) in [(5usize, 0xA0u8), (6, 0xA1)] { + let mut parser = crate::mux::codec::lpcm::LpcmParser::new_dvd(); + let mut ir = Vec::new(); + for x in r.parsed.pes.iter().filter(|x| x.key == (0xBD, Some(sub))) { + let mut data = x.sub_hdr[4..7].to_vec(); + data.extend_from_slice(&x.es); + let pes = crate::mux::ts::PesPacket { + source: None, + pid: 0, + pts: x.pts.map(|p| p as i64), + dts: None, + data, + discontinuity: false, + }; + for f in parser.parse(&pes) { + ir.extend(f.data); + } + } + let want: Vec = fx.input[&track] + .iter() + .flat_map(|f| f.1.iter().copied()) + .collect(); + assert_eq!(ir, want, "LPCM {sub:#x} round trip"); + } +} + +// MS-19 §2.7.5 guard: "A decoding_timestamp (DTS) shall appear … if and only if … the +// decoding time differs from the presentation time." IBBP: I and P carry DTS, B PTS only. +#[test] +fn dts_appears_iff_it_differs_from_the_pts() { + let fx = fixture(&Opts::default()); + let r = run(&fx); + let video: Vec<&replay::Pes> = r + .parsed + .pes + .iter() + .filter(|x| x.key.0 == 0xE0 && x.pts.is_some()) + .collect(); + for (k, x) in video.iter().enumerate() { + let coding = [1, 2, 3, 3, 2, 3, 3, 2, 3, 3][k % 10]; + assert_eq!( + x.dts.is_some(), + coding != 3, + "picture {k} (coding type {coding})" + ); + } + for x in r.parsed.pes.iter().filter(|x| x.key.0 != 0xE0) { + assert!(x.dts.is_none(), "audio and subpictures decode at their PTS"); + } +} + +// MS-20 §2.7.6 guard: "corresponding decoding/presentation units in the two layers shall have +// identical PTS values"; MPG4-7: each extension frame is exactly one whole PES. +#[test] +fn the_extension_shares_its_base_pts_one_frame_per_pes() { + let fx = fixture(&Opts::default()); + let r = run(&fx); + let base: Vec = r + .parsed + .pes + .iter() + .filter(|x| x.key.0 == 0xC0) + .filter_map(|x| x.pts) + .collect(); + let ext: Vec<&replay::Pes> = r.parsed.pes.iter().filter(|x| x.key.0 == 0xD0).collect(); + assert_eq!(ext.len(), fx.input[&2].len(), "one PES per extension frame"); + for (x, b) in ext.iter().zip(&base) { + assert_eq!(x.pts, Some(*b)); + assert_eq!(x.es.len(), 256); + } +} + +// MPG4-6: "each LPCM PES carries whole sample groups". +#[test] +fn every_lpcm_pes_holds_whole_sample_groups() { + let fx = fixture(&Opts::default()); + let r = run(&fx); + for (sub, bits) in [(0xA0u8, 16u8), (0xA1, 24)] { + let unit = super::pstd::lpcm_unit_for_test(2, bits); + for x in r.parsed.pes.iter().filter(|x| x.key == (0xBD, Some(sub))) { + assert_eq!(x.es.len() % unit, 0, "LPCM {sub:#x}: {} B", x.es.len()); + assert_eq!( + x.sub_hdr[5] >> 6, + (bits - 16) / 4, + "quantization in the header" + ); + } + } +} + +// MS-22/MS-23/MS-24 and design §2.2: the map's entries, the FMKV table and palette. +#[test] +fn the_stream_map_describes_every_stream() { + let fx = fixture(&Opts::default()); + let r = run(&fx); + let m = &r.parsed.psms[0].1; + let info_len = usize::from(u16::from_be_bytes([m[8], m[9]])); + let info = &m[10..10 + info_len]; + let es_at = 10 + info_len + 2; + let mut entries = Vec::new(); + let mut i = es_at; + while i < m.len() - 4 { + let n = usize::from(u16::from_be_bytes([m[i + 2], m[i + 3]])); + entries.push((m[i], m[i + 1], m[i + 4..i + 4 + n].to_vec())); + i += 4 + n; + } + assert_eq!(entries[0], (0x02, 0xE0, vec![])); + assert_eq!( + entries[1].0, 0x04, + "a base with its extension is 13818-3 audio" + ); + assert_eq!(entries[1].1, 0xC0); + assert_eq!(&entries[1].2[..6], &[10, 4, b'e', b'n', b'g', 0]); + assert_eq!( + &entries[1].2[6..], + &pack::hierarchy_descriptor(pack::HIERARCHY_BASE, 0, 0) + ); + assert_eq!( + entries[2], + ( + 0x04, + 0xD0, + pack::hierarchy_descriptor(pack::HIERARCHY_EXTENSION, 1, 0).to_vec() + ) + ); + assert_eq!(entries[3], (0x06, 0xBD, vec![])); + assert_eq!(entries.len(), 4); + assert_eq!(info[0], pack::FMKV_TAG); + let rows = &info[8..2 + usize::from(info[1])]; + let table: Vec<(u8, [u8; 3], u8)> = rows + .chunks(5) + .map(|r| (r[0], [r[1], r[2], r[3]], r[4])) + .collect(); + assert_eq!( + table, + vec![ + (0x80, *b"fra", 0), + (0x88, *b"deu", 0), + (0xA0, *b"spa", 0), + (0xA1, *b"ita", 0), + (0x20, *b"eng", 1) + ] + ); + let pal = &info[2 + usize::from(info[1])..]; + assert_eq!(pal[0], pack::FMKV_TAG); + assert_eq!(pal[7], pack::FMKV_PALETTE); + assert_eq!(pal.len(), 2 + 6 + 48); +} + +// Design §0: "No carriable video → MpgNoVideoTrack (E9074)"; J24: H.264 is not carriable yet. +#[test] +fn no_carriable_video_is_e9074() { + let mut fx = fixture(&Opts::default()); + let e = MpgSink::create( + Vec::new(), + &DiscTitle { + streams: fx.title.streams[1..].to_vec(), + ..DiscTitle::empty() + }, + ) + .err() + .expect("audio only"); + assert_eq!( + crate::error::error_code(&e), + Some(crate::error::E_MPG_NO_VIDEO_TRACK) + ); + if let DiscStream::Video(v) = &mut fx.title.streams[0] { + v.codec = Codec::H264; + } + let e = MpgSink::create(Vec::new(), &fx.title) + .err() + .expect("H.264 only"); + assert_eq!( + crate::error::error_code(&e), + Some(crate::error::E_MPG_NO_VIDEO_TRACK) + ); +} + +// MS-17 §2.7.1 guard: a damage gap over 0.7 s is bridged by padding packs; the PTS gap +// itself is the content's, counted (MPG2-11). +#[test] +fn a_damage_gap_is_bridged_with_padding_packs() { + let fx = fixture(&Opts { + gap: Some((2_000, 2_000)), + lpcm: false, + ..Opts::default() + }); + // Audio keeps flowing through the gap here, so blank the audio too for a real silence. + let fx = Fx { + frames: fx + .frames + .into_iter() + .filter(|f| !(f.pts > 2_100 * MS && f.pts < 3_900 * MS)) + .collect(), + ..fx + }; + let fx = rebuild_aus(fx); + let r = run(&fx); + assert_replays(&r, &fx); + assert!(r.counters.pstd.padding_packs > 0, "{:?}", r.counters); + assert!(r.counters.pstd.pts_gaps > 0); +} + +// Recompute the fixture's AU tables after frames were filtered out. +fn rebuild_aus(mut fx: Fx) -> Fx { + let mut aus: Aus = BTreeMap::new(); + let mut input: BTreeMap)>> = BTreeMap::new(); + for f in &fx.frames { + input + .entry(f.track) + .or_default() + .push((f.pts, f.data.clone())); + let key = match f.track { + 0 => Some(((0xE0, None), replay::picture_start(&f.data))), + 1 => Some(((0xC0, None), 0)), + 2 => Some(((0xD0, None), 0)), + 3 => Some(((0xBD, Some(0x80)), 0)), + 4 => Some(((0xBD, Some(0x88)), 0)), + t if matches!(fx.title.streams[t], DiscStream::Subtitle(_)) => { + let DiscStream::Subtitle(s) = &fx.title.streams[t] else { + unreachable!() + }; + Some(((0xBD, Some(s.pid as u8)), 0)) + } + _ => None, + }; + if let Some((k, mark)) = key { + aus.entry(k).or_default().push((f.data.len(), mark)); + } + } + fx.aus = aus; + fx.input = input; + fx +} + +// Design §2.3 (MPG3-8): the origin is the lowest first timestamp over ALL tracks; audio 2 s +// ahead of video maps to 1.5 s and the video keeps its offset. +#[test] +fn audio_leading_video_sets_the_origin() { + let fx = fixture(&Opts { + audio_lead_ms: 2_000, + ..Opts::default() + }); + let r = run(&fx); + assert_replays(&r, &fx); + let first_audio = r + .parsed + .pes + .iter() + .find(|x| x.key.0 == 0xC0) + .and_then(|x| x.pts); + assert_eq!(first_audio, Some(ORIGIN_TICKS as u64)); + let first_video = r + .parsed + .pes + .iter() + .find(|x| x.key.0 == 0xE0) + .and_then(|x| x.pts); + assert_eq!(first_video, Some(ORIGIN_TICKS as u64 + 2 * 90_000)); +} + +// Design §2.4 (MPG2-6d): many subpictures with coincident PTS share the one 0xBD buffer. +#[test] +fn many_subpicture_streams_share_the_private_buffer() { + let fx = fixture(&Opts { + spu_tracks: 12, + ..Opts::default() + }); + let r = run(&fx); + assert_replays(&r, &fx); +} + +// A high-rate burst: 250 KB I pictures raise the per-pack rate, never the delay past 1 s. +#[test] +fn a_high_rate_burst_raises_the_pack_rate() { + let fx = fixture(&Opts { + i_size: 180_000, + ..Opts::default() + }); + let r = run(&fx); + assert_replays(&r, &fx); + assert!( + r.parsed.packs.iter().any(|p| p.rate > R0_SD), + "the rate rose above R0" + ); +} + +// The crate's own demuxer reads the output back, stream for stream. +#[test] +fn the_ps_demuxer_reads_the_output_back() { + let fx = fixture(&Opts::default()); + let r = run(&fx); + let mut d = crate::mux::ps::PsDemuxer::new(); + let mut got: BTreeMap<(u8, Option), Vec> = BTreeMap::new(); + for p in d.feed(&r.out).into_iter().chain(d.flush()) { + got.entry((p.stream_id, p.sub_stream_id)) + .or_default() + .extend(p.data); + } + for (track, key) in [ + (0usize, (0xE0u8, None)), + (1, (0xC0, None)), + (2, (0xD0, None)), + (3, (0xBD, Some(0x80u8))), + (7, (0xBD, Some(0x20))), + ] { + let want: Vec = fx.input[&track] + .iter() + .flat_map(|f| f.1.iter().copied()) + .collect(); + assert_eq!(got.get(&key), Some(&want), "{key:?}"); + } +} + +// Design §2.3 EOF: "A short input with ≤ R units is written, not failed." +#[test] +fn a_short_input_is_written_and_nothing_is_mux_empty() { + let fx = fixture(&Opts::default()); + let mut sink = MpgSink::create(Vec::new(), &fx.title).unwrap(); + sink.write(&fx.frames.iter().find(|f| f.track == 0).unwrap().clone()) + .unwrap(); + sink.finish().unwrap(); + let out = sink.mux.take().unwrap().into_writer(); + let p = replay::parse(&out).unwrap(); + assert_eq!( + p.pes + .iter() + .filter(|x| x.key.0 == 0xE0 && x.pts.is_some()) + .count(), + 1 + ); + let mut empty = MpgSink::create(Vec::new(), &fx.title).unwrap(); + let e = empty.finish().unwrap_err(); + assert_eq!( + crate::error::error_code(&e), + Some(crate::error::E_MUX_EMPTY) + ); +} + +// Video before the first keyframe is dropped (nothing decodes without it), as tsmux does. +#[test] +fn video_before_the_first_keyframe_is_dropped() { + let fx = fixture(&Opts::default()); + let mut sink = MpgSink::create(Vec::new(), &fx.title).unwrap(); + let mut p = fx + .frames + .iter() + .find(|f| f.track == 0 && !f.keyframe) + .unwrap() + .clone(); + p.pts = 0; + sink.write(&p).unwrap(); + for f in &fx.frames { + sink.write(f).unwrap(); + } + sink.finish().unwrap(); + let out = sink.mux.take().unwrap().into_writer(); + let parsed = replay::parse(&out).unwrap(); + let want: Vec = fx.input[&0] + .iter() + .flat_map(|f| f.1.iter().copied()) + .collect(); + assert_eq!(es_of(&parsed, (0xE0, None)), want); +} + +// A Matroska-style source keeps the sequence header in codec_private: it is put back ahead +// of the first picture. +#[test] +fn a_sequence_header_from_codec_private_is_restored() { + let mut fx = fixture(&Opts { + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + let seq = seq_header(720, 576, 3, 112, false); + fx.title.codec_privates[0] = Some(seq.clone()); + for f in fx.frames.iter_mut().filter(|f| f.track == 0 && f.keyframe) { + let at = replay::picture_start(&f.data); + f.data.drain(..at); + } + let r = run(&fx); + let video = es_of(&r.parsed, (0xE0, None)); + assert!(video.starts_with(&seq)); +} + +#[test] +fn mpg_urls_parse() { + let u = crate::mux::resolve::parse_url("mpg:///out/x.mpg"); + assert_eq!(u.scheme(), "mpg"); + assert_eq!(u.path_str(), "/out/x.mpg"); +} + +#[test] +fn the_generic_fit_report_is_the_mpg_plan() { + let fx = fixture(&Opts::default()); + let r = crate::mux::fit::fit_report(&crate::mux::resolve::parse_url("mpg:///o.mpg"), &fx.title); + assert_eq!(r.included, (0..fx.title.streams.len()).collect::>()); + assert!(r.skipped.is_empty()); +} + +// §7 unit: vbv_buffer_size and the palette line parse. +#[test] +fn vbv_and_palette_parse() { + assert_eq!( + vbv_bytes(&seq_header(720, 576, 3, 112, false)), + Some(112 * 2048) + ); + let p = crate::mux::codec::dvdsub::format_palette(&[[0, 0x80, 0x80, 0x80]; 16], 720, 576); + assert!(idx_palette(&p).is_some()); + assert_eq!(idx_palette(b"palette: 000000"), None); +} + +// Design §2.3 (MPG4-1b) and MPG2-11: a slideshow DVD, I at 0 s and P at 5 s with audio between. +// The hold cap releases, the P gets DTS_last + 1 (no duplicate), both counted; the replay +// allows exactly the counted AUs. +#[test] +fn a_slideshow_is_counted_not_refused() { + let base = fixture(&Opts { + secs: 6, + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + let mut frames: Vec = base + .frames + .iter() + .filter(|f| f.track != 0) + .cloned() + .collect(); + let pic = |coding: u8, seq: bool| { + let mut d = if seq { + seq_header(720, 576, 3, 112, false) + } else { + Vec::new() + }; + d.extend(test_es::mpeg2_pic(coding, 3)); + d.resize(20_000, 0x55); + d + }; + let v = |pts: i64, key: bool, data: Vec| PesFrame { + track: 0, + pts, + keyframe: key, + data, + duration_ns: None, + discard_padding_ns: 0, + source: None, + coding: None, + }; + frames.push(v(VIDEO_START_NS, true, pic(1, true))); + frames.push(v(VIDEO_START_NS + 5_000 * MS, false, pic(2, false))); + frames.sort_by_key(|f| f.pts); + let fx = rebuild_aus(Fx { frames, ..base }); + let r = run(&fx); + assert_replays(&r, &fx); + assert!(r.counters.pstd.pts_gaps >= 1, "{:?}", r.counters); + // Design §2.3 "Cap: 1 s of IR timeline or 64 MiB": 5 s of audio behind the held I. + assert_eq!(r.counters.dts.hold_overflow, 1, "{:?}", r.counters); + let dts: Vec> = r + .parsed + .pes + .iter() + .filter(|x| x.key.0 == 0xE0 && x.pts.is_some()) + .map(|x| x.dts) + .collect(); + assert_eq!(dts.len(), 2); +} + +// MPEG-1 video is carried as stream_type 0x01 (MS-22 "ISO/IEC 11172 Video"), the output +// always an MPEG-2 program stream (design §4, J6). +#[test] +fn mpeg1_video_is_stream_type_1() { + let mut fx = fixture(&Opts { + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + if let DiscStream::Video(v) = &mut fx.title.streams[0] { + v.codec = Codec::Mpeg1; + } + let r = run(&fx); + assert_replays(&r, &fx); + let m = &r.parsed.psms[0].1; + let info = usize::from(u16::from_be_bytes([m[8], m[9]])); + assert_eq!(&m[12 + info..14 + info], &[0x01, 0xE0]); +} + +// G8: the LPCM quantization is sticky and never mixes inside one PES (the PES header states +// one depth for its whole payload). +#[test] +fn an_lpcm_depth_rise_never_splits_a_pes() { + let mut fx = fixture(&Opts::default()); + let half = fx.frames.len() / 2; + for f in fx.frames.iter_mut().skip(half).filter(|f| f.track == 5) { + for s in f.data.as_chunks_mut::<3>().0 { + s[2] = 0x81; + } + } + let fx = Fx { + input: BTreeMap::new(), + ..fx + }; + let fx = rebuild_aus(fx); + let r = run(&fx); + assert_replays(&r, &fx); + let bits: Vec = r + .parsed + .pes + .iter() + .filter(|x| x.key == (0xBD, Some(0xA0))) + .map(|x| x.sub_hdr[5] >> 6) + .collect(); + assert_eq!(bits.first(), Some(&0), "16-bit first"); + assert_eq!(bits.last(), Some(&2), "24-bit once needed"); + assert!(bits.windows(2).all(|w| w[0] <= w[1]), "never back down"); +} + +// G8 / §2.3: a 20/24-bit LPCM AU cut on a frame takes that frame's PTS less the carried +// samples' duration, so the output follows the source clock, not a running sample count. +#[test] +fn lpcm_pts_reanchor_on_every_frame() { + let fx = fixture(&Opts { + spu_tracks: 0, + ..Opts::default() + }); + let mut sink = MpgSink::create(Vec::new(), &fx.title).unwrap(); + let out = sink.route[6].unwrap(); + // 477 sample frames stamped every 10 ms (480 frames): a source clock ahead of its samples. + let data: Vec = (0..477 * 6).map(|i| (i as u8) | 1).collect(); + let mut checked = 0; + for k in 0..400i64 { + let rel = k * 900; + let carried = (sink.lpcm[out].carry.len() / 6) as i64; + for au in sink.lpcm_aus(out, rel, &data, false) { + let want = rel - (carried * 90_000 + 24_000) / 48_000; + assert!( + (au.pts - want).abs() <= 1, + "frame {k}: {} vs {want}", + au.pts + ); + checked += 1; + } + } + assert_eq!(checked, 400); +} + +// Design §2.4 step 1 (MPG2-6c): a Matroska source whose audio lags its video in the +// interleave by 3 s is held until the audio catches up; nothing is late. +#[test] +fn a_lagging_audio_interleave_is_waited_for() { + let base = fixture(&Opts { + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + let mut frames = base.frames.clone(); + let key = |f: &PesFrame| { + if f.track == 0 { + f.pts + } else { + f.pts + 3_000 * MS + } + }; + frames.sort_by_key(key); + let fx = rebuild_aus(Fx { frames, ..base }); + let r = run(&fx); + assert_replays(&r, &fx); + assert_eq!(r.counters.pstd.late_aus, 0, "{:?}", r.counters); +} + +// Tracks the plan skips are dropped silently at write (the pre-mux note lists them); an +// extension whose base is not carried is reported once its packets arrive (J23). +#[test] +fn skipped_tracks_are_dropped_and_an_orphan_extension_is_reported() { + let mut fx = fixture(&Opts { + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + fx.title.streams.push(DiscStream::Subtitle(SubtitleStream { + pid: 0x1200, + codec: Codec::Pgs, + language: "eng".into(), + forced: false, + qualifier: LabelQualifier::None, + codec_data: None, + })); + fx.title.codec_privates.push(None); + if let DiscStream::Audio(a) = &mut fx.title.streams[1] { + a.codec = Codec::TrueHd; // the base is not carried, so neither is 0xD0 + } + let mut sink = MpgSink::create(Vec::new(), &fx.title).unwrap(); + for f in &fx.frames { + sink.write(f).unwrap(); + } + sink.write(&PesFrame { + track: 5, + ..fx.frames[0].clone() + }) + .unwrap(); + sink.finish().unwrap(); + assert_eq!(sink.undelivered_streams(), vec![2]); + let out = sink.mux.take().unwrap().into_writer(); + let p = replay::parse(&out).unwrap(); + assert!(p.pes.iter().all(|x| x.key.0 != 0xC0 && x.key.0 != 0xD0)); +} + +// J23: IFO coding mode 3 only declares the extension. One whose packets never arrive holds +// nothing back (no interleave cap, no warning) and is not described; one first seen after +// the origin window is left out and reported, as the map no longer can describe it. +#[test] +fn a_declared_only_extension_is_neither_waited_for_nor_described() { + let base = fixture(&Opts { + secs: 14, + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + let fx = rebuild_aus(Fx { + frames: base + .frames + .iter() + .filter(|f| f.track != 2) + .cloned() + .collect(), + ..base + }); + let r = run(&fx); + assert_eq!( + r.counters, + MpgCounters::default(), + "a clean disc counts nothing" + ); + assert_replays(&r, &fx); + let m = &r.parsed.psms[0].1; + let info = usize::from(u16::from_be_bytes([m[8], m[9]])); + let es = &m[12 + info..m.len() - 4]; + assert!( + !es.windows(2).any(|w| w == [0x04, 0xD0]), + "0xD0 is not mapped" + ); + assert_eq!( + &es[4..6], + &[0x03, 0xC0], + "a base with no extension is 11172 audio" + ); + let sh = &r.parsed.system_headers[0].1; + assert!(sh[12..].chunks(3).all(|e| e[0] != 0xD0), "no 0xD0 bound"); + + // First seen after the origin window: left out, and reported once its packets arrive. + let late = fixture(&Opts { + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + let frames: Vec = late + .frames + .iter() + .filter(|f| f.track != 2 || f.pts > VIDEO_START_NS + 3_000 * MS) + .cloned() + .collect(); + let mut sink = MpgSink::create(Vec::new(), &late.title).unwrap(); + for f in &frames { + sink.write(f).unwrap(); + } + sink.finish().unwrap(); + assert_eq!(sink.undelivered_streams(), vec![2]); + let out = sink.mux.take().unwrap().into_writer(); + assert!( + replay::parse(&out) + .unwrap() + .pes + .iter() + .all(|x| x.key.0 != 0xD0) + ); +} + +// The replayer is not vacuous: tampered output fails it (design §7 "never more"). +#[test] +fn the_replayer_rejects_tampered_streams() { + let fx = fixture(&Opts { + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + let r = run(&fx); + // MS-16: shrink the video bound in the system header (and first PES) to 16 KiB. + let mut p = replay::parse(&r.out).unwrap(); + let sh = &mut p.system_headers[0].1; + let at = sh[12..].chunks(3).position(|e| e[0] == 0xE0).unwrap() * 3 + 12; + sh[at + 1] = 0xE0; + sh[at + 2] = 16; + p.pes.iter_mut().find(|x| x.key.0 == 0xE0).unwrap().pstd = Some((true, 16)); + assert!( + replay::replay(&p, &fx.aus) + .unwrap_err() + .contains("overflows") + ); + // MS-17: an SCR gap over 0.7 s. + let mut p = replay::parse(&r.out).unwrap(); + let n = p.packs.len(); + for k in n / 2..n { + p.packs[k].scr += super::pstd::MAX_SCR_GAP27 + 1; + } + assert!(replay::replay(&p, &fx.aus).unwrap_err().contains("SCR gap")); + // MS-19: a DTS equal to its PTS. + let mut p = replay::parse(&r.out).unwrap(); + let x = p.pes.iter_mut().find(|x| x.dts.is_some()).unwrap(); + x.dts = x.pts; + assert!(replay::replay(&p, &fx.aus).unwrap_err().contains("DTS")); + // MS-2: a pack header marker bit cleared. + let mut out = r.out.clone(); + out[pack::PACK_BYTES + 4] &= !0x04; + assert!(replay::parse(&out).unwrap_err().contains("marker")); + // MS-10: a PTS marker bit cleared in the first video PES. + let first = replay::parse(&r.out).unwrap(); + let v = first.pes.iter().find(|x| x.key.0 == 0xE0).unwrap(); + let at = r.out[..v.data_off] + .windows(4) + .rposition(|w| w == [0, 0, 1, 0xE0]) + .unwrap(); + let mut out = r.out.clone(); + out[at + 9 + 4] &= !0x01; + assert!(replay::parse(&out).unwrap_err().contains("marker")); +} + +// ── L3: `mpg://` as a source (design §4) ──────────────────────────────────────────── + +fn temp_path(tag: &str) -> std::path::PathBuf { + static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::env::temp_dir().join(format!("fmkv-mpg-{tag}-{}-{n}.mpg", std::process::id())) +} + +fn read_all(path: &std::path::Path) -> std::io::Result<(DiscTitle, Vec)> { + // The sector pipeline spawns a prefetcher (a Drive holder). + let _g = crate::sector::prefetched::holder_test_lock(); + let url = format!("mpg://{}", path.display()); + let mut input = crate::mux::resolve::input(&url, &Default::default())?; + let mut frames = Vec::new(); + while let Some(f) = input.read()? { + frames.push(f); + } + Ok((input.info().clone(), frames)) +} + +fn by_track(frames: &[PesFrame]) -> BTreeMap> { + let mut m: BTreeMap> = BTreeMap::new(); + for f in frames { + m.entry(f.track).or_default().push(f); + } + m +} + +fn tracks_match( + fx_frames: &BTreeMap)>>, + got: &[PesFrame], + lpcm: &[usize], +) { + let got = by_track(got); + let mut offset = None; + for (t, want) in fx_frames { + let g = got + .get(t) + .unwrap_or_else(|| panic!("track {t} read nothing")); + if lpcm.contains(t) { + let a: Vec = g.iter().flat_map(|f| f.data.iter().copied()).collect(); + let b: Vec = want.iter().flat_map(|f| f.1.iter().copied()).collect(); + assert_eq!(a, b, "LPCM track {t}"); + continue; + } + let mut w: Vec<&(i64, Vec)> = want.iter().collect(); + let mut g: Vec<&&PesFrame> = g.iter().collect(); + w.sort_by_key(|f| f.0); + g.sort_by_key(|f| f.pts); + assert_eq!(g.len(), w.len(), "track {t} frame count"); + for (a, b) in g.iter().zip(&w) { + assert_eq!(a.data, b.1, "track {t} frame bytes"); + let d = ns_to_ticks(a.pts) - ns_to_ticks(b.0); + assert_eq!( + *offset.get_or_insert(d), + d, + "track {t}: one constant tick offset (J16)" + ); + } + } +} + +// Design §4 steps 2-3 and §7 round trips: our own program stream reads back, stream for +// stream, with the metadata the map carries (ISO 639, the extension, FMKV languages, +// forced flag and palette) and every frame's bytes and PTS (modulo one tick offset). +#[test] +fn an_mpg_file_reads_back_through_the_ps_pipeline() { + let fx = fixture(&Opts::default()); + let r = run(&fx); + let path = temp_path("rt"); + std::fs::write(&path, &r.out).unwrap(); + let (title, frames) = read_all(&path).unwrap(); + let kinds: Vec<(u16, Codec, String)> = title + .streams + .iter() + .map(|s| match s { + DiscStream::Video(v) => (v.pid, v.codec, String::new()), + DiscStream::Audio(a) => (a.pid, a.codec, a.language.clone()), + DiscStream::Subtitle(t) => (t.pid, t.codec, t.language.clone()), + }) + .collect(); + assert_eq!( + kinds, + vec![ + (0xE0, Codec::Mpeg2, String::new()), + (0xC0, Codec::Mp2, "eng".into()), + (0xD0, Codec::Mp2, "eng".into()), + (0xBD80, Codec::Ac3, "fra".into()), + (0xBD88, Codec::Dts, "deu".into()), + (0xBDA0, Codec::Lpcm, "spa".into()), + (0xBDA1, Codec::Lpcm, "ita".into()), + (0x20, Codec::DvdSub, "eng".into()), + ] + ); + assert!(matches!(&title.streams[2], DiscStream::Audio(a) if a.is_mp2_extension())); + let DiscStream::Subtitle(sub) = &title.streams[7] else { + panic!() + }; + assert!(sub.forced); + assert!( + sub.codec_data + .as_deref() + .is_some_and(|c| idx_palette(c).is_some()) + ); + tracks_match(&fx.input, &frames, &[5, 6]); + let _ = std::fs::remove_file(&path); +} + +// §7 round trip 1: "PS → IR → mpg → IR: per-track (pts − const, keyframe, data) exactly". +#[test] +fn ps_to_ir_to_mpg_to_ir_is_exact() { + let fx = fixture(&Opts::default()); + let p1 = temp_path("a"); + std::fs::write(&p1, run(&fx).out).unwrap(); + let (t1, f1) = read_all(&p1).unwrap(); + let mut sink = MpgSink::create(Vec::new(), &t1).unwrap(); + for f in &f1 { + sink.write(f).unwrap(); + } + sink.finish().unwrap(); + let p2 = temp_path("b"); + std::fs::write(&p2, sink.mux.take().unwrap().into_writer()).unwrap(); + let (t2, f2) = read_all(&p2).unwrap(); + assert_eq!(t1.streams.len(), t2.streams.len()); + let (a, b) = (by_track(&f1), by_track(&f2)); + let mut offset = None; + for (t, fa) in &a { + let fb = &b[t]; + if matches!(&t1.streams[*t], DiscStream::Audio(x) if x.codec == Codec::Lpcm) { + // LPCM IR frames follow the PES they came in; the samples are what round-trips. + let cat = |f: &Vec<&PesFrame>| { + f.iter() + .flat_map(|x| x.data.iter().copied()) + .collect::>() + }; + assert_eq!(cat(fa), cat(fb), "LPCM track {t}"); + continue; + } + assert_eq!(fa.len(), fb.len(), "track {t}"); + for (x, y) in fa.iter().zip(fb) { + assert_eq!((x.keyframe, &x.data), (y.keyframe, &y.data), "track {t}"); + let d = ns_to_ticks(y.pts) - ns_to_ticks(x.pts); + assert_eq!(*offset.get_or_insert(d), d); + } + } + let _ = (std::fs::remove_file(&p1), std::fs::remove_file(&p2)); +} + +// Design §4 step 2 (MPG-11, J3): a CSS-scrambled `.vob` is cracked keylessly and read. +#[test] +fn a_css_scrambled_vob_is_descrambled() { + let fx = fixture(&Opts { + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + let clear = run(&fx).out; + let mut vob = clear.clone(); + let key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; + let mut scrambled = 0; + for pk in vob.as_chunks_mut::<{ pack::PACK_BYTES }>().0 { + // Only a whole-sector video pack with no stuffing, as a DVD encoder writes them. + if pk[13] & 7 == 0 && pk[17] == 0xE0 && pk[0x14] & 0x30 == 0 { + pk[0x14] |= 0x10; + crate::css::lfsr::scramble_sector(&key, pk); + scrambled += 1; + } + } + assert!(scrambled > 10); + let path = temp_path("css"); + std::fs::write(&path, &vob).unwrap(); + let (_, frames) = read_all(&path).unwrap(); + let want: Vec = fx.input[&0] + .iter() + .flat_map(|f| f.1.iter().copied()) + .collect(); + let got: Vec = frames + .iter() + .filter(|f| f.track == 0) + .flat_map(|f| f.data.iter().copied()) + .collect(); + assert_eq!(got, want, "descrambled video is byte-identical"); + let _ = std::fs::remove_file(&path); +} + +// A CSS-scrambled copy of the fixture's output: every whole-sector video pack scrambled. +fn scrambled_vob(fx: &Fx) -> Vec { + let mut vob = run(fx).out; + let key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; + for pk in vob.as_chunks_mut::<{ pack::PACK_BYTES }>().0 { + if pk[13] & 7 == 0 && pk[17] == 0xE0 && pk[0x14] & 0x30 == 0 { + pk[0x14] |= 0x10; + crate::css::lfsr::scramble_sector(&key, pk); + } + } + vob +} + +// Design §4 step 2.1: "css::resolve_dvd_title_key(… raw = false, halt)". A stop reaches the +// crack, and `--raw` still cracks so the head scan reads the streams (the mux stays raw). +#[test] +fn the_crack_honours_halt_and_runs_under_raw() { + let fx = fixture(&Opts { + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + let path = temp_path("halt"); + std::fs::write(&path, scrambled_vob(&fx)).unwrap(); + // The sequence header past the first 0x80 bytes of its pack, where CSS scrambles. + let (lead, _) = clear_ps_with_lead(false, 0, 200); + let mut vob = lead; + let key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; + for pk in vob.as_chunks_mut::<{ pack::PACK_BYTES }>().0 { + if pk[0x11] == 0xE0 { + pk[0x14] |= 0x10; + crate::css::lfsr::scramble_sector(&key, pk); + } + } + let lead_path = temp_path("lead"); + std::fs::write(&lead_path, vob).unwrap(); + let url = format!("mpg://{}", path.display()); + let _g = crate::sector::prefetched::holder_test_lock(); + let halt = crate::halt::Halt::new(); + halt.cancel(); + let e = crate::mux::resolve::input_with_halt(&url, &Default::default(), Some(&halt)) + .err() + .expect("a stopped crack is Halted"); + assert_eq!(crate::error::error_code(&e), Some(crate::error::E_HALTED)); + let raw = crate::mux::resolve::InputOptions { + raw: true, + ..Default::default() + }; + let lead_url = format!("mpg://{}", lead_path.display()); + let input = crate::mux::resolve::input(&lead_url, &raw).expect("raw input"); + assert!( + matches!(&input.info().streams[0], DiscStream::Video(v) if v.resolution == Resolution::R1080p), + "the head scan read the descrambled sequence header: {:?}", + input.info().streams.first() + ); + drop(input); + let _ = ( + std::fs::remove_file(&path), + std::fs::remove_file(&lead_path), + ); +} + +// Design §4 step 2 (J13): a truncated rip (size not a multiple of 2048) is read, its cut-off +// final packet dropped by the demuxer. +#[test] +fn a_truncated_file_is_read() { + let fx = fixture(&Opts { + lpcm: false, + spu_tracks: 0, + ..Opts::default() + }); + let mut out = run(&fx).out; + out.truncate(out.len() - 1_000); + let path = temp_path("cut"); + std::fs::write(&path, &out).unwrap(); + let (_, frames) = read_all(&path).unwrap(); + assert!(frames.iter().filter(|f| f.track == 0).count() > 100); + let _ = std::fs::remove_file(&path); +} + +// Design §4 (J6): an ISO/IEC 11172-1 system stream is read, not refused; its MPEG-1 video +// goes out as stream_type 0x01 in an MPEG-2 program stream. +#[test] +fn an_mpeg1_system_stream_is_read_and_remuxed() { + fn pack1() -> Vec { + vec![ + 0, 0, 1, 0xBA, 0x21, 0x00, 0x01, 0x00, 0x01, 0x80, 0x1B, 0x83, + ] + } + fn packet(id: u8, pts: Option, payload: &[u8]) -> Vec { + let mut body = vec![0xFF]; + match pts { + Some(t) => body.extend_from_slice(&[ + 0x21 | (((t >> 29) & 0x0E) as u8), + (t >> 22) as u8, + 1 | (((t >> 14) & 0xFE) as u8), + (t >> 7) as u8, + 1 | (((t << 1) & 0xFE) as u8), + ]), + None => body.push(0x0F), + } + body.extend_from_slice(payload); + let mut p = vec![0, 0, 1, id]; + p.extend_from_slice(&(body.len() as u16).to_be_bytes()); + p.extend(body); + p + } + let mut ps = Vec::new(); + let mut video = Vec::new(); + for k in 0..50u64 { + let mut pic = if k % 10 == 0 { + test_es::mpeg1_seq(3) + } else { + Vec::new() + }; + pic.extend(test_es::mpeg2_pic(if k % 10 == 0 { 1 } else { 2 }, 3)); + pic.resize(3_000, 0x55); + video.push(pic.clone()); + let pts = 45_000 + k * 3_600; + for (i, chunk) in pic.chunks(2_000).enumerate() { + ps.extend(pack1()); + ps.extend(packet(0xE0, (i == 0).then_some(pts), chunk)); + } + let mut a = vec![0xFF, 0xFD, 0xC4, 0x00]; + a.resize(768, 0x33); + ps.extend(pack1()); + ps.extend(packet(0xC0, Some(pts), &a)); + } + ps.extend_from_slice(&[0, 0, 1, 0xB9]); + let path = temp_path("vcd"); + std::fs::write(&path, &ps).unwrap(); + let (title, frames) = read_all(&path).unwrap(); + assert!(matches!(&title.streams[0], DiscStream::Video(v) if v.codec == Codec::Mpeg1)); + let got: Vec<&[u8]> = frames + .iter() + .filter(|f| f.track == 0) + .map(|f| &f.data[..]) + .collect(); + assert_eq!( + got.len(), + video.len(), + "one IR frame per picture (Mpeg2Parser)" + ); + let mut sink = MpgSink::create(Vec::new(), &title).unwrap(); + for f in &frames { + sink.write(f).unwrap(); + } + sink.finish().unwrap(); + let out = sink.mux.take().unwrap().into_writer(); + let p = replay::parse(&out).unwrap(); + let m = &p.psms[0].1; + let info = usize::from(u16::from_be_bytes([m[8], m[9]])); + assert_eq!( + &m[12 + info..14 + info], + &[0x01, 0xE0], + "MPEG-1 video is stream_type 0x01" + ); + let _ = std::fs::remove_file(&path); +} + +// A clear program stream of 2048-byte packs carrying `pics` as one video stream: 11172-1 +// packs whose packets carry the STD buffer field and a PTS (as FFmpeg writes them), or +// 13818-1 packs with `stuffing` pack-stuffing bytes. Returns the file and the video ES. +fn clear_ps(mpeg1: bool, stuffing: usize) -> (Vec, Vec) { + clear_ps_with_lead(mpeg1, stuffing, 0) +} + +// `clear_ps` with `lead` bytes of user data ahead of the first sequence header. +fn clear_ps_with_lead(mpeg1: bool, stuffing: usize, lead: usize) -> (Vec, Vec) { + let mut es = Vec::new(); + if lead > 0 { + es.extend_from_slice(&[0, 0, 1, 0xB2]); + es.resize(lead, 0x5A); + } + let mut starts = Vec::new(); + for k in 0..30u64 { + starts.push((es.len(), 45_000 + k * 3_600)); + if k == 0 { + es.extend(if mpeg1 { + test_es::mpeg1_seq(3) + } else { + test_es::mpeg2_seq(3, false) + }); + } + es.extend(test_es::mpeg2_pic(if k == 0 { 1 } else { 2 }, 3)); + es.resize(es.len() + 5_000, 0x55); + } + let ts = |prefix: u8, t: u64| { + [ + (prefix << 4) | (((t >> 29) & 0x0E) as u8) | 1, + (t >> 22) as u8, + 1 | (((t >> 14) & 0xFE) as u8), + (t >> 7) as u8, + 1 | (((t << 1) & 0xFE) as u8), + ] + }; + let mut out = Vec::new(); + let mut at = 0; + let mut scr = 0u64; + while at < es.len() { + // The PTS of a picture whose first byte this packet will hold. + let pts = |room: usize| { + starts + .iter() + .find(|(o, _)| *o >= at && *o < at + room) + .map(|&(_, t)| t) + }; + let mut pk = Vec::new(); + if mpeg1 { + pk.extend_from_slice(&[0, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0x1B, 0x83]); + let head = 6 + 2 + 5; + let t = pts(2048 - 12 - head); + let room = 2048 - 12 - 6 - 2 - if t.is_some() { 5 } else { 1 }; + let n = room.min(es.len() - at); + let mut body = vec![0x40 | 0x20, 46]; // STD buffer: scale 1, 46 KiB + match t { + Some(t) => body.extend_from_slice(&ts(0b0010, t)), + None => body.push(0x0F), + } + body.extend_from_slice(&es[at..at + n]); + pk.extend_from_slice(&[0, 0, 1, 0xE0]); + pk.extend_from_slice(&(body.len() as u16).to_be_bytes()); + pk.extend(body); + at += n; + } else { + pk.extend(pack::pack_header(scr, 25_200, stuffing)); + let t = pts(2048 - pk.len() - 14); + let f = pack::PesFields { + pts: t, + ..Default::default() + }; + let n = (2048 - pk.len() - pack::pes_header_len(&f)).min(es.len() - at); + pk.extend(pack::pes_header(0xE0, &f, n)); + pk.extend_from_slice(&es[at..at + n]); + at += n; + } + if pk.len() < 2048 { + let pad = 2048 - pk.len(); + pk.extend(pack::padding_pes(pad)); + } + assert_eq!(pk.len(), 2048); + out.extend(pk); + scr += 27_000; + } + out.extend_from_slice(&[0, 0, 1, 0xB9]); + (out, es) +} + +// B2: an 11172-1 stream cannot be CSS, and a stuffed 13818-1 pack header moves bytes 0x11 +// and 0x14 off the stream_id and PES flags: neither clear file is refused with E7023. +#[test] +fn clear_mpeg1_and_stuffed_mpeg2_are_never_read_as_scrambled() { + for (mpeg1, stuffing) in [(true, 0), (false, 1), (false, 3)] { + let (file, es) = clear_ps(mpeg1, stuffing); + let path = temp_path("clear"); + std::fs::write(&path, &file).unwrap(); + let got = read_all(&path); + let _ = std::fs::remove_file(&path); + let (_, frames) = got.unwrap_or_else(|e| panic!("mpeg1={mpeg1} stuffing={stuffing}: {e}")); + let video: Vec = frames + .iter() + .filter(|f| f.track == 0) + .flat_map(|f| f.data.iter().copied()) + .collect(); + assert_eq!(video, es, "mpeg1={mpeg1} stuffing={stuffing}"); + } +} + +// Scramble every video pack of `ps` whose PES flags sit at 0x14 + its pack stuffing. +fn scramble_video_packs(ps: &mut [u8]) -> usize { + let key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; + let mut n = 0; + for pk in ps.as_chunks_mut::<{ pack::PACK_BYTES }>().0 { + let at = 0x14 + usize::from(pk[13] & 7); + if pk[at - 3] == 0xE0 { + // The test scrambler flags byte 0x14; a stuffed pack's flags are at `at`. + let b14 = pk[0x14]; + pk[at] |= 0x10; + crate::css::lfsr::scramble_sector(&key, pk); + if at != 0x14 { + pk[0x14] = b14; + } + n += 1; + } + } + n +} + +// Where two byte strings first differ (their lengths when one is a prefix), for a short +// failure message. +fn first_diff(a: &[u8], b: &[u8]) -> Option<(usize, usize, usize)> { + let at = a.iter().zip(b).position(|(x, y)| x != y); + (at.is_some() || a.len() != b.len()) + .then(|| (at.unwrap_or(a.len().min(b.len())), a.len(), b.len())) +} + +// The video ES a file reads back as, or its error code. +fn read_video(file: &[u8], tag: &str) -> Result, Option> { + let path = temp_path(tag); + std::fs::write(&path, file).unwrap(); + let got = read_all(&path); + let _ = std::fs::remove_file(&path); + got.map(|(_, frames)| { + frames + .iter() + .filter(|f| f.track == 0) + .flat_map(|f| f.data.iter().copied()) + .collect() + }) + .map_err(|e| crate::error::error_code(&e)) +} + +// B-1: a clear file whose first pack begins with a program stream map (byte 0x14 holds the +// map's version byte) opens, and reads back unchanged. +#[test] +fn a_clear_file_opening_with_a_map_opens() { + let (body, es) = clear_ps(false, 0); + let e = [pack::PsmEntry { + stream_type: 0x02, + stream_id: 0xE0, + descriptors: vec![], + }]; + let map = pack::psm(&[], &e).unwrap(); + let mut first = pack::pack_header(0, 25_200, 0); + first.extend_from_slice(&map); + first.extend(pack::padding_pes(pack::PACK_BYTES - first.len())); + assert!(first[0x14] & 0x30 != 0, "fixture: 0x14 reads as scrambled"); + let file = [first, body].concat(); + let video = read_video(&file, "map-first").map(|v| v.len()); + assert_eq!(video, Ok(es.len())); +} + +// m1: an MPEG-2 file whose scrambled packs are all stuffed is judged by the flags where its +// stuffing puts them: descrambled, or refused with E7023, never muxed as ciphertext. +#[test] +fn stuffed_scrambled_packs_are_never_muxed_as_ciphertext() { + let (mut file, es) = clear_ps(false, 2); + assert!(scramble_video_packs(&mut file) > 10); + match read_video(&file, "stuffed-css") { + Ok(video) => assert_eq!(first_diff(&video, &es), None, "descrambled"), + Err(code) => assert_eq!(code, Some(crate::error::E_CSS_KEY_MISSING)), + } +} + +// m1: scrambling first met past the crack's 50 000-sector budget is refused with E7023 when +// it is read, never muxed as ciphertext. +#[test] +fn scrambling_past_the_crack_budget_is_refused() { + let (head, _) = clear_ps(false, 0); + let mut file = head[..head.len() - 4].to_vec(); + let mut pad = pack::pack_header(0, 25_200, 0); + pad.extend(pack::padding_pes(pack::PACK_BYTES - pad.len())); + for _ in 0..50_000 { + file.extend_from_slice(&pad); + } + let (mut tail, _) = clear_ps(false, 0); + scramble_video_packs(&mut tail); + file.extend(tail); + let got = read_video(&file, "late-css").map(|v| v.len()); + assert_eq!(got, Err(Some(crate::error::E_CSS_KEY_MISSING))); +} + +// m2: in a CSS file, a clear stuffed pack whose byte 0x14 happens to hold set bits (here the +// low byte of PES_packet_length) is read as clear, not "descrambled". +#[test] +fn a_clear_stuffed_pack_in_a_css_file_is_left_clear() { + let (mut first, es1) = clear_ps(false, 0); + scramble_video_packs(&mut first); + let (second, es2) = clear_ps(false, 1); + assert!( + second + .as_chunks::<{ pack::PACK_BYTES }>() + .0 + .iter() + .any(|pk| pk[0x14] & 0x30 != 0), + "fixture: a clear stuffed pack reads as scrambled at 0x14" + ); + let mut file = first[..first.len() - 4].to_vec(); + file.extend(second); + let video = read_video(&file, "css-then-stuffed").expect("reads"); + assert_eq!(first_diff(&video, &[es1, es2].concat()), None); +} + +// D3: a clear file is crack-scanned once; the pipeline is handed that verdict ("clear") +// rather than scanning again because its keys are None. +#[test] +fn a_clear_file_is_crack_scanned_once() { + let (file, _) = clear_ps(false, 0); + let path = temp_path("once"); + std::fs::write(&path, &file).unwrap(); + crate::css::CRACK_SCANS.with(|n| n.set(0)); + let got = read_all(&path); + let _ = std::fs::remove_file(&path); + got.unwrap(); + assert_eq!(crate::css::CRACK_SCANS.with(|n| n.get()), 1); +} + +// Design §4: "A file with no pack start code in its head is not a PS; it fails with E6009". +#[test] +fn a_file_with_no_pack_is_no_streams() { + let path = temp_path("junk"); + std::fs::write(&path, vec![0x5Au8; 10_000]).unwrap(); + let e = read_all(&path).unwrap_err(); + assert_eq!( + crate::error::error_code(&e), + Some(crate::error::E_NO_STREAMS) + ); + let _ = std::fs::remove_file(&path); +} + +// Design §4 step 3: without a map, 0xD0-0xD7 are classified by sync — `0x7FF` ext_syncword +// is an extension, `0xFFF` an ordinary MPEG audio stream (a foreign PS with audio at 0xD2). +#[test] +fn without_a_map_d0_streams_are_classified_by_sync() { + let mut ps = Vec::new(); + let pack2 = [0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 0, 0, 3, 0xF8]; + let pes = |id: u8, data: &[u8]| { + let mut p = vec![0, 0, 1, id, 0, 0, 0x80, 0x80, 5, 0x21, 0, 1, 0, 1]; + p.extend_from_slice(data); + let len = (p.len() - 6) as u16; + p[4..6].copy_from_slice(&len.to_be_bytes()); + p + }; + ps.extend_from_slice(&pack2); + let mut v = seq_header(720, 576, 3, 112, false); + v.extend(test_es::mpeg2_pic(1, 3)); + ps.extend(pes(0xE0, &v)); + ps.extend(pes(0xC0, &[0xFF, 0xFD, 0xC4, 0x00, 0, 0])); + ps.extend(pes(0xD0, &[0x7F, 0xF1, 0x23, 0x45])); + ps.extend(pes(0xD2, &[0xFF, 0xFD, 0xC4, 0xC0, 0, 0])); + let s = scan::scan_streams(&ps).unwrap(); + let ids: Vec<(u16, bool)> = s + .iter() + .filter_map(|x| match x { + DiscStream::Audio(a) => Some((a.pid, a.is_mp2_extension())), + _ => None, + }) + .collect(); + assert_eq!(ids, vec![(0xC0, false), (0xD0, true), (0xD2, false)]); + assert!(matches!(&s[3], DiscStream::Audio(a) if a.channels == AudioChannels::Mono)); +} + +// A pack of one MPEG-2 PES per `(stream_id, payload)`, PTS 90 000, for scan tests. +fn scan_pack(pes: &[(u8, Vec)], map: Option<&[u8]>) -> Vec { + let mut ps = vec![0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 0, 0, 3, 0xF8]; + if let Some(m) = map { + ps.extend_from_slice(m); + } + for (id, data) in pes { + let mut p = vec![0, 0, 1, *id, 0, 0, 0x80, 0x80, 5, 0x21, 0, 1, 0, 1]; + p.extend_from_slice(data); + let len = (p.len() - 6) as u16; + p[4..6].copy_from_slice(&len.to_be_bytes()); + ps.extend(p); + } + ps +} + +fn scan_video() -> Vec { + let mut v = seq_header(720, 576, 3, 112, false); + v.extend(test_es::mpeg2_pic(1, 3)); + v +} + +// Design §4 step 3: without a map, a `0xD0-0xD7` packet that starts mid-frame is classified +// by the first sync word in its bytes, not by its first two bytes. +#[test] +fn sync_classification_finds_the_first_sync() { + let ps = scan_pack( + &[ + (0xE0, scan_video()), + (0xC0, vec![0xFF, 0xFD, 0xC4, 0x00, 0, 0]), + (0xD0, vec![0x12, 0x34, 0x00, 0x7F, 0xF1, 0x23, 0x45]), + ], + None, + ); + let s = scan::scan_streams(&ps).unwrap(); + assert!( + matches!(&s[2], DiscStream::Audio(a) if a.is_mp2_extension()), + "{:?}", + s[2] + ); +} + +// MS-23 / design §4 step 3: the map pairs an extension with its base by +// hierarchy_embedded_layer_index, wherever the base sits in the map. +#[test] +fn the_map_pairs_an_extension_by_its_embedded_layer_index() { + let e = [ + pack::PsmEntry { + stream_type: 0x02, + stream_id: 0xE0, + descriptors: vec![], + }, + pack::PsmEntry { + stream_type: 0x04, + stream_id: 0xD1, + descriptors: pack::hierarchy_descriptor(pack::HIERARCHY_EXTENSION, 3, 2).to_vec(), + }, + pack::PsmEntry { + stream_type: 0x04, + stream_id: 0xC1, + descriptors: pack::hierarchy_descriptor(pack::HIERARCHY_BASE, 2, 0).to_vec(), + }, + ]; + let m = pack::psm(&[], &e).unwrap(); + let ps = scan_pack( + &[ + (0xE0, scan_video()), + (0xD1, vec![0x7F, 0xF1, 0x23, 0x45]), + (0xC1, vec![0xFF, 0xFD, 0xC4, 0x00, 0, 0]), + ], + Some(&m), + ); + let s = scan::scan_streams(&ps).unwrap(); + let audio: Vec<(u16, bool)> = s + .iter() + .filter_map(|x| match x { + DiscStream::Audio(a) => Some((a.pid, a.is_mp2_extension())), + _ => None, + }) + .collect(); + assert_eq!(audio, vec![(0xD1, true), (0xC1, false)]); +} + +// Design §4 step 3 (MPEG audio: "header + mc_header"): a 13818-3 multichannel base reports +// its main programme's channel count, not the 2 of its MPEG-1 header. +#[test] +fn mpeg_audio_channels_come_from_the_mc_header() { + use crate::mux::codec::mp2_channels::tests::{MC_3_2_LFE, STEREO_256, Spec, write}; + let f = write(Spec { + mc: Some(MC_3_2_LFE), + ..STEREO_256 + }) + .0; + let ps = scan_pack(&[(0xE0, scan_video()), (0xC0, f.repeat(4))], None); + let s = scan::scan_streams(&ps).unwrap(); + assert!( + matches!(&s[1], DiscStream::Audio(a) if a.channels == AudioChannels::Surround51), + "{:?}", + s[1] + ); +} + +// Design §4: one video stream is carried; a second video stream_id is not interleaved into it. +#[test] +fn only_the_chosen_video_stream_is_routed() { + let (a, es_a) = clear_ps(false, 0); + let (b, _) = clear_ps(false, 0); + let mut file = Vec::new(); + let packs_a = a.as_chunks::<{ pack::PACK_BYTES }>().0; + let packs_b = b.as_chunks::<{ pack::PACK_BYTES }>().0; + for (pa, pb) in packs_a.iter().zip(packs_b) { + file.extend_from_slice(pa); + let mut pb = *pb; + if pb[0x11] == 0xE0 { + pb[0x11] = 0xE1; + for x in &mut pb[0x80..] { + *x ^= 0x0F; + } + } + file.extend_from_slice(&pb); + } + file.extend_from_slice(&[0, 0, 1, 0xB9]); + let path = temp_path("two-video"); + std::fs::write(&path, &file).unwrap(); + let got = read_all(&path); + let _ = std::fs::remove_file(&path); + let (title, frames) = got.unwrap(); + assert_eq!( + title + .streams + .iter() + .filter(|s| matches!(s, DiscStream::Video(_))) + .count(), + 1 + ); + let video: Vec = frames + .iter() + .filter(|f| f.track == 0) + .flat_map(|f| f.data.iter().copied()) + .collect(); + assert_eq!(video, es_a, "0xE0 only"); +} + +#[test] +fn a_map_with_a_bad_crc_is_ignored() { + let e = [pack::PsmEntry { + stream_type: 0x02, + stream_id: 0xE0, + descriptors: vec![], + }]; + let mut m = pack::psm(&[], &e).unwrap(); + assert!(scan::parse_map(&m).is_some()); + let n = m.len(); + m[n - 1] ^= 1; + assert!(scan::parse_map(&m).is_none()); +} + +// §7 round trip (mpg → mkv): the driver remuxes an `mpg://` source to `mkv://`; the video +// and AC-3 frames read back from the mkv equal those read from the mpg (modulo one tick +// offset); the extension is the mkv sink's to exclude (M1, J23). +#[test] +fn an_mpg_source_remuxes_to_mkv() { + use crate::mux::driver::{MuxInput, MuxOptions, NoopEvents, mux_stream}; + let fx = fixture(&Opts { + spu_tracks: 0, + ..Opts::default() + }); + let mpg = temp_path("src"); + std::fs::write(&mpg, run(&fx).out).unwrap(); + let (_, from_mpg) = read_all(&mpg).unwrap(); + let mkv = std::env::temp_dir().join(format!("fmkv-mpg-out-{}.mkv", std::process::id())); + let url = format!("mpg://{}", mpg.display()); + let opts = MuxOptions { + skip_errors: false, + batch_sectors: 8192, + raw: false, + send_deadline: Some(std::time::Duration::from_secs(60)), + selection: Default::default(), + }; + let out = { + let _g = crate::sector::prefetched::holder_test_lock(); + mux_stream( + MuxInput::Url { + url: &url, + opts: Default::default(), + }, + &format!("mkv://{}", mkv.display()), + &opts, + &crate::halt::Halt::new(), + std::sync::Arc::new(NoopEvents), + ) + .unwrap() + }; + assert!(out.completed); + assert_eq!( + out.undelivered_streams, + vec![2], + "the extension, once seen (J23)" + ); + let mut input = + crate::mux::resolve::input(&format!("mkv://{}", mkv.display()), &Default::default()) + .unwrap(); + let mut from_mkv = Vec::new(); + while let Some(f) = input.read().unwrap() { + from_mkv.push(f); + } + let codec_of = |t: &DiscTitle, i: usize| match &t.streams[i] { + DiscStream::Video(v) => v.codec, + DiscStream::Audio(a) => a.codec, + DiscStream::Subtitle(s) => s.codec, + }; + let mkv_title = input.info().clone(); + let pick = |frames: &[PesFrame], title: &DiscTitle, c: Codec| -> Vec<(i64, Vec)> { + frames + .iter() + .filter(|f| codec_of(title, f.track) == c) + .map(|f| (ns_to_ticks(f.pts), f.data.clone())) + .collect() + }; + let (_, mpg_title) = (0, read_all(&mpg).unwrap().0); + for c in [Codec::Mpeg2, Codec::Ac3] { + let (a, b) = ( + pick(&from_mpg, &mpg_title, c), + pick(&from_mkv, &mkv_title, c), + ); + assert_eq!(a.len(), b.len(), "{c:?}"); + assert!(a.iter().zip(&b).all(|(x, y)| x.1 == y.1), "{c:?} bytes"); + } + let _ = (std::fs::remove_file(&mpg), std::fs::remove_file(&mkv)); +} diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index 8f08036b..8460ab2b 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -62,10 +62,16 @@ pub struct PipelinedPesStream { /// stream index. (TS titles are AU-complete already, so this is a passthrough /// there too — `consume_ts` does not use it.) au_asm: Vec, + /// Per-track PAFF second-field merge after the assembler (H.264 only, MPG2-7). + field_merge: Vec>, /// Bounds the header pump's wait for in-band codec configs (AAC). header_gate: super::header_gate::HeaderGate, /// The op's stop token; a never-cancelled stand-in when there is none. halt: crate::halt::Halt, + /// `mpg://` input: the one video `stream_id` routed; other video ids are left out. + video_stream_id: Option, + /// Packets of a video `stream_id` other than `video_stream_id`, dropped. + other_video_packets: u64, } /// The `Codec` of a stream, for configuring its [`AuAssembler`](crate::mux::au_assembly::AuAssembler). @@ -102,6 +108,14 @@ impl PipelinedPesStream { .iter() .map(|s| super::au_assembly::AuAssembler::for_codec(stream_codec(s))) .collect(); + let field_merge = title + .streams + .iter() + .map(|s| { + (stream_codec(s) == crate::disc::Codec::H264) + .then(super::au_assembly::SecondFieldMerge::default) + }) + .collect(); Self { title, parsers, @@ -116,11 +130,20 @@ impl PipelinedPesStream { resync, is_video, au_asm, + field_merge, header_gate: super::header_gate::HeaderGate::default(), halt: crate::halt::Halt::new(), + video_stream_id: None, + other_video_packets: 0, } } + // `mpg://`: route only video `stream_id` `id` (design §4: one video track). + pub(crate) fn with_video_stream_id(mut self, id: Option) -> Self { + self.video_stream_id = id; + self + } + // The op's stop token: a cancel ends a blocked `read` with `Halted` (LP11). pub(crate) fn with_halt(mut self, halt: Option) -> Self { self.halt = halt.unwrap_or_default(); @@ -221,6 +244,16 @@ impl PipelinedPesStream { fn consume_ps(&mut self, packets: Vec) { for ps in packets { + if let Some(id) = self.video_stream_id + && (0xE0..=0xEF).contains(&ps.stream_id) + && ps.stream_id != id + { + if self.other_video_packets == 0 { + tracing::warn!(target: "mux", stream_id = ps.stream_id, "mpg: a second video stream is left out"); + } + self.other_video_packets += 1; + continue; + } // Route by the REAL DVD PID (matching `scan_dvd_titles`) not a synthetic // track index. The old `(sub_id & 0x1F) + 1` heuristic collided subtitle // sub-id 0x20+j with audio track j+1, feeding VobSub PES into the AC-3 parser. @@ -289,6 +322,10 @@ impl PipelinedPesStream { discontinuity: false, }], }; + let pkts = match self.field_merge.get_mut(track).and_then(Option::as_mut) { + Some(m) => pkts.into_iter().flat_map(|p| m.push(p)).collect(), + None => pkts, + }; for pes in &pkts { if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) { for frame in parser.parse(pes) { @@ -335,6 +372,7 @@ impl PipelinedPesStream { let resync = &mut self.resync; let is_video = &self.is_video; let au_asm = &mut self.au_asm; + let field_merge = &mut self.field_merge; for (pid, parser) in self.parsers.iter_mut() { let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else { continue; @@ -344,16 +382,23 @@ impl PipelinedPesStream { // the parser's own buffer (MPEG-2 final GOP, DTS-HD tail). let mut frames = Vec::new(); let tail = au_asm.get_mut(track).map(|a| a.flush()).unwrap_or_default(); - for au in tail { - let pes = PesPacket { + let mut tail: Vec = tail + .into_iter() + .map(|au| PesPacket { source: au.source, pid: *pid, pts: au.pts, dts: au.dts, data: au.data, discontinuity: au.discontinuity, - }; - frames.extend(parser.parse(&pes)); + }) + .collect(); + if let Some(m) = field_merge.get_mut(track).and_then(Option::as_mut) { + tail = tail.into_iter().flat_map(|p| m.push(p)).collect(); + tail.extend(m.flush()); + } + for pes in &tail { + frames.extend(parser.parse(pes)); } frames.extend(parser.flush()); for frame in frames { @@ -988,6 +1033,89 @@ mod tests { assert!(stream.read().unwrap().is_none(), "unmappable PS dropped"); } + // Design §2.3 "Reader side (L3)" (MPG2-7): one AUD per field splits a PAFF field pair + // into two AUs, the second with no PES PTS; it is merged back into its first field, so + // the IR frame is the field pair and never a PTS-0 frame. + #[test] + fn a_pts_less_second_field_merges_into_its_first() { + use crate::mux::decode_ts::test_es::{H264Sps, h264_slice}; + let sps = H264Sps { + frame_mbs_only: false, + ..H264Sps::default() + }; + let mut title = DiscTitle::empty(); + title.streams.push(crate::disc::Stream::Video(VideoStream { + pid: crate::mux::ps::DVD_VIDEO_PID, + codec: Codec::H264, + resolution: Resolution::R1080i, + frame_rate: FrameRate::F29_97, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + })); + let parsers: Vec<(u16, Box)> = vec![( + crate::mux::ps::DVD_VIDEO_PID, + Box::new(CountingParser { + per_pes: 1, + flush_n: 0, + cp: None, + }), + )]; + let (mut stream, tx) = make_stream( + title, + parsers, + vec![(crate::mux::ps::DVD_VIDEO_PID, 0usize)], + ); + let annexb = |nals: Vec>| { + let mut v = vec![0, 0, 1, 0x09, 0xF0]; + for n in nals { + v.extend_from_slice(&[0, 0, 1]); + v.extend(n); + } + v + }; + let au = [ + annexb(vec![sps.nal(), h264_slice(&sps, true, 0, 0, Some(false))]), + annexb(vec![h264_slice(&sps, true, 0, 0, Some(true))]), + annexb(vec![h264_slice(&sps, false, 0, 1, Some(false))]), + annexb(vec![h264_slice(&sps, false, 0, 1, Some(true))]), + // A frame picture with no PES PTS is its own AU, never merged. + annexb(vec![h264_slice(&sps, false, 0, 2, None)]), + ]; + let frag = |pts, data: &[u8]| PsPacket { + source: None, + stream_id: 0xE0, + sub_stream_id: None, + pts, + dts: None, + data: data.to_vec(), + }; + tx.send(DemuxBatch::Ps(vec![ + frag(Some(9_000), &au[0]), + frag(None, &au[1]), + frag(Some(12_003), &au[2]), + frag(None, &au[3]), + frag(None, &au[4]), + ])) + .unwrap(); + tx.send(DemuxBatch::Eof).unwrap(); + let mut out = Vec::new(); + while let Some(f) = stream.read().unwrap() { + out.push(f.data); + } + assert_eq!( + out, + vec![ + [au[0].clone(), au[1].clone()].concat(), + [au[2].clone(), au[3].clone()].concat(), + au[4].clone() + ] + ); + } + // Single-video-stream title on `codec` + CountingParser; feeds three 0xE0 // PS fragments forming TWO AUD-delimited AUs (only AU-start has a PTS). // Returns every emitted frame. diff --git a/src/mux/ps.rs b/src/mux/ps.rs index 6c1d83fd..93fb9ee0 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -19,6 +19,9 @@ const SYSTEM_HEADER_ID: u8 = crate::consts::pes_stream_id::SYSTEM_HEADER; /// Program end start code suffix. const PROGRAM_END_ID: u8 = 0xB9; +/// program_stream_map start code suffix (MS-27 `1011 1100`). +const PROGRAM_STREAM_MAP_ID: u8 = 0xBC; + /// Private stream 1 (AC3, DTS, LPCM, subtitles). const PRIVATE_STREAM_1: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_1; /// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable @@ -61,11 +64,11 @@ pub const DVD_VIDEO_PID: u16 = 0xE0; /// DTS / LPCM / HD-DVD E-AC-3 ranges. /// /// The PID is `0xBD00 | sub_stream_id`, unique per sub-stream id (AC-3 / DTS `0x80..=0x8F`, -/// LPCM `0xA0..=0xA7`, HD-DVD E-AC-3 `0xC0..=0xC7`) — the single source of truth shared with +/// LPCM `0xA0..=0xA7`, E-AC-3 `0xC0..=0xCF` (G17)) — the single source of truth shared with /// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`). pub fn dvd_audio_pid(sub_stream_id: u8) -> Option { match sub_stream_id { - 0x80..=0x8F | 0xA0..=0xA7 | 0xC0..=0xC7 => Some(0xBD00 | sub_stream_id as u16), + 0x80..=0x8F | 0xA0..=0xA7 | 0xC0..=0xCF => Some(0xBD00 | sub_stream_id as u16), _ => None, } } @@ -162,6 +165,18 @@ impl UnstoredExtensions { true } + /// Add extension track `track` (PID `pid`) as one this sink does not write. + pub(crate) fn add(&mut self, track: usize, pid: u16) { + if !self.contains(track) { + self.tracks.push((track, pid, false)); + } + } + + /// Keep only the extension tracks `unstored` says this sink does not write. + pub(crate) fn retain(&mut self, unstored: impl Fn(usize) -> bool) { + self.tracks.retain(|t| unstored(t.0)); + } + /// Extension tracks whose packets arrived and were left out. pub(crate) fn seen(&self) -> Vec { (self.tracks.iter()).filter(|t| t.2).map(|t| t.0).collect() @@ -254,6 +269,11 @@ pub struct PsDemuxer { /// packet-level assertion can observe. #[cfg(test)] boundary_bytes_scanned: u64, + /// The current pack is an ISO/IEC 11172-1 (MPEG-1) pack: its packets use the MPEG-1 + /// header form (mpg-output-design v5 §4 step 1, chosen per pack, J6). + mpeg1: bool, + /// The first program stream map (0xBC) seen, for the `mpg://` stream scan. + psm: Option>, } impl Default for PsDemuxer { @@ -272,9 +292,16 @@ impl PsDemuxer { pending_scan: None, #[cfg(test)] boundary_bytes_scanned: 0, + mpeg1: false, + psm: None, } } + /// The first program stream map seen, start code included. + pub(crate) fn psm(&self) -> Option<&[u8]> { + self.psm.as_deref() + } + /// Feed raw MPEG-2 PS bytes, returning any completely parsed PES packets. pub fn feed(&mut self, data: &[u8]) -> Vec { self.buffer.extend_from_slice(data); @@ -330,20 +357,44 @@ impl PsDemuxer { pos = sc + 4; } PACK_HEADER_ID => { - // Pack header: need at least 14 bytes for MPEG-2 pack. - if sc + 14 > self.buffer.len() { - break; // wait for more data + if sc + 5 > self.buffer.len() { + break; // wait for the marker byte } - // DVD-Video is always MPEG-2 PS, so every 0xBA is a 14-byte MPEG-2 - // pack: low 3 bits of byte 13 are pack_stuffing_length. (An MPEG-1 - // pack is 12 bytes with no stuffing field, but DVD never emits one.) - let stuffing = (self.buffer[sc + 13] & 0x07) as usize; - let pack_len = 14 + stuffing; + // The byte after 0x000001BA picks the layout per pack (design §4 step 1): + // '01' is an H.222.0 pack (MS-2), '0010' an 11172-1 pack of 12 bytes with + // no stuffing field; anything else is a lost sync, resynced below. + let marker = self.buffer[sc + 4]; + let pack_len = if marker >> 6 == 0b01 { + if sc + 14 > self.buffer.len() { + break; // wait for more data + } + 14 + (self.buffer[sc + 13] & 0x07) as usize + } else if marker >> 4 == 0b0010 { + 12 + } else { + pos = sc + 4; + continue; + }; if sc + pack_len > self.buffer.len() { break; } + self.mpeg1 = pack_len == 12; pos = sc + pack_len; } + PROGRAM_STREAM_MAP_ID => { + // The map is length-prefixed (MS-8); kept once, never parsed as packets. + if sc + 6 > self.buffer.len() { + break; + } + let len = ((self.buffer[sc + 4] as usize) << 8) | self.buffer[sc + 5] as usize; + if sc + 6 + len > self.buffer.len() { + break; + } + if self.psm.is_none() { + self.psm = Some(self.buffer[sc..sc + 6 + len].to_vec()); + } + pos = sc + 6 + len; + } SYSTEM_HEADER_ID => { // System header: 00 00 01 BB [length:2] ... if sc + 6 > self.buffer.len() { @@ -368,7 +419,7 @@ impl PsDemuxer { // Length 0 means unbounded (video): the packet runs to the next PS-LAYER // boundary (pack / system header / program end / next PES), NOT the next // raw start code — the video ES payload is full of 00 00 01 xx codes. - let end = if pes_packet_len == 0 { + let end = if pes_packet_len == 0 && !self.mpeg1 { // Resume where the last call stopped searching for // THIS PES's terminating unit; anything before that is // already proved boundary-free. @@ -413,7 +464,12 @@ impl PsDemuxer { e }; - if let Some(mut pkt) = parse_pes_packet(&self.buffer[sc..end]) { + let parsed = if self.mpeg1 { + parse_mpeg1_packet(&self.buffer[sc..end]) + } else { + parse_pes_packet(&self.buffer[sc..end]) + }; + if let Some(mut pkt) = parsed { if self.has_base { pkt.source = Some(crate::pes::SourcePos::at_byte(self.buffer_base + sc as u64)); @@ -562,6 +618,44 @@ fn parse_stream_id_extension(data: &[u8], flags2: u8, header_end: usize) -> Opti (b & 0x80 == 0).then_some(b & 0x7F) } +/// Parse an ISO/IEC 11172-1 packet (design §4 step 1, cited: 11172-1 §2.4.3.3, F4): up to 16 +/// `0xFF` stuffing bytes, an optional `'01'` STD buffer field, then `'0010'` + PTS, `'0011'` + +/// PTS + `'0001'` + DTS, or `0000 1111`; the sub-stream handling is the MPEG-2 path's. +fn parse_mpeg1_packet(data: &[u8]) -> Option { + if data.len() < 7 || data[..3] != [0, 0, 1] { + return None; + } + let stream_id = data[3]; + if stream_id == crate::consts::pes_stream_id::PADDING_STREAM { + return None; + } + if stream_id == PRIVATE_STREAM_2 { + return parse_pes_packet(data); + } + let mut q = 6; + while q < data.len() && q < 6 + 16 && data[q] == 0xFF { + q += 1; + } + if data.get(q).is_some_and(|b| b & 0xC0 == 0x40) { + q += 2; + } + let (mut pts, mut dts) = (None, None); + match data.get(q).map(|b| b >> 4)? { + 0b0010 if data.len() >= q + 5 => { + pts = parse_pts(&data[q..q + 5]); + q += 5; + } + 0b0011 if data.len() >= q + 10 => { + pts = parse_pts(&data[q..q + 5]); + dts = parse_pts(&data[q + 5..q + 10]); + q += 10; + } + _ if data[q] == 0x0F => q += 1, + _ => return None, + } + finish_packet(stream_id, pts, dts, data, q) +} + /// Parse a single PES packet from a byte slice that starts at the start code. fn parse_pes_packet(data: &[u8]) -> Option { // Minimum: 00 00 01 [id] [len:2] = 6 bytes @@ -620,26 +714,39 @@ fn parse_pes_packet(data: &[u8]) -> Option { dts = parse_pts(&data[14..19]); } + let flags2 = data[7]; + let mut pkt = finish_packet(stream_id, pts, dts, data, header_end)?; + if stream_id == EXTENDED_STREAM_ID { + pkt.sub_stream_id = parse_stream_id_extension(data, flags2, header_end); + } + Some(pkt) +} + +// The payload after a (MPEG-1 or MPEG-2) packet header ending at `header_end`: the +// private_stream_1 sub-stream header stripped, the ES kept. +fn finish_packet( + stream_id: u8, + pts: Option, + dts: Option, + data: &[u8], + header_end: usize, +) -> Option { let payload = &data[header_end..]; // For private stream 1, the first payload byte is the sub-stream ID, // followed by a sub-header whose length depends on the sub-stream type. let (sub_stream_id, es_data) = if stream_id == EXTENDED_STREAM_ID { // HD-DVD extended-stream-id: real stream id is the stream_id_extension inside - // the PES extension. No leading sub-header byte on the payload (unlike - // private_stream_1), so the ES is the payload verbatim. - ( - parse_stream_id_extension(data, data[7], header_end), - payload.to_vec(), - ) + // the PES extension (filled in by the MPEG-2 caller). No leading sub-header byte on + // the payload (unlike private_stream_1), so the ES is the payload verbatim. + (None, payload.to_vec()) } else if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() { let sub_id = payload[0]; let skip = match sub_id { 0x80..=0x8F => 4, // AC3/DTS: sub_id + frame_count + access_unit_ptr(2) - // HD-DVD E-AC-3: 4-byte sub-header like DVD AC-3 (sub_id + num_frames(1) + - // first_access_unit_pointer(2)), verified on a real HD-DVD EVO. Strip exactly 4 - // bytes/packet for a clean ES; a shorter skip splices sub-header into a frame. - 0xC0..=0xC7 => 4, + // E-AC-3 (G17: 0xC0..=0xCF): a 4-byte sub-header like DVD AC-3, verified on a + // real HD-DVD EVO; a shorter skip splices sub-header into a frame. + 0xC0..=0xCF => 4, // LPCM: sub_id + frames + ptr(2); the 3-byte audio header (quant/rate/channels) // is left for `LpcmParser`, which needs it to unpack 20/24-bit samples. 0xA0..=0xA7 => 4, @@ -1585,7 +1692,20 @@ mod tests { assert_eq!(dvd_audio_pid(0xC7), Some(0xBDC7)); // Just outside the range. assert_eq!(dvd_audio_pid(0xBF), None); - assert_eq!(dvd_audio_pid(0xC8), None); + // G17: the whole E-AC-3 sub-id range 0xC0..=0xCF (mpg:// allocates 0xC8..). + assert_eq!(dvd_audio_pid(0xC8), Some(0xBDC8)); + assert_eq!(dvd_audio_pid(0xCF), Some(0xBDCF)); + assert_eq!(dvd_audio_pid(0xD0), None); + assert_eq!(mk(0xBD, Some(0xCA)).dvd_pid(), Some(0xBDCA)); + // …and its 4-byte sub-header is stripped like 0xC0's. + let pes = [ + 0, 0, 1, 0xBD, 0, 14, 0x81, 0x80, 5, 0x21, 0, 1, 0, 1, 0xCA, 1, 0, 1, 0x0B, 0x77, + ]; + let mut d = PsDemuxer::new(); + let mut ps = vec![0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 0, 0, 3, 0xF8]; + ps.extend_from_slice(&pes); + let got: Vec<_> = d.feed(&ps).into_iter().chain(d.flush()).collect(); + assert_eq!(got[0].data, vec![0x0B, 0x77], "sub-header stripped"); // Four DD+ tracks (as seen on a real disc) get four distinct PIDs. let pids: Vec = (0xC0u8..=0xC3).map(|s| dvd_audio_pid(s).unwrap()).collect(); assert_eq!(pids, vec![0xBDC0, 0xBDC1, 0xBDC2, 0xBDC3]); @@ -2023,4 +2143,101 @@ mod tests { "the exact-fit PES must be fully consumed, leaving nothing buffered" ); } + + // ── MPEG-1 system streams (ISO/IEC 11172-1; mpg-output-design v5 §4 step 1, J6) ── + + // An 11172-1 pack header: '0010', SCR (3+15+15 bits with markers), mux_rate. + fn mpeg1_pack(scr: u64) -> Vec { + let mut p = vec![0, 0, 1, 0xBA]; + p.push(0x21 | (((scr >> 30) & 7) << 1) as u8); + p.push((scr >> 22) as u8); + p.push(0x01 | (((scr >> 15) & 0x7F) << 1) as u8); + p.push((scr >> 7) as u8); + p.push(0x01 | ((scr & 0x7F) << 1) as u8); + p.extend_from_slice(&[0x80, 0x1B, 0x83]); + p + } + + // An 11172-1 packet: stuffing, the STD buffer field, then one timestamp form. + fn mpeg1_packet(id: u8, ts: &[u8], payload: &[u8]) -> Vec { + let mut body = vec![0xFF, 0xFF, 0x40 | 0x20, 0x2E]; + body.extend_from_slice(ts); + body.extend_from_slice(payload); + let mut p = vec![0, 0, 1, id]; + p.extend_from_slice(&(body.len() as u16).to_be_bytes()); + p.extend(body); + p + } + + fn ts5(prefix: u8, t: u64) -> [u8; 5] { + [ + (prefix << 4) | 1 | (((t >> 29) & 0x0E) as u8), + (t >> 22) as u8, + 1 | (((t >> 14) & 0xFE) as u8), + (t >> 7) as u8, + 1 | (((t << 1) & 0xFE) as u8), + ] + } + + // Design §4 step 1: "'0010' → ISO/IEC 11172-1 §2.4.3.2 pack header … 12 bytes, no stuffing + // field"; the PES form "'0010' + PTS", "'0011' + PTS + '0001' + DTS", "'0000 1111'". + #[test] + fn mpeg1_packs_and_packets_parse() { + let mut s = mpeg1_pack(90_000); + s.extend(mpeg1_packet(0xC0, &ts5(0b0010, 3_600), &[0xFF, 0xFD, 1, 2])); + s.extend(mpeg1_packet( + 0xE0, + &[&ts5(0b0011, 7_200)[..], &ts5(0b0001, 3_600)[..]].concat(), + &[0, 0, 1, 0xB3, 9], + )); + s.extend(mpeg1_packet(0xC0, &[0x0F], &[7, 8])); + s.extend_from_slice(&[0, 0, 1, 0xB9]); + let mut d = PsDemuxer::new(); + let got: Vec = d.feed(&s).into_iter().chain(d.flush()).collect(); + assert_eq!(got.len(), 3, "{got:?}"); + assert_eq!( + (got[0].stream_id, got[0].pts, got[0].data.clone()), + (0xC0, Some(3_600), vec![0xFF, 0xFD, 1, 2]) + ); + assert_eq!((got[1].pts, got[1].dts), (Some(7_200), Some(3_600))); + assert_eq!(got[1].data, vec![0, 0, 1, 0xB3, 9]); + assert_eq!((got[2].pts, got[2].data.clone()), (None, vec![7, 8])); + } + + // Design §4 step 1: the layout is chosen "per pack … from the marker bits"; anything else + // is a lost sync, resynced on the next start code. + #[test] + fn pack_forms_switch_per_pack_and_a_bad_marker_resyncs() { + let mut s = mpeg1_pack(0); + s.extend(mpeg1_packet(0xC0, &ts5(0b0010, 100), &[1])); + // A garbage "pack" whose marker bits are neither '01' nor '0010'. + s.extend_from_slice(&[0, 0, 1, 0xBA, 0xC0, 0, 0, 0]); + // An MPEG-2 pack, then an MPEG-2 PES. + s.extend_from_slice(&[0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 0, 0, 3, 0xF8]); + let mut p2 = vec![0, 0, 1, 0xC0, 0, 9, 0x80, 0x80, 5]; + p2.extend_from_slice(&ts5(0b0010, 200)); + p2.push(2); + s.extend(p2); + let mut d = PsDemuxer::new(); + let got: Vec = d.feed(&s).into_iter().chain(d.flush()).collect(); + let v: Vec<(Option, Vec)> = got.iter().map(|p| (p.pts, p.data.clone())).collect(); + assert_eq!(v, vec![(Some(100), vec![1]), (Some(200), vec![2])]); + } + + // Design §4 step 3: the program stream map (0xBC) is kept for the scan, and skipped by its + // length so its bytes are never mistaken for packets. + #[test] + fn the_program_stream_map_is_kept_and_skipped() { + let mut psm = vec![0, 0, 1, 0xBC, 0, 10, 0xE0, 0xFF, 0, 0, 0, 0, 0, 0, 1, 0xC0]; + let crc = [0u8; 0]; + psm.extend_from_slice(&crc); + let mut s = vec![0, 0, 1, 0xBA, 0x44, 0, 4, 0, 4, 1, 0, 0, 3, 0xF8]; + s.extend_from_slice(&psm); + let mut d = PsDemuxer::new(); + assert!( + d.feed(&s).is_empty(), + "the map's `00 00 01 C0` is not a packet" + ); + assert_eq!(d.psm(), Some(&psm[..])); + } } diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index eb63b443..d523e06f 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -35,6 +35,9 @@ pub enum StreamUrl { /// a single self-contained `.mp4` (ftyp+mdat+moov). Compatibility export — /// carries only MP4-mappable codecs; see `mux::mp4`. Mp4 { path: PathBuf }, + /// MPEG-2 program stream (`mpg://`, ISO/IEC 13818-1): the DVD-core carriage of + /// `mux::mpg`; tracks it cannot carry are excluded with a reason. + Mpg { path: PathBuf }, /// Network stream (host:port). Network { addr: String }, /// Standard I/O (stdin/stdout). @@ -94,6 +97,7 @@ impl StreamUrl { StreamUrl::M2ts { .. } => "m2ts", StreamUrl::Mkv { .. } => "mkv", StreamUrl::Mp4 { .. } => "mp4", + StreamUrl::Mpg { .. } => "mpg", StreamUrl::Network { .. } => "network", StreamUrl::Stdio => "stdio", StreamUrl::Iso { .. } => "iso", @@ -118,6 +122,7 @@ impl StreamUrl { StreamUrl::M2ts { path } | StreamUrl::Mkv { path } | StreamUrl::Mp4 { path } + | StreamUrl::Mpg { path } | StreamUrl::Iso { path } | StreamUrl::Dir { path } | StreamUrl::Demux { dir: path } @@ -183,6 +188,11 @@ pub fn parse_url(url: &str) -> StreamUrl { path: PathBuf::from(rest), }; } + if let Some(rest) = url.strip_prefix("mpg://") { + return StreamUrl::Mpg { + path: PathBuf::from(rest), + }; + } if let Some(rest) = url.strip_prefix("network://") { return StreamUrl::Network { addr: rest.to_string(), @@ -439,6 +449,10 @@ pub(crate) fn input_with_halt( // `mp4://` as a source: demux a progressive MP4 back into PES frames, so // `mp4://` flows to every sink (mkv://, audio://, json://, …). StreamUrl::Mp4 { ref path } => Ok(Box::new(super::mp4::Mp4Reader::open(path)?)), + StreamUrl::Mpg { ref path } => { + validate_file_path(path, "mpg")?; + Ok(Box::new(build_ps_pipeline(path, opts, halt)?)) + } // `demux://` is an output-only sink (per-track ES files); never a source. StreamUrl::Demux { .. } | StreamUrl::Video { .. } @@ -730,6 +744,15 @@ pub(crate) fn output_with( ); Ok(Box::new(super::mp4::Mp4Sink::create(writer, title)?)) } + StreamUrl::Mpg { ref path } => { + validate_file_path(path, "mpg")?; + // Streaming pack writer, no seeks: the bounded-cache writeback as for mkv://. + let writer = std::io::BufWriter::with_capacity( + IO_BUF_SIZE, + writeback_file(path, title.size_bytes, flush)?, + ); + Ok(Box::new(super::mpg::MpgSink::create(writer, title)?)) + } StreamUrl::M2ts { ref path } => { validate_file_path(path, "m2ts")?; let writer = std::io::BufWriter::with_capacity( @@ -1969,6 +1992,172 @@ fn iso_pipeline_tail( ) } +// An `mpg://` source (design §4 step 2, J13): the sector path, one extent over the zero-padded +// file. Crack first (a scrambled `.vob` descrambles, a clear one passes), scan the decrypted +// head, then build the DVD pipeline with the RESOLVED keys, so its own crack is a no-op. +fn build_ps_pipeline( + path: &Path, + opts: &InputOptions, + halt: Option<&crate::halt::Halt>, +) -> io::Result { + const PS_MUX_BATCH_SECTORS: u16 = 8192; + const HEAD_SECTORS: u32 = 2048; // 4 MiB + let mut reader = crate::io::file_sector_source::FileSectorSource::open_padded(path)?; + let capacity = reader.capacity_sectors(); + if capacity == 0 { + return Err(crate::error::Error::NoStreams.into()); + } + let extent = crate::disc::Extent { + start_lba: 0, + sector_count: capacity, + }; + // The crack's verdict (`None` = clear) is final: the pipeline never scans again (D3). + let mut keys = crate::decrypt::DecryptKeys::None; + // B2: CSS scrambles DVD-Video's 13818-1 packs only; an 11172-1 system stream (the + // first pack's '0010') cannot be CSS, so it is never cracked. + let probe = capacity.min(32); + let mut first = vec![0u8; probe as usize * 2048]; + reader + .read_sectors(0, probe as u16, &mut first, false) + .map_err(|e| -> io::Error { e.into() })?; + let mpeg2 = first + .windows(5) + .find(|w| w[..4] == crate::css::PACK_START) + .is_some_and(|w| w[4] >> 6 == 0b01); + if mpeg2 { + // Design §4 step 2.1: "css::resolve_dvd_title_key(… raw = false, halt)". Even a + // raw read cracks, so the head scan sees the streams; the mux itself stays raw. + let cracked = crate::css::resolve_ps_file_title_key( + &mut reader, + &[extent], + PS_MUX_BATCH_SECTORS, + halt, + ); + match cracked { + Ok(k) => keys = k, + // `--raw` never hard-fails on scrambled-uncrackable: scan the ciphertext. + Err(e) + if opts.raw + && crate::error::error_code(&e) == Some(crate::error::E_CSS_KEY_MISSING) => {} + Err(e) => return Err(e), + } + } + let title_key = match keys { + crate::decrypt::DecryptKeys::Css { title_key } => Some(title_key), + _ => None, + }; + let mut head_reader = PsFileCss { + inner: crate::io::file_sector_source::FileSectorSource::open_padded(path)?, + title_key, + refuse: false, + }; + let n = capacity.min(HEAD_SECTORS); + let mut head = vec![0u8; n as usize * 2048]; + let mut at = 0u32; + while at < n { + let count = (n - at).min(u32::from(PS_MUX_BATCH_SECTORS)) as u16; + let off = at as usize * 2048; + head_reader + .read_sectors( + at, + count, + &mut head[off..off + count as usize * 2048], + false, + ) + .map_err(|e| -> io::Error { e.into() })?; + at += u32::from(count); + } + let scan = super::mpg::scan::scan(&head) + .ok_or_else(|| -> io::Error { crate::error::Error::NoStreams.into() })?; + let streams = scan.streams; + let mut title = DiscTitle { + playlist: path + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_default(), + size_bytes: std::fs::metadata(path).map(|m| m.len()).unwrap_or(0), + codec_privates: vec![None; streams.len()], + streams, + extents: vec![extent], + content_format: ContentFormat::MpegPs, + ..DiscTitle::empty() + }; + opts.selection + .apply(&mut title) + .map_err(|e| -> io::Error { e.into() })?; + // CSS is PsFileCss's (m1/m2): descrambled where each pack's own stuffing puts its + // flags, and a scrambled pack with no key refused rather than muxed as ciphertext. + let reader = PsFileCss { + inner: reader, + title_key: title_key.filter(|_| !opts.raw), + refuse: !opts.raw, + }; + build_iso_pipeline( + reader, + title, + crate::decrypt::DecryptKeys::None, + PS_MUX_BATCH_SECTORS, + ContentFormat::MpegPs, + // `true` skips build_iso_pipeline's own crack: the verdict above is final (D3). + true, + halt.cloned(), + None, + None, + ) + .map(|p| p.with_video_stream_id(scan.video_id)) +} + +/// An `mpg://` file's CSS layer (design §4 step 2, m1/m2): with the title key, each scrambled +/// pack is descrambled where its own pack stuffing puts the PES flags; without one, a +/// scrambled pack is `CssKeyMissing` (E7023) unless `refuse` is off (`--raw`). +struct PsFileCss { + inner: S, + title_key: Option<[u8; 5]>, + refuse: bool, +} + +impl SectorSource for PsFileCss { + fn capacity_sectors(&self) -> u32 { + self.inner.capacity_sectors() + } + + fn set_speed(&mut self, kbs: u16) { + self.inner.set_speed(kbs); + } + + fn set_unit_base(&mut self, lba: u32) { + self.inner.set_unit_base(lba); + } + + fn unmapped_stream_files(&self) -> &[crate::sector::bus_removal::UnmappedStreamFile] { + self.inner.unmapped_stream_files() + } + + fn random_access(&self) -> bool { + self.inner.random_access() + } + + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> crate::error::Result { + let n = self.inner.read_sectors(lba, count, buf, recovery)?; + let len = n.min(buf.len()); + let got = &mut buf[..len]; + match &mut self.title_key { + Some(key) => crate::css::descramble_ps_region(got, key), + None if self.refuse && got.chunks(2048).any(crate::css::is_scrambled_ps_pack) => { + return Err(crate::error::Error::CssKeyMissing); + } + None => {} + } + Ok(n) + } +} + // Assemble the M2TS file mux pipeline (read -> demux -> parse). Scans the head // for FMKV header or PMT/PAT, then wraps a chained reader (head + remainder) // in a BytePrefetcher feeding the demux + parse threads. @@ -2850,6 +3039,8 @@ mod tests { for (i, b) in sec.iter_mut().enumerate().take(0x80).skip(4) { *b = (i as u8).wrapping_mul(7).wrapping_add(1); // non-repeating → no crib } + sec[4] = 0x44; // '01': a 13818-1 pack + sec[0x0D] = 0xF8; // pack_stuffing_length 0 sec[0x14] = 0x10; // scramble flag for (i, b) in sec.iter_mut().enumerate().skip(0x80) { *b = (i as u8) ^ 0x3C; diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index d611892c..070f4628 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -660,6 +660,8 @@ mod tests { // Real scrambled DVD sectors are MPEG-2 PS packs; the scramble policy // requires the pack start code as well as the flag bits. template[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + template[4] = 0x44; // '01': a 13818-1 pack + template[0x0D] = 0xF8; // pack_stuffing_length 0 template[0x14] = 0x30; // scramble bits (4-5) set → flags == 0x03 let pristine = template; diff --git a/src/spec/mod.rs b/src/spec/mod.rs index 99306c58..cae4d16d 100644 --- a/src/spec/mod.rs +++ b/src/spec/mod.rs @@ -2,7 +2,7 @@ //! evidence) that govern libfreemkv's behaviour. Text only: no logic. //! //! One submodule per design, each with its own ID prefix: [`keys`] holds `KS-n` -//! (AACS, UDF, libaacs corroboration, evidence), [`stop`] holds `SS-n`. IDs are sequential per prefix and +//! (AACS, UDF, libaacs corroboration, evidence), [`stop`] holds `SS-n`, [`mpg`] holds `MS-n`. IDs are sequential per prefix and //! never reused or renumbered. Const names are `__`. //! //! `text` is the passage as rendered in the source (radix subscripts as `₂`/`₁₆`, @@ -15,6 +15,7 @@ //! [`QuoteKind::Informative`] rows; otherwise "corroborated by" or "per evidence". pub mod keys; +pub mod mpg; pub mod stop; /// Where a quote's text comes from; binding on how a citing site may word it. @@ -49,7 +50,7 @@ pub struct SpecQuote { } /// Every quote group, one per submodule. New designs append their group. -pub const ALL: &[&[&SpecQuote]] = &[keys::ALL, stop::ALL]; +pub const ALL: &[&[&SpecQuote]] = &[keys::ALL, stop::ALL, mpg::ALL]; /// Every quote in [`ALL`], group by group. pub fn quotes() -> impl Iterator { diff --git a/src/spec/mpg.rs b/src/spec/mpg.rs new file mode 100644 index 00000000..5811c0c8 --- /dev/null +++ b/src/spec/mpg.rs @@ -0,0 +1,357 @@ +//! `MS-n`: the quotes behind the `mpg://` program stream sink (mpg-output-design v5 +//! §2.2-§2.4, L2): ITU-T Rec. H.222.0 (02/2000) | ISO/IEC 13818-1:2000, the local +//! `/private/tmp/h222.txt` (J24: the 2000 edition), plus FFmpeg corroboration of the +//! DVD private_stream_1 sub-stream headers, which the standard leaves "user definable". + +use super::{QuoteKind, SpecQuote}; + +const H222: &str = "ITU-T Rec. H.222.0 (02/2000) | ISO/IEC 13818-1:2000"; +const H222_URL: &str = "https://www.itu.int/rec/T-REC-H.222.0-200002-S/en"; +const FFMPEG_MPEGENC: &str = "FFmpeg 6.1.3, libavformat/mpegenc.c"; +const FFMPEG_MPEGENC_URL: &str = + "https://github.com/FFmpeg/FFmpeg/blob/n6.1.3/libavformat/mpegenc.c"; + +pub const MS_1_PACK: SpecQuote = SpecQuote { + id: "MS-1", + kind: QuoteKind::Normative, + source: H222, + section: "§2.5.3.3 Table 2-32 Program Stream pack", + locator: "h222:4620-4625", + url: H222_URL, + text: "pack() { pack_header() while (nextbits() = = packet_start_code_prefix) { PES_packet() } }", +}; + +pub const MS_2_PACK_HEADER: SpecQuote = SpecQuote { + id: "MS-2", + kind: QuoteKind::Normative, + source: H222, + section: "§2.5.3.3 Table 2-33 Program Stream pack header", + locator: "h222:4635-4650", + url: H222_URL, + text: "pack_header() { pack_start_code 32 bslbf '01' 2 bslbf system_clock_reference_base [32..30] 3 bslbf marker_bit 1 bslbf system_clock_reference_base [29..15] 15 bslbf marker_bit 1 bslbf system_clock_reference_base [14..0] 15 bslbf marker_bit 1 bslbf system_clock_reference_extension 9 uimsbf marker_bit 1 bslbf program_mux_rate 22 uimsbf marker_bit 1 bslbf marker_bit 1 bslbf reserved 5 bslbf pack_stuffing_length 3 uimsbf", +}; + +pub const MS_3_MUX_RATE: SpecQuote = SpecQuote { + id: "MS-3", + kind: QuoteKind::Normative, + source: H222, + section: "§2.5.3.4 program_mux_rate, stuffing_byte", + locator: "h222:4681-4690", + url: H222_URL, + text: "The value of program_mux_rate is measured in units of 50 bytes/second. The value 0 is forbidden. … may vary from pack to pack … In each pack header no more than 7 stuffing bytes shall be present.", +}; + +pub const MS_4_SCR: SpecQuote = SpecQuote { + id: "MS-4", + kind: QuoteKind::Normative, + source: H222, + section: "§2.5.2.2 equations 2-18 to 2-21; §2.5.2.3", + locator: "h222:4444-4526", + url: H222_URL, + text: "SCR(i) = SCR_base(i) × 300 + SCR_ext(i) … where the arrival rate within each pack is the value represented in the program_mux_rate field in that pack’s header. … For Program Streams, all bytes of each pack shall enter the P-STD before any byte of a subsequent pack.", +}; + +pub const MS_5_SYSTEM_HEADER: SpecQuote = SpecQuote { + id: "MS-5", + kind: QuoteKind::Normative, + source: H222, + section: "§2.5.3.5 Table 2-34 Program Stream system header", + locator: "h222:4701-4722", + url: H222_URL, + text: "system_header () { system_header_start_code 32 bslbf header_length 16 uimsbf marker_bit 1 bslbf rate_bound 22 uimsbf marker_bit 1 bslbf audio_bound 6 uimsbf fixed_flag 1 bslbf CSPS_flag 1 bslbf system_audio_lock_flag 1 bslbf system_video_lock_flag 1 bslbf marker_bit 1 bslbf video_bound 5 uimsbf packet_rate_restriction_flag 1 bslbf reserved_bits 7 bslbf while (nextbits () = = '1') { stream_id 8 uimsbf '11' 2 bslbf P-STD_buffer_bound_scale 1 bslbf P-STD_buffer_size_bound 13 uimsbf } }", +}; + +pub const MS_6_SYSTEM_HEADER_FIELDS: SpecQuote = SpecQuote { + id: "MS-6", + kind: QuoteKind::Normative, + source: H222, + section: "§2.5.3.6 rate_bound, audio_bound, video_bound, reserved_bits", + locator: "h222:4739-4829", + url: H222_URL, + text: "The rate_bound is an integer value greater than or equal to the maximum value of the program_mux_rate field coded in any pack of the Program Stream. … The audio_bound is an integer in the inclusive range from 0 to 32 and is set to a value greater than or equal to the maximum number of ISO/IEC 13818-3 and ISO/IEC 11172-3 audio streams in the Program Stream for which the decoding processes are simultaneously active. … The video_bound is a 5-bit integer in the inclusive range from 0 to 16 … Until otherwise specified by ITU-T | ISO/IEC it shall have the value '111 1111'.", +}; + +pub const MS_7_BUFFER_BOUND: SpecQuote = SpecQuote { + id: "MS-7", + kind: QuoteKind::Normative, + source: H222, + section: "§2.5.3.6 stream_id, P-STD_buffer_bound_scale, P-STD_buffer_size_bound", + locator: "h222:4843-4856", + url: H222_URL, + text: "Each elementary stream present in the Program Stream shall have its P-STD_buffer_bound_scale and P-STD_buffer_size_bound specified exactly once by this mechanism in each system header. … If the preceding stream_id indicates an audio stream, P-STD_buffer_bound_scale shall have the value '0'. If the preceding stream_id indicates a video stream, P-STD_buffer_bound_scale shall have the value '1'. For all other stream types, the value of the P-STD_buffer_bound_scale may be either '1' or '0'. … measures the buffer size bound in units of 128 bytes. … measures the buffer size bound in units of 1024 bytes.", +}; + +pub const MS_8_PSM: SpecQuote = SpecQuote { + id: "MS-8", + kind: QuoteKind::Normative, + source: H222, + section: "§2.5.4.1 Table 2-35 Program Stream map; §2.5.4.2", + locator: "h222:4891-4947", + url: H222_URL, + text: "program_stream_map() { packet_start_code_prefix 24 bslbf map_stream_id 8 uimsbf program_stream_map_length 16 uimsbf current_next_indicator 1 bslbf reserved 2 bslbf program_stream_map_version 5 uimsbf reserved 7 bslbf marker_bit 1 bslbf program_stream_info_length 16 uimsbf for (i = 0; i < N; i++) { descriptor() } elementary_stream_map_length 16 uimsbf for (i = 0; i < N1; i++) { stream_type 8 uimsbf elementary_stream_id 8 uimsbf elementary_stream_info_length 16 uimsbf for (i = 0; i < N2; i++) { descriptor() } } CRC_32 32 rpchof } … The maximum value of this field is 1018 (0x3FA). … The elementary_stream_id is an 8-bit field indicating the value of the stream_id field in the PES packet headers of PES packets in which this elementary stream is stored.", +}; + +pub const MS_9_CRC: SpecQuote = SpecQuote { + id: "MS-9", + kind: QuoteKind::Normative, + source: H222, + section: "§2.5.4.2 CRC_32", + locator: "h222:4950-4951", + url: H222_URL, + text: "CRC_32 – This is a 32-bit field that contains the CRC value that gives a zero output of the registers in the decoder defined in Annex A after processing the entire program stream map.", +}; + +pub const MS_10_PES_PACKET: SpecQuote = SpecQuote { + id: "MS-10", + kind: QuoteKind::Normative, + source: H222, + section: "§2.4.3.6 Table 2-17 PES packet", + locator: "h222:2981-3024", + url: H222_URL, + text: "'10' 2 bslbf PES_scrambling_control 2 bslbf PES_priority 1 bslbf data_alignment_indicator 1 bslbf copyright 1 bslbf original_or_copy 1 bslbf PTS_DTS_flags 2 bslbf ESCR_flag 1 bslbf ES_rate_flag 1 bslbf DSM_trick_mode_flag 1 bslbf additional_copy_info_flag 1 bslbf PES_CRC_flag 1 bslbf PES_extension_flag 1 bslbf PES_header_data_length 8 uimsbf if (PTS_DTS_flags = = '10') { '0010' 4 bslbf PTS [32..30] 3 bslbf marker_bit 1 bslbf PTS [29..15] 15 bslbf marker_bit 1 bslbf PTS [14..0] 15 bslbf marker_bit 1 bslbf } … if (PTS_DTS_flags = = '11') { '0011' 4 bslbf … '0001' 4 bslbf DTS [32..30] 3 bslbf", +}; + +pub const MS_11_PES_EXTENSION: SpecQuote = SpecQuote { + id: "MS-11", + kind: QuoteKind::Normative, + source: H222, + section: "§2.4.3.6 Table 2-17 PES packet (concluded)", + locator: "h222:3090-3146", + url: H222_URL, + text: "if ( PES_extension_flag = = '1') { PES_private_data_flag 1 bslbf pack_header_field_flag 1 bslbf program_packet_sequence_counter_flag 1 bslbf P-STD_buffer_flag 1 bslbf reserved 3 bslbf PES_extension_flag_2 1 bslbf … if ( P-STD_buffer_flag = = '1') { '01' 2 bslbf P-STD_buffer_scale 1 bslbf P-STD_buffer_size 13 uimsbf } … else if ( stream_id = = padding_stream) { for (i = 0; i < PES_packet_length; i++) { padding_byte 8 bslbf } }", +}; + +pub const MS_12_PES_LENGTH: SpecQuote = SpecQuote { + id: "MS-12", + kind: QuoteKind::Normative, + source: H222, + section: "§2.4.3.7 PES_packet_length", + locator: "h222:2957-2958", + url: H222_URL, + text: "A value of 0 indicates that the PES packet length is neither specified nor bounded and is allowed only in PES packets whose payload consists of bytes from a video elementary stream contained in Transport Stream packets.", +}; + +pub const MS_13_PSTD_SCALE: SpecQuote = SpecQuote { + id: "MS-13", + kind: QuoteKind::Normative, + source: H222, + section: "§2.4.3.7 P-STD_buffer_scale, P-STD_buffer_size, stuffing_byte", + locator: "h222:3564-3590", + url: H222_URL, + text: "If the preceding stream_id indicates an audio stream, P-STD_buffer_scale shall have the value '0'. If the preceding stream_id indicates a video stream, P-STD_buffer_scale shall have the value '1'. For all other stream types, the value may be either '1' or '0'. … No more than 32 stuffing bytes shall be present in one", +}; + +pub const MS_14_PRIVATE_DATA: SpecQuote = SpecQuote { + id: "MS-14", + kind: QuoteKind::Normative, + source: H222, + section: "§2.4.3.7 PES_packet_data_byte", + locator: "h222:3601-3602", + url: H222_URL, + text: "In the case of a private_stream_1, private_stream_2, ECM_stream, or EMM_stream, the contents of the PES_packet_data_byte field are user definable and will not be specified by ITU-T | ISO/IEC in the future.", +}; + +pub const MS_15_PTS_NAMES_FIRST_AU: SpecQuote = SpecQuote { + id: "MS-15", + kind: QuoteKind::Normative, + source: H222, + section: "§2.4.3.7 presentation_time_stamp", + locator: "h222:3276-3281", + url: H222_URL, + text: "In the case of audio, if a PTS is present in PES packet header it shall refer to the first access unit commencing in the PES packet. An audio access unit commences in a PES packet if the first byte of the audio access unit is present in the PES packet. In the case of video, if a PTS is present in a PES packet header it shall refer to the access unit containing the first picture start code that commences in this PES packet.", +}; + +pub const MS_16_PSTD_BUFFERS: SpecQuote = SpecQuote { + id: "MS-16", + kind: QuoteKind::Normative, + source: H222, + section: "§2.5.2.3 Buffering", + locator: "h222:4479-4506", + url: H222_URL, + text: "Bytes present in the pack header, system headers, Program Stream Maps, Program Stream Directories, or PES packet headers of the Program Stream such as SCR, DTS, PTS, and packet_length fields, are not delivered to any of the buffers … At the decoding time, tdn(j), all data for the access unit that has been in the buffer longest, An(j), and any stuffing bytes that immediately precede it that are present in the buffer at the time tdn(j), are removed instantaneously at time tdn(j). … The Program Stream shall be constructed and t(i) shall be chosen so that the input buffers of size BS1 through BSn neither overflow nor underflow in the program system target decoder. … For all Program Streams, the delay caused by system target decoder input buffering shall be less than or equal to one second except for still picture video data and ISO/IEC 14496 streams.", +}; + +pub const MS_17_SCR_FREQUENCY: SpecQuote = SpecQuote { + id: "MS-17", + kind: QuoteKind::Normative, + source: H222, + section: "§2.7.1 Frequency of coding the system clock reference", + locator: "h222:6349-6350", + url: H222_URL, + text: "The Program Stream shall be constructed such that the time interval between the bytes containing the last bit of system_clock_reference_base fields in successive packs shall be less than or equal to 0.7 s.", +}; + +pub const MS_18_PTS_FREQUENCY: SpecQuote = SpecQuote { + id: "MS-18", + kind: QuoteKind::Normative, + source: H222, + section: "§2.7.4 Frequency of presentation timestamp coding", + locator: "h222:6392-6406", + url: H222_URL, + text: "The Program Stream and Transport Stream shall be constructed so that the maximum difference between coded presentation timestamps referring to each elementary video or audio stream is 0,7 s. … In the case of still pictures the 0,7 s constraint does not apply.", +}; + +pub const MS_19_CONDITIONAL_TS: SpecQuote = SpecQuote { + id: "MS-19", + kind: QuoteKind::Normative, + source: H222, + section: "§2.7.5 Conditional coding of timestamps", + locator: "h222:6410-6429", + url: H222_URL, + text: "For each elementary stream of a Program Stream or Transport Stream, a presentation timestamp (PTS) shall be encoded for the first access unit. … A PTS may only be present in a ITU-T Rec. H.222.0 | ISO/IEC 13818-1 video or audio elementary stream PES packet header if the first byte of a picture start code or the first byte of an audio access unit is contained in the PES packet. … A decoding_timestamp (DTS) shall appear in a PES packet header if and only if the following two conditions are met: • a PTS is present in the PES packet header; • the decoding time differs from the presentation time.", +}; + +pub const MS_20_SCALABLE_AUDIO_PTS: SpecQuote = SpecQuote { + id: "MS-20", + kind: QuoteKind::Normative, + source: H222, + section: "§2.7.6 Timing constraints for scalable coding", + locator: "h222:6433-6434", + url: H222_URL, + text: "If an audio sequence is coded using an ISO/IEC 13818-3 extension bitstream, corresponding decoding/presentation units in the two layers shall have identical PTS values.", +}; + +pub const MS_21_PSTD_FIELDS_FIRST_PES: SpecQuote = SpecQuote { + id: "MS-21", + kind: QuoteKind::Normative, + source: H222, + section: "§2.7.7; §2.7.8", + locator: "h222:6464-6470", + url: H222_URL, + text: "In a Program Stream, the P-STD_buffer_scale and P-STD_buffer_size fields shall occur in the first PES packet of each elementary stream and again whenever the value changes. They may also occur in any other PES packet. … The system header shall be present in the first pack of an Program Stream. The values encoded in all the system headers in the Program Stream shall be identical.", +}; + +pub const MS_22_STREAM_TYPES: SpecQuote = SpecQuote { + id: "MS-22", + kind: QuoteKind::Normative, + source: H222, + section: "§2.4.4.10 Table 2-29 Stream type assignments", + locator: "h222:4139-4149", + url: H222_URL, + text: "0x01 ISO/IEC 11172 Video 0x02 ITU-T Rec. H.262 | ISO/IEC 13818-2 Video or ISO/IEC 11172-2 constrained parameter video stream 0x03 ISO/IEC 11172 Audio 0x04 ISO/IEC 13818-3 Audio … 0x06 ITU-T Rec. H.222.0 | ISO/IEC 13818-1 PES packets containing private data", +}; + +pub const MS_23_HIERARCHY: SpecQuote = SpecQuote { + id: "MS-23", + kind: QuoteKind::Normative, + source: H222, + section: "§2.6.6 Table 2-43 Hierarchy descriptor; Table 2-44; §2.6.7", + locator: "h222:5340-5384", + url: H222_URL, + text: "hierarchy_embedded_layer_index – The hierarchy_embedded_layer_index is a 6-bit field that defines the hierarchy table index of the program element that needs to be accessed before decoding of the elementary stream associated with this hierarchy_descriptor. This field is undefined if the hierarchy_type value is 15 (base layer). … hierarchy_descriptor() { descriptor_tag 8 uimsbf descriptor_length 8 uimsbf reserved 4 bslbf hierarchy_type 4 uimsbf reserved 2 bslbf hierarchy_layer_index 6 uimsbf reserved 2 bslbf hierarchy_embedded_layer_index 6 uimsbf reserved 2 bslbf hierarchy_channel 6 uimsbf } … 5 ISO/IEC 13818-3 Extension bitstream … 15 Base layer", +}; + +pub const MS_24_ISO_639: SpecQuote = SpecQuote { + id: "MS-24", + kind: QuoteKind::Normative, + source: H222, + section: "§2.6.18 Table 2-52 ISO 639 language descriptor", + locator: "h222:5640-5647", + url: H222_URL, + text: "ISO_639_language_descriptor() { descriptor_tag 8 uimsbf descriptor_length 8 uimsbf for (i = 0; i < N; i++) { ISO_639_language_code 24 bslbf audio_type 8 bslbf } }", +}; + +pub const MS_25_DESCRIPTOR_TAGS: SpecQuote = SpecQuote { + id: "MS-25", + kind: QuoteKind::Normative, + source: H222, + section: "§2.6.1 Table 2-39 Program and program element descriptors", + locator: "h222:5158-5210", + url: H222_URL, + text: "4 X X hierarchy_descriptor … 10 X X ISO_639_language_descriptor … 64-255 n/a n/a User Private", +}; + +pub const MS_26_CRC_POLYNOMIAL: SpecQuote = SpecQuote { + id: "MS-26", + kind: QuoteKind::Normative, + source: H222, + section: "Annex A.0 CRC decoder model, equation A-1", + locator: "h222:7507-7509", + url: H222_URL, + text: "This is the CRC calculated with the polynomial: x32 + x26 + x23 + x22 + x16 + x12 + x11 + x10 + x8 + x7 + x5 + x4 + x2 + x + 1", +}; + +pub const MS_27_STREAM_IDS: SpecQuote = SpecQuote { + id: "MS-27", + kind: QuoteKind::Normative, + source: H222, + section: "§2.4.3.7 Table 2-18 Stream_id assignments", + locator: "h222:3159-3166", + url: H222_URL, + text: "1011 1100 1 program_stream_map 1011 1101 2 private_stream_1 1011 1110 padding_stream … 110x xxxx ISO/IEC 13818-3 or ISO/IEC 11172-3 or ISO/IEC 13818-7 or ISO/IEC 14496-3 audio stream number x xxxx 1110 xxxx ITU-T Rec. H.262 | ISO/IEC 13818-2 or ISO/IEC 11172-2 or ISO/IEC 14496-2 video stream number xxxx", +}; + +pub const MS_28_PRIVATE_DESCRIPTOR: SpecQuote = SpecQuote { + id: "MS-28", + kind: QuoteKind::Informative, + source: H222, + section: "Annex H (Informative) Private data", + locator: "h222:9779-9782", + url: H222_URL, + text: "A range of private descriptors may be defined by the user. These descriptors shall commence with descriptor_tag and descriptor_length fields. … These descriptors may be placed within a program_stream_map()", +}; + +pub const MS_29_PRIVATE_STREAM_1_HEADERS: SpecQuote = SpecQuote { + id: "MS-29", + kind: QuoteKind::Corroboration, + source: FFMPEG_MPEGENC, + section: "write_packet(): the private_stream_1 sub-stream header (sub-id; AC-3/DTS frame count and first access unit pointer; LPCM header)", + locator: "mpegenc.c:913-927", + url: FFMPEG_MPEGENC_URL, + text: "if (startcode == PRIVATE_STREAM_1) { avio_w8(ctx->pb, id); if (id >= 0xa0) { /* LPCM (XXX: check nb_frames) */ avio_w8(ctx->pb, 7); avio_wb16(ctx->pb, 4); /* skip 3 header bytes */ avio_w8(ctx->pb, stream->lpcm_header[0]); avio_w8(ctx->pb, stream->lpcm_header[1]); avio_w8(ctx->pb, stream->lpcm_header[2]); } else if (id >= 0x40) { /* AC-3 */ avio_w8(ctx->pb, nb_frames); avio_wb16(ctx->pb, trailer_size + 1); } }", +}; + +pub const MS_30_PCM_DVD_HEADER: SpecQuote = SpecQuote { + id: "MS-30", + kind: QuoteKind::Corroboration, + source: FFMPEG_MPEGENC, + section: "mpeg_mux_init(): the pcm_dvd (DVD LPCM) audio header", + locator: "mpegenc.c:405-409", + url: FFMPEG_MPEGENC_URL, + text: "stream->lpcm_header[0] = 0x0c; stream->lpcm_header[1] = (freq << 4) | (((st->codecpar->bits_per_coded_sample - 16) / 4) << 6) | st->codecpar->ch_layout.nb_channels - 1; stream->lpcm_header[2] = 0x80;", +}; + +pub const MS_31_PES_HEADER_STREAM_IDS: SpecQuote = SpecQuote { + id: "MS-31", + kind: QuoteKind::Normative, + source: H222, + section: "§2.4.3.6 Table 2-17 PES packet", + locator: "h222:2973-2982", + url: H222_URL, + text: "if (stream_id != program_stream_map && stream_id != padding_stream && stream_id != private_stream_2 && stream_id != ECM && stream_id != EMM && stream_id != program_stream_directory && stream_id != DSMCC_stream && stream_id != ITU-T Rec. H.222.1 type E stream) { '10' 2 bslbf PES_scrambling_control 2 bslbf", +}; + +/// Every `MS-n` quote, in ID order. +pub const ALL: &[&SpecQuote] = &[ + &MS_1_PACK, + &MS_2_PACK_HEADER, + &MS_3_MUX_RATE, + &MS_4_SCR, + &MS_5_SYSTEM_HEADER, + &MS_6_SYSTEM_HEADER_FIELDS, + &MS_7_BUFFER_BOUND, + &MS_8_PSM, + &MS_9_CRC, + &MS_10_PES_PACKET, + &MS_11_PES_EXTENSION, + &MS_12_PES_LENGTH, + &MS_13_PSTD_SCALE, + &MS_14_PRIVATE_DATA, + &MS_15_PTS_NAMES_FIRST_AU, + &MS_16_PSTD_BUFFERS, + &MS_17_SCR_FREQUENCY, + &MS_18_PTS_FREQUENCY, + &MS_19_CONDITIONAL_TS, + &MS_20_SCALABLE_AUDIO_PTS, + &MS_21_PSTD_FIELDS_FIRST_PES, + &MS_22_STREAM_TYPES, + &MS_23_HIERARCHY, + &MS_24_ISO_639, + &MS_25_DESCRIPTOR_TAGS, + &MS_26_CRC_POLYNOMIAL, + &MS_27_STREAM_IDS, + &MS_28_PRIVATE_DESCRIPTOR, + &MS_29_PRIVATE_STREAM_1_HEADERS, + &MS_30_PCM_DVD_HEADER, + &MS_31_PES_HEADER_STREAM_IDS, +]; diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index 3ef506f2..9bcd225e 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -24,6 +24,8 @@ fn css_descramble_sector_roundtrip_via_public_api() { // requires the pack start code as well as the flag bits — byte 0x14 means // something else entirely in an IFO or UDF sector. sector[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + sector[4] = 0x44; // '01': a 13818-1 pack + sector[0x0D] = 0xF8; // pack_stuffing_length 0 sector[0x14] = 0x30; sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]); let original = sector.clone(); diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index 4a3c4d25..80406fc5 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -33,6 +33,8 @@ fn css_decrypt_of_an_uncrackable_sector_still_descrambles() { // the pack start code as well as the flag bits, because byte 0x14 means // something else entirely in an IFO, UDF or ISO 9660 sector. sector[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); + sector[4] = 0x44; // '01': a 13818-1 pack + sector[0x0D] = 0xF8; // pack_stuffing_length 0 sector[0x14] |= 0x30; // CSS scramble flag, bits 4-5 let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF]; diff --git a/tests/spec_quotes.rs b/tests/spec_quotes.rs index cb9f689b..a425f18b 100644 --- a/tests/spec_quotes.rs +++ b/tests/spec_quotes.rs @@ -6,7 +6,7 @@ use libfreemkv::spec::{self, SpecQuote}; use std::collections::{HashMap, HashSet}; const REGISTRY: &str = include_str!("spec_quotes.txt"); -const PREFIXES: &[&str] = &["KS-", "SS-"]; +const PREFIXES: &[&str] = &["KS-", "SS-", "MS-"]; const ELISION: &str = " … "; // `\t[\t// comment]` per line; `#` lines are the header. Panics on a diff --git a/tests/spec_quotes.txt b/tests/spec_quotes.txt index dddca5bc..f3ed0bbc 100644 --- a/tests/spec_quotes.txt +++ b/tests/spec_quotes.txt @@ -46,3 +46,34 @@ SS-13 This system call is extremely dangerous and should not be used in portable SS-14 Does the same thing as fsync(2) then asks the drive to flush all buffered data to the permanent storage device (arg is ignored). As this drains the entire queue of the device and acts as a barrier, data that had been fsync'd on the same device before is guaranteed to be persisted when this call returns. This is currently implemented on HFS, MS-DOS (FAT), Universal Disk Format (UDF) and APFS file systems. The operation may take quite a while to complete. Certain FireWire drives have also been known to ignore the request to flush their buffered data. SS-15 Typically the WriteFile and WriteFileEx functions write data to an internal buffer that the operating system writes to a disk or communication pipe on a regular basis. The FlushFileBuffers function writes all the buffered information for a specified file to the device or pipe. SS-23 When coupled with a store, all previous operations become ordered before any load of this value with Acquire (or stronger) ordering. In particular, all previous writes become visible to all threads that perform an Acquire (or stronger) load of this value. When coupled with a load, if the loaded value was written by a store operation with Release (or stronger) ordering, then all subsequent operations become ordered after that store. In particular, all subsequent loads will see data written before the store. +MS-1 pack() { pack_header() while (nextbits() = = packet_start_code_prefix) { PES_packet() } } +MS-2 pack_header() { pack_start_code 32 bslbf '01' 2 bslbf system_clock_reference_base [32..30] 3 bslbf marker_bit 1 bslbf system_clock_reference_base [29..15] 15 bslbf marker_bit 1 bslbf system_clock_reference_base [14..0] 15 bslbf marker_bit 1 bslbf system_clock_reference_extension 9 uimsbf marker_bit 1 bslbf program_mux_rate 22 uimsbf marker_bit 1 bslbf marker_bit 1 bslbf reserved 5 bslbf pack_stuffing_length 3 uimsbf +MS-3 The value of program_mux_rate is measured in units of 50 bytes/second. The value 0 is forbidden. The value represented in program_mux_rate is used to define the time of arrival of bytes at the input to the P-STD in 2.5.2. The value encoded in the program_mux_rate field may vary from pack to pack in an ITU-T Rec. H.222.0 | ISO/IEC 13818-1 program multiplexed stream. pack_stuffing_length – A 3-bit integer specifying the number of stuffing bytes which follow this field. stuffing_byte – This is a fixed 8-bit value equal to '1111 1111' that can be inserted by the encoder, for example to meet the requirements of the channel. It is discarded by the decoder. In each pack header no more than 7 stuffing bytes shall be present. +MS-4 SCR(i) = SCR_base(i) × 300 + SCR_ext(i) (2-18) where SCR_base(i) = ((system_clock_ frequency × t(i)) DIV 300) % 233 (2-19) SCR_ext(i) = ((system_clock_ frequency × t(i)) DIV 1) % 300 (2-20) The input arrival time, t(i), as given in equation 2-21, for all other bytes shall be constructed from SCR(i) and the rate at which data arrives, where the arrival rate within each pack is the value represented in the program_mux_rate field in that pack’s header. SCR(i′ ) i − i′ t(i) = + (2-21) system_clock_ frequency program_mux_rate × 50 where i′ is the index of the byte containing the last bit of the system_clock_reference_base field in the pack header i is the index of any byte in the pack, including the pack header SCR(i′) is the time encoded in the system clock reference base and extension fields in units of the system clock program_mux_rate is a field defined in 2.5.3.3. After delivery of the last byte of a pack there may be a time interval during which no bytes are delivered to the input of the P-STD. 2.5.2.3 Buffering The PES packet data from elementary stream n is passed to the input buffer for stream n, Bn. Transfer of byte i from the system target decoder input to Bn is instantaneous, so that byte i enters the buffer for stream n, of size BSn, at time t(i). Bytes present in the pack header, system headers, Program Stream Maps, Program Stream Directories, or PES packet headers of the Program Stream such as SCR, DTS, PTS, and packet_length fields, are not delivered to any of the buffers, but may be used to control the system. The input buffer sizes BS1 through BSn are given by the P-STD buffer size parameter in the syntax in equation 2-16 and equation 2-17. At the decoding time, tdn(j), all data for the access unit that has been in the buffer longest, An(j), and any stuffing bytes that immediately precede it that are present in the buffer at the time tdn(j), are removed instantaneously at time tdn(j). The decoding time tdn(j) is specified in the DTS or PTS fields. Decoding times tdn(j + 1), tdn(j + 2), ... of access units without encoded DTS or PTS fields which directly follow access unit j may be derived from information in the elementary stream. Refer to Annex C of ITU-T Rec. H.262 | ISO/IEC 13818-2, ISO/IEC 13818-3, ISO/IEC 11172-2 or ISO/IEC 11172-3. Also refer to 2.7.5. As the access unit is removed from the buffer, it is instantaneously decoded to a presentation unit. The Program Stream shall be constructed and t(i) shall be chosen so that the input buffers of size BS1 through BSn neither overflow nor underflow in the program system target decoder. That is: 0 ≤ Fn(t) ≤ BSn for all t and n and Fn(t) = 0 instantaneously before t = t(0). Fn(t) is the instantaneous fullness of P-STD buffer Bn. An exception to this condition is that the P-STD buffer Bn may underflow when the low_delay flag in the video sequence header is set to '1' (refer to 2.4.2.6) or when trick_mode status is true (refer to 2.4.3.8). For all Program Streams, the delay caused by system target decoder input buffering shall be less than or equal to one second except for still picture video data and ISO/IEC 14496 streams. The input buffering delay is the difference in time between a byte entering the input buffer and when it is decoded. Specifically: in the case of no still picture video data and no ISO/IEC 14496 stream the delay is constrained by: tdn(j)-t(i) <= 1 sec in the case of still picture video data the delay is constrained by: tdn(j)-t(i) <= 60 sec in the case of ISO/IEC 14496 streams the delay is constrained by: tdn(j)-t(i) <= 10 sec for all bytes contained in access unit j. For Program Streams, all bytes of each pack shall enter the P-STD before any byte of a subsequent pack. +MS-5 system_header () { system_header_start_code 32 bslbf header_length 16 uimsbf marker_bit 1 bslbf rate_bound 22 uimsbf marker_bit 1 bslbf audio_bound 6 uimsbf fixed_flag 1 bslbf CSPS_flag 1 bslbf system_audio_lock_flag 1 bslbf system_video_lock_flag 1 bslbf marker_bit 1 bslbf video_bound 5 uimsbf packet_rate_restriction_flag 1 bslbf reserved_bits 7 bslbf while (nextbits () = = '1') { stream_id 8 uimsbf '11' 2 bslbf P-STD_buffer_bound_scale 1 bslbf P-STD_buffer_size_bound 13 uimsbf } } +MS-6 The rate_bound is an integer value greater than or equal to the maximum value of the program_mux_rate field coded in any pack of the Program Stream. It may be used by a decoder to assess whether it is capable of decoding the entire stream. audio_bound – A 6-bit field. The audio_bound is an integer in the inclusive range from 0 to 32 and is set to a value greater than or equal to the maximum number of ISO/IEC 13818-3 and ISO/IEC 11172-3 audio streams in the Program Stream for which the decoding processes are simultaneously active. For the purpose of this subclause, the decoding process of an ISO/IEC 13818-3 or ISO/IEC 11172-3 audio stream is active if the STD buffer is not empty or if a Presentation Unit is being presented in the P-STD model. fixed_flag – The fixed_flag is a 1-bit flag. When set to '1' fixed bitrate operation is indicated. When set to '0' variable bitrate operation is indicated. During fixed bitrate operation, the value encoded in all system_clock_reference fields in the multiplexed ITU-T Rec. H.222.0 | ISO/IEC 13818-1 stream shall adhere to the following linear equation: SCR_base(i) = ((c1 × i + c2) DIV 300) % 233 (2-22) SCR_ext(i) = ((c1 × i + c2) DIV 300) % 300 (2-23) where c1 is a real-valued constant valid for all i c2 is a real-valued constant valid for all i i is the index in the ITU-T Rec. H.222.0 | ISO/IEC 13818-1 multiplexed stream of the byte containing the final bit of any system_clock_reference field in the stream CSPS_flag – The CSPS_flag is a 1-bit field. If its value is set to '1' the Program Stream meets the constraints defined in 2.7.9. system_audio_lock_flag – The system_audio_lock_flag is a 1-bit field indicating that there is a specified, constant rational relationship between the audio sampling rate and the system_clock_frequency in the system target decoder. The system_clock_frequency is defined in 2.5.2.1 and the audio sampling rate is specified in ISO/IEC 13818-3. The system_audio_lock_flag may only be set to '1' if, for all presentation units in all audio elementary streams in the Program Stream, the ratio of system_clock_frequency to the actual audio sampling rate, SCASR, is constant and equal to the value indicated in the following table at the nominal sampling rate indicated in the audio stream. system_clock_ frequency SCASR = (2-24) audio_sample_ rate_in _the_P−STD X The notation denotes real division. Y Nominal audio sampling 16 32 22,05 44,1 24 48 frequency (kHz) SCASR 27 000 000 27 000 000 27 000 000 27 000 000 27 000 000 27 000 000 ------------- ------------- ------------- ------------- ------------- ------------- 16 000 32 000 22 050 44 100 24 000 48 000 system_video_lock_flag – The system_video_lock_flag is a 1-bit field indicating that there is a specified, constant rational relationship between the video frame rate and the system clock frequency in the system target decoder. Subclause 2.5.2.1 defines system_clock_frequency and the video frame rate is specified in ITU-T Rec. H.262 | ISO/IEC 13818-2. The system_video_lock_flag may only be set to '1' if, for all presentation units in all video elementary streams in the ITU-T Rec. H.222.0 | ISO/IEC 13818-1 program, the ratio of system_clock_frequency to the actual video frame rate, SCFR, is constant and equal to the value indicated in the following table at the nominal frame rate indicated in the video stream. system_clock_ frequency SCFR = (2-25) frame_rate_in _the_P−STD Nominal frame rate 23,976 24 25 29,97 30 50 59,94 60 (Hz) SCFR 1 126 125 1 125 000 1 080 000 900 900 900 000 540 000 450 450 450 000 The values of the ratio SCFR are exact. The actual frame rate differs slightly from the nominal rate in cases where the nominal rate is 23,976, 29,97, or 59,94 frames per second. video_bound – The video_bound is a 5-bit integer in the inclusive range from 0 to 16 and is set to a value greater than or equal to the maximum number of ITU-T Rec. H.262 | ISO/IEC 13818-2 and ISO/IEC 11172-2 streams in the Program Stream of which the decoding processes are simultaneously active. For the purpose of this subclause, the decoding process of an ITU-T Rec. H.262 | ISO/IEC 13818-2 and ISO/IEC 11172-2 video stream is active if the P-STD buffer is not empty, or if a Presentation Unit is being presented in the P-STD model, or if the reorder buffer is not empty. packet_rate_restriction_flag – The packet_rate_restriction_flag is a 1-bit flag. If the CSPS flag is set to '1', the packet_rate_restriction_flag indicates which constraint is applicable to the packet rate, as specified in 2.7.9. If the CSPS flag is set to value of '0', then the meaning of the packet_rate_restriction_flag is undefined. reserved_bits – This 7-bit field is reserved for future use by ISO/IEC. Until otherwise specified by ITU-T | ISO/IEC it shall have the value '111 1111'. +MS-7 Each elementary stream present in the Program Stream shall have its P-STD_buffer_bound_scale and P-STD_buffer_size_bound specified exactly once by this mechanism in each system header. P-STD_buffer_bound_scale – The P-STD_buffer_bound_scale is a 1-bit field that indicates the scaling factor used to interpret the subsequent P-STD_buffer_size_bound field. If the preceding stream_id indicates an audio stream, P-STD_buffer_bound_scale shall have the value '0'. If the preceding stream_id indicates a video stream, P-STD_buffer_bound_scale shall have the value '1'. For all other stream types, the value of the P-STD_buffer_bound_scale may be either '1' or '0'. P-STD_buffer_size_bound – The P-STD_buffer_size_bound is a 13-bit unsigned integer defining a value greater than or equal to the maximum P-STD input buffer size, BSn, over all packets for stream n in the Program Stream. If P-STD_buffer_bound_scale has the value '0', then P-STD_buffer_size_bound measures the buffer size bound in units of 128 bytes. If P-STD_buffer_bound_scale has the value '1', then P-STD_buffer_size_bound measures the buffer size bound in units of 1024 bytes. +MS-8 program_stream_map() { packet_start_code_prefix 24 bslbf map_stream_id 8 uimsbf program_stream_map_length 16 uimsbf current_next_indicator 1 bslbf reserved 2 bslbf program_stream_map_version 5 uimsbf reserved 7 bslbf marker_bit 1 bslbf program_stream_info_length 16 uimsbf for (i = 0; i < N; i++) { descriptor() } elementary_stream_map_length 16 uimsbf for (i = 0; i < N1; i++) { stream_type 8 uimsbf elementary_stream_id 8 uimsbf elementary_stream_info_length 16 uimsbf for (i = 0; i < N2; i++) { descriptor() } } CRC_32 32 rpchof } 2.5.4.2 Semantic definition of fields in Program Stream map packet_start_code_prefix – The packet_start_code_prefix is a 24-bit code. Together with the map_stream_id that follows it constitutes a packet start code that identifies the beginning of a packet. The packet_start_code_prefix is the bit string '0000 0000 0000 0000 0000 0001' (0x000001 in hexadecimal). map_stream_id – This is an 8-bit field whose value shall be 0xBC. program_stream_map_length – The program_stream_map_length is a 16-bit field indicating the total number of bytes in the program_stream_map immediately following this field. The maximum value of this field is 1018 (0x3FA). current_next_indicator – This is a 1-bit field, when set to '1' indicates that the Program Stream Map sent is currently applicable. When the bit is set to '0', it indicates that the Program Stream Map sent is not yet applicable and shall be the next table to become valid. program_stream_map_version – This 5-bit field is the version number of the whole Program Stream Map. The version number shall be incremented by 1 modulo 32 whenever the definition of the Program Stream Map changes. When the current_next_indicator is set to '1', then the program_stream_map_version shall be that of the currently applicable Program Stream Map. When the current_next_indicator is set to '0', then the program_stream_map_version shall be that of the next applicable Program Stream Map. program_stream_info_length – The program_stream_info_length is a 16-bit field indicating the total length of the descriptors immediately following this field. marker_bit – A marker_bit is a 1-bit field that has the value '1'. elementary_stream_map_length – This is a 16-bit field specifying the total length, in bytes, of all elementary stream information in this program stream map. It includes the stream_type, elementary_stream_id, and elementary_stream_info_length fields. stream_type – This 8-bit field specifies the type of the stream according to Table 2-29. The stream_type field shall only identify elementary streams contained in PES packets. A value of 0x05 is prohibited. elementary_stream_id – The elementary_stream_id is an 8-bit field indicating the value of the stream_id field in the PES packet headers of PES packets in which this elementary stream is stored. +MS-9 CRC_32 – This is a 32-bit field that contains the CRC value that gives a zero output of the registers in the decoder defined in Annex A after processing the entire program stream map. +MS-10 '10' 2 bslbf PES_scrambling_control 2 bslbf PES_priority 1 bslbf data_alignment_indicator 1 bslbf copyright 1 bslbf original_or_copy 1 bslbf PTS_DTS_flags 2 bslbf ESCR_flag 1 bslbf ES_rate_flag 1 bslbf DSM_trick_mode_flag 1 bslbf additional_copy_info_flag 1 bslbf PES_CRC_flag 1 bslbf PES_extension_flag 1 bslbf PES_header_data_length 8 uimsbf if (PTS_DTS_flags = = '10') { '0010' 4 bslbf PTS [32..30] 3 bslbf marker_bit 1 bslbf PTS [29..15] 15 bslbf marker_bit 1 bslbf PTS [14..0] 15 bslbf marker_bit 1 bslbf } Table 2-17 – PES packet (continued) Syntax No. of bits Mnemonic if (PTS_DTS_flags = = '11') { '0011' 4 bslbf PTS [32..30] 3 bslbf marker_bit 1 bslbf PTS [29..15] 15 bslbf marker_bit 1 bslbf PTS [14..0] 15 bslbf marker_bit 1 bslbf '0001' 4 bslbf DTS [32..30] 3 bslbf +MS-11 if ( PES_extension_flag = = '1') { PES_private_data_flag 1 bslbf pack_header_field_flag 1 bslbf program_packet_sequence_counter_flag 1 bslbf P-STD_buffer_flag 1 bslbf reserved 3 bslbf PES_extension_flag_2 1 bslbf if ( PES_private_data_flag = = '1') { PES_private_data 128 bslbf } if (pack_header_field_flag = = '1') { pack_field_length 8 uimsbf pack_header() } if (program_packet_sequence_counter_flag = = '1') { marker_bit 1 bslbf program_packet_sequence_counter 7 uimsbf marker_bit 1 bslbf MPEG1_MPEG2_identifier 1 bslbf original_stuff_length 6 uimsbf } if ( P-STD_buffer_flag = = '1') { '01' 2 bslbf P-STD_buffer_scale 1 bslbf P-STD_buffer_size 13 uimsbf } if ( PES_extension_flag_2 = = '1') { marker_bit 1 bslbf PES_extension_field_length 7 uimsbf for (i = 0; i < PES_extension_field_length; i++) { reserved 8 bslbf } } } for (i = 0; i < N1; i++) { stuffing_byte 8 bslbf } for (i = 0; i < N2; i++) { PES_packet_data_byte 8 bslbf } } else if ( stream_id = = program_stream_map || stream_id = = private_stream_2 || stream_id = = ECM || stream_id = = EMM || stream_id = = program_stream_directory || stream_id = = DSMCC_stream || stream_id = = ITU-T Rec. H.222.1 type E stream ) { for (i = 0; i < PES_packet_length; i++) { PES_packet_data_byte 8 bslbf } } else if ( stream_id = = padding_stream) { for (i = 0; i < PES_packet_length; i++) { padding_byte 8 bslbf } } +MS-12 A value of 0 indicates that the PES packet length is neither specified nor bounded and is allowed only in PES packets whose payload consists of bytes from a video elementary stream contained in Transport Stream packets. +MS-13 If the preceding stream_id indicates an audio stream, P-STD_buffer_scale shall have the value '0'. If the preceding stream_id indicates a video stream, P-STD_buffer_scale shall have the value '1'. For all other stream types, the value may be either '1' or '0'. P-STD_buffer_size – The P-STD_buffer_size is a 13-bit unsigned integer, the meaning of which is only defined if this PES packet is contained in a Program Stream. It defines the size of the input buffer, BSn, in the P-STD. If P-STD_buffer_scale has the value '0', then the P-STD_buffer_size measures the buffer size in units of 128 bytes. If P-STD_buffer_scale has the value '1', then the P-STD_buffer_size measures the buffer size in units of 1024 bytes. Thus: if (P-STD_buffer_scale = = 0) _ (2-16) BSn = P − STD_buffer_size × 128 else BSn = P − STD_buffer_size × 1024 (2-17) The encoded value of the P-STD buffer size takes effect immediately when the P-STD_buffer_size field is received by the ITU-T Rec. H.222.0 | ISO/IEC 13818-1 System Target Decoder (refer to 2.7.7). PES_extension_field_length – This is a 7-bit field which specifies the length, in bytes, of the data following this field in the PES extension field up to and including any reserved bytes. stuffing_byte – This is a fixed 8-bit value equal to '1111 1111' that can be inserted by the encoder, for example to meet the requirements of the channel. It is discarded by the decoder. No more than 32 stuffing bytes shall be present in one +MS-14 In the case of a private_stream_1, private_stream_2, ECM_stream, or EMM_stream, the contents of the PES_packet_data_byte field are user definable and will not be specified by ITU-T | ISO/IEC in the future. +MS-15 In the case of audio, if a PTS is present in PES packet header it shall refer to the first access unit commencing in the PES packet. An audio access unit commences in a PES packet if the first byte of the audio access unit is present in the PES packet. In the case of video, if a PTS is present in a PES packet header it shall refer to the access unit containing the first picture start code that commences in this PES packet. +MS-16 Bytes present in the pack header, system headers, Program Stream Maps, Program Stream Directories, or PES packet headers of the Program Stream such as SCR, DTS, PTS, and packet_length fields, are not delivered to any of the buffers, but may be used to control the system. The input buffer sizes BS1 through BSn are given by the P-STD buffer size parameter in the syntax in equation 2-16 and equation 2-17. At the decoding time, tdn(j), all data for the access unit that has been in the buffer longest, An(j), and any stuffing bytes that immediately precede it that are present in the buffer at the time tdn(j), are removed instantaneously at time tdn(j). The decoding time tdn(j) is specified in the DTS or PTS fields. Decoding times tdn(j + 1), tdn(j + 2), ... of access units without encoded DTS or PTS fields which directly follow access unit j may be derived from information in the elementary stream. Refer to Annex C of ITU-T Rec. H.262 | ISO/IEC 13818-2, ISO/IEC 13818-3, ISO/IEC 11172-2 or ISO/IEC 11172-3. Also refer to 2.7.5. As the access unit is removed from the buffer, it is instantaneously decoded to a presentation unit. The Program Stream shall be constructed and t(i) shall be chosen so that the input buffers of size BS1 through BSn neither overflow nor underflow in the program system target decoder. That is: 0 ≤ Fn(t) ≤ BSn for all t and n and Fn(t) = 0 instantaneously before t = t(0). Fn(t) is the instantaneous fullness of P-STD buffer Bn. An exception to this condition is that the P-STD buffer Bn may underflow when the low_delay flag in the video sequence header is set to '1' (refer to 2.4.2.6) or when trick_mode status is true (refer to 2.4.3.8). For all Program Streams, the delay caused by system target decoder input buffering shall be less than or equal to one second except for still picture video data and ISO/IEC 14496 streams. +MS-17 The Program Stream shall be constructed such that the time interval between the bytes containing the last bit of system_clock_reference_base fields in successive packs shall be less than or equal to 0.7 s. +MS-18 The Program Stream and Transport Stream shall be constructed so that the maximum difference between coded presentation timestamps referring to each elementary video or audio stream is 0,7 s. Thus: | tpn(k) − tpn(k″) | ≤ 0.7 s for all n, k, and k″ satisfying: • Pn(k) and Pn(k″) are presentation units for which presentation timestamps are coded; • k and k″ are chosen so that there is no presentation unit, Pn(k′) with a coded presentation timestamp and with k < k′ < k″; and • No decoding discontinuity exists in elementary stream n between Pn(k) and Pn(k''). In the case of still pictures the 0,7 s constraint does not apply. +MS-19 For each elementary stream of a Program Stream or Transport Stream, a presentation timestamp (PTS) shall be encoded for the first access unit. A decoding discontinuity exists at the start of an access unit An(j) in an elementary stream n if the decoding time tdn(j) of that access unit is greater than the largest value permissible given the specified tolerance on the system_clock_frequency. For video, except when trick mode status is true or when low_delay flag is '1', this is allowed only at the start of a video sequence. If a decoding discontinuity exists in any elementary video or audio stream in the Transport Stream or Program Stream, then a PTS shall be encoded referring to the first access unit after each decoding discontinuity except when trick mode status is true. When low_delay is '1' a PTS shall be encoded for the first access unit after an EBn or Bn underflow. A PTS may only be present in a ITU-T Rec. H.222.0 | ISO/IEC 13818-1 video or audio elementary stream PES packet header if the first byte of a picture start code or the first byte of an audio access unit is contained in the PES packet. A decoding_timestamp (DTS) shall appear in a PES packet header if and only if the following two conditions are met: • a PTS is present in the PES packet header; • the decoding time differs from the presentation time. +MS-20 If an audio sequence is coded using an ISO/IEC 13818-3 extension bitstream, corresponding decoding/presentation units in the two layers shall have identical PTS values. +MS-21 In a Program Stream, the P-STD_buffer_scale and P-STD_buffer_size fields shall occur in the first PES packet of each elementary stream and again whenever the value changes. They may also occur in any other PES packet. 2.7.8 Coding of system header in the Program Stream In a Program Stream, the system header may be present in any pack, immediately following the pack header. The system header shall be present in the first pack of an Program Stream. The values encoded in all the system headers in the Program Stream shall be identical. +MS-22 0x01 ISO/IEC 11172 Video 0x02 ITU-T Rec. H.262 | ISO/IEC 13818-2 Video or ISO/IEC 11172-2 constrained parameter video stream 0x03 ISO/IEC 11172 Audio 0x04 ISO/IEC 13818-3 Audio 0x05 ITU-T Rec. H.222.0 | ISO/IEC 13818-1 private_sections 0x06 ITU-T Rec. H.222.0 | ISO/IEC 13818-1 PES packets containing private data +MS-23 hierarchy_embedded_layer_index – The hierarchy_embedded_layer_index is a 6-bit field that defines the hierarchy table index of the program element that needs to be accessed before decoding of the elementary stream associated with this hierarchy_descriptor. This field is undefined if the hierarchy_type value is 15 (base layer). hierarchy_channel – The hierarchy_channel is a 6-bit field that indicates the intended channel number for the associated program element in an ordered set of transmission channels. The most robust transmission channel is defined by the lowest value of this field with respect to the overall transmission hierarchy definition. NOTE – A given hierarchy_channel may at the same time be assigned to several program elements. Table 2-43 – Hierarchy descriptor Syntax No. of bits Mnemonic hierarchy_descriptor() { descriptor_tag 8 uimsbf descriptor_length 8 uimsbf reserved 4 bslbf hierarchy_type 4 uimsbf reserved 2 bslbf hierarchy_layer_index 6 uimsbf reserved 2 bslbf hierarchy_embedded_layer_index 6 uimsbf reserved 2 bslbf hierarchy_channel 6 uimsbf } Table 2-44 – Hierarchy_type field values Value Description 0 Reserved 1 ITU-T Rec. H.262 | ISO/IEC 13818-2 Spatial Scalability 2 ITU-T Rec. H.262 | ISO/IEC 13818-2 SNR Scalability 3 ITU-T Rec. H.262 | ISO/IEC 13818-2 Temporal Scalability 4 ITU-T Rec. H.262 | ISO/IEC 13818-2 Data partitioning 5 ISO/IEC 13818-3 Extension bitstream 6 ITU-T Rec.H222.0 | ISO/IEC 13818-1 Private Stream 7 ITU-T Rec. H.262 | ISO/IEC 13818-2 Multi-view Profile 8-14 Reserved 15 Base layer +MS-24 ISO_639_language_descriptor() { descriptor_tag 8 uimsbf descriptor_length 8 uimsbf for (i = 0; i < N; i++) { ISO_639_language_code 24 bslbf audio_type 8 bslbf } } +MS-25 4 X X hierarchy_descriptor 5 X X registration_descriptor 6 X X data_stream_alignment_descriptor 7 X X target_background_grid_descriptor 8 X X Video_window_descriptor 9 X X CA_descriptor 10 X X ISO_639_language_descriptor 11 X X System_clock_descriptor 12 X X Multiplex_buffer_utilization_descriptor 13 X X Copyright_descriptor 14 X Maximum_bitrate_descriptor 15 X X Private_data_indicator_descriptor 16 X X Smoothing_buffer_descriptor 17 X STD_descriptor 18 X X IBP_descriptor 19-26 X Defined in ISO/IEC 13818-6 27 X X MPEG-4_video_descriptor 28 X X MPEG-4_audio_descriptor 29 X X IOD_descriptor 30 X SL_descriptor 31 X X FMC_descriptor 32 X X External_ES_ID_descriptor 33 X X MuxCode_descriptor 34 X X FmxBufferSize_descriptor 35 X MultiplexBuffer_descriptor 36-63 n/a n/a ITU-T Rec. H.222.0 | ISO/IEC 13818-1 Reserved 64-255 n/a n/a User Private +MS-26 This is the CRC calculated with the polynomial: x32 + x26 + x23 + x22 + x16 + x12 + x11 + x10 + x8 + x7 + x5 + x4 + x2 + x + 1 +MS-27 1011 1100 1 program_stream_map 1011 1101 2 private_stream_1 1011 1110 padding_stream 1011 1111 3 private_stream_2 110x xxxx ISO/IEC 13818-3 or ISO/IEC 11172-3 or ISO/IEC 13818-7 or ISO/IEC 14496-3 audio stream number x xxxx 1110 xxxx ITU-T Rec. H.262 | ISO/IEC 13818-2 or ISO/IEC 11172-2 or ISO/IEC 14496-2 video stream number xxxx +MS-28 A range of private descriptors may be defined by the user. These descriptors shall commence with descriptor_tag and descriptor_length fields. For private descriptors, the value of descriptor_tag may take the values 64-55 as identified in Table 2-39. These descriptors may be placed within a program_stream_map() +MS-29 if (startcode == PRIVATE_STREAM_1) { avio_w8(ctx->pb, id); if (id >= 0xa0) { /* LPCM (XXX: check nb_frames) */ avio_w8(ctx->pb, 7); avio_wb16(ctx->pb, 4); /* skip 3 header bytes */ avio_w8(ctx->pb, stream->lpcm_header[0]); avio_w8(ctx->pb, stream->lpcm_header[1]); avio_w8(ctx->pb, stream->lpcm_header[2]); } else if (id >= 0x40) { /* AC-3 */ avio_w8(ctx->pb, nb_frames); avio_wb16(ctx->pb, trailer_size + 1); } } +MS-30 stream->lpcm_header[0] = 0x0c; stream->lpcm_header[1] = (freq << 4) | (((st->codecpar->bits_per_coded_sample - 16) / 4) << 6) | st->codecpar->ch_layout.nb_channels - 1; stream->lpcm_header[2] = 0x80; +MS-31 if (stream_id != program_stream_map && stream_id != padding_stream && stream_id != private_stream_2 && stream_id != ECM && stream_id != EMM && stream_id != program_stream_directory && stream_id != DSMCC_stream && stream_id != ITU-T Rec. H.222.1 type E stream) { '10' 2 bslbf PES_scrambling_control 2 bslbf diff --git a/tests/spec_sites.txt b/tests/spec_sites.txt index 2351259a..306e6b2e 100644 --- a/tests/spec_sites.txt +++ b/tests/spec_sites.txt @@ -1,6 +1,6 @@ # Spec-governed code sites (KU design §3.6.1 rule 11): `\t\t`, one row per # (site, quote). `spec_governed_sites_cite_the_spec` checks that the body of `fn ` in -# `` names ``. KU rows are KS-n; Stop adds its SS-n rows here. +# `` names ``. KU rows are KS-n; Stop adds its SS-n rows, mpg:// its MS-n. src/aacs/content.rs clear_copy_permission_indicator KS-5 src/aacs/content.rs clear_copy_permission_indicator KS-6 src/aacs/content.rs clear_copy_permission_indicator KS-22 @@ -36,3 +36,41 @@ src/keys/arrival.rs side_partners KS-7 src/keys/fmts.rs anchor KS-26 src/keys/fmts.rs phases KS-25 src/keys/fmts.rs phases KS-26 +src/mux/mpg/pack.rs pack_header MS-2 +src/mux/mpg/pack.rs pack_header MS-3 +src/mux/mpg/pack.rs pack_header MS-4 +src/mux/mpg/pack.rs system_header MS-5 +src/mux/mpg/pack.rs system_header MS-6 +src/mux/mpg/pack.rs system_header MS-7 +src/mux/mpg/pack.rs crc32 MS-26 +src/mux/mpg/pack.rs psm MS-8 +src/mux/mpg/pack.rs psm MS-9 +src/mux/mpg/pack.rs hierarchy_descriptor MS-23 +src/mux/mpg/pack.rs iso639_descriptor MS-24 +src/mux/mpg/pack.rs fmkv_descriptors MS-28 +src/mux/mpg/pack.rs pes_header MS-10 +src/mux/mpg/pack.rs pes_header MS-11 +src/mux/mpg/pack.rs pes_header MS-12 +src/mux/mpg/fit.rs want MS-22 +src/mux/mpg/fit.rs want MS-27 +src/mux/mpg/pstd.rs room MS-16 +src/mux/mpg/pstd.rs push MS-18 +src/mux/mpg/pstd.rs emit_pes MS-14 +src/mux/mpg/pstd.rs emit_pes MS-15 +src/mux/mpg/pstd.rs write_pack MS-16 +src/mux/mpg/pstd.rs emit_pes MS-19 +src/mux/mpg/pstd.rs emit_pes MS-21 +src/mux/mpg/pstd.rs emit_pes MS-29 +src/mux/mpg/pstd.rs write_pack MS-3 +src/mux/mpg/pstd.rs write_pack MS-4 +src/mux/mpg/pstd.rs pump MS-17 +src/mux/mpg/mod.rs first_pack_prefix MS-7 +src/mux/mpg/mod.rs first_pack_prefix MS-8 +src/mux/mpg/mod.rs first_pack_prefix MS-13 +src/mux/mpg/mod.rs first_pack_prefix MS-22 +src/mux/mpg/mod.rs first_pack_prefix MS-23 +src/mux/codec/lpcm.rs dvd_header MS-30 +src/mux/mpg/scan.rs parse_map MS-8 +src/mux/mpg/scan.rs parse_map MS-9 +src/mux/mpg/scan.rs scan MS-23 +src/css/mod.rs ps_scrambled_at MS-31