Add the media FEC codec, video parity v3 and audio parity records to cultnet-rs

The codec owns the erasure math for both ends: protect_video_frame and
protect_audio_block on the producer, recover_video_block and
recover_audio_block on the consumer. Each block is systematic Reed-Solomon
over GF(2^8) (reed-solomon-erasure 6, default features), named on every
parity record by the scheme id rs-gf256-v1. Any <= m erasures recover
exactly; more report BeyondRepair. Video frames split into ceil(n/16)
near-equal blocks with m = max(2, ceil(k/4)); audio is 4+2. A frame's
records come back in send order, rotating one record from each block so a
burst lands as one loss per block.

Deleted first: gamecult.media_video_parity_shard.v2 and its XOR-stripe
validator, and the reliable "audio" channel (profile entry, send-options
arm, constant, tests). Audio now rides "media" with parity.

Added: gamecult.media_video_parity_shard.v3 (block geometry, scheme id,
shard length), gamecult.media_audio_parity_shard.v1, the AudioParity wire
variant with validation, and the parity record key now names the block.
Every shard is bounded at MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES (720) so its
wrapped record fits MEDIA_FEC_MAX_WIRE_BYTES (1431), measured with
identifiers of up to 32 bytes.

Tests pin: exhaustive recovery for every shipped (k, m) and 4+2, m+1
refusal, known-answer parity bytes for both paths, the datagram bound on
every record kind, send order, deadline carriage and decoder validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 20:08:54 +02:00
commit f7c58ce5bc
10 changed files with 1818 additions and 75 deletions

View file

@ -19,6 +19,7 @@ cultcache-rs = { path = "../cultcache-rs" }
ed25519-dalek = "2.2"
hmac = "0.12"
rand = "0.9"
reed-solomon-erasure = "6"
rmp-serde = "1.3"
rmpv = { version = "1.3", features = ["with-serde"] }
serde = { version = "1", features = ["derive"] }

View file

@ -2,6 +2,7 @@ mod content;
mod contracts;
mod idunn_deployment_brake;
mod idunn_lifecycle_brake;
mod media_fec;
mod media_stream_contracts;
mod media_stream_wire;
mod provider_health_contracts;
@ -24,6 +25,7 @@ pub use content::*;
pub use contracts::*;
pub use idunn_deployment_brake::*;
pub use idunn_lifecycle_brake::*;
pub use media_fec::*;
pub use media_stream_contracts::*;
pub use media_stream_wire::*;
pub use provider_health_contracts::*;

View file

@ -0,0 +1,655 @@
//! Forward error correction for CultMesh Media Stream records.
//!
//! Pure functions over the records in [`crate::media_stream_contracts`]: no
//! sockets, no clock, no randomness. A producer calls [`protect_video_frame`]
//! and [`protect_audio_block`]; a consumer calls [`recover_video_block`] and
//! [`recover_audio_block`]. Both ends link this one implementation, so a
//! producer and a consumer cannot disagree about the math without one of them
//! failing to build against the same version of this crate.
//!
//! # The code
//!
//! Every block is a systematic Reed-Solomon code over GF(2^8): `k` data shards
//! are sent unchanged and `m` parity shards are computed from them. Any `k` of
//! the `k + m` shards recover the block (the code is MDS), so any `m` erasures
//! are repaired and any more are reported as [`MediaFecError::BeyondRepair`],
//! never as wrong bytes. The construction is named on every parity record by
//! [`MEDIA_FEC_SCHEME_RS_GF256_V1`] and backed by the `reed-solomon-erasure`
//! crate. The library is not the contract: `tests/media_fec.rs` pins the parity
//! bytes of a known input, so a library that changes the matrix fails there
//! instead of on the wire, and a decoder in another runtime has a vector to
//! meet.
//!
//! # What a block is
//!
//! * Video: a frame's chunks split into `ceil(n / 16)` contiguous, near-equal
//! blocks, each protected by `max(2, ceil(k / 4))` parity shards. A block
//! never spans two frames, so one frame's deadline governs its recovery.
//! [`protect_video_frame`] returns the frame's records in send order: blocks
//! are interleaved, so a burst of consecutive losses lands as one loss per
//! block rather than many in one.
//! * Audio: `4` consecutive packets of one payload length, protected by `2`
//! parity shards.
//!
//! Every shard, data or parity, fits one datagram once wrapped for the wire
//! ([`MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES`]), so parity never reintroduces the
//! fragment amplification it exists to remove.
//!
//! # What this module does not own
//!
//! When to give up on a block is the receiver's deadline, and the `(k, m)`
//! choice is the producer's policy ([`MediaFecPolicy`]); this module only makes
//! and consumes shards.
use std::collections::BTreeMap;
use std::fmt;
use reed_solomon_erasure::galois_8::ReedSolomon;
use crate::media_stream_contracts::{
GameCultMediaAudioPacketRecord, GameCultMediaAudioParityShardRecord,
GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord,
};
use crate::media_stream_wire::{
GAMECULT_MEDIA_CHANNEL, GameCultMediaWireRecord, validate_audio_parity_record,
validate_audio_record, validate_video_parity_record, validate_video_record,
};
use crate::rudp::RUDP_FIXED_HEADER_BYTES;
/// Names the construction on every parity record: systematic Reed-Solomon over
/// GF(2^8), as built by `reed-solomon-erasure`'s `galois_8` field. A different
/// matrix is a different scheme id.
pub const MEDIA_FEC_SCHEME_RS_GF256_V1: &str = "rs-gf256-v1";
/// The largest UDP payload an IPv4 datagram carries on a 1500-byte MTU.
pub const MEDIA_IPV4_UDP_PAYLOAD_BYTES: usize = 1_472;
/// The most a wrapped media record may occupy so that the RUDP header and the
/// channel name still leave it in one datagram (1,431 bytes).
pub const MEDIA_FEC_MAX_WIRE_BYTES: usize =
MEDIA_IPV4_UDP_PAYLOAD_BYTES - RUDP_FIXED_HEADER_BYTES - GAMECULT_MEDIA_CHANNEL.len();
/// The largest shard payload the codec produces or accepts. What is left of
/// [`MEDIA_FEC_MAX_WIRE_BYTES`] must hold the record's fields and its envelope,
/// which repeats the stream and session identifiers three times over: a parity
/// record wraps to 705 bytes around its payload when the stream id, session id
/// and codec name are 32, 32 and 16 bytes and the frame id has 20 digits. This is the largest multiple of 16
/// that fits with identifiers of that size; the test
/// `every_record_at_the_maximum_shard_size_fits_one_datagram` measures it. A
/// producer with longer identifiers must cut its chunks smaller.
pub const MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES: usize = 720;
/// A producer's choice of `(k, m)`. [`MediaFecPolicy::STANDARD`] is the ruled
/// default; each record carries the `(k, m)` it was made with, so a decoder
/// needs no policy.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MediaFecPolicy {
/// The most data chunks in one video block.
pub video_max_block_data_shards: u16,
/// The fewest parity shards a video block carries.
pub video_min_parity_shards: u16,
/// A video block of `k` data shards carries at least `ceil(k / this)` parity
/// shards.
pub video_parity_divisor: u16,
/// Packets per audio block.
pub audio_data_shards: u16,
/// Parity shards per audio block.
pub audio_parity_shards: u16,
}
impl MediaFecPolicy {
/// Video `k <= 16` with `m = max(2, ceil(k / 4))`; audio 4+2.
pub const STANDARD: Self = Self {
video_max_block_data_shards: 16,
video_min_parity_shards: 2,
video_parity_divisor: 4,
audio_data_shards: 4,
audio_parity_shards: 2,
};
/// `m` for a video block of `data_shards` chunks.
pub fn video_parity_shards(&self, data_shards: u16) -> u16 {
data_shards
.div_ceil(self.video_parity_divisor.max(1))
.max(self.video_min_parity_shards)
}
/// How a frame of `chunk_count` chunks splits: `ceil(n / max)` blocks whose
/// sizes differ by at most one, larger blocks first.
pub fn video_block_sizes(&self, chunk_count: u16) -> Vec<u16> {
let max = self.video_max_block_data_shards.max(1);
let blocks = chunk_count.div_ceil(max);
if blocks == 0 {
return Vec::new();
}
let base = chunk_count / blocks;
let extra = chunk_count % blocks;
(0..blocks).map(|index| base + u16::from(index < extra)).collect()
}
fn validate(&self) -> Result<(), MediaFecError> {
let widest = self.video_max_block_data_shards;
if widest == 0
|| self.video_min_parity_shards == 0
|| self.video_parity_divisor == 0
|| self.audio_data_shards == 0
|| self.audio_parity_shards == 0
{
return Err(MediaFecError::invalid("media FEC policy fields must be non-zero"));
}
if usize::from(widest) + usize::from(self.video_parity_shards(widest)) > 256
|| usize::from(self.audio_data_shards) + usize::from(self.audio_parity_shards) > 256
{
return Err(MediaFecError::invalid("media FEC policy exceeds 256 shards per block"));
}
Ok(())
}
}
impl Default for MediaFecPolicy {
fn default() -> Self {
Self::STANDARD
}
}
/// Why a block could not be protected or recovered.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MediaFecError {
/// Fewer than `k` of the block's shards are present. Nothing is returned:
/// a block that cannot be repaired is reported, never guessed at.
BeyondRepair { shards_present: usize, shards_needed: usize },
/// The inputs are not one well-formed block.
Invalid(String),
}
impl MediaFecError {
fn invalid(message: impl Into<String>) -> Self {
Self::Invalid(message.into())
}
}
impl fmt::Display for MediaFecError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BeyondRepair { shards_present, shards_needed } => write!(
f,
"media FEC block is beyond repair: {shards_present} shards present, {shards_needed} needed"
),
Self::Invalid(message) => write!(f, "invalid media FEC block: {message}"),
}
}
}
impl std::error::Error for MediaFecError {}
// ---------------------------------------------------------------------------
// The code: equal-length shards in, equal-length shards out.
// ---------------------------------------------------------------------------
fn parity_shards(data: &[Vec<u8>], parity_count: usize) -> Result<Vec<Vec<u8>>, MediaFecError> {
let code = ReedSolomon::new(data.len(), parity_count)
.map_err(|error| MediaFecError::invalid(format!("{error:?}")))?;
let mut parity = vec![vec![0_u8; data[0].len()]; parity_count];
code.encode_sep(data, &mut parity)
.map_err(|error| MediaFecError::invalid(format!("{error:?}")))?;
Ok(parity)
}
/// `shards` holds the block's `k` data slots then `m` parity slots, `None` for
/// each missing. Returns the `k` data shards.
fn recover_data_shards(
data_shards: usize,
mut shards: Vec<Option<Vec<u8>>>,
) -> Result<Vec<Vec<u8>>, MediaFecError> {
let parity_shards = shards.len() - data_shards;
let present = shards.iter().flatten().count();
if present < data_shards {
return Err(MediaFecError::BeyondRepair {
shards_present: present,
shards_needed: data_shards,
});
}
let code = ReedSolomon::new(data_shards, parity_shards)
.map_err(|error| MediaFecError::invalid(format!("{error:?}")))?;
code.reconstruct_data(&mut shards)
.map_err(|error| MediaFecError::invalid(format!("{error:?}")))?;
Ok(shards
.into_iter()
.take(data_shards)
.map(|shard| shard.expect("reconstruct_data fills every data shard"))
.collect())
}
fn padded(payload: &[u8], shard_bytes: usize) -> Vec<u8> {
let mut shard = vec![0_u8; shard_bytes];
shard[..payload.len()].copy_from_slice(payload);
shard
}
fn from_anyhow(error: anyhow::Error) -> MediaFecError {
MediaFecError::Invalid(error.to_string())
}
// ---------------------------------------------------------------------------
// Video
// ---------------------------------------------------------------------------
/// Protects one video frame. `chunks` are all of the frame's access-unit
/// records, in any order; the result is every data and parity record in send
/// order.
///
/// Within a block, data and parity alternate; across blocks, the sequence
/// rotates one record from each block at a time. Where every block is the same
/// size, no two consecutive `block_count` records share a block. Blocks that
/// differ in size run out at the end, and the rotation then cycles over the
/// blocks remaining.
///
/// Errors if `chunks` are not exactly one whole frame (mixed frames, a missing
/// or repeated chunk index, disagreeing framing), if a chunk is not shard
/// shaped (every chunk of a block but its last must be the same length, the
/// last no longer), or if a chunk exceeds [`MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES`].
pub fn protect_video_frame(
chunks: &[GameCultMediaVideoAccessUnitRecord],
policy: &MediaFecPolicy,
) -> Result<Vec<GameCultMediaWireRecord>, MediaFecError> {
policy.validate()?;
let first = chunks
.first()
.ok_or_else(|| MediaFecError::invalid("a video frame needs at least one chunk"))?;
let chunk_count = usize::from(first.chunk_count);
if chunks.len() != chunk_count {
return Err(MediaFecError::invalid(format!(
"frame {} declares {} chunks but {} were given",
first.frame_id,
chunk_count,
chunks.len()
)));
}
let mut by_index: BTreeMap<u16, &GameCultMediaVideoAccessUnitRecord> = BTreeMap::new();
for chunk in chunks {
validate_video_record(chunk).map_err(from_anyhow)?;
if !same_frame(first, chunk) {
return Err(MediaFecError::invalid(
"a block never spans frames: chunks disagree on stream, session, frame or framing",
));
}
if chunk.payload.len() > MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES {
return Err(MediaFecError::invalid(format!(
"chunk payload of {} bytes exceeds the {MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES}-byte shard limit",
chunk.payload.len()
)));
}
if by_index.insert(chunk.chunk_index, chunk).is_some() {
return Err(MediaFecError::invalid(format!(
"chunk {} appears twice",
chunk.chunk_index
)));
}
}
let block_sizes = policy.video_block_sizes(first.chunk_count);
let block_count = block_sizes.len() as u16;
let mut lanes: Vec<Vec<GameCultMediaWireRecord>> = Vec::with_capacity(block_sizes.len());
let mut start = 0_u16;
for (block_index, &size) in block_sizes.iter().enumerate() {
let members: Vec<&GameCultMediaVideoAccessUnitRecord> = (start..start + size)
.map(|index| by_index[&index])
.collect();
let shard_bytes = members[0].payload.len();
let (last, body) = members.split_last().expect("block has a member");
if body.iter().any(|chunk| chunk.payload.len() != shard_bytes)
|| last.payload.len() > shard_bytes
{
return Err(MediaFecError::invalid(
"every chunk of a block but its last must share one length, and the last no longer",
));
}
let data: Vec<Vec<u8>> = members
.iter()
.map(|chunk| padded(&chunk.payload, shard_bytes))
.collect();
let parity_count = policy.video_parity_shards(size);
let parity = parity_shards(&data, usize::from(parity_count))?;
let parity_records: Vec<GameCultMediaWireRecord> = parity
.into_iter()
.enumerate()
.map(|(parity_index, payload)| {
GameCultMediaWireRecord::VideoParity(GameCultMediaVideoParityShardRecord {
stream_id: first.stream_id.clone(),
session_id: first.session_id.clone(),
frame_id: first.frame_id,
codec: first.codec.clone(),
pts_ticks: first.pts_ticks,
duration_ticks: first.duration_ticks,
timebase_num: first.timebase_num,
timebase_den: first.timebase_den,
keyframe: first.keyframe,
dependency_frame_id: first.dependency_frame_id,
deadline_ticks: first.deadline_ticks,
chunk_count: first.chunk_count,
fec_scheme: MEDIA_FEC_SCHEME_RS_GF256_V1.to_string(),
block_index: block_index as u16,
block_count,
block_data_start: start,
block_data_count: size,
parity_index: parity_index as u16,
parity_count,
shard_payload_bytes: shard_bytes as u32,
last_chunk_payload_bytes: last.payload.len() as u32,
payload,
})
})
.collect();
let data_records = members
.iter()
.map(|chunk| GameCultMediaWireRecord::Video((*chunk).clone()));
lanes.push(interleave(data_records.collect(), parity_records));
start += size;
}
Ok(rotate(lanes))
}
fn same_frame(a: &GameCultMediaVideoAccessUnitRecord, b: &GameCultMediaVideoAccessUnitRecord) -> bool {
a.stream_id == b.stream_id
&& a.session_id == b.session_id
&& a.frame_id == b.frame_id
&& a.codec == b.codec
&& a.pts_ticks == b.pts_ticks
&& a.duration_ticks == b.duration_ticks
&& a.timebase_num == b.timebase_num
&& a.timebase_den == b.timebase_den
&& a.keyframe == b.keyframe
&& a.dependency_frame_id == b.dependency_frame_id
&& a.deadline_ticks == b.deadline_ticks
&& a.chunk_count == b.chunk_count
}
/// Data and parity alternate: `D0 P0 D1 P1 ...`, the longer list finishing the
/// lane.
fn interleave<T>(data: Vec<T>, parity: Vec<T>) -> Vec<T> {
let mut data = data.into_iter();
let mut parity = parity.into_iter();
let mut lane = Vec::new();
loop {
let next_data = data.next();
let next_parity = parity.next();
if next_data.is_none() && next_parity.is_none() {
return lane;
}
lane.extend(next_data);
lane.extend(next_parity);
}
}
/// One record from each lane in turn, until every lane is empty.
fn rotate<T>(lanes: Vec<Vec<T>>) -> Vec<T> {
let mut lanes: Vec<_> = lanes.into_iter().map(Vec::into_iter).collect();
let mut out = Vec::new();
loop {
let before = out.len();
for lane in &mut lanes {
out.extend(lane.next());
}
if out.len() == before {
return out;
}
}
}
/// Rebuilds the data chunks a block lost.
///
/// `parity` are the block's parity shards that arrived (at least one, for its
/// geometry) and `data` its data chunks that arrived; each may be in any order
/// and may repeat. Returns the missing chunks, ordered by `chunk_index`: empty
/// if none were missing. Fails with [`MediaFecError::BeyondRepair`] if fewer
/// than `block_data_count` shards of the block are present, and with
/// [`MediaFecError::Invalid`] if the records are not from one block.
pub fn recover_video_block(
parity: &[GameCultMediaVideoParityShardRecord],
data: &[GameCultMediaVideoAccessUnitRecord],
) -> Result<Vec<GameCultMediaVideoAccessUnitRecord>, MediaFecError> {
let geometry = parity
.first()
.ok_or_else(|| MediaFecError::invalid("recovery needs at least one parity shard"))?;
let k = usize::from(geometry.block_data_count);
let m = usize::from(geometry.parity_count);
let start = usize::from(geometry.block_data_start);
let shard_bytes = geometry.shard_payload_bytes as usize;
let mut shards: Vec<Option<Vec<u8>>> = vec![None; k + m];
for shard in parity {
validate_video_parity_record(shard).map_err(from_anyhow)?;
if !same_block(geometry, shard) {
return Err(MediaFecError::invalid("parity shards are not from one block"));
}
shards[k + usize::from(shard.parity_index)].get_or_insert_with(|| shard.payload.clone());
}
for chunk in data {
validate_video_record(chunk).map_err(from_anyhow)?;
let index = usize::from(chunk.chunk_index);
let framing_agrees = chunk.stream_id == geometry.stream_id
&& chunk.session_id == geometry.session_id
&& chunk.frame_id == geometry.frame_id
&& chunk.chunk_count == geometry.chunk_count;
if !framing_agrees || index < start || index >= start + k {
return Err(MediaFecError::invalid("data chunk does not belong to the parity's block"));
}
let expected = if index == start + k - 1 {
geometry.last_chunk_payload_bytes as usize
} else {
shard_bytes
};
if chunk.payload.len() != expected {
return Err(MediaFecError::invalid(format!(
"chunk {index} is {} bytes but the block declares {expected}",
chunk.payload.len()
)));
}
shards[index - start].get_or_insert_with(|| padded(&chunk.payload, shard_bytes));
}
let missing: Vec<usize> = (0..k).filter(|&slot| shards[slot].is_none()).collect();
if missing.is_empty() {
return Ok(Vec::new());
}
let recovered = recover_data_shards(k, shards)?;
Ok(missing
.into_iter()
.map(|slot| {
let mut payload = recovered[slot].clone();
if slot == k - 1 {
payload.truncate(geometry.last_chunk_payload_bytes as usize);
}
GameCultMediaVideoAccessUnitRecord {
stream_id: geometry.stream_id.clone(),
session_id: geometry.session_id.clone(),
frame_id: geometry.frame_id,
codec: geometry.codec.clone(),
pts_ticks: geometry.pts_ticks,
duration_ticks: geometry.duration_ticks,
timebase_num: geometry.timebase_num,
timebase_den: geometry.timebase_den,
keyframe: geometry.keyframe,
dependency_frame_id: geometry.dependency_frame_id,
deadline_ticks: geometry.deadline_ticks,
chunk_index: (start + slot) as u16,
chunk_count: geometry.chunk_count,
payload,
}
})
.collect())
}
fn same_block(a: &GameCultMediaVideoParityShardRecord, b: &GameCultMediaVideoParityShardRecord) -> bool {
a.stream_id == b.stream_id
&& a.session_id == b.session_id
&& a.frame_id == b.frame_id
&& a.codec == b.codec
&& a.pts_ticks == b.pts_ticks
&& a.duration_ticks == b.duration_ticks
&& a.timebase_num == b.timebase_num
&& a.timebase_den == b.timebase_den
&& a.keyframe == b.keyframe
&& a.dependency_frame_id == b.dependency_frame_id
&& a.deadline_ticks == b.deadline_ticks
&& a.chunk_count == b.chunk_count
&& a.fec_scheme == b.fec_scheme
&& a.block_index == b.block_index
&& a.block_count == b.block_count
&& a.block_data_start == b.block_data_start
&& a.block_data_count == b.block_data_count
&& a.parity_count == b.parity_count
&& a.shard_payload_bytes == b.shard_payload_bytes
&& a.last_chunk_payload_bytes == b.last_chunk_payload_bytes
}
// ---------------------------------------------------------------------------
// Audio
// ---------------------------------------------------------------------------
/// Protects one block of audio packets: exactly `policy.audio_data_shards`
/// packets with contiguous ids and presentation times, one payload length, and
/// one codec and timebase. Returns the parity records in `parity_index` order.
pub fn protect_audio_block(
packets: &[GameCultMediaAudioPacketRecord],
policy: &MediaFecPolicy,
) -> Result<Vec<GameCultMediaAudioParityShardRecord>, MediaFecError> {
policy.validate()?;
if packets.len() != usize::from(policy.audio_data_shards) {
return Err(MediaFecError::invalid(format!(
"an audio block is {} packets, got {}",
policy.audio_data_shards,
packets.len()
)));
}
let first = &packets[0];
let shard_bytes = first.payload.len();
for (index, packet) in packets.iter().enumerate() {
validate_audio_record(packet).map_err(from_anyhow)?;
let expected_id = first.packet_id.checked_add(index as u64);
let expected_pts = i64::from(first.duration_ticks)
.checked_mul(index as i64)
.and_then(|offset| first.pts_ticks.checked_add(offset));
if packet.stream_id != first.stream_id
|| packet.session_id != first.session_id
|| packet.codec != first.codec
|| packet.timebase_num != first.timebase_num
|| packet.timebase_den != first.timebase_den
|| packet.duration_ticks != first.duration_ticks
|| Some(packet.packet_id) != expected_id
|| Some(packet.pts_ticks) != expected_pts
|| packet.payload.len() != shard_bytes
{
return Err(MediaFecError::invalid(
"audio block packets must be contiguous and share codec, timebase, duration and payload length",
));
}
}
if shard_bytes > MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES {
return Err(MediaFecError::invalid(format!(
"audio payload of {shard_bytes} bytes exceeds the {MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES}-byte shard limit"
)));
}
let data: Vec<Vec<u8>> = packets.iter().map(|packet| packet.payload.clone()).collect();
let deadline_ticks = packets.iter().map(|packet| packet.deadline_ticks).max().unwrap_or(0);
Ok(parity_shards(&data, usize::from(policy.audio_parity_shards))?
.into_iter()
.enumerate()
.map(|(parity_index, payload)| GameCultMediaAudioParityShardRecord {
stream_id: first.stream_id.clone(),
session_id: first.session_id.clone(),
codec: first.codec.clone(),
fec_scheme: MEDIA_FEC_SCHEME_RS_GF256_V1.to_string(),
base_packet_id: first.packet_id,
base_pts_ticks: first.pts_ticks,
packet_duration_ticks: first.duration_ticks,
timebase_num: first.timebase_num,
timebase_den: first.timebase_den,
deadline_ticks,
data_shard_count: policy.audio_data_shards,
parity_index: parity_index as u16,
parity_shard_count: policy.audio_parity_shards,
shard_payload_bytes: shard_bytes as u32,
payload,
})
.collect())
}
/// Rebuilds the packets an audio block lost, ordered by `packet_id`. A
/// recovered packet takes its id and presentation time from the block's
/// arithmetic and its deadline from the block's, the latest in the block.
/// Errors as [`recover_video_block`] does.
pub fn recover_audio_block(
parity: &[GameCultMediaAudioParityShardRecord],
data: &[GameCultMediaAudioPacketRecord],
) -> Result<Vec<GameCultMediaAudioPacketRecord>, MediaFecError> {
let geometry = parity
.first()
.ok_or_else(|| MediaFecError::invalid("recovery needs at least one parity shard"))?;
let k = usize::from(geometry.data_shard_count);
let m = usize::from(geometry.parity_shard_count);
let shard_bytes = geometry.shard_payload_bytes as usize;
let mut shards: Vec<Option<Vec<u8>>> = vec![None; k + m];
for shard in parity {
validate_audio_parity_record(shard).map_err(from_anyhow)?;
if !same_audio_block(geometry, shard) {
return Err(MediaFecError::invalid("parity shards are not from one block"));
}
shards[k + usize::from(shard.parity_index)].get_or_insert_with(|| shard.payload.clone());
}
for packet in data {
validate_audio_record(packet).map_err(from_anyhow)?;
let slot = packet
.packet_id
.checked_sub(geometry.base_packet_id)
.filter(|slot| *slot < k as u64)
.ok_or_else(|| MediaFecError::invalid("audio packet does not belong to the parity's block"))?
as usize;
if packet.stream_id != geometry.stream_id
|| packet.session_id != geometry.session_id
|| packet.payload.len() != shard_bytes
{
return Err(MediaFecError::invalid("audio packet disagrees with the parity's block"));
}
shards[slot].get_or_insert_with(|| packet.payload.clone());
}
let missing: Vec<usize> = (0..k).filter(|&slot| shards[slot].is_none()).collect();
if missing.is_empty() {
return Ok(Vec::new());
}
let recovered = recover_data_shards(k, shards)?;
Ok(missing
.into_iter()
.map(|slot| GameCultMediaAudioPacketRecord {
stream_id: geometry.stream_id.clone(),
session_id: geometry.session_id.clone(),
packet_id: geometry.base_packet_id + slot as u64,
codec: geometry.codec.clone(),
pts_ticks: geometry.base_pts_ticks + i64::from(geometry.packet_duration_ticks) * slot as i64,
duration_ticks: geometry.packet_duration_ticks,
timebase_num: geometry.timebase_num,
timebase_den: geometry.timebase_den,
deadline_ticks: geometry.deadline_ticks,
payload: recovered[slot].clone(),
})
.collect())
}
fn same_audio_block(a: &GameCultMediaAudioParityShardRecord, b: &GameCultMediaAudioParityShardRecord) -> bool {
a.stream_id == b.stream_id
&& a.session_id == b.session_id
&& a.codec == b.codec
&& a.fec_scheme == b.fec_scheme
&& a.base_packet_id == b.base_packet_id
&& a.base_pts_ticks == b.base_pts_ticks
&& a.packet_duration_ticks == b.packet_duration_ticks
&& a.timebase_num == b.timebase_num
&& a.timebase_den == b.timebase_den
&& a.deadline_ticks == b.deadline_ticks
&& a.data_shard_count == b.data_shard_count
&& a.parity_shard_count == b.parity_shard_count
&& a.shard_payload_bytes == b.shard_payload_bytes
}

View file

@ -36,15 +36,15 @@
//! Nothing here specifies a delivery guarantee. Whether a stream rides a
//! reliable or lossy CultNet channel, and whether it carries parity, is a
//! transport decision made per stream — not a property of the payload shape.
//! Video parity shards exist for producers that choose forward error correction;
//! audio currently has no parity record, and a producer emitting audio on a
//! lossy channel needs one.
//! Video and audio parity shards exist for producers that choose forward error
//! correction; [`crate::media_fec`] owns the code that makes and consumes them.
use cultcache_rs::DatabaseEntry;
pub const GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA: &str = "gamecult.media_video_access_unit.v1";
pub const GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA: &str = "gamecult.media_video_parity_shard.v2";
pub const GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA: &str = "gamecult.media_video_parity_shard.v3";
pub const GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA: &str = "gamecult.media_audio_packet.v1";
pub const GAMECULT_MEDIA_AUDIO_PARITY_SHARD_SCHEMA: &str = "gamecult.media_audio_parity_shard.v1";
pub const GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA: &str = "gamecult.media_receiver_feedback.v1";
pub const GAMECULT_MEDIA_STREAM_ADVERTISEMENT_SCHEMA: &str =
"gamecult.media_stream_advertisement.v1";
@ -464,15 +464,25 @@ pub struct GameCultMediaVideoAccessUnitRecord {
pub payload: Vec<u8>,
}
/// Forward-error-correction parity for one video access unit. Carries the
/// framing fields of the access unit it protects so a receiver can recover
/// without having seen the original, plus the shard geometry needed to run
/// recovery: `parity_index` within `parity_count`, and the chunk sizes the
/// parity was computed over.
/// Forward-error-correction parity for one block of a video access unit.
///
/// A frame's `chunk_count` chunks are split into `block_count` contiguous
/// blocks, and each block carries `parity_count` parity shards computed over its
/// `block_data_count` data chunks (`block_data_start..block_data_start +
/// block_data_count`). Any `block_data_count` of a block's
/// `block_data_count + parity_count` shards recover it. The math is
/// [`crate::media_fec`]'s and is named by `fec_scheme`; a decoder that does not
/// know the scheme must drop the shard rather than guess.
///
/// It carries the framing fields of the access unit it protects, so a receiver
/// can rebuild a lost chunk without having seen any chunk of the frame.
/// `shard_payload_bytes` is the padded shard length; `last_chunk_payload_bytes`
/// is the true length of the block's final data chunk, the only one that may be
/// shorter.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(
type = "gamecult.media_video_parity_shard",
schema = "gamecult.media_video_parity_shard.v2"
schema = "gamecult.media_video_parity_shard.v3"
)]
pub struct GameCultMediaVideoParityShardRecord {
#[cultcache(key = 0)]
@ -500,14 +510,26 @@ pub struct GameCultMediaVideoParityShardRecord {
#[cultcache(key = 11)]
pub chunk_count: u16,
#[cultcache(key = 12)]
pub parity_index: u16,
pub fec_scheme: String,
#[cultcache(key = 13)]
pub parity_count: u16,
pub block_index: u16,
#[cultcache(key = 14)]
pub chunk_payload_bytes: u32,
pub block_count: u16,
#[cultcache(key = 15)]
pub block_data_start: u16,
/// `k`: how many data chunks this block protects.
#[cultcache(key = 16)]
pub block_data_count: u16,
#[cultcache(key = 17)]
pub parity_index: u16,
/// `m`: how many parity shards this block carries.
#[cultcache(key = 18)]
pub parity_count: u16,
#[cultcache(key = 19)]
pub shard_payload_bytes: u32,
#[cultcache(key = 20)]
pub last_chunk_payload_bytes: u32,
#[cultcache(key = 16, bytes)]
#[cultcache(key = 21, bytes)]
pub payload: Vec<u8>,
}
@ -541,6 +563,60 @@ pub struct GameCultMediaAudioPacketRecord {
pub payload: Vec<u8>,
}
/// Forward-error-correction parity for a block of consecutive audio packets.
///
/// The block is `data_shard_count` packets with contiguous ids starting at
/// `base_packet_id`, all with one payload length (`shard_payload_bytes`) and
/// contiguous presentation times spaced `packet_duration_ticks` apart. A packet
/// recovered from parity takes its id and time from that arithmetic, and its
/// `deadline_ticks` from the block's, which is the latest in the block: the
/// per-packet deadline is not recoverable, so a recovered packet may be held
/// past its own but never past the block's.
///
/// Codecs whose frames vary in length pad each one to a shared shard length
/// before parity is computed; that framing belongs to the codec, not to this
/// record.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(
type = "gamecult.media_audio_parity_shard",
schema = "gamecult.media_audio_parity_shard.v1"
)]
pub struct GameCultMediaAudioParityShardRecord {
#[cultcache(key = 0)]
pub stream_id: String,
#[cultcache(key = 1)]
pub session_id: String,
#[cultcache(key = 2)]
pub codec: String,
#[cultcache(key = 3)]
pub fec_scheme: String,
#[cultcache(key = 4)]
pub base_packet_id: u64,
#[cultcache(key = 5)]
pub base_pts_ticks: i64,
#[cultcache(key = 6)]
pub packet_duration_ticks: u32,
#[cultcache(key = 7)]
pub timebase_num: u32,
#[cultcache(key = 8)]
pub timebase_den: u32,
/// The latest `deadline_ticks` among the block's data packets.
#[cultcache(key = 9)]
pub deadline_ticks: i64,
/// `k`.
#[cultcache(key = 10)]
pub data_shard_count: u16,
#[cultcache(key = 11)]
pub parity_index: u16,
/// `m`.
#[cultcache(key = 12)]
pub parity_shard_count: u16,
#[cultcache(key = 13)]
pub shard_payload_bytes: u32,
#[cultcache(key = 14, bytes)]
pub payload: Vec<u8>,
}
/// What a receiver observed, published back to the producer so it can adapt:
/// what decoded, what is missing, what arrived too late, and how much queue and
/// jitter the receiver is carrying.

View file

@ -28,27 +28,27 @@ use crate::contracts::{
CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetWireContract,
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
};
use crate::media_fec::{MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES, MEDIA_FEC_SCHEME_RS_GF256_V1};
use crate::media_stream_contracts::{
GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA, GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA,
GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA, GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA,
GameCultMediaAudioPacketRecord, GameCultMediaReceiverFeedbackRecord,
GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA, GAMECULT_MEDIA_AUDIO_PARITY_SHARD_SCHEMA,
GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA, GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA,
GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA, GameCultMediaAudioPacketRecord,
GameCultMediaAudioParityShardRecord, GameCultMediaReceiverFeedbackRecord,
GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord,
};
/// The CultNet channel video and receiver feedback ride.
/// The CultNet channel every media record rides: video, audio, their parity,
/// and receiver feedback. Whether it is reliable is the transport profile's
/// choice per session; stale media is dropped, not retransmitted.
pub const GAMECULT_MEDIA_CHANNEL: &str = "media";
/// The CultNet channel audio rides. Audio has its own channel so one session
/// can carry it reliably with a short expiry while video stays lossy under
/// parity; the RUDP transport profile owns each channel's delivery.
pub const GAMECULT_MEDIA_AUDIO_CHANNEL: &str = "audio";
/// One media record on its way somewhere.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GameCultMediaWireRecord {
Video(GameCultMediaVideoAccessUnitRecord),
VideoParity(GameCultMediaVideoParityShardRecord),
Audio(GameCultMediaAudioPacketRecord),
AudioParity(GameCultMediaAudioParityShardRecord),
Feedback(GameCultMediaReceiverFeedbackRecord),
}
@ -84,6 +84,7 @@ impl GameCultMediaWireRecord {
Self::Video(_) => GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA,
Self::VideoParity(_) => GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA,
Self::Audio(_) => GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA,
Self::AudioParity(_) => GAMECULT_MEDIA_AUDIO_PARITY_SHARD_SCHEMA,
Self::Feedback(_) => GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA,
}
}
@ -96,13 +97,21 @@ impl GameCultMediaWireRecord {
record.stream_id, record.session_id, record.frame_id, record.chunk_index
),
Self::VideoParity(record) => format!(
"{}:{}:video-parity:{}:{}",
record.stream_id, record.session_id, record.frame_id, record.parity_index
"{}:{}:video-parity:{}:{}:{}",
record.stream_id,
record.session_id,
record.frame_id,
record.block_index,
record.parity_index
),
Self::Audio(record) => format!(
"{}:{}:audio:{}",
record.stream_id, record.session_id, record.packet_id
),
Self::AudioParity(record) => format!(
"{}:{}:audio-parity:{}:{}",
record.stream_id, record.session_id, record.base_packet_id, record.parity_index
),
Self::Feedback(record) => format!(
"{}:{}:feedback:{}",
record.stream_id, record.session_id, record.receiver_id
@ -124,6 +133,7 @@ impl GameCultMediaWireRecord {
Self::Video(record) => rmp_serde::to_vec(record),
Self::VideoParity(record) => rmp_serde::to_vec(record),
Self::Audio(record) => rmp_serde::to_vec(record),
Self::AudioParity(record) => rmp_serde::to_vec(record),
Self::Feedback(record) => rmp_serde::to_vec(record),
}
.map_err(Into::into)
@ -191,6 +201,12 @@ pub fn decode_media_wire_record(payload: &[u8]) -> Result<GameCultMediaWireRecor
validate_audio_record(&record)?;
GameCultMediaWireRecord::Audio(record)
}
GAMECULT_MEDIA_AUDIO_PARITY_SHARD_SCHEMA => {
let record: GameCultMediaAudioParityShardRecord =
rmp_serde::from_slice(&document.payload)?;
validate_audio_parity_record(&record)?;
GameCultMediaWireRecord::AudioParity(record)
}
GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA => {
let record: GameCultMediaReceiverFeedbackRecord =
rmp_serde::from_slice(&document.payload)?;
@ -349,39 +365,105 @@ pub fn validate_video_parity_record(record: &GameCultMediaVideoParityShardRecord
"video parity media record deadline_ticks must not precede pts_ticks"
));
}
if record.fec_scheme != MEDIA_FEC_SCHEME_RS_GF256_V1 {
return Err(anyhow!(
"video parity media record fec_scheme {:?} is not supported",
record.fec_scheme
));
}
if record.chunk_count == 0 {
return Err(anyhow!(
"video parity media record chunk_count must be non-zero"
));
}
if record.block_count == 0 || record.block_index >= record.block_count {
return Err(anyhow!(
"video parity media record block_index {} is outside block_count {}",
record.block_index,
record.block_count
));
}
if record.block_data_count == 0
|| u32::from(record.block_data_start) + u32::from(record.block_data_count)
> u32::from(record.chunk_count)
{
return Err(anyhow!(
"video parity media record block does not lie inside the frame's chunks"
));
}
if record.parity_count == 0 || record.parity_index >= record.parity_count {
return Err(anyhow!(
"video parity media record has invalid parity stripe metadata"
"video parity media record parity_index {} is outside parity_count {}",
record.parity_index,
record.parity_count
));
}
if record.parity_count > record.chunk_count {
if usize::from(record.block_data_count) + usize::from(record.parity_count) > 256 {
return Err(anyhow!(
"video parity media record parity_count exceeds chunk_count"
"video parity media record block exceeds 256 shards"
));
}
if record.chunk_payload_bytes == 0 || record.last_chunk_payload_bytes == 0 {
if record.shard_payload_bytes == 0
|| record.shard_payload_bytes as usize > MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES
{
return Err(anyhow!(
"video parity media record chunk lengths must be non-zero"
"video parity media record shard_payload_bytes must be in 1..={MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES}"
));
}
if record.last_chunk_payload_bytes > record.chunk_payload_bytes {
if record.last_chunk_payload_bytes == 0
|| record.last_chunk_payload_bytes > record.shard_payload_bytes
{
return Err(anyhow!(
"video parity media record last chunk length exceeds regular chunk length"
"video parity media record last chunk length must be in 1..=shard_payload_bytes"
));
}
if record.payload.is_empty() {
if record.payload.len() != record.shard_payload_bytes as usize {
return Err(anyhow!(
"video parity media record payload must be non-empty"
"video parity media record payload does not match declared shard length"
));
}
if record.payload.len() > record.chunk_payload_bytes as usize {
Ok(())
}
pub fn validate_audio_parity_record(record: &GameCultMediaAudioParityShardRecord) -> Result<()> {
if record.stream_id.is_empty() || record.session_id.is_empty() || record.codec.is_empty() {
return Err(anyhow!(
"video parity media record payload exceeds declared chunk length"
"audio parity media record stream_id, session_id and codec must be non-empty"
));
}
if record.timebase_num == 0 || record.timebase_den == 0 || record.packet_duration_ticks == 0 {
return Err(anyhow!(
"audio parity media record timing metadata must be non-zero"
));
}
if record.deadline_ticks < record.base_pts_ticks {
return Err(anyhow!(
"audio parity media record deadline_ticks must not precede base_pts_ticks"
));
}
if record.fec_scheme != MEDIA_FEC_SCHEME_RS_GF256_V1 {
return Err(anyhow!(
"audio parity media record fec_scheme {:?} is not supported",
record.fec_scheme
));
}
if record.data_shard_count == 0
|| record.parity_shard_count == 0
|| record.parity_index >= record.parity_shard_count
|| usize::from(record.data_shard_count) + usize::from(record.parity_shard_count) > 256
{
return Err(anyhow!("audio parity media record stripe metadata is invalid"));
}
if record.shard_payload_bytes == 0
|| record.shard_payload_bytes as usize > MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES
{
return Err(anyhow!(
"audio parity media record shard_payload_bytes must be in 1..={MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES}"
));
}
if record.payload.len() != record.shard_payload_bytes as usize {
return Err(anyhow!(
"audio parity media record payload does not match declared shard length"
));
}
Ok(())

View file

@ -29,7 +29,7 @@ use crate::encode_cultnet_message_to_vec;
const RUDP_MAGIC: [u8; 4] = [0x43, 0x4e, 0x52, 0x30];
const RUDP_VERSION: u8 = 0;
const RUDP_FIXED_HEADER_BYTES: usize = 36;
pub(crate) const RUDP_FIXED_HEADER_BYTES: usize = 36;
pub const CULTNET_RUDP_DEFAULT_MAX_PAYLOAD_BYTES: usize = 16 * 1024 * 1024;
pub const CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS: usize = 32;
const RUDP_RECEIVED_SEQUENCE_WINDOW: usize = 4_096;
@ -2215,17 +2215,6 @@ pub fn create_rudp_transport_profile(
max_pending_reliable_packets: options.max_pending_reliable_packets,
reliable_expire_after_ms: options.media_reliable_expire_after_ms,
},
// Audio beside lossy video on one session: reliable, but a packet
// older than the media expiry is not worth a retransmit.
CultNetTransportChannel {
channel_id: "audio".to_string(),
delivery: CultNetTransportDelivery::Reliable,
ordering: CultNetTransportOrdering::Unordered,
max_payload_bytes: options.max_payload_bytes,
max_fragment_bytes: options.max_fragment_bytes,
max_pending_reliable_packets: options.max_pending_reliable_packets,
reliable_expire_after_ms: options.media_reliable_expire_after_ms,
},
],
}],
}
@ -2391,7 +2380,7 @@ fn channel_send_options(
// The one channel whose delivery a caller chooses. Media that has gone
// stale is worth dropping, not retransmitting: a reliable media channel
// under loss adds load exactly when the link has least to give.
"media" | "audio" => CultNetRudpSendOptions {
"media" => CultNetRudpSendOptions {
reliable: matches!(delivery, CultNetTransportDelivery::Reliable),
ordered: false,
sequenced: false,

View file

@ -432,12 +432,6 @@ fn rudp_transport_profile_advertises_state_and_realtime_channels() {
CultNetTransportOrdering::Unordered,
Some(64)
),
(
"audio",
CultNetTransportDelivery::Reliable,
CultNetTransportOrdering::Unordered,
Some(64)
),
]
);
}
@ -3006,7 +3000,7 @@ fn accept_rudp_session(session: &mut CultNetRudpSession, connection_id: u32) ->
}
#[test]
fn rudp_profile_expires_only_the_media_channels() -> Result<()> {
fn rudp_profile_expires_only_the_media_channel() -> Result<()> {
let socket = std::net::UdpSocket::bind("127.0.0.1:0")?;
let peer = socket.local_addr()?;
let options = CultNetRudpSocketTransportOptions::client("expiry-profile", socket, peer, 11);
@ -3020,14 +3014,12 @@ fn rudp_profile_expires_only_the_media_channels() -> Result<()> {
.map(|channel| (channel.channel_id.as_str(), channel.reliable_expire_after_ms))
.collect::<Vec<_>>();
for media_channel in ["media", "audio"] {
assert!(
channels.contains(&(media_channel, Some(75))),
"the {media_channel} channel advertises its expiry so a peer can see it: {channels:?}"
);
}
assert!(
channels.contains(&("media", Some(75))),
"the media channel advertises its expiry so a peer can see it: {channels:?}"
);
for (channel_id, expiry) in &channels {
if !matches!(*channel_id, "media" | "audio") {
if *channel_id != "media" {
assert_eq!(
*expiry, None,
"state channel {channel_id} must never expire a reliable send"

View file

@ -0,0 +1,723 @@
//! 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,
GameCultMediaWireRecord, MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES, 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).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).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).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).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).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).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).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.
let stray = data.iter().find(|chunk| chunk.chunk_index == 20).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(_))));
}
#[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).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).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).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).unwrap(),
protect_video_frame(&original, &STANDARD).unwrap()
);
let block = audio_block(8, 64);
assert_eq!(
protect_audio_block(&block, &STANDARD).unwrap(),
protect_audio_block(&block, &STANDARD).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);
}
/// Identifiers are the caller's, so the bound is stated for stream and session
/// identifiers, and codec names, of up to this many bytes each.
const IDENTIFIER_BYTES: usize = 32;
#[test]
fn every_record_at_the_maximum_shard_size_fits_one_datagram() {
let long = |prefix: &str| format!("{prefix}{}", "x".repeat(IDENTIFIER_BYTES - prefix.len()));
let mut chunks = frame(u64::MAX - 1, 20, MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES, MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES);
for chunk in &mut chunks {
chunk.stream_id = long("stream-");
chunk.session_id = long("session-");
chunk.codec = long("codec-");
}
let records = protect_video_frame(&chunks, &STANDARD).unwrap();
assert!(records.len() > chunks.len());
let mut largest = 0;
for record in &records {
let wire = encode_media_wire_record(record, provenance()).unwrap();
largest = largest.max(wire.len());
assert!(
wire.len() <= MEDIA_FEC_MAX_WIRE_BYTES,
"{} record is {} bytes, over {MEDIA_FEC_MAX_WIRE_BYTES}",
record.schema_id(),
wire.len()
);
}
let mut packets = audio_block(u64::MAX - 8, MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES);
for packet in &mut packets {
packet.stream_id = long("stream-");
packet.session_id = long("session-");
packet.codec = long("codec-");
}
let mut audio: Vec<_> = packets.iter().cloned().map(GameCultMediaWireRecord::Audio).collect();
audio.extend(protect_audio_block(&packets, &STANDARD).unwrap().into_iter().map(GameCultMediaWireRecord::AudioParity));
for record in &audio {
let wire = encode_media_wire_record(record, provenance()).unwrap();
largest = largest.max(wire.len());
assert!(wire.len() <= MEDIA_FEC_MAX_WIRE_BYTES, "{} is {} bytes", record.schema_id(), wire.len());
}
// The limit is not slack: the largest record leaves less than a full extra
// identifier's worth of headroom.
assert!(
largest + 96 > MEDIA_FEC_MAX_WIRE_BYTES,
"the shard limit wastes {} bytes of datagram",
MEDIA_FEC_MAX_WIRE_BYTES - largest
);
}
#[test]
fn a_shard_over_the_limit_is_refused_at_the_producer_and_at_the_decoder() {
let too_big = MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES + 1;
let chunks = frame(1, 3, too_big, too_big);
assert!(matches!(protect_video_frame(&chunks, &STANDARD), Err(MediaFecError::Invalid(_))));
assert!(matches!(
protect_audio_block(&audio_block(1, too_big), &STANDARD),
Err(MediaFecError::Invalid(_))
));
let ok = frame(1, 3, MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES, MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES);
let (_, mut parity) = split(protect_video_frame(&ok, &STANDARD).unwrap());
parity[0].shard_payload_bytes = too_big as u32;
parity[0].payload = vec![0; too_big];
let wire = encode_media_wire_record(&GameCultMediaWireRecord::VideoParity(parity[0].clone()), provenance()).unwrap();
assert!(decode_media_wire_record(&wire).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).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).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).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).is_err(), "a missing chunk");
let mut chunks = frame(30, 4, 30, 30);
chunks[3] = chunks[2].clone();
assert!(protect_video_frame(&chunks, &STANDARD).is_err(), "a repeated chunk");
let mut chunks = frame(30, 4, 30, 30);
chunks[1].payload.truncate(29);
assert!(protect_video_frame(&chunks, &STANDARD).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).is_err(), "a last chunk longer than the shard");
assert!(protect_video_frame(&[], &STANDARD).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).unwrap(),
protect_video_frame(&shuffled, &STANDARD).unwrap()
);
}
#[test]
fn an_audio_block_is_four_contiguous_equal_length_packets() {
assert!(protect_audio_block(&audio_block(1, 20)[..3], &STANDARD).is_err(), "three packets");
let mut packets = audio_block(1, 20);
packets[2].packet_id += 1;
assert!(protect_audio_block(&packets, &STANDARD).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).is_err(), "a gap in presentation time");
let mut packets = audio_block(1, 20);
packets[3].payload.push(0);
assert!(protect_audio_block(&packets, &STANDARD).is_err(), "unequal payload lengths");
let mut packets = audio_block(1, 20);
packets[0].codec = "pcm".to_string();
assert!(protect_audio_block(&packets, &STANDARD).is_err(), "mixed codecs");
}
#[test]
fn parity_carries_its_frames_deadline_unchanged() {
let original = frame(40, 40, 50, 50);
let (_, parity) = split(protect_video_frame(&original, &STANDARD).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).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).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).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).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).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).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).is_err());
let too_wide = MediaFecPolicy { video_max_block_data_shards: 255, ..STANDARD };
assert!(protect_video_frame(&frame(1, 2, 10, 10), &too_wide).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).is_err());
}
#[test]
fn parity_records_survive_the_positional_encoding_unchanged() {
let (_, parity) = split(protect_video_frame(&frame(5, 5, 40, 17), &STANDARD).unwrap());
let audio = protect_audio_block(&audio_block(5, 25), &STANDARD).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);
}
}

View file

@ -5,9 +5,10 @@
use cultcache_rs::DatabaseEntry;
use cultnet_rs::{
GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA, GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA,
GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA, GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA,
GameCultMediaAudioPacketRecord, GameCultMediaReceiverFeedbackRecord,
GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA, GAMECULT_MEDIA_AUDIO_PARITY_SHARD_SCHEMA,
GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA, GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA,
GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA, GameCultMediaAudioPacketRecord,
GameCultMediaAudioParityShardRecord, GameCultMediaReceiverFeedbackRecord,
GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord,
};
use rmpv::Value;
@ -129,9 +130,14 @@ fn parity_shards_carry_the_geometry_recovery_needs() {
dependency_frame_id: Some(4241),
deadline_ticks: 108_000,
chunk_count: 5,
fec_scheme: "rs-gf256-v1".to_string(),
block_index: 0,
block_count: 1,
block_data_start: 0,
block_data_count: 5,
parity_index: 1,
parity_count: 2,
chunk_payload_bytes: 1024,
shard_payload_bytes: 1024,
last_chunk_payload_bytes: 512,
payload: vec![9, 9, 9],
};
@ -141,13 +147,21 @@ fn parity_shards_carry_the_geometry_recovery_needs() {
panic!("parity shards must encode as a positional array");
};
assert_eq!(slots.len(), 17, "field count is wire contract");
assert_eq!(slots[12].as_u64(), Some(1), "slot 12 is parity_index");
assert_eq!(slots[13].as_u64(), Some(2), "slot 13 is parity_count");
assert_eq!(slots.len(), 22, "field count is wire contract");
assert_eq!(slots[12].as_str(), Some("rs-gf256-v1"), "slot 12 is fec_scheme");
assert_eq!(slots[13].as_u64(), Some(0), "slot 13 is block_index");
assert_eq!(slots[16].as_u64(), Some(5), "slot 16 is block_data_count (k)");
assert_eq!(slots[17].as_u64(), Some(1), "slot 17 is parity_index");
assert_eq!(slots[18].as_u64(), Some(2), "slot 18 is parity_count (m)");
assert_eq!(
slots[15].as_u64(),
slots[20].as_u64(),
Some(512),
"slot 15 is last_chunk_payload_bytes, which recovery needs to size the tail"
"slot 20 is last_chunk_payload_bytes, which recovery needs to size the tail"
);
assert_eq!(
slots[21].as_slice(),
Some([9, 9, 9].as_slice()),
"the parity payload is bin, not an array of integers"
);
let decoded: GameCultMediaVideoParityShardRecord =
@ -155,6 +169,42 @@ fn parity_shards_carry_the_geometry_recovery_needs() {
assert_eq!(decoded, shard);
}
#[test]
fn audio_parity_shards_carry_the_geometry_recovery_needs() {
let shard = GameCultMediaAudioParityShardRecord {
stream_id: "raven-primary-av".to_string(),
session_id: "session-1".to_string(),
codec: "opus".to_string(),
fec_scheme: "rs-gf256-v1".to_string(),
base_packet_id: 77,
base_pts_ticks: 960,
packet_duration_ticks: 960,
timebase_num: 1,
timebase_den: 48_000,
deadline_ticks: 4_800,
data_shard_count: 4,
parity_index: 1,
parity_shard_count: 2,
shard_payload_bytes: 3,
payload: vec![7, 8, 9],
};
let encoded = rmp_serde::to_vec(&shard).expect("encodes");
let Value::Array(slots) = decode_as_value(&encoded) else {
panic!("audio parity shards must encode as a positional array");
};
assert_eq!(slots.len(), 15, "field count is wire contract");
assert_eq!(slots[4].as_u64(), Some(77), "slot 4 is base_packet_id");
assert_eq!(slots[10].as_u64(), Some(4), "slot 10 is data_shard_count (k)");
assert_eq!(slots[12].as_u64(), Some(2), "slot 12 is parity_shard_count (m)");
assert_eq!(slots[14].as_slice(), Some([7, 8, 9].as_slice()), "the payload is bin");
let decoded: GameCultMediaAudioParityShardRecord =
rmp_serde::from_slice(&encoded).expect("round-trips");
assert_eq!(decoded, shard);
}
/// `missing_video_chunk_keys` was appended after producers were already writing
/// feedback. A record written without it must still decode, or the append was a
/// breaking change wearing a `default` attribute.
@ -205,6 +255,11 @@ fn published_schema_constants_match_the_derived_identity() {
GameCultMediaAudioPacketRecord::SCHEMA_NAME,
GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA
);
assert_eq!(
GameCultMediaAudioParityShardRecord::SCHEMA_NAME,
GAMECULT_MEDIA_AUDIO_PARITY_SHARD_SCHEMA
);
assert_eq!(GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA, "gamecult.media_video_parity_shard.v3");
assert_eq!(
GameCultMediaReceiverFeedbackRecord::SCHEMA_NAME,
GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA

View file

@ -2,7 +2,8 @@
//! nothing on the wire.
use cultnet_rs::{
GAMECULT_MEDIA_CHANNEL, GameCultMediaAudioPacketRecord, GameCultMediaReceiverFeedbackRecord,
GAMECULT_MEDIA_CHANNEL, GameCultMediaAudioPacketRecord, GameCultMediaAudioParityShardRecord,
GameCultMediaReceiverFeedbackRecord,
GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord,
GameCultMediaWireRecord, MediaWireProvenance, decode_media_wire_record,
encode_media_wire_record,
@ -116,12 +117,34 @@ fn every_variant_round_trips() {
dependency_frame_id: Some(0),
deadline_ticks: 1,
chunk_count: 4,
fec_scheme: "rs-gf256-v1".to_string(),
block_index: 0,
block_count: 1,
block_data_start: 0,
block_data_count: 4,
parity_index: 1,
parity_count: 2,
chunk_payload_bytes: 848,
last_chunk_payload_bytes: 400,
shard_payload_bytes: 3,
last_chunk_payload_bytes: 2,
payload: vec![1, 2, 3],
}),
GameCultMediaWireRecord::AudioParity(GameCultMediaAudioParityShardRecord {
stream_id: "s".to_string(),
session_id: "x".to_string(),
codec: "opus".to_string(),
fec_scheme: "rs-gf256-v1".to_string(),
base_packet_id: 8,
base_pts_ticks: 0,
packet_duration_ticks: 960,
timebase_num: 1,
timebase_den: 48_000,
deadline_ticks: 4_000,
data_shard_count: 4,
parity_index: 0,
parity_shard_count: 2,
shard_payload_bytes: 3,
payload: vec![4, 5, 6],
}),
GameCultMediaWireRecord::Audio(GameCultMediaAudioPacketRecord {
stream_id: "s".to_string(),
session_id: "x".to_string(),
@ -215,3 +238,148 @@ fn provenance_must_be_complete() {
fn the_media_channel_is_named_once() {
assert_eq!(GAMECULT_MEDIA_CHANNEL, "media");
}
fn video_parity() -> GameCultMediaVideoParityShardRecord {
GameCultMediaVideoParityShardRecord {
stream_id: "s".to_string(),
session_id: "x".to_string(),
frame_id: 7,
codec: "h264".to_string(),
pts_ticks: 0,
duration_ticks: 1,
timebase_num: 1,
timebase_den: 90_000,
keyframe: false,
dependency_frame_id: None,
deadline_ticks: 1,
chunk_count: 20,
fec_scheme: "rs-gf256-v1".to_string(),
block_index: 1,
block_count: 2,
block_data_start: 10,
block_data_count: 10,
parity_index: 1,
parity_count: 3,
shard_payload_bytes: 4,
last_chunk_payload_bytes: 2,
payload: vec![1, 2, 3, 4],
}
}
fn audio_parity() -> GameCultMediaAudioParityShardRecord {
GameCultMediaAudioParityShardRecord {
stream_id: "s".to_string(),
session_id: "x".to_string(),
codec: "opus".to_string(),
fec_scheme: "rs-gf256-v1".to_string(),
base_packet_id: 8,
base_pts_ticks: 0,
packet_duration_ticks: 960,
timebase_num: 1,
timebase_den: 48_000,
deadline_ticks: 4_000,
data_shard_count: 4,
parity_index: 1,
parity_shard_count: 2,
shard_payload_bytes: 3,
payload: vec![4, 5, 6],
}
}
fn decodes(record: GameCultMediaWireRecord) -> Result<GameCultMediaWireRecord, String> {
let wire = encode_media_wire_record(&record, provenance()).map_err(|error| error.to_string())?;
decode_media_wire_record(&wire).map_err(|error| error.to_string())
}
/// A parity key names the block as well as the shard, or two blocks' parity
/// index 0 would share an address.
#[test]
fn parity_record_keys_name_the_block_and_the_shard() {
let mut other_block = video_parity();
other_block.block_index = 0;
let mut other_shard = video_parity();
other_shard.parity_index = 0;
let keys = [
GameCultMediaWireRecord::VideoParity(video_parity()).record_key(),
GameCultMediaWireRecord::VideoParity(other_block).record_key(),
GameCultMediaWireRecord::VideoParity(other_shard).record_key(),
];
assert_eq!(keys[0], "s:x:video-parity:7:1:1");
assert_ne!(keys[0], keys[1]);
assert_ne!(keys[0], keys[2]);
let audio = GameCultMediaWireRecord::AudioParity(audio_parity()).record_key();
assert_eq!(audio, "s:x:audio-parity:8:1");
let mut other = audio_parity();
other.parity_index = 0;
assert_ne!(audio, GameCultMediaWireRecord::AudioParity(other).record_key());
}
#[test]
fn a_well_formed_parity_record_is_admitted() {
assert!(decodes(GameCultMediaWireRecord::VideoParity(video_parity())).is_ok());
assert!(decodes(GameCultMediaWireRecord::AudioParity(audio_parity())).is_ok());
}
/// The decoder is where a consumer's idea of a well-formed shard is enforced.
/// Each corruption below would otherwise reach the recovery arithmetic.
#[test]
fn a_malformed_video_parity_record_is_refused_with_its_reason() {
let cases: Vec<(&str, Box<dyn Fn(&mut GameCultMediaVideoParityShardRecord)>)> = vec![
("fec_scheme", Box::new(|r| r.fec_scheme = "xor-v0".to_string())),
("block_index", Box::new(|r| r.block_index = 2)),
("block_index", Box::new(|r| r.block_count = 0)),
("inside the frame", Box::new(|r| r.block_data_start = 11)),
("inside the frame", Box::new(|r| r.block_data_count = 0)),
("parity_index", Box::new(|r| r.parity_index = 3)),
("parity_index", Box::new(|r| r.parity_count = 0)),
("256 shards", Box::new(|r| {
r.block_data_count = 254;
r.chunk_count = 300;
r.parity_count = 3;
})),
("shard_payload_bytes", Box::new(|r| {
r.shard_payload_bytes = 0;
r.payload.clear();
})),
("shard_payload_bytes", Box::new(|r| {
r.shard_payload_bytes = 721;
r.payload = vec![0; 721];
})),
("last chunk length", Box::new(|r| r.last_chunk_payload_bytes = 0)),
("last chunk length", Box::new(|r| r.last_chunk_payload_bytes = 5)),
("declared shard length", Box::new(|r| r.payload.push(0))),
("chunk_count", Box::new(|r| r.chunk_count = 0)),
("deadline_ticks", Box::new(|r| r.deadline_ticks = -1)),
];
for (expected, corrupt) in cases {
let mut record = video_parity();
corrupt(&mut record);
let error = decodes(GameCultMediaWireRecord::VideoParity(record)).unwrap_err();
assert!(error.contains(expected), "expected {expected:?} in {error:?}");
}
}
#[test]
fn a_malformed_audio_parity_record_is_refused_with_its_reason() {
let cases: Vec<(&str, Box<dyn Fn(&mut GameCultMediaAudioParityShardRecord)>)> = vec![
("fec_scheme", Box::new(|r| r.fec_scheme = String::new())),
("stripe", Box::new(|r| r.parity_index = 2)),
("stripe", Box::new(|r| r.data_shard_count = 0)),
("stripe", Box::new(|r| r.data_shard_count = 255)),
("timing", Box::new(|r| r.packet_duration_ticks = 0)),
("base_pts_ticks", Box::new(|r| r.deadline_ticks = -1)),
("shard_payload_bytes", Box::new(|r| {
r.shard_payload_bytes = 721;
r.payload = vec![0; 721];
})),
("declared shard length", Box::new(|r| { r.payload.pop(); })),
("codec", Box::new(|r| r.codec = String::new())),
];
for (expected, corrupt) in cases {
let mut record = audio_parity();
corrupt(&mut record);
let error = decodes(GameCultMediaWireRecord::AudioParity(record)).unwrap_err();
assert!(error.contains(expected), "expected {expected:?} in {error:?}");
}
}