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 = concat!(env!("CARGO_MANIFEST_DIR"), "/../fixtures/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}