Operator ruling, 2026-09-30: "FEC: Please own the math." reed-solomon-erasure 6.0.0 pulled lru 0.7.8 (RUSTSEC-2026-0253, unsound) and instant (RUSTSEC-2024-0384, unmaintained) into every cultnet-rs consumer. media_fec.rs now implements the construction its docs already state, read against the crate's source rather than its names: GF(2^8) reduced by 0x11D with 2 as generator, the n x k Vandermonde matrix over the evaluation points 0..n-1 with 0^0 = 1, E = V * inverse(top k rows), parity = rows k..k+m of E, and recovery through the inverse of E's rows for the first k present shards, which is the row choice the crate made. About 130 lines: const log/exp tables, a Gauss-Jordan inverse, the encoding rows, and one combine loop. It allocates only the block's shards and three k x k matrices, so the decoder's bound is still set by the wire validators (<= 256 shards of <= 64 KiB). The shard functions no longer return errors that only the crate's own checks could raise; every caller already bounds k, m and the shard length. The generator test is deleted with the crate; the fixture keeps its output and names the commit that ran it. New: a seeded property test over 300 random (k <= 48, m <= 24) geometries, any e <= m erasures recover exactly and m + 1 are refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1338 lines
57 KiB
Rust
1338 lines
57 KiB
Rust
//! The media FEC codec's guarantees, proved against its public surface.
|
|
//!
|
|
//! Every test drives `protect_*` and `recover_*` the way a producer and a
|
|
//! consumer would, and asserts what a consumer is entitled to rely on: what is
|
|
//! recovered is exactly what was sent, what cannot be recovered is reported and
|
|
//! never guessed, and a shard always fits a datagram.
|
|
|
|
use cultnet_rs::{
|
|
GameCultMediaAudioPacketRecord, GameCultMediaAudioParityShardRecord,
|
|
GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord,
|
|
GAMECULT_MEDIA_MAX_WIRE_BYTES, GameCultMediaWireRecord, MEDIA_FEC_MAX_WIRE_BYTES,
|
|
MEDIA_FEC_SCHEME_RS_GF256_V1, MediaFecError, MediaFecPolicy, MediaWireProvenance,
|
|
decode_media_wire_record, encode_media_wire_record, protect_audio_block, protect_video_frame,
|
|
recover_audio_block, recover_video_block,
|
|
};
|
|
|
|
const STANDARD: MediaFecPolicy = MediaFecPolicy::STANDARD;
|
|
|
|
/// Deterministic, non-constant bytes so a shard mix-up cannot pass by accident.
|
|
fn bytes(seed: u32, len: usize) -> Vec<u8> {
|
|
let mut state = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
|
|
(0..len)
|
|
.map(|_| {
|
|
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
|
(state >> 24) as u8
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// One frame of `chunk_count` chunks: every chunk `chunk_bytes` long except the
|
|
/// last, which is `last_bytes`.
|
|
fn frame(
|
|
frame_id: u64,
|
|
chunk_count: u16,
|
|
chunk_bytes: usize,
|
|
last_bytes: usize,
|
|
) -> Vec<GameCultMediaVideoAccessUnitRecord> {
|
|
(0..chunk_count)
|
|
.map(|index| GameCultMediaVideoAccessUnitRecord {
|
|
stream_id: "muninn.raven.av.rudp".to_string(),
|
|
session_id: "session-1".to_string(),
|
|
frame_id,
|
|
codec: "h264".to_string(),
|
|
pts_ticks: 90_000,
|
|
duration_ticks: 3_000,
|
|
timebase_num: 1,
|
|
timebase_den: 90_000,
|
|
keyframe: frame_id % 2 == 0,
|
|
dependency_frame_id: (frame_id % 2 == 1).then(|| frame_id - 1),
|
|
deadline_ticks: 90_000 + 22_500,
|
|
chunk_index: index,
|
|
chunk_count,
|
|
payload: bytes(
|
|
(frame_id as u32).wrapping_mul(1_000).wrapping_add(u32::from(index)),
|
|
if index + 1 == chunk_count { last_bytes } else { chunk_bytes },
|
|
),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn audio_block(base_packet_id: u64, payload_bytes: usize) -> Vec<GameCultMediaAudioPacketRecord> {
|
|
(0..4_u64)
|
|
.map(|index| GameCultMediaAudioPacketRecord {
|
|
stream_id: "muninn.raven.av.rudp".to_string(),
|
|
session_id: "session-1".to_string(),
|
|
packet_id: base_packet_id + index,
|
|
codec: "opus".to_string(),
|
|
pts_ticks: 48_000 + 960 * index as i64,
|
|
duration_ticks: 960,
|
|
timebase_num: 1,
|
|
timebase_den: 48_000,
|
|
deadline_ticks: 60_000 + 960 * index as i64,
|
|
payload: bytes((base_packet_id as u32).wrapping_mul(10).wrapping_add(index as u32), payload_bytes),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn split(
|
|
records: Vec<GameCultMediaWireRecord>,
|
|
) -> (Vec<GameCultMediaVideoAccessUnitRecord>, Vec<GameCultMediaVideoParityShardRecord>) {
|
|
let mut data = Vec::new();
|
|
let mut parity = Vec::new();
|
|
for record in records {
|
|
match record {
|
|
GameCultMediaWireRecord::Video(record) => data.push(record),
|
|
GameCultMediaWireRecord::VideoParity(record) => parity.push(record),
|
|
other => panic!("a video frame produced {}", other.schema_id()),
|
|
}
|
|
}
|
|
(data, parity)
|
|
}
|
|
|
|
/// The block a wire record belongs to, from its own fields.
|
|
fn block_of(record: &GameCultMediaWireRecord, sizes: &[u16]) -> usize {
|
|
match record {
|
|
GameCultMediaWireRecord::Video(chunk) => {
|
|
let mut start = 0_u16;
|
|
for (block, size) in sizes.iter().enumerate() {
|
|
if chunk.chunk_index < start + size {
|
|
return block;
|
|
}
|
|
start += size;
|
|
}
|
|
panic!("chunk {} is outside every block", chunk.chunk_index)
|
|
}
|
|
GameCultMediaWireRecord::VideoParity(parity) => usize::from(parity.block_index),
|
|
other => panic!("not a video record: {}", other.schema_id()),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Recovery: the MDS guarantee, exhaustively
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn the_policy_gives_the_ruled_block_geometry() {
|
|
// m = max(2, ceil(k / 4)), written independently of the policy.
|
|
for k in 1..=16_u16 {
|
|
let expected = std::cmp::max(2, (k + 3) / 4);
|
|
assert_eq!(STANDARD.video_parity_shards(k), expected, "k = {k}");
|
|
}
|
|
assert_eq!(STANDARD.audio_data_shards, 4);
|
|
assert_eq!(STANDARD.audio_parity_shards, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn a_frame_splits_into_ceil_n_over_16_near_equal_blocks() {
|
|
for n in 1..=200_u16 {
|
|
let sizes = STANDARD.video_block_sizes(n);
|
|
assert_eq!(sizes.len(), usize::from(n.div_ceil(16)), "n = {n}");
|
|
assert_eq!(sizes.iter().sum::<u16>(), n, "n = {n}");
|
|
assert!(sizes.iter().all(|size| (1..=16).contains(size)), "n = {n}: {sizes:?}");
|
|
let (widest, narrowest) = (sizes.iter().max().unwrap(), sizes.iter().min().unwrap());
|
|
assert!(widest - narrowest <= 1, "n = {n}: {sizes:?}");
|
|
assert!(sizes.windows(2).all(|pair| pair[0] >= pair[1]), "larger blocks first: {sizes:?}");
|
|
}
|
|
}
|
|
|
|
/// Erases every pattern of at most `m` records from a `k`-chunk frame's wire
|
|
/// output and requires the data back, byte for byte, then requires every
|
|
/// `m + 1` pattern to be refused.
|
|
fn assert_every_erasure_pattern_behaves(k: u16) {
|
|
let original = frame(u64::from(k), k, 37, 21);
|
|
let records = protect_video_frame(&original, &STANDARD, provenance()).expect("protects");
|
|
let m = usize::from(STANDARD.video_parity_shards(k));
|
|
let total = usize::from(k) + m;
|
|
assert_eq!(records.len(), total, "one block: k data and m parity records");
|
|
|
|
for erased in 0_u32..(1 << total) {
|
|
let erasures = erased.count_ones() as usize;
|
|
if erasures > m + 1 {
|
|
continue;
|
|
}
|
|
let mut data = Vec::new();
|
|
let mut parity = Vec::new();
|
|
for (slot, record) in records.iter().enumerate() {
|
|
if erased & (1 << slot) != 0 {
|
|
continue;
|
|
}
|
|
match record {
|
|
GameCultMediaWireRecord::Video(chunk) => data.push(chunk.clone()),
|
|
GameCultMediaWireRecord::VideoParity(shard) => parity.push(shard.clone()),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
if parity.is_empty() {
|
|
continue; // a receiver holding no parity has nothing to recover with
|
|
}
|
|
let outcome = recover_video_block(&parity, &data);
|
|
if erasures <= m {
|
|
let recovered = outcome.unwrap_or_else(|error| {
|
|
panic!("k={k} m={m} erased={erased:#b}: {error}");
|
|
});
|
|
data.extend(recovered);
|
|
data.sort_by_key(|chunk| chunk.chunk_index);
|
|
assert_eq!(data, original, "k={k} m={m} erased={erased:#b}");
|
|
} else {
|
|
assert!(
|
|
matches!(outcome, Err(MediaFecError::BeyondRepair { .. })),
|
|
"k={k} m={m} erased={erased:#b}: {outcome:?} must be beyond repair, never wrong bytes"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_le_m_erasure_recovers_exactly_and_m_plus_one_is_refused_for_every_video_k() {
|
|
for k in 1..=16 {
|
|
assert_every_erasure_pattern_behaves(k);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_le_m_erasure_recovers_exactly_and_m_plus_one_is_refused_for_audio_4_plus_2() {
|
|
let original = audio_block(100, 41);
|
|
let parity = protect_audio_block(&original, &STANDARD, provenance()).expect("protects");
|
|
assert_eq!(parity.len(), 2);
|
|
for erased in 0_u32..(1 << 6) {
|
|
let erasures = erased.count_ones();
|
|
let data: Vec<_> = (0..4)
|
|
.filter(|slot| erased & (1 << slot) == 0)
|
|
.map(|slot| original[slot].clone())
|
|
.collect();
|
|
let present_parity: Vec<_> = (0..2)
|
|
.filter(|slot| erased & (1 << (4 + slot)) == 0)
|
|
.map(|slot| parity[slot].clone())
|
|
.collect();
|
|
if present_parity.is_empty() {
|
|
continue;
|
|
}
|
|
let outcome = recover_audio_block(&present_parity, &data);
|
|
if erasures <= 2 {
|
|
let mut all = data.clone();
|
|
let block_deadline = original.iter().map(|p| p.deadline_ticks).max().unwrap();
|
|
for mut packet in outcome.unwrap_or_else(|error| panic!("erased={erased:#b}: {error}")) {
|
|
// A recovered packet carries the block's deadline, not its own.
|
|
assert_eq!(packet.deadline_ticks, block_deadline, "erased={erased:#b}");
|
|
packet.deadline_ticks = original[(packet.packet_id - 100) as usize].deadline_ticks;
|
|
all.push(packet);
|
|
}
|
|
all.sort_by_key(|packet| packet.packet_id);
|
|
assert_eq!(all, original, "erased={erased:#b}");
|
|
} else {
|
|
assert!(
|
|
matches!(outcome, Err(MediaFecError::BeyondRepair { .. })),
|
|
"erased={erased:#b}: {outcome:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn recovery_reports_how_short_the_block_is() {
|
|
let original = frame(9, 8, 37, 37);
|
|
let (data, parity) = split(protect_video_frame(&original, &STANDARD, provenance()).unwrap());
|
|
// 8 data + 2 parity; keep 6 data and 1 parity: 7 present, 8 needed.
|
|
let outcome = recover_video_block(&parity[..1], &data[..6]);
|
|
assert_eq!(
|
|
outcome,
|
|
Err(MediaFecError::BeyondRepair { shards_present: 7, shards_needed: 8 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_recovered_short_final_chunk_has_its_true_length() {
|
|
let original = frame(4, 5, 100, 7);
|
|
let (mut data, parity) = split(protect_video_frame(&original, &STANDARD, provenance()).unwrap());
|
|
let lost = data.pop().unwrap();
|
|
assert_eq!(lost.payload.len(), 7);
|
|
let recovered = recover_video_block(&parity, &data).unwrap();
|
|
assert_eq!(recovered, vec![lost]);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_shards_neither_count_twice_nor_break_recovery() {
|
|
let original = frame(5, 4, 50, 50);
|
|
let (mut data, parity) = split(protect_video_frame(&original, &STANDARD, provenance()).unwrap());
|
|
let lost = data.remove(1);
|
|
data.push(data[0].clone());
|
|
let mut parity_twice = parity.clone();
|
|
parity_twice.extend(parity);
|
|
assert_eq!(recover_video_block(&parity_twice, &data).unwrap(), vec![lost]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_complete_block_recovers_nothing() {
|
|
let original = frame(6, 4, 50, 50);
|
|
let (data, parity) = split(protect_video_frame(&original, &STANDARD, provenance()).unwrap());
|
|
assert_eq!(recover_video_block(&parity[..1], &data).unwrap(), Vec::new());
|
|
}
|
|
|
|
#[test]
|
|
fn recovery_refuses_records_that_are_not_one_block() {
|
|
let original = frame(7, 32, 40, 40);
|
|
let (data, parity) = split(protect_video_frame(&original, &STANDARD, provenance()).unwrap());
|
|
let block_zero: Vec<_> = parity.iter().filter(|p| p.block_index == 0).cloned().collect();
|
|
let block_one: Vec<_> = parity.iter().filter(|p| p.block_index == 1).cloned().collect();
|
|
assert_eq!(block_zero.len(), 4);
|
|
|
|
let mixed = vec![block_zero[0].clone(), block_one[0].clone()];
|
|
assert!(matches!(recover_video_block(&mixed, &[]), Err(MediaFecError::Invalid(_))));
|
|
|
|
// A chunk from block one handed to block zero's parity.
|
|
// The first chunk of block one, exactly one past block zero's last.
|
|
let stray = data.iter().find(|chunk| chunk.chunk_index == 16).unwrap().clone();
|
|
assert!(matches!(recover_video_block(&block_zero, &[stray]), Err(MediaFecError::Invalid(_))));
|
|
|
|
// A chunk whose length disagrees with the block's.
|
|
let mut wrong_length = data[0].clone();
|
|
wrong_length.payload.push(0);
|
|
assert!(matches!(
|
|
recover_video_block(&block_zero, &[wrong_length]),
|
|
Err(MediaFecError::Invalid(_))
|
|
));
|
|
|
|
assert!(matches!(recover_video_block(&[], &data), Err(MediaFecError::Invalid(_))));
|
|
|
|
// Another frame's chunk.
|
|
let mut other_frame = data.iter().find(|chunk| chunk.chunk_index == 3).unwrap().clone();
|
|
other_frame.frame_id += 1;
|
|
assert!(matches!(recover_video_block(&block_zero, &[other_frame]), Err(MediaFecError::Invalid(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn a_final_chunk_of_the_wrong_length_is_refused() {
|
|
let original = frame(8, 5, 100, 7);
|
|
let (data, parity) = split(protect_video_frame(&original, &STANDARD, provenance()).unwrap());
|
|
let mut last = data.iter().find(|chunk| chunk.chunk_index == 4).unwrap().clone();
|
|
last.payload.push(0);
|
|
assert!(matches!(recover_video_block(&parity, &[last]), Err(MediaFecError::Invalid(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn within_a_block_data_and_parity_alternate() {
|
|
let records = protect_video_frame(&frame(9, 4, 20, 20), &STANDARD, provenance()).unwrap();
|
|
let kinds: String = records
|
|
.iter()
|
|
.map(|record| match record {
|
|
GameCultMediaWireRecord::Video(_) => 'D',
|
|
GameCultMediaWireRecord::VideoParity(_) => 'P',
|
|
_ => '?',
|
|
})
|
|
.collect();
|
|
assert_eq!(kinds, "DPDPDD", "k = 4, m = 2: the longer list finishes the lane");
|
|
}
|
|
|
|
#[test]
|
|
fn audio_recovery_refuses_packets_that_are_not_from_the_block() {
|
|
let original = audio_block(100, 20);
|
|
let parity = protect_audio_block(&original, &STANDARD, provenance()).unwrap();
|
|
let next_block = audio_block(104, 20);
|
|
// One past the block's last packet, and one before its first.
|
|
for stray in [next_block[0].clone(), audio_block(96, 20)[3].clone()] {
|
|
assert!(matches!(
|
|
recover_audio_block(&parity, &[stray]),
|
|
Err(MediaFecError::Invalid(_))
|
|
));
|
|
}
|
|
let mut other_session = original[0].clone();
|
|
other_session.session_id = "session-2".to_string();
|
|
assert!(matches!(recover_audio_block(&parity, &[other_session]), Err(MediaFecError::Invalid(_))));
|
|
let mut wrong_length = original[0].clone();
|
|
wrong_length.payload.push(0);
|
|
assert!(matches!(recover_audio_block(&parity, &[wrong_length]), Err(MediaFecError::Invalid(_))));
|
|
assert!(matches!(recover_audio_block(&[], &original), Err(MediaFecError::Invalid(_))));
|
|
|
|
let mut mixed = parity.clone();
|
|
mixed[1].base_packet_id += 1;
|
|
assert!(matches!(recover_audio_block(&mixed, &original[..3]), Err(MediaFecError::Invalid(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn a_frame_of_several_blocks_recovers_each_block_from_its_own_parity() {
|
|
let original = frame(11, 40, 60, 33);
|
|
let records = protect_video_frame(&original, &STANDARD, provenance()).unwrap();
|
|
let sizes = STANDARD.video_block_sizes(40);
|
|
assert_eq!(sizes, vec![14, 13, 13]);
|
|
let (data, parity) = split(records);
|
|
|
|
// Lose the first two data chunks of every block.
|
|
let mut lost_indexes = Vec::new();
|
|
let mut start = 0_u16;
|
|
for size in &sizes {
|
|
lost_indexes.extend([start, start + 1]);
|
|
start += size;
|
|
}
|
|
let held: Vec<_> = data.iter().filter(|c| !lost_indexes.contains(&c.chunk_index)).cloned().collect();
|
|
let mut all = held.clone();
|
|
for block in 0..3_u16 {
|
|
let block_parity: Vec<_> = parity.iter().filter(|p| p.block_index == block).cloned().collect();
|
|
let lo = sizes[..usize::from(block)].iter().sum::<u16>();
|
|
let block_data: Vec<_> = held
|
|
.iter()
|
|
.filter(|c| c.chunk_index >= lo && c.chunk_index < lo + sizes[usize::from(block)])
|
|
.cloned()
|
|
.collect();
|
|
all.extend(recover_video_block(&block_parity, &block_data).unwrap());
|
|
}
|
|
all.sort_by_key(|chunk| chunk.chunk_index);
|
|
assert_eq!(all, original);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Known answers: the scheme is pinned, not the library
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn hex(bytes: &[u8]) -> String {
|
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
|
}
|
|
|
|
/// Four 37-byte audio payloads, `payload[i][j] = (i * 37 + j * 7 + 3) mod 256`.
|
|
/// If these bytes ever change, `rs-gf256-v1` changed meaning: the matrix, the
|
|
/// field polynomial, or the shard order. Bump the scheme id, do not edit them.
|
|
#[test]
|
|
fn known_answer_audio_parity_bytes() {
|
|
let mut block = audio_block(1, 37);
|
|
for (i, packet) in block.iter_mut().enumerate() {
|
|
packet.payload = (0..37).map(|j| (i * 37 + j * 7 + 3) as u8).collect();
|
|
}
|
|
let parity = protect_audio_block(&block, &STANDARD, provenance()).unwrap();
|
|
assert_eq!(parity[0].fec_scheme, "rs-gf256-v1");
|
|
assert_eq!(
|
|
hex(&parity[0].payload),
|
|
"f246399711f2ecefa4147ae3856317be83b9a87c0ed13bc552918473bc08e869a91dd98356",
|
|
"parity 0"
|
|
);
|
|
assert_eq!(
|
|
hex(&parity[1].payload),
|
|
"a1532c08e588cc05da6175c61b09a704f7d6c7597d76fbef76435656bf65e2331def91dcbf",
|
|
"parity 1"
|
|
);
|
|
}
|
|
|
|
/// The video path pads a short final chunk with zeros before computing parity.
|
|
/// Chunks of 5, 5 and 3 bytes, `payload[i][j] = (i * 11 + j * 3 + 1)`.
|
|
#[test]
|
|
fn known_answer_video_parity_bytes_pad_a_short_final_chunk_with_zeros() {
|
|
let mut chunks = frame(2, 3, 5, 3);
|
|
for chunk in &mut chunks {
|
|
let len = chunk.payload.len();
|
|
chunk.payload = (0..len).map(|j| (usize::from(chunk.chunk_index) * 11 + j * 3 + 1) as u8).collect();
|
|
}
|
|
let (_, parity) = split(protect_video_frame(&chunks, &STANDARD, provenance()).unwrap());
|
|
assert_eq!(parity.len(), 2);
|
|
assert_eq!(hex(&parity[0].payload), "1a11081f15", "parity 0");
|
|
assert_eq!(hex(&parity[1].payload), "1d18f3ce8b", "parity 1");
|
|
assert_eq!(parity[0].shard_payload_bytes, 5);
|
|
assert_eq!(parity[0].last_chunk_payload_bytes, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn parity_is_a_pure_function_of_its_inputs() {
|
|
let original = frame(3, 20, 200, 90);
|
|
assert_eq!(
|
|
protect_video_frame(&original, &STANDARD, provenance()).unwrap(),
|
|
protect_video_frame(&original, &STANDARD, provenance()).unwrap()
|
|
);
|
|
let block = audio_block(8, 64);
|
|
assert_eq!(
|
|
protect_audio_block(&block, &STANDARD, provenance()).unwrap(),
|
|
protect_audio_block(&block, &STANDARD, provenance()).unwrap()
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// One datagram
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn provenance() -> MediaWireProvenance<'static> {
|
|
MediaWireProvenance {
|
|
stored_at: "unix:1700000000000",
|
|
runtime_id: "raven-muninn-primary",
|
|
role: "muninn.media",
|
|
producer: "muninn",
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_datagram_budget_is_the_rudp_fragment_the_media_channel_leaves() {
|
|
// 1,472 bytes of UDP payload, less the 36-byte RUDP header and the 5-byte
|
|
// channel name "media".
|
|
assert_eq!(MEDIA_FEC_MAX_WIRE_BYTES, 1_431);
|
|
}
|
|
|
|
/// A provenance whose every field is 32 bytes, the widest the tests claim.
|
|
fn widest_provenance() -> MediaWireProvenance<'static> {
|
|
let field = |prefix: &str| -> &'static str { Box::leak(long(prefix).into_boxed_str()) };
|
|
MediaWireProvenance {
|
|
stored_at: field("unix:"),
|
|
runtime_id: field("runtime-"),
|
|
role: field("role-"),
|
|
producer: field("producer-"),
|
|
}
|
|
}
|
|
|
|
fn long(prefix: &str) -> String {
|
|
format!("{prefix}{}", "x".repeat(32 - prefix.len()))
|
|
}
|
|
|
|
/// Twenty chunks (two blocks) of an odd frame with a dependency, every field
|
|
/// at the width MessagePack spends the most bytes on.
|
|
fn widest_frame(payload_bytes: usize) -> Vec<GameCultMediaVideoAccessUnitRecord> {
|
|
let mut chunks = frame(u64::MAX, 20, payload_bytes, payload_bytes);
|
|
for chunk in &mut chunks {
|
|
chunk.stream_id = long("stream-");
|
|
chunk.session_id = long("session-");
|
|
chunk.codec = long("codec-");
|
|
chunk.pts_ticks = i64::MIN;
|
|
chunk.deadline_ticks = i64::MAX;
|
|
chunk.duration_ticks = u32::MAX;
|
|
chunk.timebase_num = u32::MAX;
|
|
chunk.timebase_den = u32::MAX;
|
|
assert!(chunk.dependency_frame_id.is_some() && !chunk.keyframe);
|
|
}
|
|
chunks
|
|
}
|
|
|
|
fn widest_audio(payload_bytes: usize) -> Vec<GameCultMediaAudioPacketRecord> {
|
|
let mut packets = audio_block(u64::MAX - 3, payload_bytes);
|
|
for (index, packet) in packets.iter_mut().enumerate() {
|
|
packet.stream_id = long("stream-");
|
|
packet.session_id = long("session-");
|
|
packet.codec = long("codec-");
|
|
packet.duration_ticks = u32::MAX;
|
|
packet.timebase_num = u32::MAX;
|
|
packet.timebase_den = u32::MAX;
|
|
packet.pts_ticks = i64::MIN + i64::from(u32::MAX) * index as i64;
|
|
packet.deadline_ticks = i64::MAX;
|
|
}
|
|
packets
|
|
}
|
|
|
|
/// Fitting is monotonic in the payload, so the largest payload that fits is a
|
|
/// partition point.
|
|
fn largest_fitting_payload(fits: impl Fn(usize) -> bool) -> usize {
|
|
let payloads: Vec<usize> = (1..=MEDIA_FEC_MAX_WIRE_BYTES).collect();
|
|
let count = payloads.partition_point(|&payload| fits(payload));
|
|
assert!(count > 0, "no payload fits");
|
|
payloads[count - 1]
|
|
}
|
|
|
|
fn wire_len(record: &GameCultMediaWireRecord, provenance: MediaWireProvenance<'_>) -> usize {
|
|
encode_media_wire_record(record, provenance).unwrap().len()
|
|
}
|
|
|
|
fn is_over_budget(error: &MediaFecError) -> bool {
|
|
matches!(error, MediaFecError::Invalid(message) if message.contains("datagram budget"))
|
|
}
|
|
|
|
/// The invariant is "an encoded record fits one datagram", enforced where the
|
|
/// record is produced. At the widest legal identifiers the largest payload that
|
|
/// fits is found by search, the record it produces is exactly the budget, and
|
|
/// one byte more is refused.
|
|
#[test]
|
|
fn a_video_frame_is_protected_only_if_every_record_fits_the_datagram_budget() {
|
|
let widest = widest_provenance();
|
|
let fits = |payload: usize| protect_video_frame(&widest_frame(payload), &STANDARD, widest);
|
|
let largest_payload = largest_fitting_payload(|payload| fits(payload).is_ok());
|
|
assert!(
|
|
largest_payload >= 600,
|
|
"maximum-width identifiers must still leave room for a useful chunk, got {largest_payload}"
|
|
);
|
|
|
|
let records = fits(largest_payload).unwrap();
|
|
let largest = records.iter().map(|record| wire_len(record, widest)).max().unwrap();
|
|
assert_eq!(largest, MEDIA_FEC_MAX_WIRE_BYTES, "the budget is used to the byte, not approximated");
|
|
|
|
let error = fits(largest_payload + 1).unwrap_err();
|
|
assert!(is_over_budget(&error), "{error}");
|
|
|
|
// The budget is the policy's, and it is inclusive.
|
|
let exact = MediaFecPolicy { max_wire_bytes: largest, ..STANDARD };
|
|
assert!(protect_video_frame(&widest_frame(largest_payload), &exact, widest).is_ok());
|
|
let one_short = MediaFecPolicy { max_wire_bytes: largest - 1, ..STANDARD };
|
|
let error = protect_video_frame(&widest_frame(largest_payload), &one_short, widest).unwrap_err();
|
|
assert!(is_over_budget(&error), "{error}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_audio_block_is_protected_only_if_every_record_fits_the_datagram_budget() {
|
|
let widest = widest_provenance();
|
|
let fits = |payload: usize| protect_audio_block(&widest_audio(payload), &STANDARD, widest);
|
|
let largest_payload = largest_fitting_payload(|payload| fits(payload).is_ok());
|
|
assert!(largest_payload >= 600, "got {largest_payload}");
|
|
|
|
let packets = widest_audio(largest_payload);
|
|
let parity = fits(largest_payload).unwrap();
|
|
let largest_parity = parity
|
|
.iter()
|
|
.map(|shard| wire_len(&GameCultMediaWireRecord::AudioParity(shard.clone()), widest))
|
|
.max()
|
|
.unwrap();
|
|
assert_eq!(largest_parity, MEDIA_FEC_MAX_WIRE_BYTES);
|
|
// The parity is the largest record of the block, which is why only parity
|
|
// is measured.
|
|
for packet in &packets {
|
|
assert!(wire_len(&GameCultMediaWireRecord::Audio(packet.clone()), widest) < largest_parity);
|
|
}
|
|
|
|
let error = fits(largest_payload + 1).unwrap_err();
|
|
assert!(is_over_budget(&error), "{error}");
|
|
let one_short = MediaFecPolicy { max_wire_bytes: largest_parity - 1, ..STANDARD };
|
|
let error = protect_audio_block(&packets, &one_short, widest).unwrap_err();
|
|
assert!(is_over_budget(&error), "{error}");
|
|
}
|
|
|
|
/// A path that carries less than the IPv4 default passes a smaller budget, and
|
|
/// a frame protected under the default is refused under it.
|
|
#[test]
|
|
fn a_smaller_datagram_budget_refuses_what_the_default_admits() {
|
|
let chunks = frame(2, 20, 700, 700);
|
|
assert!(protect_video_frame(&chunks, &STANDARD, provenance()).is_ok());
|
|
let tunnel = MediaFecPolicy { max_wire_bytes: 1_211, ..STANDARD };
|
|
let error = protect_video_frame(&chunks, &tunnel, provenance()).unwrap_err();
|
|
assert!(is_over_budget(&error), "{error}");
|
|
}
|
|
|
|
/// The record a shard produces is sized by the fields around it, so a shard of
|
|
/// any length the budget admits is admitted at the decoder too: a jumbo-frame
|
|
/// producer is not stopped by a fixed shard cap.
|
|
#[test]
|
|
fn a_larger_datagram_budget_admits_larger_shards() {
|
|
let jumbo = MediaFecPolicy { max_wire_bytes: 8_959, ..STANDARD };
|
|
let chunks = frame(2, 3, 4_000, 4_000);
|
|
let records = protect_video_frame(&chunks, &jumbo, provenance()).unwrap();
|
|
for record in &records {
|
|
let wire = encode_media_wire_record(record, provenance()).unwrap();
|
|
assert!(decode_media_wire_record(&wire).is_ok());
|
|
}
|
|
assert!(protect_video_frame(&chunks, &STANDARD, provenance()).is_err());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Framing: what a block may and may not contain
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn a_block_never_spans_frames() {
|
|
let mut chunks = frame(20, 4, 30, 30);
|
|
chunks[2].frame_id = 21;
|
|
let error = protect_video_frame(&chunks, &STANDARD, provenance()).unwrap_err();
|
|
assert!(matches!(error, MediaFecError::Invalid(_)), "{error}");
|
|
|
|
let mut chunks = frame(20, 4, 30, 30);
|
|
chunks[3].deadline_ticks += 1;
|
|
assert!(protect_video_frame(&chunks, &STANDARD, provenance()).is_err(), "parity would misstate the deadline");
|
|
|
|
let mut chunks = frame(20, 4, 30, 30);
|
|
chunks[1].session_id = "session-2".to_string();
|
|
assert!(protect_video_frame(&chunks, &STANDARD, provenance()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_frame_must_be_whole_and_shard_shaped() {
|
|
let mut chunks = frame(30, 4, 30, 30);
|
|
chunks.pop();
|
|
assert!(protect_video_frame(&chunks, &STANDARD, provenance()).is_err(), "a missing chunk");
|
|
|
|
let mut chunks = frame(30, 4, 30, 30);
|
|
chunks[3] = chunks[2].clone();
|
|
assert!(protect_video_frame(&chunks, &STANDARD, provenance()).is_err(), "a repeated chunk");
|
|
|
|
let mut chunks = frame(30, 4, 30, 30);
|
|
chunks[1].payload.truncate(29);
|
|
assert!(protect_video_frame(&chunks, &STANDARD, provenance()).is_err(), "a short chunk that is not last");
|
|
|
|
let mut chunks = frame(30, 4, 30, 30);
|
|
chunks[3].payload.push(0);
|
|
assert!(protect_video_frame(&chunks, &STANDARD, provenance()).is_err(), "a last chunk longer than the shard");
|
|
|
|
assert!(protect_video_frame(&[], &STANDARD, provenance()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn chunks_may_arrive_in_any_order() {
|
|
let original = frame(31, 6, 30, 12);
|
|
let mut shuffled = original.clone();
|
|
shuffled.reverse();
|
|
assert_eq!(
|
|
protect_video_frame(&original, &STANDARD, provenance()).unwrap(),
|
|
protect_video_frame(&shuffled, &STANDARD, provenance()).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_audio_block_is_four_contiguous_equal_length_packets() {
|
|
assert!(protect_audio_block(&audio_block(1, 20)[..3], &STANDARD, provenance()).is_err(), "three packets");
|
|
let mut packets = audio_block(1, 20);
|
|
packets[2].packet_id += 1;
|
|
assert!(protect_audio_block(&packets, &STANDARD, provenance()).is_err(), "a gap in packet ids");
|
|
let mut packets = audio_block(1, 20);
|
|
packets[1].pts_ticks += 1;
|
|
assert!(protect_audio_block(&packets, &STANDARD, provenance()).is_err(), "a gap in presentation time");
|
|
let mut packets = audio_block(1, 20);
|
|
packets[3].payload.push(0);
|
|
let error = protect_audio_block(&packets, &STANDARD, provenance()).unwrap_err();
|
|
assert!(error.to_string().contains("payload length"), "unequal payload lengths: {error}");
|
|
let mut packets = audio_block(1, 20);
|
|
packets[0].codec = "pcm".to_string();
|
|
assert!(protect_audio_block(&packets, &STANDARD, provenance()).is_err(), "mixed codecs");
|
|
let mut packets = audio_block(1, 20);
|
|
packets[2].session_id = "session-2".to_string();
|
|
assert!(protect_audio_block(&packets, &STANDARD, provenance()).is_err(), "mixed sessions");
|
|
let mut packets = audio_block(1, 20);
|
|
packets[2].stream_id = "other".to_string();
|
|
assert!(protect_audio_block(&packets, &STANDARD, provenance()).is_err(), "mixed streams");
|
|
let mut packets = audio_block(1, 20);
|
|
packets[1].timebase_den = 44_100;
|
|
assert!(protect_audio_block(&packets, &STANDARD, provenance()).is_err(), "mixed timebases");
|
|
let mut packets = audio_block(1, 20);
|
|
packets[3].duration_ticks = 480;
|
|
assert!(protect_audio_block(&packets, &STANDARD, provenance()).is_err(), "mixed durations");
|
|
}
|
|
|
|
#[test]
|
|
fn parity_carries_its_frames_deadline_unchanged() {
|
|
let original = frame(40, 40, 50, 50);
|
|
let (_, parity) = split(protect_video_frame(&original, &STANDARD, provenance()).unwrap());
|
|
assert!(!parity.is_empty());
|
|
for shard in &parity {
|
|
assert_eq!(shard.deadline_ticks, original[0].deadline_ticks);
|
|
assert_eq!(shard.pts_ticks, original[0].pts_ticks);
|
|
assert_eq!(shard.frame_id, 40);
|
|
assert_eq!(shard.fec_scheme, MEDIA_FEC_SCHEME_RS_GF256_V1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn audio_parity_carries_the_latest_deadline_in_its_block_and_recovery_uses_it() {
|
|
let mut packets = audio_block(50, 30);
|
|
packets[1].deadline_ticks = 99_999; // the block's latest, not its last
|
|
let parity = protect_audio_block(&packets, &STANDARD, provenance()).unwrap();
|
|
assert!(parity.iter().all(|shard| shard.deadline_ticks == 99_999));
|
|
|
|
let held = vec![packets[0].clone(), packets[2].clone(), packets[3].clone()];
|
|
let recovered = recover_audio_block(&parity[..1], &held).unwrap();
|
|
assert_eq!(recovered.len(), 1);
|
|
let packet = &recovered[0];
|
|
assert_eq!(packet.packet_id, 51, "id from the block's arithmetic");
|
|
assert_eq!(packet.pts_ticks, packets[1].pts_ticks, "pts from the block's arithmetic");
|
|
assert_eq!(packet.deadline_ticks, 99_999, "deadline from the block");
|
|
assert_eq!(packet.payload, packets[1].payload);
|
|
assert_eq!(packet.codec, "opus");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Send order: a burst must land as one loss per block
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn send_order_is_a_permutation_of_the_frames_records() {
|
|
for n in [1_u16, 2, 15, 16, 17, 31, 32, 33, 47, 100] {
|
|
let original = frame(60, n, 20, 9);
|
|
let records = protect_video_frame(&original, &STANDARD, provenance()).unwrap();
|
|
let sizes = STANDARD.video_block_sizes(n);
|
|
let expected_parity: usize = sizes.iter().map(|&k| usize::from(STANDARD.video_parity_shards(k))).sum();
|
|
assert_eq!(records.len(), usize::from(n) + expected_parity, "n = {n}");
|
|
let mut data_indexes: Vec<u16> = records
|
|
.iter()
|
|
.filter_map(|r| match r {
|
|
GameCultMediaWireRecord::Video(c) => Some(c.chunk_index),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
data_indexes.sort_unstable();
|
|
assert_eq!(data_indexes, (0..n).collect::<Vec<_>>(), "n = {n}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn equal_blocks_send_one_record_per_block_before_any_block_repeats() {
|
|
for n in [32_u16, 48, 64, 24, 20, 18] {
|
|
let sizes = STANDARD.video_block_sizes(n);
|
|
let blocks = sizes.len();
|
|
assert!(sizes.windows(2).all(|pair| pair[0] == pair[1]), "n = {n} must split equally");
|
|
let records = protect_video_frame(&frame(70, n, 20, 20), &STANDARD, provenance()).unwrap();
|
|
let order: Vec<usize> = records.iter().map(|r| block_of(r, &sizes)).collect();
|
|
for window in order.windows(blocks) {
|
|
let mut seen = window.to_vec();
|
|
seen.sort_unstable();
|
|
seen.dedup();
|
|
assert_eq!(seen.len(), blocks, "n = {n}: a window of {blocks} repeats a block: {order:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Blocks that differ in size run out at the end and cannot keep the full
|
|
/// spacing. What holds: two records of one block are never closer than the
|
|
/// number of blocks still sending from the later one onward.
|
|
#[test]
|
|
fn unequal_blocks_keep_every_block_spaced_by_the_blocks_still_sending() {
|
|
for n in [17_u16, 19, 35, 40, 50, 100] {
|
|
let sizes = STANDARD.video_block_sizes(n);
|
|
assert!(sizes.windows(2).any(|pair| pair[0] != pair[1]), "n = {n} must split unequally");
|
|
let records = protect_video_frame(&frame(71, n, 20, 20), &STANDARD, provenance()).unwrap();
|
|
let order: Vec<usize> = records.iter().map(|r| block_of(r, &sizes)).collect();
|
|
for later in 0..order.len() {
|
|
let Some(earlier) = (0..later).rev().find(|&i| order[i] == order[later]) else { continue };
|
|
let mut still_sending = order[later..].to_vec();
|
|
still_sending.sort_unstable();
|
|
still_sending.dedup();
|
|
assert!(
|
|
later - earlier >= still_sending.len(),
|
|
"n = {n}: block {} sent at {earlier} and again at {later} with {} blocks still sending: {order:?}",
|
|
order[later],
|
|
still_sending.len()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The wire round trip, and the policy
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn a_frame_survives_the_wire_and_recovers_from_what_arrives() {
|
|
let original = frame(80, 20, 300, 111);
|
|
let sizes = STANDARD.video_block_sizes(20);
|
|
let records = protect_video_frame(&original, &STANDARD, provenance()).unwrap();
|
|
|
|
// The network eats a burst of four consecutive records.
|
|
let arrived: Vec<GameCultMediaWireRecord> = records
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(slot, _)| !(9..13).contains(slot))
|
|
.map(|(_, record)| {
|
|
let wire = encode_media_wire_record(record, provenance()).unwrap();
|
|
decode_media_wire_record(&wire).unwrap()
|
|
})
|
|
.collect();
|
|
|
|
let mut all = Vec::new();
|
|
for block in 0..sizes.len() {
|
|
let mut data = Vec::new();
|
|
let mut parity = Vec::new();
|
|
for record in arrived.iter().filter(|r| block_of(r, &sizes) == block) {
|
|
match record {
|
|
GameCultMediaWireRecord::Video(c) => data.push(c.clone()),
|
|
GameCultMediaWireRecord::VideoParity(p) => parity.push(p.clone()),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
let recovered = recover_video_block(&parity, &data).expect("a burst of 4 over 2 blocks is 2 per block");
|
|
all.extend(data);
|
|
all.extend(recovered);
|
|
}
|
|
all.sort_by_key(|chunk| chunk.chunk_index);
|
|
assert_eq!(all, original);
|
|
}
|
|
|
|
#[test]
|
|
fn a_custom_policy_sizes_blocks_by_its_own_fields() {
|
|
let policy = MediaFecPolicy {
|
|
video_max_block_data_shards: 8,
|
|
video_min_parity_shards: 1,
|
|
video_parity_divisor: 2,
|
|
..STANDARD
|
|
};
|
|
assert_eq!(policy.video_block_sizes(20), vec![7, 7, 6]);
|
|
assert_eq!(policy.video_parity_shards(7), 4);
|
|
assert_eq!(policy.video_parity_shards(1), 1);
|
|
let (data, parity) = split(protect_video_frame(&frame(90, 20, 20, 20), &policy, provenance()).unwrap());
|
|
assert_eq!(data.len(), 20);
|
|
assert_eq!(parity.len(), 4 + 4 + 3);
|
|
assert_eq!(MediaFecPolicy::default(), STANDARD);
|
|
}
|
|
|
|
#[test]
|
|
fn a_policy_that_cannot_form_a_block_is_refused() {
|
|
let zero = MediaFecPolicy { video_min_parity_shards: 0, ..STANDARD };
|
|
assert!(protect_video_frame(&frame(1, 2, 10, 10), &zero, provenance()).is_err());
|
|
let no_divisor = MediaFecPolicy { video_parity_divisor: 0, ..STANDARD };
|
|
assert!(protect_video_frame(&frame(1, 2, 10, 10), &no_divisor, provenance()).is_err());
|
|
let no_audio = MediaFecPolicy { audio_data_shards: 0, ..STANDARD };
|
|
let error = protect_audio_block(&audio_block(1, 10), &no_audio, provenance()).unwrap_err();
|
|
assert!(is_invalid_mentioning(&error, "non-zero"), "{error}");
|
|
let no_audio_parity = MediaFecPolicy { audio_parity_shards: 0, ..STANDARD };
|
|
let error = protect_audio_block(&audio_block(1, 10), &no_audio_parity, provenance()).unwrap_err();
|
|
assert!(is_invalid_mentioning(&error, "non-zero"), "{error}");
|
|
let too_wide = MediaFecPolicy { video_max_block_data_shards: 255, ..STANDARD };
|
|
assert!(protect_video_frame(&frame(1, 2, 10, 10), &too_wide, provenance()).is_err());
|
|
let audio_too_wide = MediaFecPolicy { audio_data_shards: 255, audio_parity_shards: 2, ..STANDARD };
|
|
assert!(protect_audio_block(&audio_block(1, 10), &audio_too_wide, provenance()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parity_records_survive_the_positional_encoding_unchanged() {
|
|
let (_, parity) = split(protect_video_frame(&frame(5, 5, 40, 17), &STANDARD, provenance()).unwrap());
|
|
let audio = protect_audio_block(&audio_block(5, 25), &STANDARD, provenance()).unwrap();
|
|
for shard in parity {
|
|
let bytes = rmp_serde::to_vec(&shard).unwrap();
|
|
assert_eq!(rmp_serde::from_slice::<GameCultMediaVideoParityShardRecord>(&bytes).unwrap(), shard);
|
|
}
|
|
for shard in audio {
|
|
let bytes = rmp_serde::to_vec(&shard).unwrap();
|
|
assert_eq!(rmp_serde::from_slice::<GameCultMediaAudioParityShardRecord>(&bytes).unwrap(), shard);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rulings pinned: policy bounds, block identity, audio arithmetic, bursts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn is_invalid_mentioning(error: &MediaFecError, needle: &str) -> bool {
|
|
matches!(error, MediaFecError::Invalid(message) if message.contains(needle))
|
|
}
|
|
|
|
fn audio_run(count: u64, payload_bytes: usize) -> Vec<GameCultMediaAudioPacketRecord> {
|
|
(0..count)
|
|
.map(|index| GameCultMediaAudioPacketRecord {
|
|
stream_id: "muninn.raven.av.rudp".to_string(),
|
|
session_id: "session-1".to_string(),
|
|
packet_id: 500 + index,
|
|
codec: "opus".to_string(),
|
|
pts_ticks: 960 * index as i64,
|
|
duration_ticks: 960,
|
|
timebase_num: 1,
|
|
timebase_den: 48_000,
|
|
deadline_ticks: 960 * index as i64 + 5_000,
|
|
payload: bytes(index as u32, payload_bytes),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// A block is at most 256 shards, data and parity together, and a block of
|
|
/// exactly 256 is admitted.
|
|
#[test]
|
|
fn a_policy_admits_256_shards_per_block_and_refuses_257() {
|
|
let audio_ok = MediaFecPolicy { audio_data_shards: 250, audio_parity_shards: 6, ..STANDARD };
|
|
let parity = protect_audio_block(&audio_run(250, 16), &audio_ok, provenance()).unwrap();
|
|
assert_eq!(parity.len(), 6);
|
|
let audio_wide = MediaFecPolicy { audio_data_shards: 250, audio_parity_shards: 7, ..STANDARD };
|
|
let error = protect_audio_block(&audio_run(250, 16), &audio_wide, provenance()).unwrap_err();
|
|
assert!(is_invalid_mentioning(&error, "256 shards"), "{error}");
|
|
|
|
// One video block of 250 chunks with 6 parity shards.
|
|
let video_ok = MediaFecPolicy {
|
|
video_max_block_data_shards: 250,
|
|
video_min_parity_shards: 6,
|
|
video_parity_divisor: 100,
|
|
..STANDARD
|
|
};
|
|
let records = protect_video_frame(&frame(1, 250, 4, 4), &video_ok, provenance()).unwrap();
|
|
assert_eq!(records.len(), 256);
|
|
let video_wide = MediaFecPolicy { video_min_parity_shards: 7, ..video_ok };
|
|
let error = protect_video_frame(&frame(1, 250, 4, 4), &video_wide, provenance()).unwrap_err();
|
|
assert!(is_invalid_mentioning(&error, "256 shards"), "{error}");
|
|
}
|
|
|
|
/// Two parity shards that differ only in where the block sits in the frame are
|
|
/// not one block, even when each is a well-formed record.
|
|
#[test]
|
|
fn parity_shards_that_differ_only_in_block_count_or_start_do_not_combine() {
|
|
let (_, parity) = split(protect_video_frame(&frame(7, 32, 40, 40), &STANDARD, provenance()).unwrap());
|
|
let block_zero: Vec<_> = parity.iter().filter(|p| p.block_index == 0).cloned().collect();
|
|
|
|
let mut more_blocks = block_zero[1].clone();
|
|
more_blocks.block_count += 1;
|
|
let error = recover_video_block(&[block_zero[0].clone(), more_blocks], &[]).unwrap_err();
|
|
assert!(is_invalid_mentioning(&error, "one block"), "block_count: {error}");
|
|
|
|
let mut shifted = block_zero[1].clone();
|
|
shifted.block_data_start += 1;
|
|
let error = recover_video_block(&[block_zero[0].clone(), shifted], &[]).unwrap_err();
|
|
assert!(is_invalid_mentioning(&error, "one block"), "block_data_start: {error}");
|
|
}
|
|
|
|
/// The largest packet id and pts a block may end on are admitted and recover
|
|
/// to exactly those values; one tick or id more is refused everywhere: at the
|
|
/// wire, in recovery, and at the producer.
|
|
#[test]
|
|
fn an_audio_block_that_would_overflow_its_last_id_or_pts_is_refused() {
|
|
// Id: the block's last packet is u64::MAX.
|
|
let base = u64::MAX - 3;
|
|
let packets = audio_block(base, 20);
|
|
let parity = protect_audio_block(&packets, &STANDARD, provenance()).unwrap();
|
|
let recovered = recover_audio_block(&parity, &packets[..3]).unwrap();
|
|
assert_eq!(recovered.len(), 1);
|
|
assert_eq!(recovered[0].packet_id, u64::MAX);
|
|
assert_eq!(recovered[0], packets[3]);
|
|
|
|
let mut past_end = parity.clone();
|
|
for shard in &mut past_end {
|
|
shard.base_packet_id += 1;
|
|
}
|
|
let error = recover_audio_block(&past_end, &[]).unwrap_err();
|
|
assert!(is_invalid_mentioning(&error, "packet id or pts range"), "{error}");
|
|
let wire = encode_media_wire_record(
|
|
&GameCultMediaWireRecord::AudioParity(past_end[0].clone()),
|
|
provenance(),
|
|
)
|
|
.unwrap();
|
|
assert!(decode_media_wire_record(&wire).unwrap_err().to_string().contains("packet id or pts range"));
|
|
let mut overflowing = audio_block(base, 20);
|
|
overflowing[0].packet_id += 1;
|
|
assert!(protect_audio_block(&overflowing, &STANDARD, provenance()).is_err());
|
|
|
|
// Pts: the block's last packet ends on i64::MAX.
|
|
let mut packets = audio_block(9, 20);
|
|
let first_pts = i64::MAX - 3 * 960;
|
|
for (index, packet) in packets.iter_mut().enumerate() {
|
|
packet.pts_ticks = first_pts + 960 * index as i64;
|
|
packet.deadline_ticks = i64::MAX;
|
|
}
|
|
let parity = protect_audio_block(&packets, &STANDARD, provenance()).unwrap();
|
|
let recovered = recover_audio_block(&parity, &packets[..3]).unwrap();
|
|
assert_eq!(recovered[0].pts_ticks, i64::MAX);
|
|
assert_eq!(recovered[0], packets[3]);
|
|
|
|
let mut past_end = parity.clone();
|
|
for shard in &mut past_end {
|
|
shard.base_pts_ticks += 1;
|
|
}
|
|
let error = recover_audio_block(&past_end, &[]).unwrap_err();
|
|
assert!(is_invalid_mentioning(&error, "packet id or pts range"), "{error}");
|
|
let mut overflowing = packets.clone();
|
|
overflowing[1].pts_ticks += 1;
|
|
assert!(protect_audio_block(&overflowing, &STANDARD, provenance()).is_err());
|
|
}
|
|
|
|
/// Sixteen 8-byte chunks, the widest standard video block (m = 4).
|
|
/// `payload[i][j] = (i * 29 + j * 13 + 5) mod 256`. The expected bytes come
|
|
/// from an independent implementation of the construction in the module docs.
|
|
#[test]
|
|
fn known_answer_video_parity_bytes_for_k16_m4() {
|
|
let mut chunks = frame(2, 16, 8, 8);
|
|
for chunk in &mut chunks {
|
|
let i = usize::from(chunk.chunk_index);
|
|
chunk.payload = (0..8).map(|j| (i * 29 + j * 13 + 5) as u8).collect();
|
|
}
|
|
let (_, parity) = split(protect_video_frame(&chunks, &STANDARD, provenance()).unwrap());
|
|
let hexes: Vec<String> = parity.iter().map(|shard| hex(&shard.payload)).collect();
|
|
assert_eq!(
|
|
hexes,
|
|
["5ed2f608c328adbe", "e46d011eabb600e2", "7c1168f2ba8a2343", "4f03d220d182f232"]
|
|
);
|
|
}
|
|
|
|
/// Blocks interleave, so a burst of consecutive losses costs each block a
|
|
/// share: the longest burst that leaves every frame whole, wherever it starts,
|
|
/// is the block count times the smallest parity count among the blocks.
|
|
#[test]
|
|
fn the_worst_burst_that_always_recovers_is_blocks_times_the_smallest_m() {
|
|
for chunk_count in 1..=399_u16 {
|
|
let sizes = STANDARD.video_block_sizes(chunk_count);
|
|
let parity_of: Vec<usize> =
|
|
sizes.iter().map(|&size| usize::from(STANDARD.video_parity_shards(size))).collect();
|
|
let records = protect_video_frame(&frame(u64::from(chunk_count), chunk_count, 1, 1), &STANDARD, provenance())
|
|
.unwrap();
|
|
let blocks: Vec<usize> = records.iter().map(|record| block_of(record, &sizes)).collect();
|
|
|
|
// The shortest burst, over every start, that costs some block more
|
|
// than its m; the burst one shorter always recovers.
|
|
let mut shortest_failing = usize::MAX;
|
|
for start in 0..blocks.len() {
|
|
let mut lost = vec![0_usize; sizes.len()];
|
|
for (offset, &block) in blocks[start..].iter().enumerate() {
|
|
lost[block] += 1;
|
|
if lost[block] > parity_of[block] {
|
|
shortest_failing = shortest_failing.min(offset + 1);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let smallest_m = *parity_of.iter().min().unwrap();
|
|
assert_eq!(
|
|
shortest_failing - 1,
|
|
sizes.len() * smallest_m,
|
|
"{chunk_count} chunks in {} blocks",
|
|
sizes.len()
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The ceiling, and what the audio budget check relies on
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Only the parity is measured against the budget, on the claim that it is the
|
|
/// largest record of an audio block. It carries every field a packet does plus
|
|
/// the stripe geometry, so this holds across the id, pts, duration and deadline
|
|
/// widths that vary the encoding.
|
|
#[test]
|
|
fn an_audio_parity_record_is_larger_on_the_wire_than_every_packet_it_protects() {
|
|
let bases: [u64; 8] = [0, 0x7c, 0xfc, 0xfffc, 0xffff_fffc, 99_997, u64::MAX - 3, 1];
|
|
let starts: [i64; 7] = [i64::MIN, -1, 0, 125, 65_530, 4_294_967_290, i64::MAX - 3 * 4_000_000_000];
|
|
let durations: [u32; 5] = [1, 120, 960, 65_535, 4_000_000_000];
|
|
for base in bases {
|
|
for start in starts {
|
|
for duration in durations {
|
|
for wide_deadline in [false, true] {
|
|
let mut packets = audio_block(base, 40);
|
|
for (index, packet) in packets.iter_mut().enumerate() {
|
|
packet.pts_ticks = start + i64::from(duration) * index as i64;
|
|
packet.duration_ticks = duration;
|
|
packet.deadline_ticks = if wide_deadline { i64::MAX } else { packet.pts_ticks };
|
|
}
|
|
let parity = protect_audio_block(&packets, &STANDARD, provenance()).unwrap();
|
|
let parity_len = wire_len(&GameCultMediaWireRecord::AudioParity(parity[0].clone()), provenance());
|
|
for packet in &packets {
|
|
let packet_len = wire_len(&GameCultMediaWireRecord::Audio(packet.clone()), provenance());
|
|
assert!(
|
|
packet_len < parity_len,
|
|
"base {base} start {start} duration {duration}: packet {packet_len} >= parity {parity_len}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A parity whose deadline precedes its last packet's pts would recover a packet
|
|
/// that fails `validate_audio_record`. Recovery refuses it, and so does the wire.
|
|
#[test]
|
|
fn audio_parity_with_a_deadline_before_its_last_packet_recovers_nothing() {
|
|
let packets = audio_block(10, 8);
|
|
let mut parity = protect_audio_block(&packets, &STANDARD, provenance()).unwrap();
|
|
for shard in &mut parity {
|
|
shard.deadline_ticks = shard.base_pts_ticks;
|
|
}
|
|
let error = recover_audio_block(&parity, &packets[..2]).unwrap_err();
|
|
assert!(is_invalid_mentioning(&error, "last pts_ticks"), "{error}");
|
|
let wire = encode_media_wire_record(
|
|
&GameCultMediaWireRecord::AudioParity(parity[0].clone()),
|
|
provenance(),
|
|
)
|
|
.unwrap();
|
|
assert!(decode_media_wire_record(&wire).is_err());
|
|
}
|
|
|
|
/// The datagram budget is the producer's, but no budget may promise a record the
|
|
/// decoder would refuse.
|
|
#[test]
|
|
fn a_datagram_budget_over_the_media_record_ceiling_is_refused() {
|
|
let over = MediaFecPolicy { max_wire_bytes: GAMECULT_MEDIA_MAX_WIRE_BYTES + 1, ..STANDARD };
|
|
let error = protect_audio_block(&audio_block(1, 100), &over, provenance()).unwrap_err();
|
|
assert!(is_invalid_mentioning(&error, "ceiling"), "{error}");
|
|
let error = protect_video_frame(&frame(2, 3, 100, 100), &over, provenance()).unwrap_err();
|
|
assert!(is_invalid_mentioning(&error, "ceiling"), "{error}");
|
|
}
|
|
|
|
/// A budget at the ceiling admits shards up to it, and the decoder accepts
|
|
/// what the policy admitted.
|
|
#[test]
|
|
fn a_budget_at_the_media_record_ceiling_admits_large_shards_end_to_end() {
|
|
let at_ceiling = MediaFecPolicy { max_wire_bytes: GAMECULT_MEDIA_MAX_WIRE_BYTES, ..STANDARD };
|
|
let records =
|
|
protect_video_frame(&frame(2, 3, 60_000, 60_000), &at_ceiling, provenance()).unwrap();
|
|
for record in &records {
|
|
let wire = encode_media_wire_record(record, provenance()).unwrap();
|
|
assert!(wire.len() <= GAMECULT_MEDIA_MAX_WIRE_BYTES);
|
|
decode_media_wire_record(&wire).unwrap();
|
|
}
|
|
let parity = protect_audio_block(&audio_block(1, 60_000), &at_ceiling, provenance()).unwrap();
|
|
let wire = encode_media_wire_record(
|
|
&GameCultMediaWireRecord::AudioParity(parity[0].clone()),
|
|
provenance(),
|
|
)
|
|
.unwrap();
|
|
decode_media_wire_record(&wire).unwrap();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Known answers from the fixture: every standard geometry and the policy's
|
|
// edges, byte for byte. The fixture's header states how its data is made.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The data of a fixture case, as the fixture's header defines it.
|
|
fn kat_data(k: usize, m: usize, shard_bytes: usize, fill: &str) -> Vec<Vec<u8>> {
|
|
(0..k)
|
|
.map(|i| match fill {
|
|
"lcg" => bytes((k * 65_536 + m * 256 + i) as u32, shard_bytes),
|
|
"zero" => vec![0; shard_bytes],
|
|
"ff" => vec![0xff; shard_bytes],
|
|
other => panic!("unknown fill {other}"),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
struct KatCase {
|
|
k: usize,
|
|
m: usize,
|
|
shard_bytes: usize,
|
|
fill: String,
|
|
parity: Vec<Vec<u8>>,
|
|
erasures: Vec<Vec<usize>>,
|
|
}
|
|
|
|
impl KatCase {
|
|
fn name(&self) -> String {
|
|
format!("k={} m={} {} bytes {}", self.k, self.m, self.shard_bytes, self.fill)
|
|
}
|
|
|
|
/// The case's data as an audio block: the codec path that takes any `(k, m)`.
|
|
fn packets(&self) -> Vec<GameCultMediaAudioPacketRecord> {
|
|
let mut packets = audio_run(self.k as u64, self.shard_bytes);
|
|
for (packet, data) in packets.iter_mut().zip(kat_data(self.k, self.m, self.shard_bytes, &self.fill)) {
|
|
packet.payload = data;
|
|
}
|
|
packets
|
|
}
|
|
|
|
fn policy(&self) -> MediaFecPolicy {
|
|
MediaFecPolicy {
|
|
audio_data_shards: self.k as u16,
|
|
audio_parity_shards: self.m as u16,
|
|
max_wire_bytes: GAMECULT_MEDIA_MAX_WIRE_BYTES,
|
|
..STANDARD
|
|
}
|
|
}
|
|
}
|
|
|
|
fn unhex(text: &str) -> Vec<u8> {
|
|
(0..text.len())
|
|
.step_by(2)
|
|
.map(|at| u8::from_str_radix(&text[at..at + 2], 16).expect("fixture hex"))
|
|
.collect()
|
|
}
|
|
|
|
fn kat_cases() -> Vec<KatCase> {
|
|
let mut cases: Vec<KatCase> = Vec::new();
|
|
for line in include_str!("fixtures/media_fec_rs_gf256_v1.kat").lines() {
|
|
let fields: Vec<&str> = line.split_whitespace().collect();
|
|
match fields.as_slice() {
|
|
[] => {}
|
|
[comment, ..] if comment.starts_with('#') => {}
|
|
["case", k, m, shard_bytes, fill] => cases.push(KatCase {
|
|
k: k.parse().unwrap(),
|
|
m: m.parse().unwrap(),
|
|
shard_bytes: shard_bytes.parse().unwrap(),
|
|
fill: fill.to_string(),
|
|
parity: Vec::new(),
|
|
erasures: Vec::new(),
|
|
}),
|
|
["parity", index, bytes] => {
|
|
let case = cases.last_mut().expect("parity follows a case");
|
|
assert_eq!(index.parse::<usize>().unwrap(), case.parity.len(), "{}", case.name());
|
|
case.parity.push(unhex(bytes));
|
|
}
|
|
["erase", slots] => cases
|
|
.last_mut()
|
|
.expect("erase follows a case")
|
|
.erasures
|
|
.push(slots.split(',').map(|slot| slot.parse().unwrap()).collect()),
|
|
other => panic!("unreadable fixture line {other:?}"),
|
|
}
|
|
}
|
|
cases
|
|
}
|
|
|
|
/// Parity bytes of every fixture case, byte for byte. If these ever change,
|
|
/// `rs-gf256-v1` changed meaning: bump the scheme id, do not edit the fixture.
|
|
#[test]
|
|
fn every_rs_gf256_v1_known_answer_parity_matches_byte_for_byte() {
|
|
let cases = kat_cases();
|
|
assert_eq!(cases.len(), 35, "the fixture's case count");
|
|
for case in &cases {
|
|
assert_eq!(case.parity.len(), case.m, "{}", case.name());
|
|
let parity = protect_audio_block(&case.packets(), &case.policy(), provenance())
|
|
.unwrap_or_else(|error| panic!("{}: {error}", case.name()));
|
|
let produced: Vec<String> = parity.iter().map(|shard| hex(&shard.payload)).collect();
|
|
let expected: Vec<String> = case.parity.iter().map(|shard| hex(shard)).collect();
|
|
assert_eq!(produced, expected, "{}", case.name());
|
|
}
|
|
}
|
|
|
|
/// Every fixture erasure pattern, data and parity mixed, recovers exactly
|
|
/// the erased data from the fixture's own parity bytes.
|
|
#[test]
|
|
fn every_rs_gf256_v1_known_erasure_pattern_recovers_the_data() {
|
|
for case in kat_cases() {
|
|
let packets = case.packets();
|
|
let mut parity = protect_audio_block(&packets, &case.policy(), provenance()).unwrap();
|
|
for (shard, bytes) in parity.iter_mut().zip(&case.parity) {
|
|
shard.payload = bytes.clone(); // decode from the fixture, not from the encoder
|
|
}
|
|
assert!(!case.erasures.is_empty(), "{}", case.name());
|
|
for erased in &case.erasures {
|
|
let data: Vec<_> =
|
|
(0..case.k).filter(|slot| !erased.contains(slot)).map(|slot| packets[slot].clone()).collect();
|
|
let present: Vec<_> = (0..case.m)
|
|
.filter(|slot| !erased.contains(&(case.k + slot)))
|
|
.map(|slot| parity[slot].clone())
|
|
.collect();
|
|
let recovered = recover_audio_block(&present, &data)
|
|
.unwrap_or_else(|error| panic!("{} erased {erased:?}: {error}", case.name()));
|
|
let lost: Vec<usize> = erased.iter().copied().filter(|&slot| slot < case.k).collect();
|
|
assert_eq!(recovered.len(), lost.len(), "{} erased {erased:?}", case.name());
|
|
for (packet, slot) in recovered.iter().zip(lost) {
|
|
assert_eq!(packet.packet_id, packets[slot].packet_id, "{} erased {erased:?}", case.name());
|
|
assert_eq!(packet.payload, packets[slot].payload, "{} erased {erased:?}", case.name());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Seeded random geometries past the fixture's: any `e <= m` erasures, data
|
|
/// and parity mixed, recover the data exactly, and `m + 1` are refused.
|
|
#[test]
|
|
fn random_erasures_up_to_m_recover_exactly_and_m_plus_one_is_refused() {
|
|
let mut state = 0x9e37_79b9_u32;
|
|
let mut next = |bound: usize| {
|
|
state ^= state << 13;
|
|
state ^= state >> 17;
|
|
state ^= state << 5;
|
|
state as usize % bound
|
|
};
|
|
for trial in 0..300 {
|
|
let k = 1 + next(48);
|
|
let m = 1 + next(24);
|
|
let shard_bytes = 1 + next(40);
|
|
let mut packets = audio_run(k as u64, shard_bytes);
|
|
for (index, packet) in packets.iter_mut().enumerate() {
|
|
packet.payload = bytes((trial * 1_000 + index) as u32, shard_bytes);
|
|
}
|
|
let policy = MediaFecPolicy {
|
|
audio_data_shards: k as u16,
|
|
audio_parity_shards: m as u16,
|
|
..STANDARD
|
|
};
|
|
let parity = protect_audio_block(&packets, &policy, provenance()).unwrap();
|
|
for erasures in [1 + next(m), m + 1] {
|
|
if erasures + 1 > k + m {
|
|
continue; // at least one parity shard must arrive to recover with
|
|
}
|
|
// A random erasure set that leaves at least one parity shard.
|
|
let mut slots: Vec<usize> = (0..k + m).collect();
|
|
let erased = loop {
|
|
for at in 0..slots.len() {
|
|
let swap = at + next(slots.len() - at);
|
|
slots.swap(at, swap);
|
|
}
|
|
let erased = slots[..erasures].to_vec();
|
|
if (k..k + m).any(|slot| !erased.contains(&slot)) {
|
|
break erased;
|
|
}
|
|
};
|
|
let data: Vec<_> =
|
|
(0..k).filter(|slot| !erased.contains(slot)).map(|slot| packets[slot].clone()).collect();
|
|
let present: Vec<_> =
|
|
(0..m).filter(|slot| !erased.contains(&(k + slot))).map(|slot| parity[slot].clone()).collect();
|
|
let outcome = recover_audio_block(&present, &data);
|
|
let context = format!("trial {trial}: k={k} m={m} {shard_bytes} bytes erased {erased:?}");
|
|
if erasures <= m {
|
|
let mut all = data;
|
|
all.extend(outcome.unwrap_or_else(|error| panic!("{context}: {error}")));
|
|
all.sort_by_key(|packet| packet.packet_id);
|
|
let payloads: Vec<_> = all.iter().map(|packet| &packet.payload).collect();
|
|
let expected: Vec<_> = packets.iter().map(|packet| &packet.payload).collect();
|
|
assert_eq!(payloads, expected, "{context}");
|
|
} else {
|
|
assert!(matches!(outcome, Err(MediaFecError::BeyondRepair { .. })), "{context}: {outcome:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|