472 lines
20 KiB
Rust
472 lines
20 KiB
Rust
//! ADR-110 §A0.12 sync packet decoder (firmware v0.6.9+).
|
||
//!
|
||
//! Emitted by the firmware on the same UDP socket as ADR-018 CSI frames,
|
||
//! distinguished by leading magic `0xC511A110`. Pairs `(node_id, sequence)`
|
||
//! across the two UDP streams so a host aggregator can recover mesh-aligned
|
||
//! timestamps for every CSI frame — see `WITNESS-LOG-110 §A0.12` for live
|
||
//! verification, `archive/v1/src/hardware/csi_extractor.py:SyncPacketParser`
|
||
//! for the matching Python decoder.
|
||
//!
|
||
//! Wire format (32 bytes, little-endian):
|
||
//! ```text
|
||
//! [0..3] magic 0xC511A110 (LE u32)
|
||
//! [4] node_id
|
||
//! [5] proto_ver (currently 0x01)
|
||
//! [6] flags: bit 0 = is_leader
|
||
//! bit 1 = is_valid (fresh sync within VALID_WINDOW_MS)
|
||
//! bit 2 = smoothed_used (EMA filter active)
|
||
//! [7] reserved
|
||
//! [8..15] local esp_timer_get_time() (u64)
|
||
//! [16..23] mesh-aligned epoch = local + smoothed offset (u64)
|
||
//! [24..27] high-water CSI sequence (u32) — pairing key against ADR-018 frames
|
||
//! [28..31] reserved
|
||
//! ```
|
||
//!
|
||
//! Recover the per-board offset for a given sync packet as
|
||
//! `local_us - epoch_us` (signed). Follower nodes report the EMA-smoothed
|
||
//! offset measured in §A0.10; leader nodes report `~0` modulo call-stack
|
||
//! elapsed time (`leader_epoch_us = now_us` by definition).
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::error::ParseError;
|
||
|
||
/// Magic constant in the first 4 little-endian bytes of every sync packet.
|
||
pub const SYNC_PACKET_MAGIC: u32 = 0xC511_A110;
|
||
/// Total wire size of a v0.6.9+ sync packet.
|
||
pub const SYNC_PACKET_SIZE: usize = 32;
|
||
/// Wire protocol version currently emitted by firmware.
|
||
pub const SYNC_PACKET_PROTO_VER: u8 = 0x01;
|
||
|
||
/// Decoded ADR-110 §A0.12 sync packet.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct SyncPacket {
|
||
pub node_id: u8,
|
||
pub proto_ver: u8,
|
||
pub flags: SyncPacketFlags,
|
||
/// Node-local `esp_timer_get_time()` snapshot at emission time.
|
||
pub local_us: u64,
|
||
/// Mesh-aligned epoch — `local_us + smoothed_offset`.
|
||
pub epoch_us: u64,
|
||
/// High-water ADR-018 CSI sequence number at emission time. Host
|
||
/// aggregator pairs (`node_id`, `sequence`) across the two UDP streams
|
||
/// to apply the recovered offset back to in-flight CSI frames.
|
||
pub sequence: u32,
|
||
}
|
||
|
||
/// Flag bits packed into byte 6 of the sync packet.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
pub struct SyncPacketFlags {
|
||
pub is_leader: bool,
|
||
pub is_valid: bool,
|
||
pub smoothed_used: bool,
|
||
}
|
||
|
||
impl SyncPacketFlags {
|
||
pub fn from_byte(b: u8) -> Self {
|
||
Self {
|
||
is_leader: (b & 0x01) != 0,
|
||
is_valid: (b & 0x02) != 0,
|
||
smoothed_used: (b & 0x04) != 0,
|
||
}
|
||
}
|
||
|
||
pub fn to_byte(self) -> u8 {
|
||
let mut b = 0u8;
|
||
if self.is_leader { b |= 0x01; }
|
||
if self.is_valid { b |= 0x02; }
|
||
if self.smoothed_used { b |= 0x04; }
|
||
b
|
||
}
|
||
}
|
||
|
||
impl SyncPacket {
|
||
/// Decode a 32-byte sync packet. Returns `ParseError::InvalidMagic` if
|
||
/// the leading u32 doesn't match `SYNC_PACKET_MAGIC` (host should
|
||
/// dispatch on the magic before calling this — see crate-level docs).
|
||
pub fn from_bytes(buf: &[u8]) -> Result<Self, ParseError> {
|
||
if buf.len() < SYNC_PACKET_SIZE {
|
||
return Err(ParseError::InsufficientData {
|
||
needed: SYNC_PACKET_SIZE,
|
||
got: buf.len(),
|
||
});
|
||
}
|
||
let magic = u32::from_le_bytes(buf[0..4].try_into().unwrap());
|
||
if magic != SYNC_PACKET_MAGIC {
|
||
return Err(ParseError::InvalidMagic { expected: SYNC_PACKET_MAGIC, got: magic });
|
||
}
|
||
let node_id = buf[4];
|
||
let proto_ver = buf[5];
|
||
let flags = SyncPacketFlags::from_byte(buf[6]);
|
||
// buf[7] reserved
|
||
let local_us = u64::from_le_bytes(buf[8..16].try_into().unwrap());
|
||
let epoch_us = u64::from_le_bytes(buf[16..24].try_into().unwrap());
|
||
let sequence = u32::from_le_bytes(buf[24..28].try_into().unwrap());
|
||
// buf[28..32] reserved
|
||
Ok(Self {
|
||
node_id,
|
||
proto_ver,
|
||
flags,
|
||
local_us,
|
||
epoch_us,
|
||
sequence,
|
||
})
|
||
}
|
||
|
||
/// Recover the signed offset between this node's local monotonic clock
|
||
/// and the mesh epoch (`local_us - epoch_us`). For followers this is
|
||
/// the EMA-smoothed offset; for leaders this is approximately 0 (a few
|
||
/// µs of call-stack elapsed only).
|
||
pub fn local_minus_epoch_us(&self) -> i64 {
|
||
(self.local_us as i64) - (self.epoch_us as i64)
|
||
}
|
||
|
||
/// Given a CSI frame's node-local `esp_timer_get_time()` snapshot,
|
||
/// recover the mesh-aligned timestamp using this sync packet as the
|
||
/// reference point.
|
||
///
|
||
/// Math (all in node-local µs, see ADR-110 §A0.12):
|
||
///
|
||
/// ```text
|
||
/// offset = epoch_us - local_us (signed; this packet)
|
||
/// mesh_epoch(frame) = local_at_frame_us + offset
|
||
/// = local_at_frame_us + (epoch_us - local_us)
|
||
/// ```
|
||
///
|
||
/// On the leader this gives `≈ local_at_frame_us`. On a follower this
|
||
/// gives the mesh-aligned time aligned to the leader's clock within
|
||
/// the §A0.10 measured 104 µs stdev (the same EMA-smoothed offset
|
||
/// the firmware applied when it built this sync packet's `epoch_us`).
|
||
///
|
||
/// Use this on the host side whenever a CSI frame arrives with
|
||
/// ADR-018 byte 19 bit 4 set: look up the matching node's most-recent
|
||
/// `SyncPacket`, call `apply_to_local(frame.local_us)`, stamp the
|
||
/// result on the frame for downstream multistatic fusion.
|
||
pub fn apply_to_local(&self, local_at_frame_us: u64) -> u64 {
|
||
// Compute the offset as a signed delta in the µs domain. Adding it
|
||
// back to the frame's local snapshot recovers the mesh epoch.
|
||
let offset = (self.epoch_us as i64).wrapping_sub(self.local_us as i64);
|
||
(local_at_frame_us as i64).wrapping_add(offset) as u64
|
||
}
|
||
|
||
/// Recover the mesh-aligned timestamp for an in-flight CSI frame
|
||
/// **using its ADR-018 sequence number** as the timeline anchor.
|
||
///
|
||
/// CSI frames carry no per-frame `local_us` field (ADR-018 v1 wire
|
||
/// format reserves no slot for it — see WITNESS-LOG-110 §A0.11),
|
||
/// but they do carry a 32-bit sequence number. The firmware emits
|
||
/// a sync packet alongside CSI frames, stamping the sequence
|
||
/// high-water observed at emit time into [`SyncPacket::sequence`].
|
||
///
|
||
/// Given a frame's sequence and the node's observed CSI frame rate,
|
||
/// estimate the node-local time at the frame and apply the mesh
|
||
/// offset:
|
||
///
|
||
/// ```text
|
||
/// Δframes = frame_seq - sync.sequence (wrapping)
|
||
/// Δus = Δframes × 1_000_000 / fps_hz (node-local)
|
||
/// local_at = sync.local_us + Δus
|
||
/// mesh = local_at + (sync.epoch_us - sync.local_us)
|
||
/// ```
|
||
///
|
||
/// `fps_hz` must be > 0; pass the firmware's `CSI_MIN_SEND_INTERVAL_US`
|
||
/// inverse (≈ 20 fps) or a measured rate from the broadcast-tick task.
|
||
/// The estimate is exact when the frame rate is stable (a node holding
|
||
/// 20 fps within ±1 frame for the sync→frame interval gives
|
||
/// |error| < 1/fps_hz ≈ 50 ms × the per-frame jitter ratio).
|
||
pub fn mesh_aligned_us_for_sequence(&self, frame_seq: u32, fps_hz: f64) -> u64 {
|
||
debug_assert!(fps_hz > 0.0, "fps_hz must be positive");
|
||
let dframes = (frame_seq.wrapping_sub(self.sequence)) as i64;
|
||
let dus = (dframes as f64 * 1_000_000.0 / fps_hz) as i64;
|
||
let local_at = (self.local_us as i64).wrapping_add(dus) as u64;
|
||
self.apply_to_local(local_at)
|
||
}
|
||
|
||
/// Serialize back to wire bytes (32 bytes, little-endian).
|
||
pub fn to_bytes(&self) -> [u8; SYNC_PACKET_SIZE] {
|
||
let mut out = [0u8; SYNC_PACKET_SIZE];
|
||
out[0..4].copy_from_slice(&SYNC_PACKET_MAGIC.to_le_bytes());
|
||
out[4] = self.node_id;
|
||
out[5] = self.proto_ver;
|
||
out[6] = self.flags.to_byte();
|
||
// out[7] reserved zero
|
||
out[8..16].copy_from_slice(&self.local_us.to_le_bytes());
|
||
out[16..24].copy_from_slice(&self.epoch_us.to_le_bytes());
|
||
out[24..28].copy_from_slice(&self.sequence.to_le_bytes());
|
||
// out[28..32] reserved zero
|
||
out
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Reproduces the COM9 follower sync-pkt #1 captured in WITNESS-LOG-110 §A0.12.
|
||
#[test]
|
||
fn follower_typical_packet_roundtrips() {
|
||
let pkt = SyncPacket {
|
||
node_id: 9,
|
||
proto_ver: 1,
|
||
flags: SyncPacketFlags { is_leader: false, is_valid: true, smoothed_used: true },
|
||
local_us: 28_798_450,
|
||
epoch_us: 27_634_885,
|
||
sequence: 20,
|
||
};
|
||
let wire = pkt.to_bytes();
|
||
let decoded = SyncPacket::from_bytes(&wire).unwrap();
|
||
assert_eq!(decoded, pkt);
|
||
// The 1.16-second boot delta §A0.10 measured between COM9 and COM12.
|
||
assert_eq!(decoded.local_minus_epoch_us(), 1_163_565);
|
||
assert_eq!(decoded.flags.to_byte(), 0x06);
|
||
}
|
||
|
||
/// COM12 leader case from WITNESS-LOG-110 §A0.12: flags=0x03, epoch ≈ local.
|
||
#[test]
|
||
fn leader_packet_has_local_close_to_epoch() {
|
||
let pkt = SyncPacket {
|
||
node_id: 12,
|
||
proto_ver: 1,
|
||
flags: SyncPacketFlags { is_leader: true, is_valid: true, smoothed_used: false },
|
||
local_us: 28_864_932,
|
||
epoch_us: 28_864_939,
|
||
sequence: 20,
|
||
};
|
||
let wire = pkt.to_bytes();
|
||
let decoded = SyncPacket::from_bytes(&wire).unwrap();
|
||
assert_eq!(decoded.flags.to_byte(), 0x03);
|
||
assert_eq!(decoded.local_minus_epoch_us(), -7); // leader has zero offset modulo call-stack
|
||
assert!(decoded.flags.is_leader);
|
||
assert!(decoded.flags.is_valid);
|
||
assert!(!decoded.flags.smoothed_used);
|
||
}
|
||
|
||
#[test]
|
||
fn magic_mismatch_is_typed_error() {
|
||
let mut wire = SyncPacket {
|
||
node_id: 1, proto_ver: 1, flags: SyncPacketFlags::default(),
|
||
local_us: 0, epoch_us: 0, sequence: 0,
|
||
}.to_bytes();
|
||
wire[0] = 0x01; // corrupt magic low byte
|
||
let err = SyncPacket::from_bytes(&wire).unwrap_err();
|
||
match err {
|
||
ParseError::InvalidMagic { got, .. } => assert_ne!(got, SYNC_PACKET_MAGIC),
|
||
other => panic!("expected InvalidMagic, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn short_packet_is_typed_error() {
|
||
let wire = [0u8; 16]; // half a packet
|
||
let err = SyncPacket::from_bytes(&wire).unwrap_err();
|
||
match err {
|
||
ParseError::InsufficientData { needed, got } => {
|
||
assert_eq!(needed, SYNC_PACKET_SIZE);
|
||
assert_eq!(got, 16);
|
||
}
|
||
other => panic!("expected InsufficientData, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Every (leader, valid, smoothed_used) triple round-trips independently.
|
||
#[test]
|
||
fn all_flag_combinations_roundtrip() {
|
||
for &is_leader in &[false, true] {
|
||
for &is_valid in &[false, true] {
|
||
for &smoothed_used in &[false, true] {
|
||
let flags = SyncPacketFlags { is_leader, is_valid, smoothed_used };
|
||
let pkt = SyncPacket {
|
||
node_id: 1, proto_ver: 1, flags,
|
||
local_us: 1234, epoch_us: 5678, sequence: 99,
|
||
};
|
||
let wire = pkt.to_bytes();
|
||
let decoded = SyncPacket::from_bytes(&wire).unwrap();
|
||
assert_eq!(decoded.flags, flags);
|
||
assert_eq!(decoded.flags.to_byte(), flags.to_byte());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A host dispatches CSI vs sync purely on the leading u32. The two
|
||
/// magics must therefore never collide.
|
||
#[test]
|
||
fn sync_and_csi_magics_differ() {
|
||
assert_ne!(SYNC_PACKET_MAGIC, crate::esp32_parser::ESP32_CSI_MAGIC);
|
||
}
|
||
|
||
/// Applying a sync packet to its own local_us must recover its own
|
||
/// epoch_us. Foundational identity for the math.
|
||
#[test]
|
||
fn apply_to_local_recovers_packet_epoch() {
|
||
let pkt = SyncPacket {
|
||
node_id: 9, proto_ver: 1,
|
||
flags: SyncPacketFlags { is_leader: false, is_valid: true, smoothed_used: true },
|
||
local_us: 28_798_450, epoch_us: 27_634_885, sequence: 20,
|
||
};
|
||
assert_eq!(pkt.apply_to_local(pkt.local_us), pkt.epoch_us);
|
||
}
|
||
|
||
/// A CSI frame's local timestamp arriving after the sync packet
|
||
/// gets the same offset applied — the µs delta between sync and frame
|
||
/// is preserved on both clocks.
|
||
#[test]
|
||
fn apply_to_local_preserves_inter_frame_delta() {
|
||
let pkt = SyncPacket {
|
||
node_id: 9, proto_ver: 1,
|
||
flags: SyncPacketFlags { is_leader: false, is_valid: true, smoothed_used: true },
|
||
local_us: 28_798_450, epoch_us: 27_634_885, sequence: 20,
|
||
};
|
||
// Frame arrives 100 ms after the sync packet on the follower's local clock.
|
||
let local_at_frame = pkt.local_us + 100_000;
|
||
let mesh_epoch = pkt.apply_to_local(local_at_frame);
|
||
// Mesh epoch should also be 100 ms after the sync packet's epoch.
|
||
assert_eq!(mesh_epoch, pkt.epoch_us + 100_000);
|
||
// Offset must equal local - epoch on both clocks.
|
||
assert_eq!(local_at_frame - mesh_epoch, pkt.local_us - pkt.epoch_us);
|
||
}
|
||
|
||
/// Leader sync packet has near-zero offset, so apply_to_local is
|
||
/// approximately identity (modulo the few µs call-stack delta).
|
||
#[test]
|
||
fn apply_to_local_on_leader_is_near_identity() {
|
||
let pkt = SyncPacket {
|
||
node_id: 12, proto_ver: 1,
|
||
flags: SyncPacketFlags { is_leader: true, is_valid: true, smoothed_used: false },
|
||
local_us: 28_864_932, epoch_us: 28_864_939, sequence: 20,
|
||
};
|
||
let frame_local = 30_000_000u64;
|
||
let mesh = pkt.apply_to_local(frame_local);
|
||
assert!((mesh as i64 - frame_local as i64).abs() <= 100,
|
||
"leader apply should be within 100 µs of identity, got {} delta",
|
||
mesh as i64 - frame_local as i64);
|
||
}
|
||
|
||
/// At the sync packet's own sequence number, the interpolated mesh
|
||
/// time must equal `epoch_us` exactly.
|
||
#[test]
|
||
fn mesh_aligned_for_sequence_identity_at_sync_point() {
|
||
let pkt = SyncPacket {
|
||
node_id: 9, proto_ver: 1,
|
||
flags: SyncPacketFlags { is_leader: false, is_valid: true, smoothed_used: true },
|
||
local_us: 28_798_450, epoch_us: 27_634_885, sequence: 20,
|
||
};
|
||
assert_eq!(pkt.mesh_aligned_us_for_sequence(20, 20.0), pkt.epoch_us);
|
||
}
|
||
|
||
/// 20 frames after the sync packet at 20 Hz → mesh time advances by 1 s,
|
||
/// preserving the leader/follower clock offset.
|
||
#[test]
|
||
fn mesh_aligned_for_sequence_extrapolates_forward() {
|
||
let pkt = SyncPacket {
|
||
node_id: 9, proto_ver: 1,
|
||
flags: SyncPacketFlags { is_leader: false, is_valid: true, smoothed_used: true },
|
||
local_us: 28_798_450, epoch_us: 27_634_885, sequence: 20,
|
||
};
|
||
// 20 frames at 20 fps = 1 000 000 µs
|
||
let mesh = pkt.mesh_aligned_us_for_sequence(40, 20.0);
|
||
assert_eq!(mesh, pkt.epoch_us + 1_000_000);
|
||
}
|
||
|
||
/// Sequence wraparound (u32 overflow) must extrapolate forward by one
|
||
/// frame, not jump backward by 2^32. The wrapping_sub semantics in
|
||
/// the implementation guard this.
|
||
#[test]
|
||
fn mesh_aligned_for_sequence_handles_seq_wraparound() {
|
||
let pkt = SyncPacket {
|
||
node_id: 9, proto_ver: 1,
|
||
flags: SyncPacketFlags { is_leader: false, is_valid: true, smoothed_used: true },
|
||
local_us: 10_000, epoch_us: 10_000, sequence: u32::MAX,
|
||
};
|
||
// Next sequence after u32::MAX is 0 (wrap). Δframes = 1, not -2^32.
|
||
let mesh = pkt.mesh_aligned_us_for_sequence(0, 20.0);
|
||
assert_eq!(mesh, pkt.epoch_us + 50_000); // 1 frame at 20 fps = 50 ms
|
||
}
|
||
|
||
/// End-to-end ADR-110 pipeline sanity:
|
||
/// (1) firmware emits sync packet (bytes built here as a stand-in)
|
||
/// (2) host wire-decodes via from_bytes
|
||
/// (3) a CSI frame arrives 100 sequences later (≈ 5 s @ 20 fps)
|
||
/// (4) mesh_aligned_us_for_sequence recovers its mesh timestamp
|
||
/// Asserts that the recovered mesh time matches sync.epoch_us + Δus exactly,
|
||
/// and cross-checks against apply_to_local. This is the contract every
|
||
/// downstream multistatic-fusion consumer relies on.
|
||
#[test]
|
||
fn end_to_end_sync_decode_then_frame_mesh_recovery() {
|
||
let pkt = SyncPacket {
|
||
node_id: 9,
|
||
proto_ver: 1,
|
||
flags: SyncPacketFlags { is_leader: false, is_valid: true, smoothed_used: true },
|
||
local_us: 28_798_450,
|
||
epoch_us: 27_634_885,
|
||
sequence: 20,
|
||
};
|
||
let wire = pkt.to_bytes();
|
||
assert_eq!(wire.len(), SYNC_PACKET_SIZE);
|
||
let decoded = SyncPacket::from_bytes(&wire).unwrap();
|
||
assert_eq!(decoded, pkt);
|
||
|
||
// 5 s after sync at 20 fps = 100 frames later
|
||
let frame_seq = pkt.sequence + 100;
|
||
let mesh_us = decoded.mesh_aligned_us_for_sequence(frame_seq, 20.0);
|
||
assert_eq!(mesh_us, pkt.epoch_us + 5_000_000);
|
||
|
||
// Same mesh time via direct apply_to_local — both paths must agree
|
||
let local_at_frame = pkt.local_us + 5_000_000;
|
||
assert_eq!(decoded.apply_to_local(local_at_frame), mesh_us);
|
||
}
|
||
|
||
#[test]
|
||
fn wire_size_constant_is_correct() {
|
||
let pkt = SyncPacket {
|
||
node_id: 0, proto_ver: 1, flags: SyncPacketFlags::default(),
|
||
local_us: 0, epoch_us: 0, sequence: 0,
|
||
};
|
||
assert_eq!(pkt.to_bytes().len(), SYNC_PACKET_SIZE);
|
||
assert_eq!(SYNC_PACKET_SIZE, 32);
|
||
}
|
||
|
||
/// ADR-110 iter 21 — cross-language wire-format conformance gate.
|
||
///
|
||
/// These exact bytes are ALSO pinned in the Python test
|
||
/// `test_canonical_wire_bytes_match_rust_decoder` in
|
||
/// `archive/v1/tests/unit/test_esp32_binary_parser.py`. If this
|
||
/// canonical hex stops matching what Python emits for the same
|
||
/// SyncPacket fields, ONE of the decoders has drifted from the wire.
|
||
///
|
||
/// Canonical packet: COM9 sync-pkt #1 from §A0.12 live capture.
|
||
#[test]
|
||
fn canonical_wire_bytes_match_python_decoder() {
|
||
// Exact bytes matching the Python pin (hex-decoded by hand to bytes).
|
||
let canonical: [u8; 32] = [
|
||
0x10, 0xa1, 0x11, 0xc5, // magic 0xC511A110 (LE u32)
|
||
0x09, // node_id = 9
|
||
0x01, // proto_ver = 1
|
||
0x06, // flags: bit1=is_valid, bit2=smoothed_used
|
||
0x00, // reserved
|
||
0xf2, 0x6d, 0xb7, 0x01, 0x00, 0x00, 0x00, 0x00, // local_us = 28_798_450
|
||
0xc5, 0xac, 0xa5, 0x01, 0x00, 0x00, 0x00, 0x00, // epoch_us = 27_634_885
|
||
0x14, 0x00, 0x00, 0x00, // sequence = 20
|
||
0x00, 0x00, 0x00, 0x00, // reserved
|
||
];
|
||
let decoded = SyncPacket::from_bytes(&canonical).unwrap();
|
||
assert_eq!(decoded.node_id, 9);
|
||
assert_eq!(decoded.proto_ver, 1);
|
||
assert_eq!(decoded.flags.to_byte(), 0x06);
|
||
assert!(!decoded.flags.is_leader);
|
||
assert!(decoded.flags.is_valid);
|
||
assert!(decoded.flags.smoothed_used);
|
||
assert_eq!(decoded.local_us, 28_798_450);
|
||
assert_eq!(decoded.epoch_us, 27_634_885);
|
||
assert_eq!(decoded.sequence, 20);
|
||
// §A0.10's measured 1.16-second boot delta.
|
||
assert_eq!(decoded.local_minus_epoch_us(), 1_163_565);
|
||
|
||
// Round-trip: re-encoding the decoded struct must produce the same
|
||
// canonical bytes — this is what catches any drift in to_bytes.
|
||
let re_encoded = decoded.to_bytes();
|
||
assert_eq!(re_encoded, canonical,
|
||
"Rust to_bytes drifted from the canonical pin — Python decoder will break");
|
||
}
|
||
}
|