use alloc::collections::btree_map::BTreeMap;
use crate::Diagnostic;
use crate::Report;
use crate::report::{Finding, Location, Severity};
use mpeg_pes::PesPacket;
use mpeg_ts::ts::{TS_PACKET_SIZE, TsPacket};
const PTS_MODULUS: u64 = broadcast_common::clock33::WRAP_33BIT;
const PTS_HALF: u64 = broadcast_common::clock33::WRAP_33BIT_HALF;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TsValue {
raw: u64,
initialised: bool,
}
#[derive(Debug)]
struct PtsPidState {
assembler: mpeg_pes::PesAssembler,
prev_decode: TsValue,
}
impl Default for PtsPidState {
fn default() -> Self {
Self {
assembler: mpeg_pes::PesAssembler::new(),
prev_decode: TsValue {
raw: 0,
initialised: false,
},
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct PtsCheck;
impl Diagnostic for PtsCheck {
fn run(&self, ts: &[u8], report: &mut Report) {
let n_packets = ts.len() / TS_PACKET_SIZE;
let mut pid_states: BTreeMap<u16, PtsPidState> = BTreeMap::new();
for i in 0..n_packets {
let offset = i * TS_PACKET_SIZE;
let raw = &ts[offset..offset + TS_PACKET_SIZE];
let Ok(pkt) = TsPacket::parse(raw) else {
continue;
};
let pid = pkt.header.pid;
if pkt.header.has_adaptation
&& let Some(Ok(af)) = pkt.adaptation_field()
&& af.discontinuity_indicator
{
pid_states.remove(&pid);
continue;
}
let payload = match pkt.payload {
Some(p) => p,
None => continue,
};
if payload.is_empty() {
continue;
}
let pus = pkt.header.pusi;
let state = pid_states.entry(pid).or_default();
if let Some(pes_bytes) = state.assembler.feed(pus, payload) {
check_pes(&pes_bytes, i, pid, report, &mut state.prev_decode);
}
}
for (&pid, state) in pid_states.iter_mut() {
if let Some(pes_bytes) = state.assembler.flush() {
check_pes(
&pes_bytes,
n_packets.saturating_sub(1),
pid,
report,
&mut state.prev_decode,
);
}
}
}
}
fn is_pes_with_optional_header(stream_id: u8) -> bool {
stream_id == 0xBD || (0xC0..=0xEF).contains(&stream_id)
}
fn check_pes(
pes_bytes: &[u8],
packet_index: usize,
pid: u16,
report: &mut Report,
prev_decode: &mut TsValue,
) {
if pes_bytes.len() < 9 || pes_bytes[0..3] != [0x00, 0x00, 0x01] {
return;
}
let stream_id = pes_bytes[3];
if !is_pes_with_optional_header(stream_id) {
return;
}
let pts_dts_flags = (pes_bytes[7] >> 6) & 0x03;
if pts_dts_flags == 0b01 {
report.push(Finding::new(
Severity::Error,
Location::new(packet_index, pid),
"pts-forbidden-flags",
alloc::format!(
"Forbidden PTS_DTS_flags == 0b01 on PID 0x{pid:04X} \
(stream_id 0x{stream_id:02X}) โ ITU-T H.222.0 ยง2.4.3.7",
),
));
}
let Ok(pes) = PesPacket::parse(pes_bytes) else {
return;
};
let Some(ref header) = pes.header else {
return;
};
let (raw, present, kind) = match (header.dts, header.pts) {
(Some(dts), _) => (dts.ticks(), true, "DTS"),
(None, Some(pts)) => (pts.ticks(), true, "PTS"),
(None, None) => (0, false, ""),
};
if !present {
return;
}
if prev_decode.initialised {
let delta = broadcast_common::clock33::wrapping_forward_distance(prev_decode.raw, raw);
if delta != 0 && delta > PTS_HALF {
let rule = if kind == "DTS" {
"dts-backward"
} else {
"pts-backward"
};
report.push(Finding::new(
Severity::Error,
Location::new(packet_index, pid),
rule,
alloc::format!(
"Non-monotonic {} (decode order) on PID 0x{:04X}: raw {} โ {} (backward delta {})",
kind,
pid,
prev_decode.raw,
raw,
PTS_MODULUS - delta,
),
));
}
}
prev_decode.raw = raw;
prev_decode.initialised = true;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::Report;
fn make_pes_packet(pid: u16, cc: u8, pes_bytes: &[u8]) -> Vec<u8> {
let mut pkt = vec![0x47u8; 188];
pkt[1] = ((pid >> 8) as u8) & 0x1F;
pkt[2] = (pid & 0xFF) as u8;
pkt[1] |= 0x40; pkt[3] = 0x10 | (cc & 0x0F);
let len = pes_bytes.len().min(184);
pkt[4..4 + len].copy_from_slice(&pes_bytes[..len]);
pkt
}
fn build_pes_with_pts(stream_id: u8, pts_raw: u64, payload: &[u8]) -> Vec<u8> {
let pts_bytes = mpeg_pes::Pts(pts_raw).to_field_bytes();
let _hdr_data_len = 5 + payload.len(); let hdr_len = 5u8; let pes_len = 9 + hdr_len as usize + payload.len();
let mut pes = Vec::with_capacity(pes_len);
pes.extend_from_slice(&[0x00, 0x00, 0x01, stream_id]);
let length = if pes_len > 6 { (pes_len - 6) as u16 } else { 0 };
pes.extend_from_slice(&length.to_be_bytes());
pes.push(0x80); pes.push(0x80); pes.push(hdr_len); pes.extend_from_slice(&pts_bytes);
pes.extend_from_slice(payload);
pes
}
fn ts_field(marker: u8, v: u64) -> [u8; 5] {
[
(marker << 4) | ((((v >> 30) & 0x7) as u8) << 1) | 1,
((v >> 22) & 0xFF) as u8,
((((v >> 15) & 0x7F) as u8) << 1) | 1,
((v >> 7) & 0xFF) as u8,
(((v & 0x7F) as u8) << 1) | 1,
]
}
fn build_pes_with_pts_dts(stream_id: u8, pts: u64, dts: u64, payload: &[u8]) -> Vec<u8> {
let hdr_len = 10u8; let pes_len = 9 + hdr_len as usize + payload.len();
let mut pes = Vec::with_capacity(pes_len);
pes.extend_from_slice(&[0x00, 0x00, 0x01, stream_id]);
pes.extend_from_slice(&((pes_len - 6) as u16).to_be_bytes());
pes.push(0x80); pes.push(0xC0); pes.push(hdr_len);
pes.extend_from_slice(&ts_field(0b0011, pts));
pes.extend_from_slice(&ts_field(0b0001, dts));
pes.extend_from_slice(payload);
pes
}
#[test]
fn bframe_pts_reorder_with_monotonic_dts_not_flagged() {
let pid = 0x0100;
let mut ts = Vec::new();
ts.extend_from_slice(&make_pes_packet(
pid,
0,
&build_pes_with_pts_dts(0xE0, 90_000, 90_000, &[0xAA]),
));
ts.extend_from_slice(&make_pes_packet(
pid,
1,
&build_pes_with_pts_dts(0xE0, 87_000, 93_000, &[0xBB]),
));
let mut report = Report::new();
PtsCheck.run(&ts, &mut report);
assert!(
report.findings().is_empty(),
"B-frame PTS reorder with monotonic DTS must not be flagged, got {:?}",
report.findings()
);
}
#[test]
fn dts_backward_flagged() {
let pid = 0x0100;
let mut ts = Vec::new();
ts.extend_from_slice(&make_pes_packet(
pid,
0,
&build_pes_with_pts_dts(0xE0, 90_000, 90_000, &[0xAA]),
));
ts.extend_from_slice(&make_pes_packet(
pid,
1,
&build_pes_with_pts_dts(0xE0, 96_000, 84_000, &[0xBB]),
));
let mut report = Report::new();
PtsCheck.run(&ts, &mut report);
let dts_back: Vec<_> = report
.findings()
.iter()
.filter(|f| f.rule_id == "dts-backward")
.collect();
assert_eq!(
dts_back.len(),
1,
"expected one dts-backward finding, got {:?}",
report.findings()
);
}
fn build_pes_forbidden_flags(stream_id: u8, payload: &[u8]) -> Vec<u8> {
let pes_len = 9 + payload.len();
let mut pes = vec![0x00, 0x00, 0x01, stream_id];
let length = if pes_len > 6 { (pes_len - 6) as u16 } else { 0 };
pes.extend_from_slice(&length.to_be_bytes());
pes.push(0x80); pes.push(0x40); pes.push(0x00); pes.extend_from_slice(payload);
pes
}
const PTS_MOD: u64 = 1u64 << 33;
#[test]
fn single_pes_no_findings() {
let pid = 0x0100;
let pes = build_pes_with_pts(0xE0, 90_000, &[0xAA, 0xBB]);
let ts = make_pes_packet(pid, 0, &pes);
let mut report = Report::new();
PtsCheck.run(&ts, &mut report);
assert!(
report.is_empty(),
"single PES should produce no findings, got {:?}",
report.findings()
);
}
#[test]
fn forward_pts_no_findings() {
let pid = 0x0100;
let mut ts = Vec::new();
ts.extend_from_slice(&make_pes_packet(
pid,
0,
&build_pes_with_pts(0xE0, 90_000, &[0xAA]),
));
ts.extend_from_slice(&make_pes_packet(
pid,
1,
&build_pes_with_pts(0xE0, 93_000, &[0xBB]),
));
let mut report = Report::new();
PtsCheck.run(&ts, &mut report);
assert!(
report.is_empty(),
"forward PTS should produce no findings, got {:?}",
report.findings()
);
}
#[test]
fn backward_pts_flags_error() {
let pid = 0x0100;
let mut ts = Vec::new();
ts.extend_from_slice(&make_pes_packet(
pid,
0,
&build_pes_with_pts(0xE0, 90_000, &[0xAA]),
));
ts.extend_from_slice(&make_pes_packet(
pid,
1,
&build_pes_with_pts(0xE0, 40_000, &[0xBB]),
));
let mut report = Report::new();
PtsCheck.run(&ts, &mut report);
let bw: Vec<_> = report
.findings()
.iter()
.filter(|f| f.rule_id == "pts-backward")
.collect();
assert_eq!(
bw.len(),
1,
"expected one pts-backward finding, got {:?}",
report.findings()
);
assert_eq!(bw[0].severity, Severity::Error);
}
#[test]
fn legal_pts_wrap_not_flagged() {
let pid = 0x0100;
let mut ts = Vec::new();
let near_wrap = PTS_MOD - 5000;
ts.extend_from_slice(&make_pes_packet(
pid,
0,
&build_pes_with_pts(0xE0, near_wrap, &[0xAA]),
));
ts.extend_from_slice(&make_pes_packet(
pid,
1,
&build_pes_with_pts(0xE0, 1000, &[0xBB]),
));
let mut report = Report::new();
PtsCheck.run(&ts, &mut report);
let bw: Vec<_> = report
.findings()
.iter()
.filter(|f| f.rule_id == "pts-backward")
.collect();
assert!(
bw.is_empty(),
"legal PTS wrap should not be flagged: {:?}",
report.findings()
);
}
#[test]
fn forbidden_pts_dts_flags_detected() {
let pid = 0x0100;
let pes = build_pes_forbidden_flags(0xE0, &[0xAA, 0xBB]);
let ts = make_pes_packet(pid, 0, &pes);
let mut report = Report::new();
PtsCheck.run(&ts, &mut report);
let ff: Vec<_> = report
.findings()
.iter()
.filter(|f| f.rule_id == "pts-forbidden-flags")
.collect();
assert_eq!(
ff.len(),
1,
"expected one pts-forbidden-flags finding, got {:?}",
report.findings()
);
assert_eq!(ff[0].severity, Severity::Error);
}
#[test]
fn discontinuity_resets_baseline() {
let pid = 0x0100;
fn make_disc_pkt(pid: u16, cc: u8, pes_bytes: &[u8], discontinuity: bool) -> Vec<u8> {
let mut pkt = vec![0x47u8; 188];
pkt[1] = 0x40 | ((pid >> 8) as u8) & 0x1F;
pkt[2] = (pid & 0xFF) as u8;
pkt[3] = 0x30 | (cc & 0x0F); pkt[4] = 1; pkt[5] = if discontinuity { 0x80 } else { 0x00 };
let len = pes_bytes.len().min(188 - 6);
pkt[6..6 + len].copy_from_slice(&pes_bytes[..len]);
pkt
}
let pes0 = build_pes_with_pts(0xE0, 90_000, &[0xAA]);
let pes1 = build_pes_with_pts(0xE0, 40_000, &[0xBB]);
let pes2 = build_pes_with_pts(0xE0, 45_000, &[0xCC]);
let mut ts = Vec::new();
ts.extend_from_slice(&make_disc_pkt(pid, 0, &pes0, false));
ts.extend_from_slice(&make_disc_pkt(pid, 1, &pes1, true));
ts.extend_from_slice(&make_disc_pkt(pid, 2, &pes2, false));
let mut report = Report::new();
PtsCheck.run(&ts, &mut report);
let bw: Vec<_> = report
.findings()
.iter()
.filter(|f| f.rule_id == "pts-backward")
.collect();
assert!(
bw.is_empty(),
"discontinuity-reset baseline should not produce backward errors: {:?}",
report.findings()
);
}
}