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    pub fn feed(&mut self, data: &[u8]) -> Vec<[u8; TS_PACKET_SIZE]> {
143        let mut emitted = Vec::new();
144        self.feed_into(data, &mut emitted);
145        emitted
146    }
147
148    /// [`feed`](Self::feed) appending to a caller-owned `out` (not cleared),
149    /// so a steady-state caller that reuses one `Vec` allocates nothing per
150    /// call (audit r01-O4). The output capacity is reserved once from the
151    /// buffered byte count instead of growing by doubling.
152    ///
153    /// Returns the number of packets appended.
154    pub fn feed_into(&mut self, data: &[u8], emitted: &mut Vec<[u8; TS_PACKET_SIZE]>) -> usize {
155        self.buf.extend_from_slice(data);
156        let before = emitted.len();
157        emitted.reserve((self.buf.len() - self.head) / TS_PACKET_SIZE);
158
159        loop {
160            match self.stride {
161                None => {
162                    if let Some((offset, s)) = find_sync(&self.buf[self.head..]) {
163                        self.stats.dropped_bytes += offset as u64;
164                        self.head += offset;
165                        self.stride = Some(s);
166                        if let Some(start) = self.open_loss.take() {
167                            self.losses.push(ResyncLoss {
168                                offset: start,
169                                dropped_bytes: self.consumed + self.head as u64 - start,
170                                packet_index: self.stats.packets,
171                                truncated_tail: false,
172                            });
173                        }
174                    } else {
175                        // Keep at most enough bytes to detect a future lock.
176                        let keep = LOCK_CONFIRMATIONS * RS_PACKET_SIZE;
177                        let tail_len = self.buf.len() - self.head;
178                        if tail_len > keep {
179                            let excess = tail_len - keep;
180                            self.stats.dropped_bytes += excess as u64;
181                            self.head += excess;
182                        }
183                        self.compact();
184                        return emitted.len() - before;
185                    }
186                }
187                Some(stride) => {
188                    let s = match stride {
189                        PacketStride::Ts188 => TS_PACKET_SIZE,
190                        PacketStride::Rs204 => RS_PACKET_SIZE,
191                    };
192                    let tail_len = self.buf.len() - self.head;
193                    if tail_len < s {
194                        self.compact();
195                        return emitted.len() - before;
196                    }
197                    if self.buf[self.head] == TS_SYNC_BYTE {
198                        let mut packet = [0u8; TS_PACKET_SIZE];
199                        packet.copy_from_slice(&self.buf[self.head..self.head + TS_PACKET_SIZE]);
200                        emitted.push(packet);
201                        self.head += s;
202                        self.stats.packets += 1;
203                    } else {
204                        // Sync byte missing — record the loss and re-scan.
205                        self.stats.resyncs += 1;
206                        self.stats.dropped_bytes += 1;
207                        self.open_loss
208                            .get_or_insert(self.consumed + self.head as u64);
209                        self.head += 1;
210                        self.stride = None;
211                    }
212                }
213            }
214        }
215    }
216
217    /// Detected packet stride, or [`None`] before the stream has locked.
218    pub fn stride(&self) -> Option<PacketStride> {
219        self.stride
220    }
221
222    /// Drain the sync losses recorded since the last call (see [`ResyncLoss`]).
223    pub fn take_losses(&mut self) -> Vec<ResyncLoss> {
224        core::mem::take(&mut self.losses)
225    }
226
227    /// Declare end of input: close a sync loss that never re-locked and record a
228    /// truncated trailing packet, both retrievable with
229    /// [`take_losses`](Self::take_losses). The buffer is discarded.
230    pub fn finish(&mut self) {
231        let end = self.consumed + self.buf.len() as u64;
232        if let Some(start) = self.open_loss.take() {
233            self.losses.push(ResyncLoss {
234                offset: start,
235                dropped_bytes: end - start,
236                packet_index: self.stats.packets,
237                truncated_tail: false,
238            });
239        } else if self.stride.is_some() && self.head < self.buf.len() {
240            self.losses.push(ResyncLoss {
241                offset: self.consumed + self.head as u64,
242                dropped_bytes: (self.buf.len() - self.head) as u64,
243                packet_index: self.stats.packets,
244                truncated_tail: true,
245            });
246        }
247        self.stats.dropped_bytes += (self.buf.len() - self.head) as u64;
248        self.head = self.buf.len();
249        self.compact();
250    }
251
252    /// Accumulated statistics.
253    pub fn stats(&self) -> ResyncStats {
254        self.stats
255    }
256
257    /// Compact the internal buffer by discarding consumed bytes.
258    fn compact(&mut self) {
259        if self.head > 0 {
260            self.consumed += self.head as u64;
261            self.buf.drain(..self.head);
262            self.head = 0;
263        }
264    }
265}
266
267/// Scan `buf` for the smallest offset `o` such that stride `S` (tried 188
268/// first, then 204) yields [`LOCK_CONFIRMATIONS`] consecutive sync bytes at
269/// positions `o + k*S` for `k = 0 .. LOCK_CONFIRMATIONS`.
270///
271/// Returns `(offset, stride)` on success, or `None` if no lock is found.
272fn find_sync(buf: &[u8]) -> Option<(usize, PacketStride)> {
273    for o in 0..buf.len() {
274        if buf[o] != TS_SYNC_BYTE {
275            continue;
276        }
277        if try_stride(buf, o, TS_PACKET_SIZE) {
278            return Some((o, PacketStride::Ts188));
279        }
280        if try_stride(buf, o, RS_PACKET_SIZE) {
281            return Some((o, PacketStride::Rs204));
282        }
283    }
284    None
285}
286
287/// Return `true` if `LOCK_CONFIRMATIONS` consecutive sync bytes exist at
288/// stride `s` starting from `offset` (the first is already known to be
289/// [`TS_SYNC_BYTE`]).
290fn try_stride(buf: &[u8], offset: usize, s: usize) -> bool {
291    for k in 1..LOCK_CONFIRMATIONS {
292        let pos = offset + k * s;
293        if pos >= buf.len() || buf[pos] != TS_SYNC_BYTE {
294            return false;
295        }
296    }
297    true
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use alloc::vec;
304    use alloc::vec::Vec;
305    extern crate std;
306
307    /// Build a 188-byte TS packet starting with `TS_SYNC_BYTE` followed by
308    /// `tag` (repeated).  All non-sync bytes are kept away from `0x47` so
309    /// that no false lock occurs in the fixture data.
310    fn ts_packet(tag: u8) -> [u8; TS_PACKET_SIZE] {
311        assert_ne!(tag, TS_SYNC_BYTE, "tag must not equal sync byte");
312        let mut pkt = [tag; TS_PACKET_SIZE];
313        pkt[0] = TS_SYNC_BYTE;
314        pkt
315    }
316
317    /// Build a 204-byte RS-coded packet: 188-byte TS payload (with `ts_tag`)
318    /// plus 16 parity bytes filled with `parity` (must not be `0x47`).
319    fn rs_packet(ts_tag: u8, parity: u8) -> [u8; RS_PACKET_SIZE] {
320        assert_ne!(ts_tag, TS_SYNC_BYTE);
321        assert_ne!(parity, TS_SYNC_BYTE);
322        let mut pkt = [parity; RS_PACKET_SIZE];
323        pkt[0] = TS_SYNC_BYTE;
324        pkt[1..TS_PACKET_SIZE].fill(ts_tag);
325        pkt
326    }
327
328    /// Concatenate several 188-byte packet arrays into a flat `Vec<u8>`.
329    fn concat_ts(packets: &[[u8; TS_PACKET_SIZE]]) -> Vec<u8> {
330        let mut v = Vec::with_capacity(packets.len() * TS_PACKET_SIZE);
331        for p in packets {
332            v.extend_from_slice(p);
333        }
334        v
335    }
336
337    // ------------------------------------------------------------------
338    // Test 1 — aligned 188-byte passthrough
339    // ------------------------------------------------------------------
340    #[test]
341    fn aligned_188_passthrough() {
342        let packets: Vec<_> = (0u8..5).map(|i| ts_packet(i + 1)).collect();
343        let data = concat_ts(&packets);
344
345        let mut r = TsResync::new();
346        let emitted = r.feed(&data);
347
348        assert_eq!(emitted.len(), 5);
349        for (i, (e, p)) in emitted.iter().zip(packets.iter()).enumerate() {
350            assert_eq!(e, p, "packet {i} mismatch");
351        }
352        assert_eq!(r.stride(), Some(PacketStride::Ts188));
353        let s = r.stats();
354        assert_eq!(s.packets, 5);
355        assert_eq!(s.resyncs, 0);
356        assert_eq!(s.dropped_bytes, 0);
357    }
358
359    // ------------------------------------------------------------------
360    // Test 2 — lock only after LOCK_CONFIRMATIONS sync bytes
361    // ------------------------------------------------------------------
362    #[test]
363    fn requires_n_confirmations_before_lock() {
364        // Pin the default lock window. The hardcoded 4-vs-5 boundary below only
365        // bites the threshold when this is 5 — assert it explicitly so a change
366        // to the default (or to this test's literals) cannot silently pass.
367        assert_eq!(
368            LOCK_CONFIRMATIONS, 5,
369            "this test pins the default lock window of 5"
370        );
371
372        // FOUR confirming, stride-aligned packets are one short of the window:
373        // only four sync bytes sit at the 188-stride boundaries, so the
374        // resynchroniser must NOT lock and must emit nothing (it buffers).
375        let four = concat_ts(&(1u8..=4).map(ts_packet).collect::<Vec<_>>());
376        let mut r = TsResync::new();
377        assert_eq!(
378            r.feed(&four).len(),
379            0,
380            "4 confirmations (< 5) must not lock or emit"
381        );
382        assert_eq!(r.stride(), None, "stride must remain None below the window");
383
384        // The FIFTH stride-aligned sync byte completes the window → lock.
385        let mut out = r.feed(&ts_packet(5));
386        out.extend(r.feed(&[]));
387        assert!(
388            r.stride().is_some(),
389            "the 5th confirmation must trigger lock"
390        );
391        // Once locked, the buffered packets are emitted (5 fed, all aligned).
392        assert_eq!(out.len(), 5, "all five aligned packets emit once locked");
393    }
394
395    // ------------------------------------------------------------------
396    // Test 3 — junk prefix: leading garbage dropped, correct count returned
397    // ------------------------------------------------------------------
398    #[test]
399    fn junk_prefix_correct_dropped_count() {
400        let pkts: Vec<_> = (0u8..6).map(|i| ts_packet(i + 1)).collect();
401        let stream = concat_ts(&pkts);
402
403        let junk_len = 7usize;
404        let junk: Vec<u8> = vec![0x00; junk_len];
405        let mut data = junk;
406        data.extend_from_slice(&stream);
407
408        let mut r = TsResync::new();
409        let emitted = r.feed(&data);
410
411        assert_eq!(emitted.len(), 6);
412        for (i, (e, p)) in emitted.iter().zip(pkts.iter()).enumerate() {
413            assert_eq!(*e, *p, "packet {i} mismatch after junk prefix");
414        }
415        let s = r.stats();
416        assert_eq!(
417            s.dropped_bytes, junk_len as u64,
418            "dropped bytes must equal junk prefix"
419        );
420        assert_eq!(s.resyncs, 0);
421        assert_eq!(s.packets, 6);
422    }
423
424    // ------------------------------------------------------------------
425    // Test 4 — mid-stream sync loss and reacquisition
426    // ------------------------------------------------------------------
427    #[test]
428    fn midstream_loss_resync() {
429        // Need > LOCK_CONFIRMATIONS clean packets before and after the stray
430        // byte so the stream locks, loses sync, and re-locks.
431        let pkts: Vec<_> = (0u8..14).map(|i| ts_packet(i + 1)).collect();
432        let clean = concat_ts(&pkts);
433
434        // Insert a single stray byte 12 bytes into packet 7.
435        let insert_at = 7 * TS_PACKET_SIZE + 12;
436        let mut data = clean[..insert_at].to_vec();
437        data.push(0x00);
438        data.extend_from_slice(&clean[insert_at..]);
439
440        let mut r = TsResync::new();
441        let emitted = r.feed(&data);
442
443        let s = r.stats();
444        assert!(
445            s.resyncs >= 1,
446            "mid-stream corruption must trigger a resync, got {}",
447            s.resyncs
448        );
449        assert_eq!(emitted[0], pkts[0], "first emitted packet is P0");
450        assert!(
451            emitted.len() >= 10,
452            "should recover and emit most packets, got {}",
453            emitted.len()
454        );
455    }
456
457    // ------------------------------------------------------------------
458    // Test 5 — 204-byte RS-coded packets detected + RS stripped
459    // ------------------------------------------------------------------
460    #[test]
461    fn rs204_detected_and_stripped() {
462        let mut stream = Vec::new();
463        let mut expected = Vec::new();
464        for i in 0u8..6 {
465            let tag = i + 1;
466            let rs = rs_packet(tag, 0xFF);
467            stream.extend_from_slice(&rs);
468            expected.push(ts_packet(tag));
469        }
470
471        let mut r = TsResync::new();
472        let emitted = r.feed(&stream);
473
474        assert_eq!(emitted.len(), 6, "RS-coded stream must emit 6 packets");
475        assert_eq!(
476            r.stride(),
477            Some(PacketStride::Rs204),
478            "stride must be Rs204"
479        );
480        for (i, (e, p)) in emitted.iter().zip(expected.iter()).enumerate() {
481            assert_eq!(e, p, "RS-stripped packet {i} mismatch");
482        }
483        // Confirm the emitted 188-byte packets are parseable by TsPacket.
484        for (i, pkt) in emitted.iter().enumerate() {
485            crate::ts::TsPacket::parse(pkt)
486                .unwrap_or_else(|e| panic!("RS-stripped packet {i} TsPacket::parse failed: {e}"));
487        }
488        let s = r.stats();
489        assert_eq!(s.packets, 6);
490        assert_eq!(s.resyncs, 0);
491        assert_eq!(s.dropped_bytes, 0);
492    }
493
494    // ------------------------------------------------------------------
495    // Test 6 — aligned 188 stream yields same packets as plain chunks(188)
496    //   (equivalence: fixture-less variant using synthetic data)
497    // ------------------------------------------------------------------
498    #[test]
499    fn broken_sync_byte_after_lock_is_one_loss_with_offset() {
500        let mut bytes = Vec::new();
501        for t in 1..=12u8 {
502            bytes.extend_from_slice(&ts_packet(t));
503        }
504        bytes[5 * TS_PACKET_SIZE] = 0x00;
505        let mut r = TsResync::new();
506        let got = r.feed(&bytes);
507        assert_eq!(got.len(), 11);
508        r.finish();
509        let losses = r.take_losses();
510        assert_eq!(losses.len(), 1);
511        assert_eq!(losses[0].offset, 5 * TS_PACKET_SIZE as u64);
512        assert_eq!(losses[0].dropped_bytes, TS_PACKET_SIZE as u64);
513        assert_eq!(losses[0].packet_index, 5);
514        assert!(!losses[0].truncated_tail);
515    }
516
517    #[test]
518    fn leading_junk_is_not_a_loss_but_truncated_tail_is() {
519        let mut bytes = vec![0x11; 30];
520        for t in 1..=8u8 {
521            bytes.extend_from_slice(&ts_packet(t));
522        }
523        bytes.extend_from_slice(&[0x22; 50]);
524        let mut r = TsResync::new();
525        let _ = r.feed(&bytes);
526        r.finish();
527        let losses = r.take_losses();
528        assert_eq!(losses.len(), 1);
529        assert!(losses[0].truncated_tail);
530        assert_eq!(losses[0].offset, 30 + 8 * TS_PACKET_SIZE as u64);
531        assert_eq!(losses[0].dropped_bytes, 50);
532    }
533
534    #[test]
535    fn aligned_188_matches_plain_chunks() {
536        let pkts: Vec<_> = (0u8..10).map(|i| ts_packet(i + 1)).collect();
537        let data = concat_ts(&pkts);
538
539        // Oracle: plain chunks(188) filtered by sync byte.
540        let oracle: Vec<[u8; TS_PACKET_SIZE]> = data
541            .chunks_exact(TS_PACKET_SIZE)
542            .filter(|c| c[0] == TS_SYNC_BYTE)
543            .map(|c| c.try_into().unwrap())
544            .collect();
545
546        let mut r = TsResync::new();
547        let emitted = r.feed(&data);
548
549        assert_eq!(emitted.len(), oracle.len(), "count mismatch");
550        for (i, (e, o)) in emitted.iter().zip(oracle.iter()).enumerate() {
551            assert_eq!(e, o, "packet {i} differs from chunks-oracle");
552        }
553    }
554
555    // ------------------------------------------------------------------
556    // Test 7 — chunked feed equivalence (same result in small increments)
557    // ------------------------------------------------------------------
558    #[test]
559    fn chunked_feed_equivalence() {
560        let pkts: Vec<_> = (0u8..6).map(|i| ts_packet(i + 1)).collect();
561        let stream = concat_ts(&pkts);
562
563        let whole = {
564            let mut r = TsResync::new();
565            r.feed(&stream)
566        };
567
568        let chunked = {
569            let mut r = TsResync::new();
570            let mut out = Vec::new();
571            for chunk in stream.chunks(100) {
572                out.extend(r.feed(chunk));
573            }
574            out
575        };
576
577        assert_eq!(whole.len(), chunked.len());
578        for (i, (w, c)) in whole.iter().zip(chunked.iter()).enumerate() {
579            assert_eq!(w, c, "packet {i} mismatch");
580        }
581    }
582
583    // ------------------------------------------------------------------
584    // Test 8 — fixture-based: m6-single.ts differential
585    //   feeding the real fixture through TsResync must yield the same
586    //   188-byte packets as plain chunks_exact(188) + sync-byte filter.
587    // ------------------------------------------------------------------
588    #[test]
589    fn fixture_m6_matches_plain_chunks() {
590        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../fixtures/ts/m6-single.ts");
591        let data = std::fs::read(path).expect("m6-single.ts fixture not found");
592
593        // Oracle: plain aligned 188-byte reads.
594        let oracle: Vec<[u8; TS_PACKET_SIZE]> = data
595            .chunks_exact(TS_PACKET_SIZE)
596            .filter(|c| c[0] == TS_SYNC_BYTE)
597            .map(|c| c.try_into().unwrap())
598            .collect();
599        assert!(!oracle.is_empty(), "oracle empty — fixture empty?");
600
601        let mut r = TsResync::new();
602        let emitted = r.feed(&data);
603
604        assert_eq!(
605            emitted.len(),
606            oracle.len(),
607            "packet count: TsResync={} oracle={}",
608            emitted.len(),
609            oracle.len()
610        );
611        for (i, (e, o)) in emitted.iter().zip(oracle.iter()).enumerate() {
612            assert_eq!(e, o, "packet {i} mismatch vs oracle");
613        }
614    }
615}