Skip to main content

mpeg_ts/
resync.rs

1//! Stateful TS byte-stream resynchroniser — ITU-T H.222.0 §2.4.3.2 (= ISO/IEC 13818-1 §2.4.3.2).
2//!
3//! Recovers 188-byte MPEG-TS packet alignment from an arbitrary byte stream
4//! (file reads, UDP payloads) that may start mid-packet or contain leading
5//! garbage.  Also detects 204-byte Reed-Solomon-coded packets (DVB RS-coded
6//! outer forward-error-correction layer) and strips the 16 parity bytes,
7//! yielding standard 188-byte TS packets in both cases.
8//!
9//! # Feature gate
10//!
11//! This module is only compiled when the `ts` feature is enabled (the
12//! default), because it depends on the TS constants in [`crate::ts`].
13//!
14//! # Example
15//!
16//! ```
17//! use mpeg_ts::resync::TsResync;
18//!
19//! let mut r = TsResync::new();
20//! // Feed arbitrary bytes (file chunks, UDP datagrams, etc.).
21//! let packets: Vec<[u8; 188]> = r.feed(b"some raw bytes");
22//! let stats = r.stats();
23//! ```
24
25use alloc::vec::Vec;
26
27use crate::ts::{TS_PACKET_SIZE, TS_SYNC_BYTE};
28
29/// Reed-Solomon-coded TS packet size: 188-byte payload + 16 parity bytes
30/// (DVB RS outer FEC, ISO/IEC 13818-1 §2.4.3.2 informative note).
31pub const RS_PACKET_SIZE: usize = 204;
32
33/// Number of Reed-Solomon parity bytes appended to a 204-byte packet.
34pub const RS_PARITY_LEN: usize = RS_PACKET_SIZE - TS_PACKET_SIZE;
35
36/// Consecutive sync bytes at the candidate stride required to declare lock.
37pub const LOCK_CONFIRMATIONS: usize = 5;
38
39/// Detected packet size after locking.
40#[non_exhaustive]
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize))]
43pub enum PacketStride {
44    /// Standard 188-byte TS packets (ISO/IEC 13818-1 §2.4.3.2).
45    Ts188,
46    /// 204-byte packets (188-byte TS + 16 Reed-Solomon parity bytes).
47    Rs204,
48}
49
50impl PacketStride {
51    /// Short machine-readable label for this stride.
52    pub fn name(&self) -> &'static str {
53        match self {
54            PacketStride::Ts188 => "ts_188",
55            PacketStride::Rs204 => "rs_204",
56        }
57    }
58}
59
60broadcast_common::impl_spec_display!(PacketStride);
61
62/// Counters accumulated during resynchronisation.
63#[non_exhaustive]
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize))]
66pub struct ResyncStats {
67    /// Total 188-byte TS packets emitted.
68    pub packets: u64,
69    /// Times sync was lost and reacquired.
70    pub resyncs: u64,
71    /// Bytes skipped/dropped (junk before lock + sync-loss bytes).
72    pub dropped_bytes: u64,
73}
74
75/// One span of bytes the resynchroniser discarded **after** it had locked
76/// (the stream lost sync mid-way, or ended on a truncated packet).
77///
78/// Drained with [`TsResync::take_losses`]; one entry per lost-sync event, so a
79/// caller can report each skipped unit exactly once. Junk *before* the first
80/// lock (a capture that starts mid-packet) is not a loss and is only counted in
81/// [`ResyncStats::dropped_bytes`].
82#[non_exhaustive]
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize))]
85pub struct ResyncLoss {
86    /// Byte offset, from the first byte ever fed, where the discarded span starts.
87    pub offset: u64,
88    /// Length of the discarded span in bytes.
89    pub dropped_bytes: u64,
90    /// Number of whole packets emitted before this loss, i.e. the index the
91    /// next intact packet will carry.
92    pub packet_index: u64,
93    /// `true` when the span is a truncated trailing packet found by
94    /// [`TsResync::finish`] rather than a sync loss in mid-stream.
95    pub truncated_tail: bool,
96}
97
98/// Stateful TS byte-stream resynchroniser (ISO/IEC 13818-1 §2.4.3.2).
99///
100/// Recovers 188-byte MPEG-TS packet alignment from an arbitrary byte stream
101/// that may start mid-packet or contain junk, and detects 204-byte
102/// Reed-Solomon-coded packets (stripping the 16 parity bytes).
103///
104/// Feed raw bytes with [`feed`](Self::feed); each call returns a `Vec` of
105/// aligned 188-byte TS packets.  Bytes are buffered across calls so that
106/// packet boundaries may span call boundaries freely.
107///
108/// Lock is declared after [`LOCK_CONFIRMATIONS`] consecutive sync bytes are
109/// found at the candidate stride (188 or 204).  On sync loss the resynchroniser
110/// re-scans from the byte after the lost position.
111///
112/// # Stats
113///
114/// [`stats`](Self::stats) returns cumulative counters (packets emitted,
115/// resyncs, dropped bytes).
116#[derive(Debug, Default)]
117pub struct TsResync {
118    buf: Vec<u8>,
119    /// Logical read head into `buf`; compacted periodically.
120    head: usize,
121    stride: Option<PacketStride>,
122    stats: ResyncStats,
123    /// Bytes already compacted away (stream offset of `buf[0]`).
124    consumed: u64,
125    /// Stream offset where the currently open sync loss began.
126    open_loss: Option<u64>,
127    /// Closed losses not yet drained by [`take_losses`](Self::take_losses).
128    losses: Vec<ResyncLoss>,
129}
130
131impl TsResync {
132    /// Create a new resynchroniser with an empty internal buffer.
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    /// Feed `data` and emit every newly-aligned 188-byte TS packet.
138    ///
139    /// For a 204-byte stream the 16 Reed-Solomon parity bytes are stripped;
140    /// only the 188-byte TS payload is returned.  Bytes that cannot yet form
141    /// a complete packet (or fall before lock) are buffered for the next call.
142    ///
143    /// **Drain [`take_losses`](Self::take_losses).** Every span discarded after
144    /// lock is appended to an internal list (about 32-40 bytes each) that only
145    /// `take_losses` empties; a caller that feeds forever and never drains it
146    /// grows memory linearly with the number of re-locks on a lossy input.
147    pub fn feed(&mut self, data: &[u8]) -> Vec<[u8; TS_PACKET_SIZE]> {
148        let mut emitted = Vec::new();
149        self.feed_into(data, &mut emitted);
150        emitted
151    }
152
153    /// [`feed`](Self::feed) appending to a caller-owned `out` (not cleared),
154    /// so a steady-state caller that reuses one `Vec` allocates nothing per
155    /// call (audit r01-O4). The output capacity is reserved once from the
156    /// buffered byte count instead of growing by doubling.
157    ///
158    /// Returns the number of packets appended.
159    pub fn feed_into(&mut self, data: &[u8], emitted: &mut Vec<[u8; TS_PACKET_SIZE]>) -> usize {
160        self.buf.extend_from_slice(data);
161        let before = emitted.len();
162        emitted.reserve((self.buf.len() - self.head) / TS_PACKET_SIZE);
163
164        loop {
165            match self.stride {
166                None => {
167                    if let Some((offset, s)) = find_sync(&self.buf[self.head..]) {
168                        self.stats.dropped_bytes += offset as u64;
169                        self.head += offset;
170                        self.stride = Some(s);
171                        if let Some(start) = self.open_loss.take() {
172                            self.losses.push(ResyncLoss {
173                                offset: start,
174                                dropped_bytes: self.consumed + self.head as u64 - start,
175                                packet_index: self.stats.packets,
176                                truncated_tail: false,
177                            });
178                        }
179                    } else {
180                        // Keep at most enough bytes to detect a future lock.
181                        let keep = LOCK_CONFIRMATIONS * RS_PACKET_SIZE;
182                        let tail_len = self.buf.len() - self.head;
183                        if tail_len > keep {
184                            let excess = tail_len - keep;
185                            self.stats.dropped_bytes += excess as u64;
186                            self.head += excess;
187                        }
188                        self.compact();
189                        return emitted.len() - before;
190                    }
191                }
192                Some(stride) => {
193                    let s = match stride {
194                        PacketStride::Ts188 => TS_PACKET_SIZE,
195                        PacketStride::Rs204 => RS_PACKET_SIZE,
196                    };
197                    let tail_len = self.buf.len() - self.head;
198                    if tail_len < s {
199                        self.compact();
200                        return emitted.len() - before;
201                    }
202                    if self.buf[self.head] == TS_SYNC_BYTE {
203                        let mut packet = [0u8; TS_PACKET_SIZE];
204                        packet.copy_from_slice(&self.buf[self.head..self.head + TS_PACKET_SIZE]);
205                        emitted.push(packet);
206                        self.head += s;
207                        self.stats.packets += 1;
208                    } else {
209                        // Sync byte missing — record the loss and re-scan.
210                        self.stats.resyncs += 1;
211                        self.stats.dropped_bytes += 1;
212                        self.open_loss
213                            .get_or_insert(self.consumed + self.head as u64);
214                        self.head += 1;
215                        self.stride = None;
216                    }
217                }
218            }
219        }
220    }
221
222    /// Detected packet stride, or [`None`] before the stream has locked.
223    pub fn stride(&self) -> Option<PacketStride> {
224        self.stride
225    }
226
227    /// Drain the sync losses recorded since the last call (see [`ResyncLoss`]).
228    ///
229    /// This is the only thing that empties the internal loss list, which is
230    /// unbounded by design (dropping a loss would hide data loss): call it
231    /// regularly — e.g. after every [`feed`](Self::feed) — or the list grows
232    /// with every re-lock on a lossy input. If you do not need the losses,
233    /// still call it and discard the result.
234    pub fn take_losses(&mut self) -> Vec<ResyncLoss> {
235        core::mem::take(&mut self.losses)
236    }
237
238    /// Declare end of input: close a sync loss that never re-locked and record a
239    /// truncated trailing packet, both retrievable with
240    /// [`take_losses`](Self::take_losses). The buffer is discarded.
241    pub fn finish(&mut self) {
242        let end = self.consumed + self.buf.len() as u64;
243        if let Some(start) = self.open_loss.take() {
244            self.losses.push(ResyncLoss {
245                offset: start,
246                dropped_bytes: end - start,
247                packet_index: self.stats.packets,
248                truncated_tail: false,
249            });
250        } else if self.stride.is_some() && self.head < self.buf.len() {
251            self.losses.push(ResyncLoss {
252                offset: self.consumed + self.head as u64,
253                dropped_bytes: (self.buf.len() - self.head) as u64,
254                packet_index: self.stats.packets,
255                truncated_tail: true,
256            });
257        }
258        self.stats.dropped_bytes += (self.buf.len() - self.head) as u64;
259        self.head = self.buf.len();
260        self.compact();
261    }
262
263    /// Accumulated statistics.
264    pub fn stats(&self) -> ResyncStats {
265        self.stats
266    }
267
268    /// Compact the internal buffer by discarding consumed bytes.
269    fn compact(&mut self) {
270        if self.head > 0 {
271            self.consumed += self.head as u64;
272            self.buf.drain(..self.head);
273            self.head = 0;
274        }
275    }
276}
277
278/// Scan `buf` for the smallest offset `o` such that stride `S` (tried 188
279/// first, then 204) yields [`LOCK_CONFIRMATIONS`] consecutive sync bytes at
280/// positions `o + k*S` for `k = 0 .. LOCK_CONFIRMATIONS`.
281///
282/// Returns `(offset, stride)` on success, or `None` if no lock is found.
283fn find_sync(buf: &[u8]) -> Option<(usize, PacketStride)> {
284    for o in 0..buf.len() {
285        if buf[o] != TS_SYNC_BYTE {
286            continue;
287        }
288        if try_stride(buf, o, TS_PACKET_SIZE) {
289            return Some((o, PacketStride::Ts188));
290        }
291        if try_stride(buf, o, RS_PACKET_SIZE) {
292            return Some((o, PacketStride::Rs204));
293        }
294    }
295    None
296}
297
298/// Return `true` if `LOCK_CONFIRMATIONS` consecutive sync bytes exist at
299/// stride `s` starting from `offset` (the first is already known to be
300/// [`TS_SYNC_BYTE`]).
301fn try_stride(buf: &[u8], offset: usize, s: usize) -> bool {
302    for k in 1..LOCK_CONFIRMATIONS {
303        let pos = offset + k * s;
304        if pos >= buf.len() || buf[pos] != TS_SYNC_BYTE {
305            return false;
306        }
307    }
308    true
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use alloc::vec;
315    use alloc::vec::Vec;
316    extern crate std;
317
318    /// Build a 188-byte TS packet starting with `TS_SYNC_BYTE` followed by
319    /// `tag` (repeated).  All non-sync bytes are kept away from `0x47` so
320    /// that no false lock occurs in the fixture data.
321    fn ts_packet(tag: u8) -> [u8; TS_PACKET_SIZE] {
322        assert_ne!(tag, TS_SYNC_BYTE, "tag must not equal sync byte");
323        let mut pkt = [tag; TS_PACKET_SIZE];
324        pkt[0] = TS_SYNC_BYTE;
325        pkt
326    }
327
328    /// Build a 204-byte RS-coded packet: 188-byte TS payload (with `ts_tag`)
329    /// plus 16 parity bytes filled with `parity` (must not be `0x47`).
330    fn rs_packet(ts_tag: u8, parity: u8) -> [u8; RS_PACKET_SIZE] {
331        assert_ne!(ts_tag, TS_SYNC_BYTE);
332        assert_ne!(parity, TS_SYNC_BYTE);
333        let mut pkt = [parity; RS_PACKET_SIZE];
334        pkt[0] = TS_SYNC_BYTE;
335        pkt[1..TS_PACKET_SIZE].fill(ts_tag);
336        pkt
337    }
338
339    /// Concatenate several 188-byte packet arrays into a flat `Vec<u8>`.
340    fn concat_ts(packets: &[[u8; TS_PACKET_SIZE]]) -> Vec<u8> {
341        let mut v = Vec::with_capacity(packets.len() * TS_PACKET_SIZE);
342        for p in packets {
343            v.extend_from_slice(p);
344        }
345        v
346    }
347
348    // ------------------------------------------------------------------
349    // Test 1 — aligned 188-byte passthrough
350    // ------------------------------------------------------------------
351    #[test]
352    fn aligned_188_passthrough() {
353        let packets: Vec<_> = (0u8..5).map(|i| ts_packet(i + 1)).collect();
354        let data = concat_ts(&packets);
355
356        let mut r = TsResync::new();
357        let emitted = r.feed(&data);
358
359        assert_eq!(emitted.len(), 5);
360        for (i, (e, p)) in emitted.iter().zip(packets.iter()).enumerate() {
361            assert_eq!(e, p, "packet {i} mismatch");
362        }
363        assert_eq!(r.stride(), Some(PacketStride::Ts188));
364        let s = r.stats();
365        assert_eq!(s.packets, 5);
366        assert_eq!(s.resyncs, 0);
367        assert_eq!(s.dropped_bytes, 0);
368    }
369
370    // ------------------------------------------------------------------
371    // Test 2 — lock only after LOCK_CONFIRMATIONS sync bytes
372    // ------------------------------------------------------------------
373    #[test]
374    fn requires_n_confirmations_before_lock() {
375        // Pin the default lock window. The hardcoded 4-vs-5 boundary below only
376        // bites the threshold when this is 5 — assert it explicitly so a change
377        // to the default (or to this test's literals) cannot silently pass.
378        assert_eq!(
379            LOCK_CONFIRMATIONS, 5,
380            "this test pins the default lock window of 5"
381        );
382
383        // FOUR confirming, stride-aligned packets are one short of the window:
384        // only four sync bytes sit at the 188-stride boundaries, so the
385        // resynchroniser must NOT lock and must emit nothing (it buffers).
386        let four = concat_ts(&(1u8..=4).map(ts_packet).collect::<Vec<_>>());
387        let mut r = TsResync::new();
388        assert_eq!(
389            r.feed(&four).len(),
390            0,
391            "4 confirmations (< 5) must not lock or emit"
392        );
393        assert_eq!(r.stride(), None, "stride must remain None below the window");
394
395        // The FIFTH stride-aligned sync byte completes the window → lock.
396        let mut out = r.feed(&ts_packet(5));
397        out.extend(r.feed(&[]));
398        assert!(
399            r.stride().is_some(),
400            "the 5th confirmation must trigger lock"
401        );
402        // Once locked, the buffered packets are emitted (5 fed, all aligned).
403        assert_eq!(out.len(), 5, "all five aligned packets emit once locked");
404    }
405
406    // ------------------------------------------------------------------
407    // Test 3 — junk prefix: leading garbage dropped, correct count returned
408    // ------------------------------------------------------------------
409    #[test]
410    fn junk_prefix_correct_dropped_count() {
411        let pkts: Vec<_> = (0u8..6).map(|i| ts_packet(i + 1)).collect();
412        let stream = concat_ts(&pkts);
413
414        let junk_len = 7usize;
415        let junk: Vec<u8> = vec![0x00; junk_len];
416        let mut data = junk;
417        data.extend_from_slice(&stream);
418
419        let mut r = TsResync::new();
420        let emitted = r.feed(&data);
421
422        assert_eq!(emitted.len(), 6);
423        for (i, (e, p)) in emitted.iter().zip(pkts.iter()).enumerate() {
424            assert_eq!(*e, *p, "packet {i} mismatch after junk prefix");
425        }
426        let s = r.stats();
427        assert_eq!(
428            s.dropped_bytes, junk_len as u64,
429            "dropped bytes must equal junk prefix"
430        );
431        assert_eq!(s.resyncs, 0);
432        assert_eq!(s.packets, 6);
433    }
434
435    // ------------------------------------------------------------------
436    // Test 4 — mid-stream sync loss and reacquisition
437    // ------------------------------------------------------------------
438    #[test]
439    fn midstream_loss_resync() {
440        // Need > LOCK_CONFIRMATIONS clean packets before and after the stray
441        // byte so the stream locks, loses sync, and re-locks.
442        let pkts: Vec<_> = (0u8..14).map(|i| ts_packet(i + 1)).collect();
443        let clean = concat_ts(&pkts);
444
445        // Insert a single stray byte 12 bytes into packet 7.
446        let insert_at = 7 * TS_PACKET_SIZE + 12;
447        let mut data = clean[..insert_at].to_vec();
448        data.push(0x00);
449        data.extend_from_slice(&clean[insert_at..]);
450
451        let mut r = TsResync::new();
452        let emitted = r.feed(&data);
453
454        let s = r.stats();
455        assert!(
456            s.resyncs >= 1,
457            "mid-stream corruption must trigger a resync, got {}",
458            s.resyncs
459        );
460        assert_eq!(emitted[0], pkts[0], "first emitted packet is P0");
461        assert!(
462            emitted.len() >= 10,
463            "should recover and emit most packets, got {}",
464            emitted.len()
465        );
466    }
467
468    // ------------------------------------------------------------------
469    // Test 5 — 204-byte RS-coded packets detected + RS stripped
470    // ------------------------------------------------------------------
471    #[test]
472    fn rs204_detected_and_stripped() {
473        let mut stream = Vec::new();
474        let mut expected = Vec::new();
475        for i in 0u8..6 {
476            let tag = i + 1;
477            let rs = rs_packet(tag, 0xFF);
478            stream.extend_from_slice(&rs);
479            expected.push(ts_packet(tag));
480        }
481
482        let mut r = TsResync::new();
483        let emitted = r.feed(&stream);
484
485        assert_eq!(emitted.len(), 6, "RS-coded stream must emit 6 packets");
486        assert_eq!(
487            r.stride(),
488            Some(PacketStride::Rs204),
489            "stride must be Rs204"
490        );
491        for (i, (e, p)) in emitted.iter().zip(expected.iter()).enumerate() {
492            assert_eq!(e, p, "RS-stripped packet {i} mismatch");
493        }
494        // Confirm the emitted 188-byte packets are parseable by TsPacket.
495        for (i, pkt) in emitted.iter().enumerate() {
496            crate::ts::TsPacket::parse(pkt)
497                .unwrap_or_else(|e| panic!("RS-stripped packet {i} TsPacket::parse failed: {e}"));
498        }
499        let s = r.stats();
500        assert_eq!(s.packets, 6);
501        assert_eq!(s.resyncs, 0);
502        assert_eq!(s.dropped_bytes, 0);
503    }
504
505    // ------------------------------------------------------------------
506    // Test 6 — aligned 188 stream yields same packets as plain chunks(188)
507    //   (equivalence: fixture-less variant using synthetic data)
508    // ------------------------------------------------------------------
509    #[test]
510    fn broken_sync_byte_after_lock_is_one_loss_with_offset() {
511        let mut bytes = Vec::new();
512        for t in 1..=12u8 {
513            bytes.extend_from_slice(&ts_packet(t));
514        }
515        bytes[5 * TS_PACKET_SIZE] = 0x00;
516        let mut r = TsResync::new();
517        let got = r.feed(&bytes);
518        assert_eq!(got.len(), 11);
519        r.finish();
520        let losses = r.take_losses();
521        assert_eq!(losses.len(), 1);
522        assert_eq!(losses[0].offset, 5 * TS_PACKET_SIZE as u64);
523        assert_eq!(losses[0].dropped_bytes, TS_PACKET_SIZE as u64);
524        assert_eq!(losses[0].packet_index, 5);
525        assert!(!losses[0].truncated_tail);
526    }
527
528    #[test]
529    fn leading_junk_is_not_a_loss_but_truncated_tail_is() {
530        let mut bytes = vec![0x11; 30];
531        for t in 1..=8u8 {
532            bytes.extend_from_slice(&ts_packet(t));
533        }
534        bytes.extend_from_slice(&[0x22; 50]);
535        let mut r = TsResync::new();
536        let _ = r.feed(&bytes);
537        r.finish();
538        let losses = r.take_losses();
539        assert_eq!(losses.len(), 1);
540        assert!(losses[0].truncated_tail);
541        assert_eq!(losses[0].offset, 30 + 8 * TS_PACKET_SIZE as u64);
542        assert_eq!(losses[0].dropped_bytes, 50);
543    }
544
545    #[test]
546    fn aligned_188_matches_plain_chunks() {
547        let pkts: Vec<_> = (0u8..10).map(|i| ts_packet(i + 1)).collect();
548        let data = concat_ts(&pkts);
549
550        // Oracle: plain chunks(188) filtered by sync byte.
551        let oracle: Vec<[u8; TS_PACKET_SIZE]> = data
552            .chunks_exact(TS_PACKET_SIZE)
553            .filter(|c| c[0] == TS_SYNC_BYTE)
554            .map(|c| c.try_into().unwrap())
555            .collect();
556
557        let mut r = TsResync::new();
558        let emitted = r.feed(&data);
559
560        assert_eq!(emitted.len(), oracle.len(), "count mismatch");
561        for (i, (e, o)) in emitted.iter().zip(oracle.iter()).enumerate() {
562            assert_eq!(e, o, "packet {i} differs from chunks-oracle");
563        }
564    }
565
566    // ------------------------------------------------------------------
567    // Test 7 — chunked feed equivalence (same result in small increments)
568    // ------------------------------------------------------------------
569    #[test]
570    fn chunked_feed_equivalence() {
571        let pkts: Vec<_> = (0u8..6).map(|i| ts_packet(i + 1)).collect();
572        let stream = concat_ts(&pkts);
573
574        let whole = {
575            let mut r = TsResync::new();
576            r.feed(&stream)
577        };
578
579        let chunked = {
580            let mut r = TsResync::new();
581            let mut out = Vec::new();
582            for chunk in stream.chunks(100) {
583                out.extend(r.feed(chunk));
584            }
585            out
586        };
587
588        assert_eq!(whole.len(), chunked.len());
589        for (i, (w, c)) in whole.iter().zip(chunked.iter()).enumerate() {
590            assert_eq!(w, c, "packet {i} mismatch");
591        }
592    }
593
594    // ------------------------------------------------------------------
595    // Test 8 — fixture-based: m6-single.ts differential
596    //   feeding the real fixture through TsResync must yield the same
597    //   188-byte packets as plain chunks_exact(188) + sync-byte filter.
598    // ------------------------------------------------------------------
599    #[test]
600    fn fixture_m6_matches_plain_chunks() {
601        let path = test_fixtures::path("ts/m6-single.ts");
602        let data = std::fs::read(path).expect("m6-single.ts fixture not found");
603
604        // Oracle: plain aligned 188-byte reads.
605        let oracle: Vec<[u8; TS_PACKET_SIZE]> = data
606            .chunks_exact(TS_PACKET_SIZE)
607            .filter(|c| c[0] == TS_SYNC_BYTE)
608            .map(|c| c.try_into().unwrap())
609            .collect();
610        assert!(!oracle.is_empty(), "oracle empty — fixture empty?");
611
612        let mut r = TsResync::new();
613        let emitted = r.feed(&data);
614
615        assert_eq!(
616            emitted.len(),
617            oracle.len(),
618            "packet count: TsResync={} oracle={}",
619            emitted.len(),
620            oracle.len()
621        );
622        for (i, (e, o)) in emitted.iter().zip(oracle.iter()).enumerate() {
623            assert_eq!(e, o, "packet {i} mismatch vs oracle");
624        }
625    }
626}