diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index 748135d..54a4261 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -660,6 +660,15 @@ pub struct DemuxSink { /// Index = track id; `None` for unselected tracks. tracks: Vec>, ref_video_track: Option, + /// First PTS observed on `ref_video_track`, recorded in `write()` REGARDLESS + /// of whether that track has a `TrackOut`. The DELAY reference cannot live in + /// `TrackOut::first_pts_ns`: `audio://` / `sub://` filter the video track's + /// output away in `create()`, so no `TrackOut` exists to record it, and the + /// old `unwrap_or(0)` fallback then measured every delay against a reference + /// of zero and baked a plausible-looking wrong `DELAY` into the filename. + /// `None` = no reference seen → no delay is emitted at all (see + /// `apply_delays`). + ref_first_pts_ns: Option, timeline: TimelineContinuity, finished: bool, } @@ -732,6 +741,7 @@ impl DemuxSink { opts: opts.clone(), tracks, ref_video_track, + ref_first_pts_ns: None, timeline: TimelineContinuity::new(), finished: false, }) @@ -758,11 +768,19 @@ impl DemuxSink { if self.opts.delay_mode == DelayMode::None { return Ok(()); } - let ref_pts = self - .ref_video_track - .and_then(|t| self.tracks.get(t).and_then(|o| o.as_ref())) - .and_then(|t| t.first_pts_ns) - .unwrap_or(0); + // No video reference (audio-only title, or the reference track never + // produced a frame) → there is nothing to measure a delay against. + // OMIT the delay entirely rather than fall back to a reference of zero: + // a filename claiming `DELAY 600ms` when the real offset is unknown is a + // silently wrong number that downstream muxers will act on, while a + // missing tag is simply "no delay information", which is the truth. + let Some(ref_pts) = self.ref_first_pts_ns else { + tracing::warn!( + target: "mux", + "demux sink: no reference video PTS observed; omitting audio DELAY metadata" + ); + return Ok(()); + }; let mut sidecar_lines = String::new(); @@ -849,6 +867,12 @@ impl Stream for DemuxSink { // non-video epoch driver would ratchet the frontier on sparse/lagging PTS. let drives = Some(frame.track) == self.ref_video_track; let pts = self.timeline.adjust(frame.pts, drives); + if drives { + // Delay reference: recorded here, not in the track's `TrackOut`, so + // it survives the `audio://` / `sub://` kind filter dropping the + // video output. + self.ref_first_pts_ns.get_or_insert(pts); + } if let Some(Some(t)) = self.tracks.get_mut(frame.track) { t.first_pts_ns.get_or_insert(pts); t.writer.write_frame(&mut t.w, frame, pts)?; @@ -967,6 +991,95 @@ mod tests { ); } + /// `audio://` filters the video track's file out, but the video track is + /// still the DELAY reference. The delay must be measured against the video's + /// actual first PTS, not against an assumed zero — a filename that says + /// `DELAY 600ms` when the true audio offset is 100ms is worse than no tag. + #[test] + fn audio_only_sink_delays_against_filtered_video_reference() { + let dir = tempdir(); + let title = title_with( + vec![video_stream(Codec::Mpeg2), audio_stream(Codec::Ac3, "eng")], + vec![None, None], + ); + let opts = DemuxOptions { + base: "Ao".to_string(), + kind_filter: Some(TrackKind::Audio), + export_chapters: false, + ..Default::default() + }; + let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap(); + // Video starts at 500ms, audio at 600ms → true delay is +100ms. + sink.write(&PesFrame { + coding: None, + source: None, + track: 0, + pts: 500_000_000, + keyframe: true, + data: vec![0x00, 0x00, 0x01, 0xB3], + duration_ns: None, + }) + .unwrap(); + sink.write(&PesFrame { + coding: None, + source: None, + track: 1, + pts: 600_000_000, + keyframe: true, + data: vec![0x0B, 0x77], + duration_ns: None, + }) + .unwrap(); + sink.finish().unwrap(); + + let names: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + names.iter().any(|n| n == "Ao t01 eng AC3 DELAY 100ms.ac3"), + "audio delay must be relative to the filtered video reference \ + (500ms), got {names:?}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// With no video reference at all (audio-only title), there is nothing to + /// measure the delay against. Omit the DELAY tag rather than emit one + /// computed against a fabricated zero reference. + #[test] + fn no_video_reference_omits_delay_tag() { + let dir = tempdir(); + let title = title_with(vec![audio_stream(Codec::Ac3, "eng")], vec![None]); + let opts = DemuxOptions { + base: "NoRef".to_string(), + export_chapters: false, + ..Default::default() + }; + let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap(); + sink.write(&PesFrame { + coding: None, + source: None, + track: 0, + pts: 600_000_000, + keyframe: true, + data: vec![0x0B, 0x77], + duration_ns: None, + }) + .unwrap(); + sink.finish().unwrap(); + + let names: Vec = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + names.iter().all(|n| !n.to_lowercase().contains("delay")), + "no video reference → no DELAY tag, got {names:?}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + // ── Annex-B reframing ──────────────────────────────────────────────────── // // The length-prefixed → Annex-B conversion and the hvcC/avcC param-set diff --git a/src/mux/disc.rs b/src/mux/disc.rs index b5a80a1..ae76394 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -265,34 +265,17 @@ impl DiscStream { // for non-DVD or when the probe yields nothing. crate::disc::dvd_audio_probe::probe_and_remap(&mut reader, &mut title); - let mut pids = Vec::new(); - let mut parsers = Vec::new(); - let mut pid_to_track = Vec::new(); - for (idx, s) in title.streams.iter().enumerate() { - let (pid, codec) = match s { - crate::disc::Stream::Video(v) => (v.pid, v.codec), - crate::disc::Stream::Audio(a) => (a.pid, a.codec), - crate::disc::Stream::Subtitle(s) => (s.pid, s.codec), - }; - pids.push(pid); - pid_to_track.push((pid, idx)); - let is_dvd_ps = matches!(content_format, crate::disc::ContentFormat::MpegPs); - parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps))); - } - - let mut ts_demuxer = None; - let mut ps_demuxer = None; - match content_format { - crate::disc::ContentFormat::MpegPs => { - ps_demuxer = Some(super::ps::PsDemuxer::new()); - } - crate::disc::ContentFormat::BdTs => { - let ts_pids: Vec = pids.clone(); - if !ts_pids.is_empty() { - ts_demuxer = Some(super::ts::TsDemuxer::new(&ts_pids)); - } - } - } + // Parser table + PID map + demuxer come from the CANONICAL builder shared + // with the file-backed highway (`resolve::build_demux_state`). This used + // to be an open-coded copy of the same loop, which drifted: it built every + // parser with plain `parser_for_codec` and so never routed a Blu-ray 3D + // MVC dependent (right-eye) view to the param-set-passthrough parser + // (ISO/IEC 14496-10 Annex H) — the same 3D disc muxed correctly from an + // ISO and incorrectly from a live drive. Call the canonical builder rather + // than repeating its dispatch, so a future rule added there cannot go + // missing on the `disc://` path again. + let (parsers, pid_to_track, ts_demuxer, ps_demuxer) = + super::resolve::build_demux_state(&title, content_format); // AACS decrypts whole 6144-byte (3-sector) units keyed off each read // buffer's first 16 bytes, so reads/skips must stay 3-sector aligned. @@ -1139,6 +1122,123 @@ mod tests { assert_eq!(frames, 0); } + /// A Blu-ray 3D title: an AVC base view plus the MVC dependent (right-eye) + /// view, marked by `MVC_DEPENDENT_LABEL`. + fn mvc_title() -> DiscTitle { + use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream}; + let view = |pid: u16, label: &str| { + crate::disc::Stream::Video(VideoStream { + pid, + codec: Codec::H264, + resolution: Resolution::R1080p, + frame_rate: FrameRate::F23_976, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + display_aspect: None, + secondary: false, + label: label.to_string(), + measured_cicp: None, + }) + }; + let mut t = synthetic_title(8); + t.streams = vec![ + view(0x1011, ""), // base view + view(0x1012, crate::disc::MVC_DEPENDENT_LABEL), // dependent view + ]; + t.content_format = ContentFormat::BdTs; + t + } + + /// A dependent-view access unit: PPS (NAL 8) + coded-slice-extension (NAL 20), + /// no IDR. The base-view parser strips the PPS from a non-keyframe AU; the + /// MVC passthrough parser keeps every parameter set in-band so each dependent + /// frame is a self-contained access unit (ISO/IEC 14496-10 Annex H). + fn mvc_dependent_au_pes(pid: u16) -> crate::mux::ts::PesPacket { + let nal = |t: u8, body: &[u8]| { + let mut v = vec![0x00, 0x00, 0x01, t]; + v.extend_from_slice(body); + v + }; + let mut data = Vec::new(); + data.extend_from_slice(&nal(0x68, &[0xCE, 0x01])); // PPS (8) + data.extend_from_slice(&nal(0x74, &[0x11, 0x22])); // slice extension (20) + crate::mux::ts::PesPacket { + source: None, + pid, + pts: Some(90_000), + dts: None, + data, + discontinuity: false, + } + } + + /// Whether a parser's output for a dependent-view AU still carries the PPS — + /// the observable signature of the MVC param-set-passthrough parser. + fn keeps_pps_inband(parser: &mut dyn super::super::codec::CodecParser, pid: u16) -> bool { + let frames = parser.parse(&mvc_dependent_au_pes(pid)); + assert_eq!(frames.len(), 1, "one access unit in, one frame out"); + // Frame payload is length-prefixed NALs (4-byte BE length + NAL). + let d = &frames[0].data; + let mut i = 0; + while i + 4 <= d.len() { + let len = u32::from_be_bytes([d[i], d[i + 1], d[i + 2], d[i + 3]]) as usize; + i += 4; + if i + len > d.len() { + break; + } + if len > 0 && d[i] & 0x1F == 8 { + return true; + } + i += len; + } + false + } + + /// The LIVE `disc://` path (`DiscStream::new`) must dispatch the Blu-ray 3D + /// MVC dependent view to the param-set-passthrough parser, exactly as the + /// file-backed ISO path (`resolve::build_demux_state`) does. Before this was + /// shared, the live path built every parser with plain `parser_for_codec`, so + /// the same 3D disc muxed correctly from an ISO and incorrectly from a drive. + #[test] + fn live_path_dispatches_mvc_dependent_view_to_passthrough_parser() { + let title = mvc_title(); + let mut stream = DiscStream::new( + Box::new(ZeroReader { capacity: 8 }), + title.clone(), + crate::decrypt::DecryptKeys::None, + 8, + ContentFormat::BdTs, + false, + None, + ) + .unwrap(); + + let idx_of = |parsers: &Vec<(u16, Box)>, pid: u16| { + parsers.iter().position(|(p, _)| *p == pid).unwrap() + }; + + // Live path: dependent view keeps its PPS in-band, base view does not. + let dep = idx_of(&stream.parsers, 0x1012); + assert!( + keeps_pps_inband(stream.parsers[dep].1.as_mut(), 0x1012), + "live disc:// path must give the MVC dependent view the passthrough parser" + ); + let base = idx_of(&stream.parsers, 0x1011); + assert!( + !keeps_pps_inband(stream.parsers[base].1.as_mut(), 0x1011), + "base view keeps the ordinary parser (discriminator is real, not vacuous)" + ); + + // And it must agree with the ISO path, which is the canonical builder. + let (mut iso_parsers, _, _, _) = + crate::mux::resolve::build_demux_state(&title, ContentFormat::BdTs); + let iso_dep = idx_of(&iso_parsers, 0x1012); + assert!( + keeps_pps_inband(iso_parsers[iso_dep].1.as_mut(), 0x1012), + "ISO path reference behaviour" + ); + } + /// `is_halted()` must observe a cancellation signal installed via /// `with_halt(Halt)` — flipping the token must cause the next /// `fill_extents` retry boundary to bail. diff --git a/src/mux/meta_sink.rs b/src/mux/meta_sink.rs index d7ece1e..16ee186 100644 --- a/src/mux/meta_sink.rs +++ b/src/mux/meta_sink.rs @@ -178,12 +178,26 @@ fn stream_json(s: &DiscStream) -> serde_json::Value { "pid": a.pid, "language": a.language, "channels": a.channels.to_string(), - "channel_count": a.channels.count(), "sample_rate": a.sample_rate.to_string(), - "sample_rate_hz": a.sample_rate.hz(), "secondary": a.secondary, "purpose": purpose_id(a.purpose), }); + // `AudioChannels::count()` and `SampleRate::hz()` FABRICATE a concrete + // value for the `Unknown` variant (6 channels / 48000 Hz), so calling + // them unconditionally reported a confident 5.1 / 48 kHz for audio + // whose format is genuinely unknown — contradicting the neighbouring + // `channels` / `sample_rate` strings, which honestly say "unknown". + // Omit the numeric key entirely instead: the same guard `mkv.rs` + // applies before writing Channels / SamplingFrequency (there it emits + // 0 so the EBML serializer drops the element). Kept as a guard here + // rather than fixed in `count()`/`hz()` because those return + // non-optional scalars that other callers rely on. + if !matches!(a.channels, crate::disc::AudioChannels::Unknown) { + o["channel_count"] = json!(a.channels.count()); + } + if !matches!(a.sample_rate, crate::disc::SampleRate::Unknown) { + o["sample_rate_hz"] = json!(a.sample_rate.hz()); + } if !a.label.is_empty() { o["label"] = json!(a.label); } @@ -346,6 +360,44 @@ mod tests { assert_eq!(v["chapters"][1]["name"], "2"); } + /// An audio stream whose channel layout / sample rate are genuinely unknown + /// must not be reported with a fabricated 5.1 / 48 kHz. `AudioChannels::count()` + /// maps `Unknown` to 6 and `SampleRate::hz()` maps `Unknown` to 48000, so the + /// numeric fields must be omitted rather than computed — otherwise the JSON + /// contradicts its own `channels` / `sample_rate` strings ("unknown"). + #[test] + fn unknown_audio_layout_omits_fabricated_numeric_fields() { + use crate::disc::{AudioChannels, AudioStream, Codec, DiscTitle}; + use crate::disc::{LabelPurpose, SampleRate, Stream as DiscStream}; + let mut t = DiscTitle::empty(); + t.streams = vec![DiscStream::Audio(AudioStream { + pid: 0x1100, + codec: Codec::DtsHdMa, + channels: AudioChannels::Unknown, + language: "eng".into(), + sample_rate: SampleRate::Unknown, + secondary: false, + purpose: LabelPurpose::Normal, + label: String::new(), + })]; + let v = title_json(&t); + let a = &v["streams"][0]; + // The honest string fields. + assert_eq!(a["channels"], "unknown"); + assert_eq!(a["sample_rate"], "unknown"); + // The numeric fields must not assert a value the scan never resolved. + assert!( + a["channel_count"].is_null(), + "unknown channel layout must not report a channel_count, got {}", + a["channel_count"] + ); + assert!( + a["sample_rate_hz"].is_null(), + "unknown sample rate must not report a sample_rate_hz, got {}", + a["sample_rate_hz"] + ); + } + #[test] fn video_json_carries_resolution_and_hdr() { use crate::disc::Codec; diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 4b60544..27c49fb 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -720,7 +720,7 @@ pub fn output( /// table (keyed by PID), the PID-to-track index map, and an initial /// `TsDemuxer` / `PsDemuxer` (whichever the content format calls /// for). -type DemuxState = ( +pub(crate) type DemuxState = ( Vec<(u16, Box)>, Vec<(u16, usize)>, Option, @@ -729,7 +729,7 @@ type DemuxState = ( /// Build the title's codec parser table + initial `TsDemuxer` / /// `PsDemuxer`. Used by both the ISO and M2TS pipeline builders. -fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState { +pub(crate) fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState { let mut pids = Vec::new(); let mut parsers = Vec::new(); let mut pid_to_track = Vec::new(); diff --git a/src/mux/ts.rs b/src/mux/ts.rs index 4ab238c..5316a02 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -487,14 +487,7 @@ impl TsDemuxer { // adaptation == 0x02 (AF only) already returned above, so only 0x03 // (AF + payload) can carry an adaptation field here. let discontinuity_flag = adaptation == 0x03 && ts[4] > 0 && (ts[5] & 0x80) != 0; - // A gap is a CC that is neither the expected `(prev + 1) & 0xf` nor a - // duplicate `prev` (ISO 13818-1 permits a packet to repeat its CC; a - // duplicate is not a loss). Anything else means one or more packets for - // this PID were dropped. - let cc_gap = match asm.last_cc { - Some(prev) => cc != ((prev + 1) & 0x0f) && cc != prev, - None => false, - }; + let cc_gap = cc_is_gap(asm.last_cc, cc); asm.last_cc = Some(cc); // A continuity gap means packets for THIS PID were lost (a damaged source, // or — for the conceal path — a loss that the CC-independent NULL-TS marker @@ -657,6 +650,31 @@ fn parse_timestamp(data: &[u8]) -> Option { Some(((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1) } +/// Canonical continuity-counter gap test (ISO/IEC 13818-1 §2.4.3.3). +/// +/// The 4-bit `continuity_counter` increments by one for each TS packet of a PID +/// that CARRIES PAYLOAD — a packet with adaptation field only does not increment +/// it, so such packets must be excluded by the caller rather than diffed here. +/// A packet MAY legally repeat the previous counter (the spec's duplicate +/// packet, whose payload is identical); that is not a loss. Anything else means +/// one or more packets for the PID were dropped. +/// +/// `last_cc` is `None` before the first payload packet of a PID, where there is +/// nothing to diff against and no gap can be asserted. +/// +/// Single source of truth for BOTH users in this file: the PES assembler +/// (`process_packet`) and the PSI section reassembler (`collect_psi_section`). +/// `collect_psi_section` used to reimplement it as a strict `cc != expected`, +/// which rejected legal duplicates and legal AF-only packets — a spec-conformant +/// PMT continuation was then reported as desync and the title's stream list came +/// back empty. +fn cc_is_gap(last_cc: Option, cc: u8) -> bool { + match last_cc { + Some(prev) => cc != ((prev + 1) & 0x0f) && cc != prev, + None => false, + } +} + // ============================================================ // Stream scanning (PAT/PMT → stream list) // ============================================================ @@ -765,13 +783,18 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option Option Vec { + let section_length = 9 + entries.len() * 5 + 4; + let mut section = Vec::new(); + section.push(0x02); // table_id + section.push(0xB0 | (((section_length >> 8) as u8) & 0x0F)); + section.push((section_length & 0xFF) as u8); + section.extend_from_slice(&[0x00, 0x01]); // program_number + section.push(0xC1); // version/current_next + section.push(0x00); // section_number + section.push(0x00); // last_section_number + section.extend_from_slice(&[0xE0, 0x00]); // PCR PID + section.extend_from_slice(&[0xF0, 0x00]); // program_info_length = 0 + for &(stype, es_pid) in entries { + section.push(stype); + section.push(0xE0 | (((es_pid >> 8) as u8) & 0x1F)); + section.push((es_pid & 0xFF) as u8); + section.extend_from_slice(&[0xF0, 0x00]); // ES_info_length = 0 + } + section.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]); // CRC (unchecked) + section + } + + /// Split a PSI section into BD-TS packets on `pid`: a PUSI packet carrying + /// the pointer_field + head, then continuation packets, each with the + /// continuity counter incremented by one (every packet here carries payload). + fn psi_packets(pid: u16, section: &[u8]) -> Vec> { + let mut pkts = Vec::new(); + let head_len = (184 - 1).min(section.len()); + let mut p0 = [0xFFu8; 184]; + p0[0] = 0x00; // pointer_field + p0[1..1 + head_len].copy_from_slice(§ion[..head_len]); + pkts.push(bdts_packet(p0, pid, true)); + let mut pos = head_len; + let mut cc = 0u8; + while pos < section.len() { + let n = 184.min(section.len() - pos); + let mut p = [0xFFu8; 184]; + p[..n].copy_from_slice(§ion[pos..pos + n]); + let mut pkt = bdts_packet(p, pid, false); + cc = (cc + 1) & 0x0F; + pkt[7] = (pkt[7] & 0xF0) | cc; + pkts.push(pkt); + pos += n; + } + pkts + } + + /// An adaptation-field-ONLY BD-TS packet (AFC = 0b10, no payload) on `pid`. + /// ISO/IEC 13818-1 §2.4.3.3: such a packet does NOT increment the + /// continuity_counter, so it repeats the previous packet's value. + fn af_only_packet(pid: u16, cc: u8) -> Vec { + let mut pkt = vec![0xFFu8; BD_SOURCE_PACKET_BYTES]; + pkt[..4].fill(0); // TP_extra_header + pkt[4] = SYNC_BYTE; + pkt[5] = ((pid >> 8) as u8) & 0x1F; // no PUSI + pkt[6] = (pid & 0xFF) as u8; + pkt[7] = 0x20 | (cc & 0x0F); // AFC = 0b10 (adaptation field only) + pkt[8] = 183; // adaptation_field_length fills the packet + pkt[9] = 0x00; // AF flags (no discontinuity_indicator) + pkt + } + + /// A spec-legal adaptation-field-only packet interleaved between PMT + /// continuations must not be mistaken for a continuity desync: per ISO/IEC + /// 13818-1 §2.4.3.3 a packet with no payload does not increment the + /// continuity_counter, so it repeats the previous value. Misdiagnosing it + /// abandoned the PMT and returned an EMPTY stream list for a perfectly + /// valid title. + #[test] + fn scan_streams_tolerates_af_only_packet_between_pmt_continuations() { + let pmt_pid = 0x0100; + let mut entries: Vec<(u8, u16)> = vec![(0x1B, 0x1011)]; + for i in 0..40u16 { + entries.push((0x80, 0x1100 + i)); + } + let pkts = psi_packets(pmt_pid, &pmt_section(&entries)); + assert!(pkts.len() >= 2, "section must span a continuation"); + + let mut data = pat_packet(pmt_pid); + data.extend(pkts[0].clone()); + // AF-only packet after the PUSI packet (CC = 0, unchanged). + data.extend(af_only_packet(pmt_pid, 0)); + for p in &pkts[1..] { + data.extend(p.clone()); + } + + let streams = scan_streams(&data) + .expect("an adaptation-field-only packet must not abort PMT assembly"); + assert_eq!(streams.len(), entries.len(), "every PMT entry reassembled"); + } + + /// A duplicate TS packet (same continuity_counter, identical payload) is + /// explicitly legal (ISO/IEC 13818-1 §2.4.3.3) — `process_packet` already + /// tolerates it. The PSI reassembler must too: treat it as a duplicate + /// (payload NOT appended a second time), not as a desync. + #[test] + fn scan_streams_tolerates_duplicate_pmt_continuation_packet() { + use crate::disc::{Codec, Stream}; + let pmt_pid = 0x0100; + // Enough entries that the section spans three packets, so the duplicate + // lands mid-assembly rather than after the section has completed. + let mut entries: Vec<(u8, u16)> = vec![(0x1B, 0x1011)]; + for i in 0..90u16 { + entries.push((0x80, 0x1100 + i)); + } + let pkts = psi_packets(pmt_pid, &pmt_section(&entries)); + assert!( + pkts.len() >= 3, + "section must span at least two continuations, got {}", + pkts.len() + ); + + let mut data = pat_packet(pmt_pid); + data.extend(pkts[0].clone()); + data.extend(pkts[1].clone()); + data.extend(pkts[1].clone()); // legal duplicate of the first continuation + for p in &pkts[2..] { + data.extend(p.clone()); + } + + let streams = + scan_streams(&data).expect("a duplicate PSI packet must not abort PMT assembly"); + assert_eq!(streams.len(), entries.len(), "every PMT entry reassembled"); + assert!( + streams.iter().any( + |s| matches!(s, Stream::Audio(a) if a.pid == 0x1100 + 89 && a.codec == Codec::Lpcm) + ), + "the trailing entry survives (duplicate payload was not spliced in twice)" + ); + } + /// Regression for the PSI continuity-counter guard: a continuation packet /// whose CC does NOT increment from the PUSI packet is a desync (dropped or /// reordered packet). `collect_psi_section` must abandon that assembly