From f7c58ce5bc4eeb7e5991a374ff580db831bf9ca6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:08:54 +0200 Subject: [PATCH 01/13] 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 --- packages/cultnet-rs/Cargo.toml | 1 + packages/cultnet-rs/src/lib.rs | 2 + packages/cultnet-rs/src/media_fec.rs | 655 ++++++++++++++++ .../cultnet-rs/src/media_stream_contracts.rs | 104 ++- packages/cultnet-rs/src/media_stream_wire.rs | 126 ++- packages/cultnet-rs/src/rudp.rs | 15 +- packages/cultnet-rs/tests/cultnet.rs | 20 +- packages/cultnet-rs/tests/media_fec.rs | 723 ++++++++++++++++++ .../tests/media_stream_contracts.rs | 73 +- .../cultnet-rs/tests/media_stream_wire.rs | 174 ++++- 10 files changed, 1818 insertions(+), 75 deletions(-) create mode 100644 packages/cultnet-rs/src/media_fec.rs create mode 100644 packages/cultnet-rs/tests/media_fec.rs diff --git a/packages/cultnet-rs/Cargo.toml b/packages/cultnet-rs/Cargo.toml index f289d86e..d4585be2 100644 --- a/packages/cultnet-rs/Cargo.toml +++ b/packages/cultnet-rs/Cargo.toml @@ -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"] } diff --git a/packages/cultnet-rs/src/lib.rs b/packages/cultnet-rs/src/lib.rs index 5bfd1857..621150ba 100644 --- a/packages/cultnet-rs/src/lib.rs +++ b/packages/cultnet-rs/src/lib.rs @@ -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::*; diff --git a/packages/cultnet-rs/src/media_fec.rs b/packages/cultnet-rs/src/media_fec.rs new file mode 100644 index 00000000..29f35e8c --- /dev/null +++ b/packages/cultnet-rs/src/media_fec.rs @@ -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 { + 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) -> 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], parity_count: usize) -> Result>, 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>>, +) -> Result>, 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 { + 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, 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 = 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::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> = 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 = 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(data: Vec, parity: Vec) -> Vec { + 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(lanes: Vec>) -> Vec { + 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, 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>> = 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 = (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, 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> = 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, 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>> = 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 = (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 +} diff --git a/packages/cultnet-rs/src/media_stream_contracts.rs b/packages/cultnet-rs/src/media_stream_contracts.rs index 5704912e..2991bb43 100644 --- a/packages/cultnet-rs/src/media_stream_contracts.rs +++ b/packages/cultnet-rs/src/media_stream_contracts.rs @@ -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, } -/// 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, } @@ -541,6 +563,60 @@ pub struct GameCultMediaAudioPacketRecord { pub payload: Vec, } +/// 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, +} + /// 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. diff --git a/packages/cultnet-rs/src/media_stream_wire.rs b/packages/cultnet-rs/src/media_stream_wire.rs index 9b7fbc1b..b1119ca0 100644 --- a/packages/cultnet-rs/src/media_stream_wire.rs +++ b/packages/cultnet-rs/src/media_stream_wire.rs @@ -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 { + 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(()) diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index 174739be..d1afca7c 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -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, diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index 426e2ed9..845ec846 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -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::>(); - 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" diff --git a/packages/cultnet-rs/tests/media_fec.rs b/packages/cultnet-rs/tests/media_fec.rs new file mode 100644 index 00000000..8e121b48 --- /dev/null +++ b/packages/cultnet-rs/tests/media_fec.rs @@ -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 { + 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 { + (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 { + (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, +) -> (Vec, Vec) { + 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::(), 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::(); + 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 = 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::>(), "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 = 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 = 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 = 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::(&bytes).unwrap(), shard); + } + for shard in audio { + let bytes = rmp_serde::to_vec(&shard).unwrap(); + assert_eq!(rmp_serde::from_slice::(&bytes).unwrap(), shard); + } +} diff --git a/packages/cultnet-rs/tests/media_stream_contracts.rs b/packages/cultnet-rs/tests/media_stream_contracts.rs index 963d78a2..08db4b30 100644 --- a/packages/cultnet-rs/tests/media_stream_contracts.rs +++ b/packages/cultnet-rs/tests/media_stream_contracts.rs @@ -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 diff --git a/packages/cultnet-rs/tests/media_stream_wire.rs b/packages/cultnet-rs/tests/media_stream_wire.rs index cee31a04..8137a7cc 100644 --- a/packages/cultnet-rs/tests/media_stream_wire.rs +++ b/packages/cultnet-rs/tests/media_stream_wire.rs @@ -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 { + 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)> = 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)> = 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:?}"); + } +} From 858e6ec6080238d74f17d2240f9a3136ba7510aa Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:17:46 +0200 Subject: [PATCH 02/13] Add the cultnet_impair proxy example and a socketless media FEC loss matrix cultnet_impair is a deterministic bidirectional UDP proxy for pressure testing real CultNet RUDP sessions with seeded loss, burst, duplicate, reorder, jitter and stall schedules. It is ported from the Odin donor (80adfd3) as an example, not library surface. The decision policy (Profile, Scheduler, seeded RNG) is split from the sockets into policy.rs, which the proxy and the tests both compile, so a schedule has one owner. The ten donor profiles live in tests/impairment/. tests/impairment_policy.rs keeps the donor's six deterministic policy tests (the burst test now pins the exact drops rather than a count) and adds profile loading and rejection. tests/media_fec_loss.rs sends protected video frames through those schedules with no sockets and asserts the guarantee: a block recovers exactly when at most m of its records were lost, recovered chunks equal what was sent, and a burst that the send order spreads under m per block loses no frame. The recovered-frame ratios are printed, not asserted; tests/impairment/loss-matrix.txt is that output. Co-Authored-By: Claude Opus 5.5 --- packages/cultnet-rs/Cargo.toml | 5 + .../examples/cultnet_impair/main.rs | 336 ++++++++++++++++++ .../examples/cultnet_impair/policy.rs | 298 ++++++++++++++++ .../cultnet-rs/tests/impairment/burst-4.toml | 3 + .../cultnet-rs/tests/impairment/burst-8.toml | 3 + .../cultnet-rs/tests/impairment/clean.toml | 2 + .../tests/impairment/delay-jitter.toml | 3 + .../tests/impairment/duplicate.toml | 2 + .../tests/impairment/loss-1pct.toml | 2 + .../tests/impairment/loss-3pct.toml | 2 + .../tests/impairment/loss-5pct.toml | 2 + .../tests/impairment/loss-matrix.txt | 37 ++ .../cultnet-rs/tests/impairment/reorder.toml | 3 + .../tests/impairment/stall-250ms.toml | 3 + .../cultnet-rs/tests/impairment_policy.rs | 158 ++++++++ packages/cultnet-rs/tests/media_fec_loss.rs | 199 +++++++++++ 16 files changed, 1058 insertions(+) create mode 100644 packages/cultnet-rs/examples/cultnet_impair/main.rs create mode 100644 packages/cultnet-rs/examples/cultnet_impair/policy.rs create mode 100644 packages/cultnet-rs/tests/impairment/burst-4.toml create mode 100644 packages/cultnet-rs/tests/impairment/burst-8.toml create mode 100644 packages/cultnet-rs/tests/impairment/clean.toml create mode 100644 packages/cultnet-rs/tests/impairment/delay-jitter.toml create mode 100644 packages/cultnet-rs/tests/impairment/duplicate.toml create mode 100644 packages/cultnet-rs/tests/impairment/loss-1pct.toml create mode 100644 packages/cultnet-rs/tests/impairment/loss-3pct.toml create mode 100644 packages/cultnet-rs/tests/impairment/loss-5pct.toml create mode 100644 packages/cultnet-rs/tests/impairment/loss-matrix.txt create mode 100644 packages/cultnet-rs/tests/impairment/reorder.toml create mode 100644 packages/cultnet-rs/tests/impairment/stall-250ms.toml create mode 100644 packages/cultnet-rs/tests/impairment_policy.rs create mode 100644 packages/cultnet-rs/tests/media_fec_loss.rs diff --git a/packages/cultnet-rs/Cargo.toml b/packages/cultnet-rs/Cargo.toml index d4585be2..b1cd8d11 100644 --- a/packages/cultnet-rs/Cargo.toml +++ b/packages/cultnet-rs/Cargo.toml @@ -36,3 +36,8 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Securit [dev-dependencies] pretty_assertions = "1.4" tempfile = "3" + +[[example]] +name = "cultnet_impair" +path = "examples/cultnet_impair/main.rs" +test = true diff --git a/packages/cultnet-rs/examples/cultnet_impair/main.rs b/packages/cultnet-rs/examples/cultnet_impair/main.rs new file mode 100644 index 00000000..c2ebd874 --- /dev/null +++ b/packages/cultnet-rs/examples/cultnet_impair/main.rs @@ -0,0 +1,336 @@ +//! `cultnet-impair`: a deterministic bidirectional UDP proxy for pressure-testing +//! real CultNet RUDP sessions. It owns transport impairment only; what a stream +//! does with the loss is the stream's business. +//! +//! The proxy listens at the endpoint a client dials and forwards to `--upstream` +//! through one upstream socket per client, so the reverse ACK and feedback +//! traffic crosses the same schedule. The schedule is `policy.rs`. +//! +//! ```text +//! cargo run --release --example cultnet_impair -- \ +//! --listen 0.0.0.0:17890 --upstream 127.0.0.1:17990 \ +//! --profile tests/impairment/loss-1pct.toml --seed 424242 --metrics loss-1pct.csv +//! ``` +//! +//! Profiles are a small TOML subset of unsigned integers; see +//! `tests/impairment/`. The receiver binds the upstream endpoint and the client +//! dials the proxy's; never one port for both. For a two-host test, run the proxy +//! on the receiver host so reverse traffic is impaired too. + +mod policy; + +use std::collections::HashMap; +use std::env; +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::net::{SocketAddr, UdpSocket}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use policy::{Direction, Profile, Scheduler, Stats}; + +const MAX_DATAGRAM: usize = 65_535; +const MAX_CLIENT_FLOWS: usize = 64; +const CLIENT_FLOW_IDLE_TIMEOUT_MS: u64 = 30_000; +const MAX_DATAGRAMS_PER_FLOW_PER_TURN: usize = 16; + +#[derive(Debug)] +struct UpstreamFlow { + socket: UdpSocket, + last_activity_ms: u64, +} + +fn open_upstream_flow(upstream: SocketAddr, now_ms: u64) -> io::Result { + let socket = UdpSocket::bind("0.0.0.0:0")?; + socket.connect(upstream)?; + socket.set_nonblocking(true)?; + Ok(UpstreamFlow { + socket, + last_activity_ms: now_ms, + }) +} + +fn ensure_upstream_flow( + flows: &mut HashMap, + client: SocketAddr, + upstream: SocketAddr, + now_ms: u64, + stats: &mut Stats, +) -> io::Result<()> { + if flows.contains_key(&client) { + return Ok(()); + } + if flows.len() >= MAX_CLIENT_FLOWS + && let Some(oldest) = flows + .iter() + .min_by_key(|(_, flow)| flow.last_activity_ms) + .map(|(address, _)| *address) + { + flows.remove(&oldest); + stats.flow_evicted += 1; + } + flows.insert(client, open_upstream_flow(upstream, now_ms)?); + stats.max_flows = stats.max_flows.max(flows.len()); + Ok(()) +} + +fn reap_idle_flows( + flows: &mut HashMap, + now_ms: u64, + stats: &mut Stats, +) { + let before = flows.len(); + flows.retain(|_, flow| { + now_ms.saturating_sub(flow.last_activity_ms) < CLIENT_FLOW_IDLE_TIMEOUT_MS + }); + stats.flow_evicted += (before - flows.len()) as u64; +} + +#[derive(Debug)] +struct Options { + listen: SocketAddr, + upstream: SocketAddr, + profile: PathBuf, + seed: u64, + metrics: Option, +} + +fn parse_options() -> Result { + let mut args = env::args().skip(1); + let mut listen = None; + let mut upstream = None; + let mut profile = None; + let mut seed = 1; + let mut metrics = None; + while let Some(arg) = args.next() { + let mut value = || args.next().ok_or_else(|| format!("{arg} requires a value")); + match arg.as_str() { + "--listen" => { + listen = Some( + value()? + .parse() + .map_err(|_| "--listen must be a socket address")?, + ) + } + "--upstream" => { + upstream = Some( + value()? + .parse() + .map_err(|_| "--upstream must be a socket address")?, + ) + } + "--profile" => profile = Some(PathBuf::from(value()?)), + "--seed" => { + seed = value()? + .parse() + .map_err(|_| "--seed must be an unsigned integer")? + } + "--metrics" => metrics = Some(PathBuf::from(value()?)), + "--help" | "-h" => return Err(usage().into()), + _ => return Err(format!("unknown argument {arg}\n{}", usage())), + } + } + Ok(Options { + listen: listen.ok_or("--listen is required")?, + upstream: upstream.ok_or("--upstream is required")?, + profile: profile.ok_or("--profile is required")?, + seed, + metrics, + }) +} + +fn usage() -> &'static str { + "usage: cultnet-impair --listen HOST:PORT --upstream HOST:PORT --profile PATH [--seed N] [--metrics PATH]" +} + +fn elapsed_ms(start: Instant) -> u64 { + start.elapsed().as_millis().try_into().unwrap_or(u64::MAX) +} + +fn write_metrics(path: &Path, stats: &Stats, active_flows: usize) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = File::create(path)?; + writeln!( + file, + "received,forwarded,dropped,duplicated,reordered,stalled,queue_overflow,max_queue,flow_evicted,max_flows,active_flows" + )?; + writeln!( + file, + "{},{},{},{},{},{},{},{},{},{},{}", + stats.received, + stats.forwarded, + stats.dropped, + stats.duplicated, + stats.reordered, + stats.stalled, + stats.queue_overflow, + stats.max_queue, + stats.flow_evicted, + stats.max_flows, + active_flows + ) +} + +fn run(options: Options) -> Result<(), String> { + let profile = Profile::load(&options.profile)?; + let client_socket = UdpSocket::bind(options.listen) + .map_err(|error| format!("binding {}: {error}", options.listen))?; + client_socket + .set_nonblocking(true) + .map_err(|error| error.to_string())?; + println!( + "cultnet-impair listen={} upstream={} seed={} profile={}", + options.listen, + options.upstream, + options.seed, + options.profile.display() + ); + let start = Instant::now(); + let mut scheduler = Scheduler::new(profile, options.seed); + let mut flows = HashMap::::new(); + let mut last_flow_reap_at = 0_u64; + let mut buffer = vec![0; MAX_DATAGRAM]; + loop { + let now = elapsed_ms(start); + let mut did_work = false; + for _ in 0..MAX_DATAGRAMS_PER_FLOW_PER_TURN { + match client_socket.recv_from(&mut buffer) { + Ok((size, address)) => { + did_work = true; + ensure_upstream_flow( + &mut flows, + address, + options.upstream, + now, + &mut scheduler.stats, + ) + .map_err(|error| format!("opening upstream flow for {address}: {error}"))?; + if let Some(flow) = flows.get_mut(&address) { + flow.last_activity_ms = now; + } + scheduler.admit( + now, + Direction::ClientToUpstream, + &buffer[..size], + address, + ); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) => return Err(error.to_string()), + } + } + for (address, flow) in &mut flows { + for _ in 0..MAX_DATAGRAMS_PER_FLOW_PER_TURN { + match flow.socket.recv(&mut buffer) { + Ok(size) => { + did_work = true; + flow.last_activity_ms = now; + scheduler.admit( + now, + Direction::UpstreamToClient, + &buffer[..size], + *address, + ); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) => return Err(error.to_string()), + } + } + } + while let Some(item) = scheduler.pop_due(now) { + did_work = true; + let result = match item.direction { + Direction::ClientToUpstream => match flows.get_mut(&item.client) { + Some(flow) => { + flow.last_activity_ms = now; + flow.socket.send(&item.bytes) + } + None => { + scheduler.stats.dropped += 1; + continue; + } + }, + Direction::UpstreamToClient => client_socket.send_to(&item.bytes, item.client), + }; + if result.is_ok() { + scheduler.stats.forwarded += 1; + } + } + if let Some(path) = options.metrics.as_deref() { + if now % 1000 == 0 { + let _ = write_metrics(path, &scheduler.stats, flows.len()); + } + } + if now.saturating_sub(last_flow_reap_at) >= 1_000 { + reap_idle_flows(&mut flows, now, &mut scheduler.stats); + last_flow_reap_at = now; + } + if did_work { + thread::yield_now(); + } else { + thread::sleep(Duration::from_millis(1)); + } + } +} + +fn main() { + match parse_options().and_then(run) { + Ok(()) => {} + Err(error) => { + eprintln!("{error}"); + std::process::exit(2); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn independent_clients_keep_independent_upstream_udp_identities() { + let mut flows = HashMap::new(); + let mut stats = Stats::default(); + let upstream = "127.0.0.1:54321".parse().unwrap(); + let first = "127.0.0.1:12001".parse().unwrap(); + let second = "127.0.0.1:12002".parse().unwrap(); + + ensure_upstream_flow(&mut flows, first, upstream, 1, &mut stats).unwrap(); + ensure_upstream_flow(&mut flows, second, upstream, 2, &mut stats).unwrap(); + + assert_eq!(flows.len(), 2); + assert_ne!( + flows[&first].socket.local_addr().unwrap(), + flows[&second].socket.local_addr().unwrap() + ); + assert_eq!(stats.max_flows, 2); + assert_eq!(stats.flow_evicted, 0); + } + + #[test] + fn idle_client_flows_are_reaped_without_touching_active_flows() { + let mut flows = HashMap::new(); + let mut stats = Stats::default(); + let upstream = "127.0.0.1:54321".parse().unwrap(); + let idle = "127.0.0.1:12001".parse().unwrap(); + let active = "127.0.0.1:12002".parse().unwrap(); + + ensure_upstream_flow(&mut flows, idle, upstream, 0, &mut stats).unwrap(); + ensure_upstream_flow( + &mut flows, + active, + upstream, + CLIENT_FLOW_IDLE_TIMEOUT_MS - 1, + &mut stats, + ) + .unwrap(); + reap_idle_flows(&mut flows, CLIENT_FLOW_IDLE_TIMEOUT_MS, &mut stats); + + assert!(!flows.contains_key(&idle)); + assert!(flows.contains_key(&active)); + assert_eq!(stats.flow_evicted, 1); + } +} diff --git a/packages/cultnet-rs/examples/cultnet_impair/policy.rs b/packages/cultnet-rs/examples/cultnet_impair/policy.rs new file mode 100644 index 00000000..844052cf --- /dev/null +++ b/packages/cultnet-rs/examples/cultnet_impair/policy.rs @@ -0,0 +1,298 @@ +//! The impairment policy: what happens to each datagram, and nothing about +//! sockets. `Profile` says what to do, `Scheduler` decides per datagram and +//! holds the delayed ones, and every decision is a pure function of the +//! profile, the seed and the datagram's position, so a schedule replays +//! exactly. The proxy in `main.rs` and the loss-matrix test in +//! `tests/media_fec_loss.rs` both take their schedule from here. + +use std::collections::VecDeque; +use std::fs; +use std::net::SocketAddr; +use std::path::Path; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Profile { + pub loss_basis_points: u32, + pub burst_every: u64, + pub burst_length: u64, + pub duplicate_every: u64, + pub reorder_every: u64, + pub reorder_delay_ms: u64, + pub delay_ms: u64, + pub jitter_ms: u64, + pub stall_at_ms: u64, + pub stall_for_ms: u64, + pub max_scheduled: usize, +} + +impl Default for Profile { + fn default() -> Self { + Self { + loss_basis_points: 0, + burst_every: 0, + burst_length: 0, + duplicate_every: 0, + reorder_every: 0, + reorder_delay_ms: 0, + delay_ms: 0, + jitter_ms: 0, + stall_at_ms: 0, + stall_for_ms: 0, + max_scheduled: 4096, + } + } +} + +impl Profile { + pub fn load(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("reading {}: {error}", path.display()))?; + let mut profile = Self::default(); + for (index, raw) in text.lines().enumerate() { + let line = raw.split('#').next().unwrap_or("").trim(); + if line.is_empty() || line.starts_with('[') { + continue; + } + let (key, value) = line + .split_once('=') + .ok_or_else(|| format!("{}:{}: expected key = value", path.display(), index + 1))?; + let key = key.trim(); + let value = value.trim().trim_matches('"'); + let number = value.parse::().map_err(|_| { + format!( + "{}:{}: {key} must be an unsigned integer", + path.display(), + index + 1 + ) + })?; + match key { + "loss_basis_points" => { + profile.loss_basis_points = number + .try_into() + .map_err(|_| "loss_basis_points is too large")? + } + "burst_every" => profile.burst_every = number, + "burst_length" => profile.burst_length = number, + "duplicate_every" => profile.duplicate_every = number, + "reorder_every" => profile.reorder_every = number, + "reorder_delay_ms" => profile.reorder_delay_ms = number, + "delay_ms" => profile.delay_ms = number, + "jitter_ms" => profile.jitter_ms = number, + "stall_at_ms" => profile.stall_at_ms = number, + "stall_for_ms" => profile.stall_for_ms = number, + "max_scheduled" => { + profile.max_scheduled = number + .try_into() + .map_err(|_| "max_scheduled is too large")? + } + _ => { + return Err(format!( + "{}:{}: unknown profile key {key}", + path.display(), + index + 1 + )); + } + } + } + if profile.loss_basis_points > 10_000 { + return Err("loss_basis_points must be <= 10000".into()); + } + if profile.max_scheduled == 0 { + return Err("max_scheduled must be greater than zero".into()); + } + Ok(profile) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Direction { + ClientToUpstream, + UpstreamToClient, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Decision { + pub drop: bool, + pub copies: u8, + pub delay_ms: u64, + pub reordered: bool, + pub stalled: bool, +} + +#[derive(Clone, Debug)] +pub struct DeterministicRng(u64); + +impl DeterministicRng { + pub fn new(seed: u64) -> Self { + Self(if seed == 0 { 0x9e3779b97f4a7c15 } else { seed }) + } + pub fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } +} + +#[derive(Debug)] +pub struct Scheduler { + pub profile: Profile, + pub rng: DeterministicRng, + pub seen: u64, + pub burst_remaining: u64, + pub queue: VecDeque, + pub stats: Stats, +} + +#[derive(Debug)] +pub struct Scheduled { + pub due_ms: u64, + pub direction: Direction, + pub bytes: Vec, + pub client: SocketAddr, +} + +#[derive(Debug, Default)] +pub struct Stats { + pub received: u64, + pub forwarded: u64, + pub dropped: u64, + pub duplicated: u64, + pub reordered: u64, + pub stalled: u64, + pub queue_overflow: u64, + pub max_queue: usize, + pub flow_evicted: u64, + pub max_flows: usize, +} + +impl Scheduler { + pub fn new(profile: Profile, seed: u64) -> Self { + Self { + profile, + rng: DeterministicRng::new(seed), + seen: 0, + burst_remaining: 0, + queue: VecDeque::new(), + stats: Stats::default(), + } + } + + pub fn decide(&mut self, now_ms: u64) -> Decision { + self.seen += 1; + let stalled = self.profile.stall_for_ms > 0 + && now_ms >= self.profile.stall_at_ms + && now_ms + < self + .profile + .stall_at_ms + .saturating_add(self.profile.stall_for_ms); + if stalled { + return Decision { + drop: true, + copies: 0, + delay_ms: 0, + reordered: false, + stalled: true, + }; + } + if self.burst_remaining == 0 + && self.profile.burst_every > 0 + && self.seen % self.profile.burst_every == 0 + { + self.burst_remaining = self.profile.burst_length; + } + if self.burst_remaining > 0 { + self.burst_remaining -= 1; + return Decision { + drop: true, + copies: 0, + delay_ms: 0, + reordered: false, + stalled: false, + }; + } + if self.profile.loss_basis_points > 0 + && self.rng.next() % 10_000 < u64::from(self.profile.loss_basis_points) + { + return Decision { + drop: true, + copies: 0, + delay_ms: 0, + reordered: false, + stalled: false, + }; + } + let duplicate = + self.profile.duplicate_every > 0 && self.seen % self.profile.duplicate_every == 0; + let reordered = + self.profile.reorder_every > 0 && self.seen % self.profile.reorder_every == 0; + let jitter = if self.profile.jitter_ms == 0 { + 0 + } else { + self.rng.next() % (self.profile.jitter_ms + 1) + }; + Decision { + drop: false, + copies: if duplicate { 2 } else { 1 }, + delay_ms: self.profile.delay_ms + + jitter + + if reordered { + self.profile.reorder_delay_ms + } else { + 0 + }, + reordered, + stalled: false, + } + } + + pub fn admit(&mut self, now_ms: u64, direction: Direction, bytes: &[u8], client: SocketAddr) { + self.stats.received += 1; + let decision = self.decide(now_ms); + if decision.drop { + self.stats.dropped += 1; + if decision.stalled { + self.stats.stalled += 1; + } + return; + } + if decision.copies == 2 { + self.stats.duplicated += 1; + } + if decision.reordered { + self.stats.reordered += 1; + } + for copy in 0..decision.copies { + if self.queue.len() >= self.profile.max_scheduled { + self.stats.queue_overflow += 1; + self.stats.dropped += 1; + continue; + } + let delay = decision.delay_ms + u64::from(copy); + let item = Scheduled { + due_ms: now_ms.saturating_add(delay), + direction, + bytes: bytes.to_vec(), + client, + }; + let position = self + .queue + .iter() + .position(|queued| queued.due_ms > item.due_ms) + .unwrap_or(self.queue.len()); + self.queue.insert(position, item); + self.stats.max_queue = self.stats.max_queue.max(self.queue.len()); + } + } + + pub fn pop_due(&mut self, now_ms: u64) -> Option { + if self.queue.front().is_some_and(|item| item.due_ms <= now_ms) { + self.queue.pop_front() + } else { + None + } + } +} diff --git a/packages/cultnet-rs/tests/impairment/burst-4.toml b/packages/cultnet-rs/tests/impairment/burst-4.toml new file mode 100644 index 00000000..aafe36d0 --- /dev/null +++ b/packages/cultnet-rs/tests/impairment/burst-4.toml @@ -0,0 +1,3 @@ +burst_every = 200 +burst_length = 4 +max_scheduled = 4096 diff --git a/packages/cultnet-rs/tests/impairment/burst-8.toml b/packages/cultnet-rs/tests/impairment/burst-8.toml new file mode 100644 index 00000000..cc7dd38b --- /dev/null +++ b/packages/cultnet-rs/tests/impairment/burst-8.toml @@ -0,0 +1,3 @@ +burst_every = 200 +burst_length = 8 +max_scheduled = 4096 diff --git a/packages/cultnet-rs/tests/impairment/clean.toml b/packages/cultnet-rs/tests/impairment/clean.toml new file mode 100644 index 00000000..ec79f35e --- /dev/null +++ b/packages/cultnet-rs/tests/impairment/clean.toml @@ -0,0 +1,2 @@ +# No impairment. The proxy itself remains in the measured path. +max_scheduled = 4096 diff --git a/packages/cultnet-rs/tests/impairment/delay-jitter.toml b/packages/cultnet-rs/tests/impairment/delay-jitter.toml new file mode 100644 index 00000000..2f62ce3d --- /dev/null +++ b/packages/cultnet-rs/tests/impairment/delay-jitter.toml @@ -0,0 +1,3 @@ +delay_ms = 5 +jitter_ms = 10 +max_scheduled = 4096 diff --git a/packages/cultnet-rs/tests/impairment/duplicate.toml b/packages/cultnet-rs/tests/impairment/duplicate.toml new file mode 100644 index 00000000..ad6c761a --- /dev/null +++ b/packages/cultnet-rs/tests/impairment/duplicate.toml @@ -0,0 +1,2 @@ +duplicate_every = 50 +max_scheduled = 4096 diff --git a/packages/cultnet-rs/tests/impairment/loss-1pct.toml b/packages/cultnet-rs/tests/impairment/loss-1pct.toml new file mode 100644 index 00000000..e63dacd0 --- /dev/null +++ b/packages/cultnet-rs/tests/impairment/loss-1pct.toml @@ -0,0 +1,2 @@ +loss_basis_points = 100 +max_scheduled = 4096 diff --git a/packages/cultnet-rs/tests/impairment/loss-3pct.toml b/packages/cultnet-rs/tests/impairment/loss-3pct.toml new file mode 100644 index 00000000..63c4e732 --- /dev/null +++ b/packages/cultnet-rs/tests/impairment/loss-3pct.toml @@ -0,0 +1,2 @@ +loss_basis_points = 300 +max_scheduled = 4096 diff --git a/packages/cultnet-rs/tests/impairment/loss-5pct.toml b/packages/cultnet-rs/tests/impairment/loss-5pct.toml new file mode 100644 index 00000000..d7cb6d70 --- /dev/null +++ b/packages/cultnet-rs/tests/impairment/loss-5pct.toml @@ -0,0 +1,2 @@ +loss_basis_points = 500 +max_scheduled = 4096 diff --git a/packages/cultnet-rs/tests/impairment/loss-matrix.txt b/packages/cultnet-rs/tests/impairment/loss-matrix.txt new file mode 100644 index 00000000..cf4f08cc --- /dev/null +++ b/packages/cultnet-rs/tests/impairment/loss-matrix.txt @@ -0,0 +1,37 @@ +profile chunks frames whole/no-fec whole/fec recovered beyond +clean 8 200 200 200 0 0 +clean 16 200 200 200 0 0 +clean 17 200 200 200 0 0 +clean 24 200 200 200 0 0 +clean 32 200 200 200 0 0 +clean 48 200 200 200 0 0 +loss-1pct 8 200 183 200 17 0 +loss-1pct 16 200 170 200 30 0 +loss-1pct 17 200 170 200 31 0 +loss-1pct 24 200 154 200 48 0 +loss-1pct 32 200 144 200 59 0 +loss-1pct 48 200 137 200 75 0 +loss-3pct 8 200 158 199 41 1 +loss-3pct 16 200 126 200 74 0 +loss-3pct 17 200 119 199 90 0 +loss-3pct 24 200 101 200 116 0 +loss-3pct 32 200 78 200 147 0 +loss-3pct 48 200 57 200 208 0 +loss-5pct 8 200 130 197 67 3 +loss-5pct 16 200 93 199 106 1 +loss-5pct 17 200 92 198 132 1 +loss-5pct 24 200 59 198 180 2 +loss-5pct 32 200 41 200 214 0 +loss-5pct 48 200 21 199 318 1 +burst-4 8 200 181 191 10 9 +burst-4 16 200 161 200 39 0 +burst-4 17 200 175 200 46 0 +burst-4 24 200 161 200 68 0 +burst-4 32 200 121 200 118 0 +burst-4 48 200 121 200 177 0 +burst-8 8 200 181 191 10 0 +burst-8 16 200 161 181 20 19 +burst-8 17 200 171 183 20 30 +burst-8 24 200 161 171 19 49 +burst-8 32 200 121 200 118 0 +burst-8 48 200 121 200 197 0 diff --git a/packages/cultnet-rs/tests/impairment/reorder.toml b/packages/cultnet-rs/tests/impairment/reorder.toml new file mode 100644 index 00000000..23c5bf57 --- /dev/null +++ b/packages/cultnet-rs/tests/impairment/reorder.toml @@ -0,0 +1,3 @@ +reorder_every = 20 +reorder_delay_ms = 12 +max_scheduled = 4096 diff --git a/packages/cultnet-rs/tests/impairment/stall-250ms.toml b/packages/cultnet-rs/tests/impairment/stall-250ms.toml new file mode 100644 index 00000000..83a30cf7 --- /dev/null +++ b/packages/cultnet-rs/tests/impairment/stall-250ms.toml @@ -0,0 +1,3 @@ +stall_at_ms = 10000 +stall_for_ms = 250 +max_scheduled = 4096 diff --git a/packages/cultnet-rs/tests/impairment_policy.rs b/packages/cultnet-rs/tests/impairment_policy.rs new file mode 100644 index 00000000..b962a241 --- /dev/null +++ b/packages/cultnet-rs/tests/impairment_policy.rs @@ -0,0 +1,158 @@ +//! The impairment policy is deterministic, or a loss experiment cannot be +//! replayed. These pin each decision the proxy example makes, against the same +//! `policy.rs` the example compiles. + +#[allow(dead_code)] +#[path = "../examples/cultnet_impair/policy.rs"] +mod policy; + +use std::net::SocketAddr; + +use policy::{Direction, Profile, Scheduler}; + +fn client() -> SocketAddr { + "127.0.0.1:1234".parse().unwrap() +} + +#[test] +fn iid_decisions_are_seed_reproducible() { + let profile = Profile { + loss_basis_points: 2500, + ..Default::default() + }; + let mut a = Scheduler::new(profile.clone(), 42); + let mut b = Scheduler::new(profile, 42); + let left: Vec<_> = (0..100).map(|_| a.decide(0)).collect(); + let right: Vec<_> = (0..100).map(|_| b.decide(0)).collect(); + assert_eq!(left, right); + assert!(left.iter().any(|d| d.drop)); + assert!(left.iter().any(|d| !d.drop)); +} + +#[test] +fn burst_drop_is_exact() { + let profile = Profile { + burst_every: 5, + burst_length: 3, + ..Default::default() + }; + let mut scheduler = Scheduler::new(profile, 1); + let drops: Vec<_> = (1..=9).filter(|_| scheduler.decide(0).drop).collect(); + assert_eq!(drops, vec![5, 6, 7], "a burst starts on the Nth datagram and runs burst_length"); +} + +#[test] +fn duplicate_delay_and_reorder_are_deterministic() { + let profile = Profile { + duplicate_every: 2, + reorder_every: 3, + reorder_delay_ms: 20, + delay_ms: 5, + ..Default::default() + }; + let mut scheduler = Scheduler::new(profile, 1); + assert_eq!(scheduler.decide(0).copies, 1); + assert_eq!(scheduler.decide(0).copies, 2); + let third = scheduler.decide(0); + assert!(third.reordered); + assert_eq!(third.delay_ms, 25); +} + +#[test] +fn stall_drops_only_inside_window() { + let profile = Profile { + stall_at_ms: 100, + stall_for_ms: 50, + ..Default::default() + }; + let mut scheduler = Scheduler::new(profile, 1); + assert!(!scheduler.decide(99).drop); + let stalled = scheduler.decide(100); + assert!(stalled.drop && stalled.stalled); + assert!(scheduler.decide(149).drop); + assert!(!scheduler.decide(150).drop); +} + +#[test] +fn reorder_delay_places_packet_behind_later_packet() { + let profile = Profile { + reorder_every: 2, + reorder_delay_ms: 20, + ..Default::default() + }; + let mut scheduler = Scheduler::new(profile, 1); + scheduler.admit(0, Direction::ClientToUpstream, b"first", client()); + scheduler.admit(0, Direction::ClientToUpstream, b"held", client()); + scheduler.admit(1, Direction::ClientToUpstream, b"later", client()); + assert_eq!(scheduler.pop_due(1).unwrap().bytes, b"first"); + assert_eq!(scheduler.pop_due(1).unwrap().bytes, b"later"); + assert!(scheduler.pop_due(19).is_none()); + assert_eq!(scheduler.pop_due(20).unwrap().bytes, b"held"); +} + +#[test] +fn scheduled_queue_is_bounded_and_counts_overflow() { + let profile = Profile { + delay_ms: 100, + max_scheduled: 2, + ..Default::default() + }; + let mut scheduler = Scheduler::new(profile, 1); + for value in 0..5 { + scheduler.admit(0, Direction::ClientToUpstream, &[value], client()); + } + assert_eq!(scheduler.queue.len(), 2); + assert_eq!(scheduler.stats.max_queue, 2); + assert_eq!(scheduler.stats.queue_overflow, 3); + assert_eq!(scheduler.stats.dropped, 3); +} + +fn profile_path(name: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/impairment") + .join(format!("{name}.toml")) +} + +#[test] +fn every_committed_profile_loads_with_its_named_meaning() { + let load = |name: &str| Profile::load(&profile_path(name)).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(load("clean"), Profile::default()); + assert_eq!(load("loss-1pct").loss_basis_points, 100); + assert_eq!(load("loss-3pct").loss_basis_points, 300); + assert_eq!(load("loss-5pct").loss_basis_points, 500); + let burst4 = load("burst-4"); + assert_eq!((burst4.burst_every, burst4.burst_length), (200, 4)); + let burst8 = load("burst-8"); + assert_eq!((burst8.burst_every, burst8.burst_length), (200, 8)); + let jitter = load("delay-jitter"); + assert_eq!((jitter.delay_ms, jitter.jitter_ms), (5, 10)); + assert_eq!(load("duplicate").duplicate_every, 50); + let reorder = load("reorder"); + assert_eq!((reorder.reorder_every, reorder.reorder_delay_ms), (20, 12)); + let stall = load("stall-250ms"); + assert_eq!((stall.stall_at_ms, stall.stall_for_ms), (10_000, 250)); +} + +#[test] +fn a_malformed_profile_is_refused_with_its_line() { + let dir = tempfile::tempdir().unwrap(); + let write = |name: &str, text: &str| { + let path = dir.path().join(name); + std::fs::write(&path, text).unwrap(); + Profile::load(&path).unwrap_err() + }; + assert!(write("unknown.toml", "jitter = 3 +").contains("unknown profile key jitter")); + assert!(write("word.toml", "delay_ms = soon +").contains("unsigned integer")); + assert!(write("shape.toml", "delay_ms +").contains("key = value")); + assert!(write("loss.toml", "loss_basis_points = 10001 +").contains("<= 10000")); + assert!(write("queue.toml", "max_scheduled = 0 +").contains("greater than zero")); + assert!(write("line.toml", " + +delay_ms = x +").contains(":3:")); +} diff --git a/packages/cultnet-rs/tests/media_fec_loss.rs b/packages/cultnet-rs/tests/media_fec_loss.rs new file mode 100644 index 00000000..dd40b443 --- /dev/null +++ b/packages/cultnet-rs/tests/media_fec_loss.rs @@ -0,0 +1,199 @@ +//! Video frames protected by the media FEC codec, sent through the impairment +//! schedules `cultnet_impair` applies to real sockets, with no sockets: each +//! record of a frame in send order takes the next datagram decision, and what +//! survives is handed to `recover_video_block`. +//! +//! Two kinds of claim. The guarantee is asserted: a block recovers exactly when +//! at most `m` of its records were lost, and a burst short enough that the send +//! order spreads it under `m` per block always leaves every frame whole. The +//! recovered-frame ratios are evidence, not assertions; `--nocapture` prints the +//! table and `tests/impairment/loss-matrix.txt` is that output (200 frames per cell, seed 20260930). + +#[allow(dead_code)] +#[path = "../examples/cultnet_impair/policy.rs"] +mod policy; + +use std::fmt::Write as _; +use std::path::Path; + +use cultnet_rs::{ + GameCultMediaVideoAccessUnitRecord, GameCultMediaWireRecord, MediaFecError, MediaFecPolicy, + protect_video_frame, recover_video_block, +}; +use policy::{Profile, Scheduler}; + +const POLICY: MediaFecPolicy = MediaFecPolicy::STANDARD; +const SEED: u64 = 20_260_930; +const FRAMES: u64 = 200; + +fn frame(frame_id: u64, chunk_count: u16) -> Vec { + (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: frame_id as i64 * 3_000, + duration_ticks: 3_000, + timebase_num: 1, + timebase_den: 90_000, + keyframe: false, + dependency_frame_id: frame_id.checked_sub(1), + deadline_ticks: frame_id as i64 * 3_000 + 22_500, + chunk_index: index, + chunk_count, + payload: (0..if index + 1 == chunk_count { 60 } else { 100 }) + .map(|byte| (frame_id as usize * 31 + usize::from(index) * 7 + byte) as u8) + .collect(), + }) + .collect() +} + +#[derive(Default)] +struct Tally { + frames: u64, + whole_without_parity: u64, + whole_with_parity: u64, + blocks_recovered: u64, + blocks_beyond_repair: u64, +} + +/// Runs one shape through one profile's schedule and asserts the per-block +/// guarantee on every block of every frame. +fn run(profile: &Profile, chunk_count: u16) -> Tally { + let mut scheduler = Scheduler::new(profile.clone(), SEED); + let sizes = POLICY.video_block_sizes(chunk_count); + let mut tally = Tally::default(); + + for frame_id in 0..FRAMES { + let original = frame(frame_id, chunk_count); + let records = protect_video_frame(&original, &POLICY).unwrap(); + let survivors: Vec = records + .into_iter() + .filter(|_| !scheduler.decide(0).drop) + .collect(); + + let mut whole = true; + let mut whole_without_parity = true; + let mut start = 0_u16; + for (block, &size) in sizes.iter().enumerate() { + let m = usize::from(POLICY.video_parity_shards(size)); + let mut data = Vec::new(); + let mut parity = Vec::new(); + for record in &survivors { + match record { + GameCultMediaWireRecord::Video(chunk) + if chunk.chunk_index >= start && chunk.chunk_index < start + size => + { + data.push(chunk.clone()) + } + GameCultMediaWireRecord::VideoParity(shard) if usize::from(shard.block_index) == block => { + parity.push(shard.clone()) + } + _ => {} + } + } + let lost = usize::from(size) + m - data.len() - parity.len(); + let missing_data = usize::from(size) - data.len(); + whole_without_parity &= missing_data == 0; + + let block_whole = if missing_data == 0 { + true + } else if parity.is_empty() { + assert!(lost > m, "no parity survived, so more than m shards were lost"); + false + } else { + match recover_video_block(&parity, &data) { + Ok(recovered) => { + assert!(lost <= m, "block {block} of frame {frame_id} recovered {lost} losses with m = {m}"); + assert_eq!(recovered.len(), missing_data); + for chunk in &recovered { + assert_eq!( + chunk, &original[usize::from(chunk.chunk_index)], + "a recovered chunk must be the chunk that was sent" + ); + } + tally.blocks_recovered += 1; + true + } + Err(MediaFecError::BeyondRepair { .. }) => { + assert!(lost > m, "block {block} of frame {frame_id} refused a recoverable {lost} losses with m = {m}"); + tally.blocks_beyond_repair += 1; + false + } + Err(other) => panic!("{other}"), + } + }; + whole &= block_whole; + start += size; + } + tally.frames += 1; + tally.whole_with_parity += u64::from(whole); + tally.whole_without_parity += u64::from(whole_without_parity); + assert!(whole || !whole_without_parity, "parity never makes a whole frame worse"); + } + tally +} + +fn profile(name: &str) -> Profile { + Profile::load(&Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("tests/impairment/{name}.toml"))) + .unwrap() +} + +/// Every shape here splits into equal blocks, so a burst of `L` consecutive +/// datagrams costs each of `B` blocks at most `ceil(L / B)` of its records. +const EQUAL_BLOCK_SHAPES: [u16; 5] = [8, 16, 24, 32, 48]; + +#[test] +fn the_loss_matrix() { + let cells = ["clean", "loss-1pct", "loss-3pct", "loss-5pct", "burst-4", "burst-8"]; + let shapes = [8_u16, 16, 17, 24, 32, 48]; + let mut table = String::new(); + writeln!( + table, + "{:<10} {:>6} {:>7} {:>13} {:>12} {:>10} {:>9}", + "profile", "chunks", "frames", "whole/no-fec", "whole/fec", "recovered", "beyond" + ) + .unwrap(); + + for cell in cells { + let loaded = profile(cell); + for &chunks in &shapes { + let tally = run(&loaded, chunks); + writeln!( + table, + "{:<10} {:>6} {:>7} {:>13} {:>12} {:>10} {:>9}", + cell, + chunks, + tally.frames, + tally.whole_without_parity, + tally.whole_with_parity, + tally.blocks_recovered, + tally.blocks_beyond_repair + ) + .unwrap(); + + if cell == "clean" { + assert_eq!(tally.whole_with_parity, tally.frames, "a clean link loses nothing"); + } + let burst = match cell { + "burst-4" => 4_usize, + "burst-8" => 8, + _ => continue, + }; + if EQUAL_BLOCK_SHAPES.contains(&chunks) { + let sizes = POLICY.video_block_sizes(chunks); + let per_block = burst.div_ceil(sizes.len()); + let m = usize::from(POLICY.video_parity_shards(sizes[0])); + if per_block <= m { + assert_eq!( + tally.whole_with_parity, tally.frames, + "{cell} over {chunks} chunks costs each of {} blocks at most {per_block} of m = {m}: no frame may be lost", + sizes.len() + ); + } + } + } + } + println!("{table}"); +} From 6285a9284191281d8e571f4eb17750cebb0baa84 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:20:06 +0200 Subject: [PATCH 03/13] Close the media FEC test gaps found before mutation Pins the block-boundary chunk and last-chunk length refusals in video recovery, the audio recovery refusals (packets one either side of the block, another session, wrong length, parity from different blocks), the remaining audio block identity rules (session, stream, timebase, duration), and the data-parity alternation inside a block. Co-Authored-By: Claude Opus 5.5 --- packages/cultnet-rs/tests/media_fec.rs | 68 +++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/cultnet-rs/tests/media_fec.rs b/packages/cultnet-rs/tests/media_fec.rs index 8e121b48..6c1b6c29 100644 --- a/packages/cultnet-rs/tests/media_fec.rs +++ b/packages/cultnet-rs/tests/media_fec.rs @@ -281,7 +281,8 @@ fn recovery_refuses_records_that_are_not_one_block() { 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(); + // 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. @@ -293,6 +294,59 @@ fn recovery_refuses_records_that_are_not_one_block() { )); 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).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).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).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] @@ -542,6 +596,18 @@ fn an_audio_block_is_four_contiguous_equal_length_packets() { let mut packets = audio_block(1, 20); packets[0].codec = "pcm".to_string(); assert!(protect_audio_block(&packets, &STANDARD).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).is_err(), "mixed sessions"); + let mut packets = audio_block(1, 20); + packets[2].stream_id = "other".to_string(); + assert!(protect_audio_block(&packets, &STANDARD).is_err(), "mixed streams"); + let mut packets = audio_block(1, 20); + packets[1].timebase_den = 44_100; + assert!(protect_audio_block(&packets, &STANDARD).is_err(), "mixed timebases"); + let mut packets = audio_block(1, 20); + packets[3].duration_ticks = 480; + assert!(protect_audio_block(&packets, &STANDARD).is_err(), "mixed durations"); } #[test] From 1da2a64fbcbf763d2eebeebed3814474564136b2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:26:49 +0200 Subject: [PATCH 04/13] Pin the media FEC and impairment rules that mutation left standing Adds the policy fields that must be non-zero, the inclusive shard limit at the decoder for video and audio parity, admission statistics, seeded jitter bounds, exact seeded loss, and arrival order for datagrams due together. Co-Authored-By: Claude Opus 5.5 --- .../cultnet-rs/tests/impairment_policy.rs | 61 +++++++++++++++++++ packages/cultnet-rs/tests/media_fec.rs | 6 ++ .../cultnet-rs/tests/media_stream_wire.rs | 16 +++++ 3 files changed, 83 insertions(+) diff --git a/packages/cultnet-rs/tests/impairment_policy.rs b/packages/cultnet-rs/tests/impairment_policy.rs index b962a241..2fc612b5 100644 --- a/packages/cultnet-rs/tests/impairment_policy.rs +++ b/packages/cultnet-rs/tests/impairment_policy.rs @@ -156,3 +156,64 @@ fn a_malformed_profile_is_refused_with_its_line() { delay_ms = x ").contains(":3:")); } + +#[test] +fn admission_counts_what_it_did_to_each_datagram() { + let mut scheduler = Scheduler::new( + Profile { duplicate_every: 2, reorder_every: 3, reorder_delay_ms: 10, ..Default::default() }, + 1, + ); + for value in 0..6 { + scheduler.admit(0, Direction::ClientToUpstream, &[value], client()); + } + // Datagrams 2, 4 and 6 are duplicated, 3 and 6 are reordered (6 is both). + assert_eq!(scheduler.stats.received, 6); + assert_eq!(scheduler.stats.duplicated, 3); + assert_eq!(scheduler.stats.reordered, 2); + assert_eq!(scheduler.queue.len(), 9); + + let mut stalled = Scheduler::new(Profile { stall_at_ms: 0, stall_for_ms: 10, ..Default::default() }, 1); + stalled.admit(5, Direction::UpstreamToClient, b"x", client()); + assert_eq!((stalled.stats.stalled, stalled.stats.dropped, stalled.queue.len()), (1, 1, 0)); +} + +#[test] +fn jitter_is_seeded_uniform_within_its_bound() { + let profile = Profile { delay_ms: 7, jitter_ms: 5, ..Default::default() }; + let mut scheduler = Scheduler::new(profile, 9); + let delays: Vec = (0..300).map(|_| scheduler.decide(0).delay_ms).collect(); + assert!(delays.iter().all(|delay| (7..=12).contains(delay)), "{delays:?}"); + for delay in 7..=12 { + assert!(delays.contains(&delay), "delay {delay} never drawn"); + } +} + +#[test] +fn the_loss_ceiling_is_inclusive() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("all.toml"); + std::fs::write(&path, "loss_basis_points = 10000\n").unwrap(); + let profile = Profile::load(&path).unwrap(); + let mut scheduler = Scheduler::new(profile, 1); + assert!((0..50).all(|_| scheduler.decide(0).drop), "10000 basis points drops everything"); +} + +/// The seeded stream is part of the contract: the same seed must drop the same +/// datagrams on every run, on every host. 100,000 draws of xorshift64 from seed +/// 42, taken modulo 10,000 and compared against 100, give 1,008. +#[test] +fn seeded_loss_drops_the_exact_datagrams_the_stream_dictates() { + let mut scheduler = Scheduler::new(Profile { loss_basis_points: 100, ..Default::default() }, 42); + let dropped = (0..100_000).filter(|_| scheduler.decide(0).drop).count(); + assert_eq!(dropped, 1_008); +} + +#[test] +fn datagrams_due_together_leave_in_arrival_order() { + let mut scheduler = Scheduler::new(Profile { delay_ms: 5, ..Default::default() }, 1); + for value in 0..4_u8 { + scheduler.admit(0, Direction::ClientToUpstream, &[value], client()); + } + let order: Vec = std::iter::from_fn(|| scheduler.pop_due(5)).map(|item| item.bytes[0]).collect(); + assert_eq!(order, vec![0, 1, 2, 3]); +} diff --git a/packages/cultnet-rs/tests/media_fec.rs b/packages/cultnet-rs/tests/media_fec.rs index 6c1b6c29..b8ac75c3 100644 --- a/packages/cultnet-rs/tests/media_fec.rs +++ b/packages/cultnet-rs/tests/media_fec.rs @@ -768,6 +768,12 @@ fn a_custom_policy_sizes_blocks_by_its_own_fields() { 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 no_divisor = MediaFecPolicy { video_parity_divisor: 0, ..STANDARD }; + assert!(protect_video_frame(&frame(1, 2, 10, 10), &no_divisor).is_err()); + let no_audio = MediaFecPolicy { audio_data_shards: 0, ..STANDARD }; + assert!(protect_audio_block(&audio_block(1, 10), &no_audio).is_err()); + let no_audio_parity = MediaFecPolicy { audio_parity_shards: 0, ..STANDARD }; + assert!(protect_audio_block(&audio_block(1, 10), &no_audio_parity).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 }; diff --git a/packages/cultnet-rs/tests/media_stream_wire.rs b/packages/cultnet-rs/tests/media_stream_wire.rs index 8137a7cc..1242a511 100644 --- a/packages/cultnet-rs/tests/media_stream_wire.rs +++ b/packages/cultnet-rs/tests/media_stream_wire.rs @@ -383,3 +383,19 @@ fn a_malformed_audio_parity_record_is_refused_with_its_reason() { assert!(error.contains(expected), "expected {expected:?} in {error:?}"); } } + +/// The shard limit is inclusive: a shard of exactly the largest size is +/// admitted, one byte more is not. +#[test] +fn a_parity_shard_at_the_size_limit_is_admitted() { + use cultnet_rs::MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES as MAX; + let mut video = video_parity(); + video.shard_payload_bytes = MAX as u32; + video.last_chunk_payload_bytes = MAX as u32; + video.payload = vec![0; MAX]; + assert!(decodes(GameCultMediaWireRecord::VideoParity(video)).is_ok()); + let mut audio = audio_parity(); + audio.shard_payload_bytes = MAX as u32; + audio.payload = vec![0; MAX]; + assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok()); +} From 4b85a173e866a667208fea1bf9ebfc599ad8b66a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:35:18 +0200 Subject: [PATCH 05/13] Drop the redundant complete-block short circuit from FEC recovery A block with every data shard present passes through the code as a no-op and yields no recovered records, so the early return in both recovery functions did nothing the code did not already do. Mutation showed both survive removal. The audio payload-length rule is now pinned by its message, since the codec's own shard-size error would otherwise mask its removal. Co-Authored-By: Claude Opus 5.5 --- packages/cultnet-rs/src/media_fec.rs | 6 ------ packages/cultnet-rs/tests/media_fec.rs | 3 ++- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/cultnet-rs/src/media_fec.rs b/packages/cultnet-rs/src/media_fec.rs index 29f35e8c..a2752514 100644 --- a/packages/cultnet-rs/src/media_fec.rs +++ b/packages/cultnet-rs/src/media_fec.rs @@ -450,9 +450,6 @@ pub fn recover_video_block( } let missing: Vec = (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() @@ -617,9 +614,6 @@ pub fn recover_audio_block( } let missing: Vec = (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() diff --git a/packages/cultnet-rs/tests/media_fec.rs b/packages/cultnet-rs/tests/media_fec.rs index b8ac75c3..e89e58ca 100644 --- a/packages/cultnet-rs/tests/media_fec.rs +++ b/packages/cultnet-rs/tests/media_fec.rs @@ -592,7 +592,8 @@ fn an_audio_block_is_four_contiguous_equal_length_packets() { 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 error = protect_audio_block(&packets, &STANDARD).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).is_err(), "mixed codecs"); From bd58963701cab803347aff73642483b869ae90bc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 23:11:20 +0200 Subject: [PATCH 06/13] Pin each disjunct and edge of the parity record validators The audio parity validator now has a refusal row for every field in its identity and timing disjunctions, and both parity validators have tests for the inclusive edges: 256 shards, the video block that would make 257, and a deadline equal to the base presentation time. cargo-mutants over the diff had left these operators standing. Co-Authored-By: Claude Opus 5.5 --- .../cultnet-rs/tests/media_stream_wire.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/cultnet-rs/tests/media_stream_wire.rs b/packages/cultnet-rs/tests/media_stream_wire.rs index 1242a511..4007f603 100644 --- a/packages/cultnet-rs/tests/media_stream_wire.rs +++ b/packages/cultnet-rs/tests/media_stream_wire.rs @@ -350,6 +350,12 @@ fn a_malformed_video_parity_record_is_refused_with_its_reason() { ("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)), + ("256 shards", Box::new(|r| { + r.block_data_count = 256; + r.chunk_count = 300; + r.parity_count = 1; + r.parity_index = 0; + })), ("deadline_ticks", Box::new(|r| r.deadline_ticks = -1)), ]; for (expected, corrupt) in cases { @@ -375,6 +381,10 @@ fn a_malformed_audio_parity_record_is_refused_with_its_reason() { })), ("declared shard length", Box::new(|r| { r.payload.pop(); })), ("codec", Box::new(|r| r.codec = String::new())), + ("stream_id", Box::new(|r| r.stream_id = String::new())), + ("stream_id", Box::new(|r| r.session_id = String::new())), + ("timing", Box::new(|r| r.timebase_num = 0)), + ("timing", Box::new(|r| r.timebase_den = 0)), ]; for (expected, corrupt) in cases { let mut record = audio_parity(); @@ -399,3 +409,24 @@ fn a_parity_shard_at_the_size_limit_is_admitted() { audio.payload = vec![0; MAX]; assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok()); } + +/// The 256-shard ceiling and the deadline floor are inclusive on the side that +/// keeps a valid record valid. +#[test] +fn records_on_the_edge_of_each_bound_are_admitted() { + let mut video = video_parity(); + video.chunk_count = 300; + video.block_data_count = 254; + video.parity_count = 2; + video.parity_index = 1; + assert!(decodes(GameCultMediaWireRecord::VideoParity(video)).is_ok(), "254 + 2 = 256 shards"); + + let mut audio = audio_parity(); + audio.data_shard_count = 254; + audio.parity_shard_count = 2; + assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok(), "254 + 2 = 256 shards"); + + let mut audio = audio_parity(); + audio.deadline_ticks = audio.base_pts_ticks; + assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok(), "a deadline at the base pts"); +} From a813c09b6d5d4cf245807ec5a0382fbea2f5386d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 02:50:11 +0200 Subject: [PATCH 07/13] Media FEC: enforce the datagram invariant at the producer, refuse overflowing audio blocks The 720-byte shard cap stood in for "an encoded record fits one datagram" and failed it at legal maximum-width identifiers. The invariant is now checked where records are produced: protect_video_frame and protect_audio_block take the producer's provenance, encode each record, and refuse the block when one exceeds MediaFecPolicy::max_wire_bytes (default 1431, the IPv4/1500 MTU budget; smaller paths pass less). The fixed cap is deleted; it contradicted a larger budget and bounded nothing the decoder does not already hold in memory. Audio block arithmetic lives in one function, audio_slot, used by the protect path, the wire validator and recovery. The validator refuses a parity record whose last packet id or pts overflows; recovery uses the same checked function instead of adding. Tests pin the policy 256-shard bounds, block identity on block_count and block_data_start, the k=16 m=4 known answer, and the burst property (worst always-recovering burst = blocks x smallest m for 1..=399 chunks), and the wire behaviour of the media channel profile (delivery and expiry). Docs state the rs-gf256-v1 construction and that audio playout gates on pts. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-rs/src/media_fec.rs | 209 +++++--- packages/cultnet-rs/src/media_stream_wire.rs | 24 +- .../tests/media_channel_delivery.rs | 113 +++++ packages/cultnet-rs/tests/media_fec.rs | 456 ++++++++++++++---- packages/cultnet-rs/tests/media_fec_loss.rs | 13 +- .../cultnet-rs/tests/media_stream_wire.rs | 36 +- 6 files changed, 657 insertions(+), 194 deletions(-) diff --git a/packages/cultnet-rs/src/media_fec.rs b/packages/cultnet-rs/src/media_fec.rs index a2752514..88a3b811 100644 --- a/packages/cultnet-rs/src/media_fec.rs +++ b/packages/cultnet-rs/src/media_fec.rs @@ -15,10 +15,35 @@ //! 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. +//! crate. The library is not the contract: the construction below is, and +//! `tests/media_fec.rs` pins the parity bytes of known inputs, so a library that +//! changes the matrix fails there instead of on the wire, and a decoder in +//! another runtime has vectors to meet. +//! +//! # The `rs-gf256-v1` construction +//! +//! Enough to implement it in another runtime without this crate. +//! +//! * The field is GF(2^8) over the polynomial `x^8 + x^4 + x^3 + x^2 + 1` +//! (`0x11D`). Addition is XOR; multiplication is carry-less multiplication +//! reduced by `0x11D`. +//! * For a block of `k` data shards and `m` parity shards, let `n = k + m` +//! (`n <= 256`) and build the `n x k` Vandermonde matrix `V` whose entry in +//! row `r`, column `c` is `r^c`, where the row number `r` (0 through +//! `n - 1`) is itself read as a field element and `0^0 = 1`. The evaluation +//! points are the integers 0 through `n - 1`, not powers of a generator. +//! * The encoding matrix is `E = V * inverse(top)`, where `top` is the first +//! `k` rows of `V`. Its first `k` rows are the identity, so data shards are +//! sent unchanged; its last `m` rows generate parity. +//! * Every shard of a block has the same length, `shard_payload_bytes`. A +//! shorter final video chunk is zero-padded to that length before encoding. +//! Byte `j` of parity shard `p` is the XOR over data shards `i` of +//! `E[k + p][i] * data[i][j]`. +//! * Any `k` surviving shards recover the block: invert the `k x k` submatrix +//! of `E` for the surviving rows and multiply. +//! +//! The known-answer vectors in `tests/media_fec.rs` were checked against an +//! independent implementation of exactly this text. //! //! # What a block is //! @@ -31,9 +56,28 @@ //! * 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. +//! # One datagram +//! +//! Every record the protect functions return, data or parity, fits one +//! datagram once wrapped for the wire, so parity never reintroduces the +//! fragment amplification it exists to remove. The protect functions enforce +//! that against [`MediaFecPolicy::max_wire_bytes`]: they encode each record +//! with the caller's provenance and refuse the block if any is larger. +//! The default budget is [`MEDIA_FEC_MAX_WIRE_BYTES`], an IPv4 path with a +//! 1500-byte MTU. Pass a smaller budget when the path carries less: IPv6 over +//! 1500 leaves a UDP payload of 1452 bytes, and a WireGuard or Tailscale +//! tunnel about 1252, so subtract the 41 bytes of RUDP header and channel name +//! from the path's UDP payload limit. +//! +//! The budget is measured with the provenance given, so a producer whose +//! `stored_at` or identifiers grow later must pass the widest it will use. +//! +//! # Audio playout +//! +//! A recovered audio packet has the block's latest `deadline_ticks`, so a +//! packet recovered early in the block reports a deadline later than its own. +//! Audio consumers must gate playout on `pts_ticks`, which recovery derives +//! exactly, and use `deadline_ticks` only to give up on the block as a whole. //! //! # What this module does not own //! @@ -51,8 +95,9 @@ use crate::media_stream_contracts::{ 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, + GAMECULT_MEDIA_CHANNEL, GameCultMediaWireRecord, MediaWireProvenance, + encode_media_wire_record, validate_audio_parity_record, validate_audio_record, + validate_video_parity_record, validate_video_record, }; use crate::rudp::RUDP_FIXED_HEADER_BYTES; @@ -64,24 +109,15 @@ 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). +/// The default datagram budget: the most a wrapped media record may occupy so +/// that the RUDP header and the channel name still leave it in one IPv4 +/// datagram on a 1500-byte MTU (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. +/// A producer's choice of `(k, m)` and datagram budget. +/// [`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. @@ -95,16 +131,21 @@ pub struct MediaFecPolicy { pub audio_data_shards: u16, /// Parity shards per audio block. pub audio_parity_shards: u16, + /// The most a wrapped record may occupy. The protect functions refuse a + /// block with any record over it. See the module docs for smaller paths. + pub max_wire_bytes: usize, } impl MediaFecPolicy { - /// Video `k <= 16` with `m = max(2, ceil(k / 4))`; audio 4+2. + /// Video `k <= 16` with `m = max(2, ceil(k / 4))`; audio 4+2; the IPv4 + /// datagram budget. 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, + max_wire_bytes: MEDIA_FEC_MAX_WIRE_BYTES, }; /// `m` for a video block of `data_shards` chunks. @@ -230,6 +271,40 @@ fn from_anyhow(error: anyhow::Error) -> MediaFecError { MediaFecError::Invalid(error.to_string()) } +/// Refuses a record that would not fit one datagram once wrapped. +fn fits_datagram( + record: &GameCultMediaWireRecord, + provenance: MediaWireProvenance<'_>, + policy: &MediaFecPolicy, +) -> Result<(), MediaFecError> { + let wire = encode_media_wire_record(record, provenance).map_err(from_anyhow)?; + if wire.len() > policy.max_wire_bytes { + return Err(MediaFecError::invalid(format!( + "a {} record wraps to {} bytes, over the {}-byte datagram budget: cut chunks smaller or raise max_wire_bytes", + record.schema_id(), + wire.len(), + policy.max_wire_bytes + ))); + } + Ok(()) +} + +/// Where slot `slot` of an audio block lands: its packet id and presentation +/// time, or `None` if either overflows. The one place block arithmetic lives; +/// the protect path, the wire validator and recovery all ask it. +pub(crate) fn audio_slot( + base_packet_id: u64, + base_pts_ticks: i64, + packet_duration_ticks: u32, + slot: u64, +) -> Option<(u64, i64)> { + let packet_id = base_packet_id.checked_add(slot)?; + let pts_ticks = i64::from(packet_duration_ticks) + .checked_mul(i64::try_from(slot).ok()?)? + .checked_add(base_pts_ticks)?; + Some((packet_id, pts_ticks)) +} + // --------------------------------------------------------------------------- // Video // --------------------------------------------------------------------------- @@ -247,10 +322,12 @@ fn from_anyhow(error: anyhow::Error) -> MediaFecError { /// 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`]. +/// last no longer), or if any resulting record, wrapped with `provenance`, +/// exceeds `policy.max_wire_bytes`. pub fn protect_video_frame( chunks: &[GameCultMediaVideoAccessUnitRecord], policy: &MediaFecPolicy, + provenance: MediaWireProvenance<'_>, ) -> Result, MediaFecError> { policy.validate()?; let first = chunks @@ -273,12 +350,6 @@ pub fn protect_video_frame( "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", @@ -347,7 +418,11 @@ pub fn protect_video_frame( lanes.push(interleave(data_records.collect(), parity_records)); start += size; } - Ok(rotate(lanes)) + let records = rotate(lanes); + for record in &records { + fits_datagram(record, provenance, policy)?; + } + Ok(records) } fn same_frame(a: &GameCultMediaVideoAccessUnitRecord, b: &GameCultMediaVideoAccessUnitRecord) -> bool { @@ -508,9 +583,12 @@ fn same_block(a: &GameCultMediaVideoParityShardRecord, b: &GameCultMediaVideoPar /// 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. +/// Errors if a parity record, wrapped with `provenance`, exceeds +/// `policy.max_wire_bytes`; the packets are smaller than their parity. pub fn protect_audio_block( packets: &[GameCultMediaAudioPacketRecord], policy: &MediaFecPolicy, + provenance: MediaWireProvenance<'_>, ) -> Result, MediaFecError> { policy.validate()?; if packets.len() != usize::from(policy.audio_data_shards) { @@ -524,18 +602,15 @@ pub fn protect_audio_block( 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)); + let expected = + audio_slot(first.packet_id, first.pts_ticks, first.duration_ticks, index as u64); 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 + || Some((packet.packet_id, packet.pts_ticks)) != expected || packet.payload.len() != shard_bytes { return Err(MediaFecError::invalid( @@ -543,14 +618,10 @@ pub fn protect_audio_block( )); } } - 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> = 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))? + let parity: Vec = + parity_shards(&data, usize::from(policy.audio_parity_shards))? .into_iter() .enumerate() .map(|(parity_index, payload)| GameCultMediaAudioParityShardRecord { @@ -570,13 +641,20 @@ pub fn protect_audio_block( shard_payload_bytes: shard_bytes as u32, payload, }) - .collect()) + .collect(); + // A parity record repeats every field of a packet and adds the scheme and + // the geometry, so it is the larger of the block's records: if it fits, the + // packets do. + for shard in &parity { + fits_datagram(&GameCultMediaWireRecord::AudioParity(shard.clone()), provenance, policy)?; + } + Ok(parity) } /// 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. +/// arithmetic and its deadline from the block's, the latest in the block: +/// gate playout on `pts_ticks`, not `deadline_ticks`. Errors as [`recover_video_block`] does. pub fn recover_audio_block( parity: &[GameCultMediaAudioParityShardRecord], data: &[GameCultMediaAudioPacketRecord], @@ -615,21 +693,30 @@ pub fn recover_audio_block( let missing: Vec = (0..k).filter(|&slot| shards[slot].is_none()).collect(); let recovered = recover_data_shards(k, shards)?; - Ok(missing + 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(), + .map(|slot| { + let (packet_id, pts_ticks) = audio_slot( + geometry.base_packet_id, + geometry.base_pts_ticks, + geometry.packet_duration_ticks, + slot as u64, + ) + .ok_or_else(|| MediaFecError::invalid("audio block runs past the packet id or pts range"))?; + Ok(GameCultMediaAudioPacketRecord { + stream_id: geometry.stream_id.clone(), + session_id: geometry.session_id.clone(), + packet_id, + codec: geometry.codec.clone(), + pts_ticks, + 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()) + .collect() } fn same_audio_block(a: &GameCultMediaAudioParityShardRecord, b: &GameCultMediaAudioParityShardRecord) -> bool { diff --git a/packages/cultnet-rs/src/media_stream_wire.rs b/packages/cultnet-rs/src/media_stream_wire.rs index b1119ca0..5c401571 100644 --- a/packages/cultnet-rs/src/media_stream_wire.rs +++ b/packages/cultnet-rs/src/media_stream_wire.rs @@ -28,7 +28,7 @@ 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_fec::{MEDIA_FEC_SCHEME_RS_GF256_V1, audio_slot}; use crate::media_stream_contracts::{ GAMECULT_MEDIA_AUDIO_PACKET_SCHEMA, GAMECULT_MEDIA_AUDIO_PARITY_SHARD_SCHEMA, GAMECULT_MEDIA_RECEIVER_FEEDBACK_SCHEMA, GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA, @@ -403,11 +403,9 @@ pub fn validate_video_parity_record(record: &GameCultMediaVideoParityShardRecord "video parity media record block exceeds 256 shards" )); } - if record.shard_payload_bytes == 0 - || record.shard_payload_bytes as usize > MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES - { + if record.shard_payload_bytes == 0 { return Err(anyhow!( - "video parity media record shard_payload_bytes must be in 1..={MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES}" + "video parity media record shard_payload_bytes must be non-zero" )); } if record.last_chunk_payload_bytes == 0 @@ -454,11 +452,21 @@ pub fn validate_audio_parity_record(record: &GameCultMediaAudioParityShardRecord { 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 + if audio_slot( + record.base_packet_id, + record.base_pts_ticks, + record.packet_duration_ticks, + u64::from(record.data_shard_count) - 1, + ) + .is_none() { return Err(anyhow!( - "audio parity media record shard_payload_bytes must be in 1..={MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES}" + "audio parity media record block runs past the packet id or pts range" + )); + } + if record.shard_payload_bytes == 0 { + return Err(anyhow!( + "audio parity media record shard_payload_bytes must be non-zero" )); } if record.payload.len() != record.shard_payload_bytes as usize { diff --git a/packages/cultnet-rs/tests/media_channel_delivery.rs b/packages/cultnet-rs/tests/media_channel_delivery.rs index 499be0d3..def38d4f 100644 --- a/packages/cultnet-rs/tests/media_channel_delivery.rs +++ b/packages/cultnet-rs/tests/media_channel_delivery.rs @@ -90,3 +90,116 @@ fn the_other_channels_keep_their_fixed_delivery() { assert_eq!(delivery_of("schema"), CultNetTransportDelivery::Reliable); assert_eq!(delivery_of("realtime"), CultNetTransportDelivery::Unreliable); } + +// --------------------------------------------------------------------------- +// What the media channel puts on the wire +// --------------------------------------------------------------------------- + +use std::thread::sleep; +use std::time::Duration; + +use cultnet_rs::{ + CultNetRudpPacket, CultNetRudpSession, CultNetRudpSessionOptions, decode_rudp_packet, + encode_rudp_packet, +}; + +/// A connected client transport and the raw socket standing in for its peer. +struct Link { + client: CultNetRudpSocketTransportConnection, + peer: UdpSocket, +} + +impl Link { + fn open(delivery: Option, expire_after_ms: Option) -> Self { + let peer = UdpSocket::bind("127.0.0.1:0").expect("binds"); + peer.set_read_timeout(Some(Duration::from_millis(500))).unwrap(); + let socket = UdpSocket::bind("127.0.0.1:0").expect("binds"); + socket.set_read_timeout(Some(Duration::from_millis(300))).unwrap(); + let mut options = CultNetRudpSocketTransportOptions::client( + "test-media", + socket, + peer.local_addr().unwrap(), + 7, + ); + options.media_delivery = delivery; + options.media_reliable_expire_after_ms = expire_after_ms; + options.resend_delay_ms = 10; + let mut client = CultNetRudpSocketTransportConnection::new(options).expect("opens"); + + client.connect(Vec::new()).expect("sends connect"); + let mut wire = vec![0_u8; 2_048]; + let (received, client_addr) = peer.recv_from(&mut wire).expect("the connect arrives"); + let connect = decode_rudp_packet(&wire[..received]).expect("decodes"); + let mut server = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 7, + ..CultNetRudpSessionOptions::default() + }); + let accept = server.accept_connect(&connect, 0, Vec::new()).expect("accepts"); + peer.send_to(&encode_rudp_packet(&accept).unwrap(), client_addr).unwrap(); + client.receive_once().expect("takes the accept"); + assert!(client.connected()); + let mut link = Self { client, peer }; + link.datagrams(Duration::from_millis(100)); + link + } + + /// Everything the peer receives within `window`. + fn datagrams(&mut self, window: Duration) -> Vec { + self.peer.set_read_timeout(Some(window)).unwrap(); + let mut packets = Vec::new(); + let mut wire = vec![0_u8; 2_048]; + while let Ok((received, _)) = self.peer.recv_from(&mut wire) { + packets.push(decode_rudp_packet(&wire[..received]).expect("decodes")); + } + packets + } + + fn send_media(&mut self, window: Duration) -> Vec { + self.client.send("media", vec![1, 2, 3]).expect("sends"); + self.datagrams(window) + } +} + +fn media_only(packets: Vec) -> Vec { + packets.into_iter().filter(|packet| packet.channel_id == "media").collect() +} + +/// The channel the profile describes as reliable is sent reliable, unordered +/// and unsequenced; asking for an unreliable lane sends it unreliable. +#[test] +fn the_media_channel_is_sent_with_the_delivery_the_profile_advertises() { + let mut reliable = Link::open(None, None); + let sent = media_only(reliable.send_media(Duration::from_millis(100))); + assert_eq!(sent.len(), 1); + assert!(sent[0].reliable, "the default media lane is reliable"); + assert!(!sent[0].ordered && !sent[0].sequenced, "media is neither ordered nor sequenced"); + + let mut unreliable = Link::open(Some(CultNetTransportDelivery::Unreliable), None); + let sent = media_only(unreliable.send_media(Duration::from_millis(100))); + assert_eq!(sent.len(), 1); + assert!(!sent[0].reliable, "an unreliable media lane is not retransmitted"); + assert!(!sent[0].ordered && !sent[0].sequenced); +} + +/// A reliable media packet stops being retransmitted once its expiry passes, +/// and keeps being retransmitted when it has none. +#[test] +fn the_media_channels_expiry_is_applied_to_its_packets() { + let mut expiring = Link::open(None, Some(20)); + assert_eq!(media_only(expiring.send_media(Duration::from_millis(50))).len(), 1); + sleep(Duration::from_millis(150)); + expiring.client.poll_resends().unwrap(); + assert!( + media_only(expiring.datagrams(Duration::from_millis(150))).is_empty(), + "an expired media packet is dropped, not retransmitted" + ); + + let mut patient = Link::open(None, None); + assert_eq!(media_only(patient.send_media(Duration::from_millis(50))).len(), 1); + sleep(Duration::from_millis(150)); + patient.client.poll_resends().unwrap(); + assert!( + !media_only(patient.datagrams(Duration::from_millis(150))).is_empty(), + "without an expiry the unacknowledged packet is retransmitted" + ); +} diff --git a/packages/cultnet-rs/tests/media_fec.rs b/packages/cultnet-rs/tests/media_fec.rs index e89e58ca..11cc82d9 100644 --- a/packages/cultnet-rs/tests/media_fec.rs +++ b/packages/cultnet-rs/tests/media_fec.rs @@ -8,7 +8,7 @@ use cultnet_rs::{ GameCultMediaAudioPacketRecord, GameCultMediaAudioParityShardRecord, GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord, - GameCultMediaWireRecord, MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES, MEDIA_FEC_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, @@ -141,7 +141,7 @@ fn a_frame_splits_into_ceil_n_over_16_near_equal_blocks() { /// `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 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"); @@ -193,7 +193,7 @@ fn every_le_m_erasure_recovers_exactly_and_m_plus_one_is_refused_for_every_video #[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"); + 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(); @@ -232,7 +232,7 @@ fn every_le_m_erasure_recovers_exactly_and_m_plus_one_is_refused_for_audio_4_plu #[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()); + 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!( @@ -244,7 +244,7 @@ fn recovery_reports_how_short_the_block_is() { #[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 (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(); @@ -254,7 +254,7 @@ fn a_recovered_short_final_chunk_has_its_true_length() { #[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 (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(); @@ -265,14 +265,14 @@ fn duplicate_shards_neither_count_twice_nor_break_recovery() { #[test] fn a_complete_block_recovers_nothing() { let original = frame(6, 4, 50, 50); - let (data, parity) = split(protect_video_frame(&original, &STANDARD).unwrap()); + 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).unwrap()); + 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); @@ -304,7 +304,7 @@ fn recovery_refuses_records_that_are_not_one_block() { #[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).unwrap()); + 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(_)))); @@ -312,7 +312,7 @@ fn a_final_chunk_of_the_wrong_length_is_refused() { #[test] fn within_a_block_data_and_parity_alternate() { - let records = protect_video_frame(&frame(9, 4, 20, 20), &STANDARD).unwrap(); + let records = protect_video_frame(&frame(9, 4, 20, 20), &STANDARD, provenance()).unwrap(); let kinds: String = records .iter() .map(|record| match record { @@ -327,7 +327,7 @@ fn within_a_block_data_and_parity_alternate() { #[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).unwrap(); + 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()] { @@ -352,7 +352,7 @@ fn audio_recovery_refuses_packets_that_are_not_from_the_block() { #[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 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); @@ -397,7 +397,7 @@ fn known_answer_audio_parity_bytes() { 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(); + let parity = protect_audio_block(&block, &STANDARD, provenance()).unwrap(); assert_eq!(parity[0].fec_scheme, "rs-gf256-v1"); assert_eq!( hex(&parity[0].payload), @@ -420,7 +420,7 @@ fn known_answer_video_parity_bytes_pad_a_short_final_chunk_with_zeros() { 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()); + 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"); @@ -432,13 +432,13 @@ fn known_answer_video_parity_bytes_pad_a_short_final_chunk_with_zeros() { 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() + 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).unwrap(), - protect_audio_block(&block, &STANDARD).unwrap() + protect_audio_block(&block, &STANDARD, provenance()).unwrap(), + protect_audio_block(&block, &STANDARD, provenance()).unwrap() ); } @@ -462,71 +462,152 @@ fn the_datagram_budget_is_the_rudp_fragment_the_media_channel_leaves() { 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; +/// 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-"), + } +} -#[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); +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 { + 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); } - 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() - ); - } + chunks +} - let mut packets = audio_block(u64::MAX - 8, MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES); - for packet in &mut packets { +fn widest_audio(payload_bytes: usize) -> Vec { + 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; } - 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. + 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 = (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 + 96 > MEDIA_FEC_MAX_WIRE_BYTES, - "the shard limit wastes {} bytes of datagram", - MEDIA_FEC_MAX_WIRE_BYTES - largest + 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 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(_)) - )); +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 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()); + 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()); } // --------------------------------------------------------------------------- @@ -537,37 +618,37 @@ fn a_shard_over_the_limit_is_refused_at_the_producer_and_at_the_decoder() { 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(); + 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).is_err(), "parity would misstate the deadline"); + 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).is_err()); + 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).is_err(), "a missing chunk"); + 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).is_err(), "a repeated chunk"); + 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).is_err(), "a short chunk that is not last"); + 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).is_err(), "a last chunk longer than the shard"); + assert!(protect_video_frame(&chunks, &STANDARD, provenance()).is_err(), "a last chunk longer than the shard"); - assert!(protect_video_frame(&[], &STANDARD).is_err()); + assert!(protect_video_frame(&[], &STANDARD, provenance()).is_err()); } #[test] @@ -576,45 +657,45 @@ fn chunks_may_arrive_in_any_order() { let mut shuffled = original.clone(); shuffled.reverse(); assert_eq!( - protect_video_frame(&original, &STANDARD).unwrap(), - protect_video_frame(&shuffled, &STANDARD).unwrap() + 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).is_err(), "three 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).is_err(), "a gap in packet ids"); + 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).is_err(), "a gap in presentation time"); + 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).unwrap_err(); + 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).is_err(), "mixed codecs"); + 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).is_err(), "mixed sessions"); + 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).is_err(), "mixed streams"); + 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).is_err(), "mixed timebases"); + 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).is_err(), "mixed durations"); + 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).unwrap()); + 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); @@ -628,7 +709,7 @@ fn parity_carries_its_frames_deadline_unchanged() { 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(); + 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()]; @@ -650,7 +731,7 @@ fn audio_parity_carries_the_latest_deadline_in_its_block_and_recovery_uses_it() 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 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}"); @@ -672,7 +753,7 @@ fn equal_blocks_send_one_record_per_block_before_any_block_repeats() { 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 records = protect_video_frame(&frame(70, n, 20, 20), &STANDARD, provenance()).unwrap(); let order: Vec = records.iter().map(|r| block_of(r, &sizes)).collect(); for window in order.windows(blocks) { let mut seen = window.to_vec(); @@ -691,7 +772,7 @@ 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 records = protect_video_frame(&frame(71, n, 20, 20), &STANDARD, provenance()).unwrap(); let order: Vec = 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 }; @@ -716,7 +797,7 @@ fn unequal_blocks_keep_every_block_spaced_by_the_blocks_still_sending() { 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(); + let records = protect_video_frame(&original, &STANDARD, provenance()).unwrap(); // The network eats a burst of four consecutive records. let arrived: Vec = records @@ -759,7 +840,7 @@ fn a_custom_policy_sizes_blocks_by_its_own_fields() { 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()); + 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); @@ -768,23 +849,23 @@ fn a_custom_policy_sizes_blocks_by_its_own_fields() { #[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()); + 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).is_err()); + assert!(protect_video_frame(&frame(1, 2, 10, 10), &no_divisor, provenance()).is_err()); let no_audio = MediaFecPolicy { audio_data_shards: 0, ..STANDARD }; - assert!(protect_audio_block(&audio_block(1, 10), &no_audio).is_err()); + assert!(protect_audio_block(&audio_block(1, 10), &no_audio, provenance()).is_err()); let no_audio_parity = MediaFecPolicy { audio_parity_shards: 0, ..STANDARD }; - assert!(protect_audio_block(&audio_block(1, 10), &no_audio_parity).is_err()); + assert!(protect_audio_block(&audio_block(1, 10), &no_audio_parity, provenance()).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()); + 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).is_err()); + 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).unwrap()); - let audio = protect_audio_block(&audio_block(5, 25), &STANDARD).unwrap(); + 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::(&bytes).unwrap(), shard); @@ -794,3 +875,178 @@ fn parity_records_survive_the_positional_encoding_unchanged() { assert_eq!(rmp_serde::from_slice::(&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 { + (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 = 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 = + 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 = 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() + ); + } +} diff --git a/packages/cultnet-rs/tests/media_fec_loss.rs b/packages/cultnet-rs/tests/media_fec_loss.rs index dd40b443..19b8fd20 100644 --- a/packages/cultnet-rs/tests/media_fec_loss.rs +++ b/packages/cultnet-rs/tests/media_fec_loss.rs @@ -18,7 +18,7 @@ use std::path::Path; use cultnet_rs::{ GameCultMediaVideoAccessUnitRecord, GameCultMediaWireRecord, MediaFecError, MediaFecPolicy, - protect_video_frame, recover_video_block, + MediaWireProvenance, protect_video_frame, recover_video_block, }; use policy::{Profile, Scheduler}; @@ -26,6 +26,15 @@ const POLICY: MediaFecPolicy = MediaFecPolicy::STANDARD; const SEED: u64 = 20_260_930; const FRAMES: u64 = 200; +fn provenance() -> MediaWireProvenance<'static> { + MediaWireProvenance { + stored_at: "unix:1700000000000", + runtime_id: "raven-muninn-primary", + role: "muninn.media", + producer: "muninn", + } +} + fn frame(frame_id: u64, chunk_count: u16) -> Vec { (0..chunk_count) .map(|index| GameCultMediaVideoAccessUnitRecord { @@ -67,7 +76,7 @@ fn run(profile: &Profile, chunk_count: u16) -> Tally { for frame_id in 0..FRAMES { let original = frame(frame_id, chunk_count); - let records = protect_video_frame(&original, &POLICY).unwrap(); + let records = protect_video_frame(&original, &POLICY, provenance()).unwrap(); let survivors: Vec = records .into_iter() .filter(|_| !scheduler.decide(0).drop) diff --git a/packages/cultnet-rs/tests/media_stream_wire.rs b/packages/cultnet-rs/tests/media_stream_wire.rs index 4007f603..762fa78d 100644 --- a/packages/cultnet-rs/tests/media_stream_wire.rs +++ b/packages/cultnet-rs/tests/media_stream_wire.rs @@ -342,10 +342,6 @@ fn a_malformed_video_parity_record_is_refused_with_its_reason() { 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))), @@ -375,9 +371,10 @@ fn a_malformed_audio_parity_record_is_refused_with_its_reason() { ("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]; + ("packet id or pts range", Box::new(|r| r.base_packet_id = u64::MAX - 2)), + ("packet id or pts range", Box::new(|r| { + r.base_pts_ticks = i64::MAX - 960 * 3 + 1; + r.deadline_ticks = i64::MAX; })), ("declared shard length", Box::new(|r| { r.payload.pop(); })), ("codec", Box::new(|r| r.codec = String::new())), @@ -394,22 +391,6 @@ fn a_malformed_audio_parity_record_is_refused_with_its_reason() { } } -/// The shard limit is inclusive: a shard of exactly the largest size is -/// admitted, one byte more is not. -#[test] -fn a_parity_shard_at_the_size_limit_is_admitted() { - use cultnet_rs::MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES as MAX; - let mut video = video_parity(); - video.shard_payload_bytes = MAX as u32; - video.last_chunk_payload_bytes = MAX as u32; - video.payload = vec![0; MAX]; - assert!(decodes(GameCultMediaWireRecord::VideoParity(video)).is_ok()); - let mut audio = audio_parity(); - audio.shard_payload_bytes = MAX as u32; - audio.payload = vec![0; MAX]; - assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok()); -} - /// The 256-shard ceiling and the deadline floor are inclusive on the side that /// keeps a valid record valid. #[test] @@ -429,4 +410,13 @@ fn records_on_the_edge_of_each_bound_are_admitted() { let mut audio = audio_parity(); audio.deadline_ticks = audio.base_pts_ticks; assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok(), "a deadline at the base pts"); + + let mut audio = audio_parity(); + audio.base_packet_id = u64::MAX - 3; + assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok(), "the last packet id is u64::MAX"); + + let mut audio = audio_parity(); + audio.base_pts_ticks = i64::MAX - 960 * 3; + audio.deadline_ticks = i64::MAX; + assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok(), "the last pts is i64::MAX"); } From 51a6a6c1c11e956ca493e51dd5d4139749ff7bbf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 02:51:45 +0200 Subject: [PATCH 08/13] Media channel test: observe expiry through the next send, where the session reclaims packets Co-Authored-By: Claude Sonnet 5.5 --- .../tests/media_channel_delivery.rs | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/cultnet-rs/tests/media_channel_delivery.rs b/packages/cultnet-rs/tests/media_channel_delivery.rs index def38d4f..d902e120 100644 --- a/packages/cultnet-rs/tests/media_channel_delivery.rs +++ b/packages/cultnet-rs/tests/media_channel_delivery.rs @@ -181,25 +181,30 @@ fn the_media_channel_is_sent_with_the_delivery_the_profile_advertises() { assert!(!sent[0].ordered && !sent[0].sequenced); } -/// A reliable media packet stops being retransmitted once its expiry passes, -/// and keeps being retransmitted when it has none. +/// A reliable media packet with an expiry is dropped once it has passed, and +/// one without is retransmitted until acknowledged. The session reclaims +/// expired packets on the next send, so the probe sends a second packet. +fn first_packet_is_resent_after_a_later_send(expire_after_ms: Option) -> bool { + let mut link = Link::open(None, expire_after_ms); + let first = media_only(link.send_media(Duration::from_millis(50))); + assert_eq!(first.len(), 1); + sleep(Duration::from_millis(150)); + assert_eq!(media_only(link.send_media(Duration::from_millis(50))).len(), 1); + sleep(Duration::from_millis(15)); + link.client.poll_resends().unwrap(); + media_only(link.datagrams(Duration::from_millis(100))) + .iter() + .any(|packet| packet.sequence == first[0].sequence) +} + #[test] fn the_media_channels_expiry_is_applied_to_its_packets() { - let mut expiring = Link::open(None, Some(20)); - assert_eq!(media_only(expiring.send_media(Duration::from_millis(50))).len(), 1); - sleep(Duration::from_millis(150)); - expiring.client.poll_resends().unwrap(); assert!( - media_only(expiring.datagrams(Duration::from_millis(150))).is_empty(), + !first_packet_is_resent_after_a_later_send(Some(20)), "an expired media packet is dropped, not retransmitted" ); - - let mut patient = Link::open(None, None); - assert_eq!(media_only(patient.send_media(Duration::from_millis(50))).len(), 1); - sleep(Duration::from_millis(150)); - patient.client.poll_resends().unwrap(); assert!( - !media_only(patient.datagrams(Duration::from_millis(150))).is_empty(), + first_packet_is_resent_after_a_later_send(None), "without an expiry the unacknowledged packet is retransmitted" ); } From b90fdc45aa2b243b38c24603cbf75a4712f81c76 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 06:46:14 +0200 Subject: [PATCH 09/13] Pin the zero-field policy refusal to the validator, not to a downstream failure Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-rs/tests/media_fec.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cultnet-rs/tests/media_fec.rs b/packages/cultnet-rs/tests/media_fec.rs index 11cc82d9..661fe449 100644 --- a/packages/cultnet-rs/tests/media_fec.rs +++ b/packages/cultnet-rs/tests/media_fec.rs @@ -853,9 +853,11 @@ fn a_policy_that_cannot_form_a_block_is_refused() { 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 }; - assert!(protect_audio_block(&audio_block(1, 10), &no_audio, provenance()).is_err()); + 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 }; - assert!(protect_audio_block(&audio_block(1, 10), &no_audio_parity, provenance()).is_err()); + 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 }; From 51e9a550f92d847593b289b249903d7ad41c3b94 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 08:10:07 +0200 Subject: [PATCH 10/13] Media wire: bound every record by one ceiling, and validate audio parity against its last pts Removing the 720-byte shard cap left the decoder with no per-record size bound: RUDP reassembles up to 16 MiB and the media channel set no cap, so a 4 MiB parity record decoded and recovery work scaled with it. GAMECULT_MEDIA_MAX_WIRE_BYTES (64 KiB, one maximum UDP datagram, ~7x a jumbo frame) is now the ceiling. decode_media_wire_record refuses a larger record, the RUDP session caps the media channel to it on send and on reassembly (and the profile advertises it), and MediaFecPolicy::validate refuses a max_wire_bytes above it so no producer can promise a record the decoder drops. validate_audio_parity_record now checks the deadline against the last slot's pts, not the base, so recovery can no longer produce a packet that violates deadline_ticks >= pts_ticks. Tests: ceiling +-1 at decode for audio and video parity; jumbo record accepted; session send and reassembly on the media channel only; profile advertisement; the audio deadline boundary and recovery refusal; a policy over the ceiling refused; a budget at the ceiling end to end; audio parity larger than every packet it protects (the budget check relies on it). MediaFecPolicy docs tell callers to build by struct update from STANDARD. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-rs/src/media_fec.rs | 15 +- packages/cultnet-rs/src/media_stream_wire.rs | 38 ++- packages/cultnet-rs/src/rudp.rs | 39 +++- packages/cultnet-rs/tests/media_fec.rs | 91 +++++++- .../cultnet-rs/tests/media_stream_wire.rs | 219 ++++++++++++++++++ 5 files changed, 381 insertions(+), 21 deletions(-) diff --git a/packages/cultnet-rs/src/media_fec.rs b/packages/cultnet-rs/src/media_fec.rs index 88a3b811..490eaaed 100644 --- a/packages/cultnet-rs/src/media_fec.rs +++ b/packages/cultnet-rs/src/media_fec.rs @@ -95,7 +95,7 @@ use crate::media_stream_contracts::{ GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord, }; use crate::media_stream_wire::{ - GAMECULT_MEDIA_CHANNEL, GameCultMediaWireRecord, MediaWireProvenance, + GAMECULT_MEDIA_CHANNEL, GAMECULT_MEDIA_MAX_WIRE_BYTES, GameCultMediaWireRecord, MediaWireProvenance, encode_media_wire_record, validate_audio_parity_record, validate_audio_record, validate_video_parity_record, validate_video_record, }; @@ -118,6 +118,13 @@ pub const MEDIA_FEC_MAX_WIRE_BYTES: usize = /// A producer's choice of `(k, m)` and datagram budget. /// [`MediaFecPolicy::STANDARD`] is the ruled default; each record carries the /// `(k, m)` it was made with, so a decoder needs no policy. +/// +/// Build one by overriding fields of the default, `MediaFecPolicy { +/// max_wire_bytes: 1_211, ..MediaFecPolicy::STANDARD }`, not by naming every +/// field: a field added later then cannot break, or silently mis-set, the +/// caller. `max_wire_bytes` may not exceed +/// [`GAMECULT_MEDIA_MAX_WIRE_BYTES`], the ceiling a decoder holds every record +/// to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct MediaFecPolicy { /// The most data chunks in one video block. @@ -183,6 +190,12 @@ impl MediaFecPolicy { { return Err(MediaFecError::invalid("media FEC policy exceeds 256 shards per block")); } + if self.max_wire_bytes > GAMECULT_MEDIA_MAX_WIRE_BYTES { + return Err(MediaFecError::invalid(format!( + "media FEC policy max_wire_bytes {} exceeds the {GAMECULT_MEDIA_MAX_WIRE_BYTES}-byte media record ceiling", + self.max_wire_bytes + ))); + } Ok(()) } } diff --git a/packages/cultnet-rs/src/media_stream_wire.rs b/packages/cultnet-rs/src/media_stream_wire.rs index 5c401571..78915a75 100644 --- a/packages/cultnet-rs/src/media_stream_wire.rs +++ b/packages/cultnet-rs/src/media_stream_wire.rs @@ -42,6 +42,19 @@ use crate::media_stream_contracts::{ /// choice per session; stale media is dropped, not retransmitted. pub const GAMECULT_MEDIA_CHANNEL: &str = "media"; +/// The largest wrapped media record the decoder accepts, and the most the RUDP +/// `media` channel reassembles: 64 KiB. +/// +/// It is the bound a receiver holds a peer to. A parity shard's length is +/// whatever its producer's [`MediaFecPolicy::max_wire_bytes`](crate::MediaFecPolicy::max_wire_bytes) allowed, and +/// recovery work grows with the square of the block size times that length, so +/// without a ceiling one hostile record could name any length RUDP would +/// reassemble (16 MiB). 64 KiB is one maximum UDP datagram: it covers every +/// path MTU in use, jumbo frames (9,000 bytes) with a wide margin, and a +/// record is still one message rather than a stream. A policy may not ask for +/// more; see [`MediaFecPolicy`](crate::MediaFecPolicy). +pub const GAMECULT_MEDIA_MAX_WIRE_BYTES: usize = 64 * 1024; + /// One media record on its way somewhere. #[derive(Clone, Debug, PartialEq, Eq)] pub enum GameCultMediaWireRecord { @@ -174,6 +187,12 @@ pub fn encode_media_wire_record( /// Unwraps a media record from the wire. pub fn decode_media_wire_record(payload: &[u8]) -> Result { + if payload.len() > GAMECULT_MEDIA_MAX_WIRE_BYTES { + return Err(anyhow!( + "media record is {} bytes, over the {GAMECULT_MEDIA_MAX_WIRE_BYTES}-byte ceiling", + payload.len() + )); + } let message = decode_cultnet_message_from_slice(payload, CultNetWireContract::CultNetSchemaV0)?; let CultNetMessage::DocumentPutRaw { document, .. } = message else { return Err(anyhow!("expected cultnet.document_put_raw.v0")); @@ -434,11 +453,6 @@ pub fn validate_audio_parity_record(record: &GameCultMediaAudioParityShardRecord "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", @@ -452,17 +466,23 @@ pub fn validate_audio_parity_record(record: &GameCultMediaAudioParityShardRecord { return Err(anyhow!("audio parity media record stripe metadata is invalid")); } - if audio_slot( + let Some((_, last_pts_ticks)) = audio_slot( record.base_packet_id, record.base_pts_ticks, record.packet_duration_ticks, u64::from(record.data_shard_count) - 1, - ) - .is_none() - { + ) else { return Err(anyhow!( "audio parity media record block runs past the packet id or pts range" )); + }; + // Recovery gives every recovered packet the block's deadline, so it must + // not precede the last packet's pts or a recovered packet would fail + // `validate_audio_record`. + if record.deadline_ticks < last_pts_ticks { + return Err(anyhow!( + "audio parity media record deadline_ticks must not precede the block's last pts_ticks" + )); } if record.shard_payload_bytes == 0 { return Err(anyhow!( diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index d1afca7c..d5cc512b 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -23,6 +23,8 @@ use crate::CultNetTransportProfile; use crate::CultNetTransportProtocol; use crate::CultNetTransportStats; use crate::CultNetWireContract; +use crate::GAMECULT_MEDIA_CHANNEL; +use crate::GAMECULT_MEDIA_MAX_WIRE_BYTES; use crate::create_reconnect_policy; use crate::decode_cultnet_message_from_slice; use crate::encode_cultnet_message_to_vec; @@ -429,7 +431,7 @@ impl CultNetRudpSession { if options.ordered && !options.reliable { return Err(anyhow!("RUDP ordered delivery requires reliability")); } - self.require_payload_size(payload.len())?; + self.require_payload_size(channel_id, payload.len())?; // Reclaim deadline-passed sends before measuring window capacity, so an // expiring stream cannot be refused for a backlog it no longer owns. self.purge_expired_reliable(options.now_ms); @@ -958,7 +960,7 @@ impl CultNetRudpSession { packet: &CultNetRudpPacket, ) -> Result> { if packet.fragment_count == 0 { - self.require_payload_size(packet.payload.len())?; + self.require_payload_size(&packet.channel_id, packet.payload.len())?; return Ok(Some(( CultNetRudpDeliveredFrame { channel_id: packet.channel_id.clone(), @@ -1010,7 +1012,7 @@ impl CultNetRudpSession { .sum::() }) .unwrap_or(0); - if let Err(error) = self.require_payload_size(buffered_bytes + packet.payload.len()) { + if let Err(error) = self.require_payload_size(&packet.channel_id, buffered_bytes + packet.payload.len()) { self.fragment_buffers.remove(&key); return Err(error); } @@ -1068,11 +1070,12 @@ impl CultNetRudpSession { ))) } - fn require_payload_size(&self, payload_bytes: usize) -> Result<()> { - if self - .max_payload_bytes - .is_some_and(|max_payload_bytes| payload_bytes > max_payload_bytes) - { + /// The session's cap, tightened on the media channel to the ceiling every + /// media record is held to (`GAMECULT_MEDIA_MAX_WIRE_BYTES`), so media + /// reassembly never buffers more than a record may be. + fn require_payload_size(&self, channel_id: &str, payload_bytes: usize) -> Result<()> { + let limit = channel_payload_limit(channel_id, self.max_payload_bytes); + if limit.is_some_and(|limit| payload_bytes > limit) { return Err(anyhow!("RUDP payload exceeds max_payload_bytes")); } Ok(()) @@ -2156,6 +2159,18 @@ pub struct RudpTransportProfileOptions { pub media_delivery: Option, } +/// The largest payload a channel accepts: the session-wide cap, tightened on +/// the media channel to the media ceiling. +fn channel_payload_limit(channel_id: &str, session_limit: Option) -> Option { + if channel_id == GAMECULT_MEDIA_CHANNEL { + Some(session_limit.map_or(GAMECULT_MEDIA_MAX_WIRE_BYTES, |limit| { + limit.min(GAMECULT_MEDIA_MAX_WIRE_BYTES) + })) + } else { + session_limit + } +} + pub fn create_rudp_transport_profile( runtime_id: impl Into, options: RudpTransportProfileOptions, @@ -2205,12 +2220,16 @@ pub fn create_rudp_transport_profile( reliable_expire_after_ms: None, }, CultNetTransportChannel { - channel_id: "media".to_string(), + channel_id: GAMECULT_MEDIA_CHANNEL.to_string(), delivery: options .media_delivery .unwrap_or(CultNetTransportDelivery::Reliable), ordering: CultNetTransportOrdering::Unordered, - max_payload_bytes: options.max_payload_bytes, + max_payload_bytes: channel_payload_limit( + GAMECULT_MEDIA_CHANNEL, + options.max_payload_bytes.map(|limit| limit as usize), + ) + .map(|limit| limit as u32), 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, diff --git a/packages/cultnet-rs/tests/media_fec.rs b/packages/cultnet-rs/tests/media_fec.rs index 661fe449..4af9f243 100644 --- a/packages/cultnet-rs/tests/media_fec.rs +++ b/packages/cultnet-rs/tests/media_fec.rs @@ -8,7 +8,7 @@ use cultnet_rs::{ GameCultMediaAudioPacketRecord, GameCultMediaAudioParityShardRecord, GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord, - GameCultMediaWireRecord, MEDIA_FEC_MAX_WIRE_BYTES, + 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, @@ -1052,3 +1052,92 @@ fn the_worst_burst_that_always_recovers_is_blocks_times_the_smallest_m() { ); } } + +// --------------------------------------------------------------------------- +// 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(); +} diff --git a/packages/cultnet-rs/tests/media_stream_wire.rs b/packages/cultnet-rs/tests/media_stream_wire.rs index 762fa78d..530123f7 100644 --- a/packages/cultnet-rs/tests/media_stream_wire.rs +++ b/packages/cultnet-rs/tests/media_stream_wire.rs @@ -2,6 +2,8 @@ //! nothing on the wire. use cultnet_rs::{ + CultNetRudpDeliveredFrame, CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, + GAMECULT_MEDIA_MAX_WIRE_BYTES, RudpTransportProfileOptions, create_rudp_transport_profile, GAMECULT_MEDIA_CHANNEL, GameCultMediaAudioPacketRecord, GameCultMediaAudioParityShardRecord, GameCultMediaReceiverFeedbackRecord, GameCultMediaVideoAccessUnitRecord, GameCultMediaVideoParityShardRecord, @@ -420,3 +422,220 @@ fn records_on_the_edge_of_each_bound_are_admitted() { audio.deadline_ticks = i64::MAX; assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok(), "the last pts is i64::MAX"); } + +// --------------------------------------------------------------------------- +// The ceiling on one media record +// --------------------------------------------------------------------------- + +fn audio_parity(payload_bytes: usize, deadline_ticks: i64) -> 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: 100, + timebase_num: 1, + timebase_den: 48_000, + deadline_ticks, + data_shard_count: 4, + parity_index: 0, + parity_shard_count: 2, + shard_payload_bytes: payload_bytes as u32, + payload: vec![7; payload_bytes], + } +} + +fn video_parity(payload_bytes: usize) -> GameCultMediaVideoParityShardRecord { + GameCultMediaVideoParityShardRecord { + stream_id: "s".to_string(), + session_id: "x".to_string(), + frame_id: 1, + codec: "h264".to_string(), + pts_ticks: 0, + duration_ticks: 1, + timebase_num: 1, + timebase_den: 90_000, + keyframe: false, + 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, + shard_payload_bytes: payload_bytes as u32, + last_chunk_payload_bytes: 2, + payload: vec![7; payload_bytes], + } +} + +fn wire_len(record: &GameCultMediaWireRecord) -> usize { + encode_media_wire_record(record, provenance()).unwrap().len() +} + +/// The payload length whose wrapped record is exactly `wire_bytes` long, found +/// from the record's own linear growth in its payload. +fn payload_for_wire_len( + wire_bytes: usize, + wrap: &dyn Fn(usize) -> GameCultMediaWireRecord, +) -> usize { + let probe = 1_000; + let payload = probe + wire_bytes - wire_len(&wrap(probe)); + assert_eq!(wire_len(&wrap(payload)), wire_bytes, "the record grows one byte per payload byte here"); + payload +} + +/// RUDP would reassemble a 16 MiB record, and recovery work scales with the +/// shard length, so the decoder holds every record to one ceiling. +#[test] +fn a_media_record_one_byte_over_the_ceiling_is_refused_at_decode() { + let ceiling = GAMECULT_MEDIA_MAX_WIRE_BYTES; + let wraps: [(&str, Box GameCultMediaWireRecord>); 2] = [ + ( + "audio parity", + Box::new(|payload| GameCultMediaWireRecord::AudioParity(audio_parity(payload, 300))), + ), + ( + "video parity", + Box::new(|payload| GameCultMediaWireRecord::VideoParity(video_parity(payload))), + ), + ]; + for (name, wrap) in &wraps { + let at = payload_for_wire_len(ceiling, wrap.as_ref()); + let wire = encode_media_wire_record(&wrap(at), provenance()).unwrap(); + assert_eq!(wire.len(), ceiling); + decode_media_wire_record(&wire) + .unwrap_or_else(|error| panic!("{name} at the ceiling is refused: {error}")); + + let over = encode_media_wire_record(&wrap(at + 1), provenance()).unwrap(); + assert_eq!(over.len(), ceiling + 1); + let error = decode_media_wire_record(&over).unwrap_err(); + assert!(error.to_string().contains("ceiling"), "{name}: {error}"); + } +} + +#[test] +fn a_jumbo_frame_sized_record_is_accepted() { + let wire = encode_media_wire_record( + &GameCultMediaWireRecord::AudioParity(audio_parity(8_800, 300)), + provenance(), + ) + .unwrap(); + assert!(wire.len() > 8_800 && wire.len() < GAMECULT_MEDIA_MAX_WIRE_BYTES); + decode_media_wire_record(&wire).expect("a jumbo-frame record decodes"); +} + +/// Recovery hands every recovered packet the block's deadline, so a parity +/// whose deadline precedes the last packet's pts would recover a packet that +/// fails `validate_audio_record`. +#[test] +fn audio_parity_must_not_have_a_deadline_before_its_last_packets_pts() { + // Four packets, 100 ticks each from 0: the last plays at 300. + let decodes = |deadline_ticks: i64| { + let wire = encode_media_wire_record( + &GameCultMediaWireRecord::AudioParity(audio_parity(8, deadline_ticks)), + provenance(), + ) + .unwrap(); + decode_media_wire_record(&wire) + }; + assert!(decodes(300).is_ok()); + assert!(decodes(301).is_ok()); + let error = decodes(299).unwrap_err(); + assert!(error.to_string().contains("last pts_ticks"), "{error}"); + let error = decodes(0).unwrap_err(); + assert!(error.to_string().contains("last pts_ticks"), "{error}"); +} + +// --------------------------------------------------------------------------- +// The media channel's own payload cap +// --------------------------------------------------------------------------- + +fn connected_session() -> CultNetRudpSession { + let mut session = CultNetRudpSession::new(CultNetRudpSessionOptions { + connection_id: 9, + ..CultNetRudpSessionOptions::default() + }); + session.assume_connected(0); + session +} + +const FRAGMENT_BYTES: usize = 16 * 1024; + +fn media_payload_limit(session_limit: Option) -> Option { + let profile = create_rudp_transport_profile( + "raven", + RudpTransportProfileOptions { + max_payload_bytes: session_limit, + ..RudpTransportProfileOptions::default() + }, + ); + profile.transports[0] + .channels + .iter() + .find(|channel| channel.channel_id == GAMECULT_MEDIA_CHANNEL) + .expect("the profile advertises the media channel") + .max_payload_bytes +} + +#[test] +fn the_media_channel_advertises_the_ceiling_or_a_tighter_session_cap() { + let ceiling = GAMECULT_MEDIA_MAX_WIRE_BYTES as u32; + assert_eq!(media_payload_limit(None), Some(ceiling)); + assert_eq!(media_payload_limit(Some(16 * 1024 * 1024)), Some(ceiling)); + assert_eq!(media_payload_limit(Some(4_096)), Some(4_096)); +} + +#[test] +fn a_session_refuses_to_send_more_than_the_ceiling_on_the_media_channel_only() { + let ceiling = GAMECULT_MEDIA_MAX_WIRE_BYTES; + let send = |channel: &str, payload: usize| { + connected_session().send_many( + channel, + vec![0; payload], + CultNetRudpSendOptions::default(), + Some(FRAGMENT_BYTES), + ) + }; + assert!(send(GAMECULT_MEDIA_CHANNEL, ceiling).is_ok()); + let error = send(GAMECULT_MEDIA_CHANNEL, ceiling + 1).unwrap_err(); + assert!(error.to_string().contains("max_payload_bytes"), "{error}"); + assert!(send("schema", ceiling + 1).is_ok(), "other channels keep the session cap"); +} + +/// Reassembly is where memory is spent, so the receiver enforces the ceiling +/// itself, not only the sender. +#[test] +fn a_session_refuses_to_reassemble_more_than_the_ceiling_on_the_media_channel() { + let ceiling = GAMECULT_MEDIA_MAX_WIRE_BYTES; + let packets = connected_session() + .send_many( + "schema", + vec![0; ceiling + 1], + CultNetRudpSendOptions::default(), + Some(FRAGMENT_BYTES), + ) + .unwrap(); + assert!(packets.len() > 1); + + let deliver = |channel: &str| -> anyhow::Result> { + let mut receiver = connected_session(); + let mut delivered = Vec::new(); + for packet in &packets { + let mut packet = packet.clone(); + packet.channel_id = channel.to_string(); + delivered.extend(receiver.receive(&packet, 0)?.delivered); + } + Ok(delivered) + }; + let frames = deliver("schema").expect("the same fragments reassemble on another channel"); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].payload.len(), ceiling + 1); + let error = deliver(GAMECULT_MEDIA_CHANNEL).unwrap_err(); + assert!(error.to_string().contains("max_payload_bytes"), "{error}"); +} From 1bbc0ace69e201c23fd5a8725497b19b1303effe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 08:11:26 +0200 Subject: [PATCH 11/13] Media wire: the encoder refuses a record over the ceiling too A producer that builds an oversized record without going through the FEC policy would otherwise learn only when the receiver silently dropped it. encode_media_wire_record now refuses anything over GAMECULT_MEDIA_MAX_WIRE_BYTES, with the decoder's message. The decode ceiling test pads a valid record by one byte to stand in for a peer that sent one. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-rs/src/media_stream_wire.rs | 9 ++++++++- packages/cultnet-rs/tests/media_stream_wire.rs | 17 +++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/cultnet-rs/src/media_stream_wire.rs b/packages/cultnet-rs/src/media_stream_wire.rs index 78915a75..5c89a8c3 100644 --- a/packages/cultnet-rs/src/media_stream_wire.rs +++ b/packages/cultnet-rs/src/media_stream_wire.rs @@ -182,7 +182,14 @@ pub fn encode_media_wire_record( }, }; - encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).map_err(Into::into) + let wire = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)?; + if wire.len() > GAMECULT_MEDIA_MAX_WIRE_BYTES { + return Err(anyhow!( + "media record is {} bytes, over the {GAMECULT_MEDIA_MAX_WIRE_BYTES}-byte ceiling", + wire.len() + )); + } + Ok(wire) } /// Unwraps a media record from the wire. diff --git a/packages/cultnet-rs/tests/media_stream_wire.rs b/packages/cultnet-rs/tests/media_stream_wire.rs index 530123f7..cad8882f 100644 --- a/packages/cultnet-rs/tests/media_stream_wire.rs +++ b/packages/cultnet-rs/tests/media_stream_wire.rs @@ -512,8 +512,10 @@ fn a_media_record_one_byte_over_the_ceiling_is_refused_at_decode() { decode_media_wire_record(&wire) .unwrap_or_else(|error| panic!("{name} at the ceiling is refused: {error}")); - let over = encode_media_wire_record(&wrap(at + 1), provenance()).unwrap(); - assert_eq!(over.len(), ceiling + 1); + // The encoder refuses to make an over-ceiling record, so one byte of + // padding stands in for a peer that sent one. + let mut over = wire.clone(); + over.push(0); let error = decode_media_wire_record(&over).unwrap_err(); assert!(error.to_string().contains("ceiling"), "{name}: {error}"); } @@ -639,3 +641,14 @@ fn a_session_refuses_to_reassemble_more_than_the_ceiling_on_the_media_channel() let error = deliver(GAMECULT_MEDIA_CHANNEL).unwrap_err(); assert!(error.to_string().contains("max_payload_bytes"), "{error}"); } + +/// A producer learns at encode time that a record is over the ceiling, rather +/// than the receiver dropping it. +#[test] +fn the_encoder_refuses_a_record_over_the_ceiling() { + let wrap = |payload: usize| GameCultMediaWireRecord::AudioParity(audio_parity(payload, 300)); + let at = payload_for_wire_len(GAMECULT_MEDIA_MAX_WIRE_BYTES, &wrap); + assert!(encode_media_wire_record(&wrap(at), provenance()).is_ok()); + let error = encode_media_wire_record(&wrap(at + 1), provenance()).unwrap_err(); + assert!(error.to_string().contains("ceiling"), "{error}"); +} From 7c149bf0c8e7e81011cdf9f0c18678ca1a99b1d3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 08:14:22 +0200 Subject: [PATCH 12/13] Media wire tests: fix helper name clash and move fixtures to the last-pts deadline rule The sized parity helpers collided with the existing fixtures of the same name, so they are now sized_audio_parity and sized_video_parity. Two existing cases encoded the old base-pts deadline rule: the corruption table expects the new last pts_ticks message, the edge-of-bound cases admit a deadline at the last pts, and the 254-shard audio case sets a deadline that covers its last slot. Co-Authored-By: Claude Sonnet 5.5 --- .../cultnet-rs/tests/media_stream_wire.rs | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/cultnet-rs/tests/media_stream_wire.rs b/packages/cultnet-rs/tests/media_stream_wire.rs index cad8882f..2b06e94e 100644 --- a/packages/cultnet-rs/tests/media_stream_wire.rs +++ b/packages/cultnet-rs/tests/media_stream_wire.rs @@ -372,7 +372,7 @@ fn a_malformed_audio_parity_record_is_refused_with_its_reason() { ("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)), + ("last pts_ticks", Box::new(|r| r.deadline_ticks = -1)), ("packet id or pts range", Box::new(|r| r.base_packet_id = u64::MAX - 2)), ("packet id or pts range", Box::new(|r| { r.base_pts_ticks = i64::MAX - 960 * 3 + 1; @@ -406,12 +406,13 @@ fn records_on_the_edge_of_each_bound_are_admitted() { let mut audio = audio_parity(); audio.data_shard_count = 254; + audio.deadline_ticks = i64::MAX; audio.parity_shard_count = 2; assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok(), "254 + 2 = 256 shards"); let mut audio = audio_parity(); - audio.deadline_ticks = audio.base_pts_ticks; - assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok(), "a deadline at the base pts"); + audio.deadline_ticks = audio.base_pts_ticks + 3 * i64::from(audio.packet_duration_ticks); + assert!(decodes(GameCultMediaWireRecord::AudioParity(audio)).is_ok(), "a deadline at the last pts"); let mut audio = audio_parity(); audio.base_packet_id = u64::MAX - 3; @@ -427,7 +428,7 @@ fn records_on_the_edge_of_each_bound_are_admitted() { // The ceiling on one media record // --------------------------------------------------------------------------- -fn audio_parity(payload_bytes: usize, deadline_ticks: i64) -> GameCultMediaAudioParityShardRecord { +fn sized_audio_parity(payload_bytes: usize, deadline_ticks: i64) -> GameCultMediaAudioParityShardRecord { GameCultMediaAudioParityShardRecord { stream_id: "s".to_string(), session_id: "x".to_string(), @@ -447,7 +448,7 @@ fn audio_parity(payload_bytes: usize, deadline_ticks: i64) -> GameCultMediaAudio } } -fn video_parity(payload_bytes: usize) -> GameCultMediaVideoParityShardRecord { +fn sized_video_parity(payload_bytes: usize) -> GameCultMediaVideoParityShardRecord { GameCultMediaVideoParityShardRecord { stream_id: "s".to_string(), session_id: "x".to_string(), @@ -498,11 +499,11 @@ fn a_media_record_one_byte_over_the_ceiling_is_refused_at_decode() { let wraps: [(&str, Box GameCultMediaWireRecord>); 2] = [ ( "audio parity", - Box::new(|payload| GameCultMediaWireRecord::AudioParity(audio_parity(payload, 300))), + Box::new(|payload| GameCultMediaWireRecord::AudioParity(sized_audio_parity(payload, 300))), ), ( "video parity", - Box::new(|payload| GameCultMediaWireRecord::VideoParity(video_parity(payload))), + Box::new(|payload| GameCultMediaWireRecord::VideoParity(sized_video_parity(payload))), ), ]; for (name, wrap) in &wraps { @@ -524,7 +525,7 @@ fn a_media_record_one_byte_over_the_ceiling_is_refused_at_decode() { #[test] fn a_jumbo_frame_sized_record_is_accepted() { let wire = encode_media_wire_record( - &GameCultMediaWireRecord::AudioParity(audio_parity(8_800, 300)), + &GameCultMediaWireRecord::AudioParity(sized_audio_parity(8_800, 300)), provenance(), ) .unwrap(); @@ -540,7 +541,7 @@ fn audio_parity_must_not_have_a_deadline_before_its_last_packets_pts() { // Four packets, 100 ticks each from 0: the last plays at 300. let decodes = |deadline_ticks: i64| { let wire = encode_media_wire_record( - &GameCultMediaWireRecord::AudioParity(audio_parity(8, deadline_ticks)), + &GameCultMediaWireRecord::AudioParity(sized_audio_parity(8, deadline_ticks)), provenance(), ) .unwrap(); @@ -646,7 +647,7 @@ fn a_session_refuses_to_reassemble_more_than_the_ceiling_on_the_media_channel() /// than the receiver dropping it. #[test] fn the_encoder_refuses_a_record_over_the_ceiling() { - let wrap = |payload: usize| GameCultMediaWireRecord::AudioParity(audio_parity(payload, 300)); + let wrap = |payload: usize| GameCultMediaWireRecord::AudioParity(sized_audio_parity(payload, 300)); let at = payload_for_wire_len(GAMECULT_MEDIA_MAX_WIRE_BYTES, &wrap); assert!(encode_media_wire_record(&wrap(at), provenance()).is_ok()); let error = encode_media_wire_record(&wrap(at + 1), provenance()).unwrap_err(); From 4d93dc858e2c1b6b66c77b1cf06720c70f013d56 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 08:29:05 +0200 Subject: [PATCH 13/13] Media wire tests: hold an unfragmented media packet to the ceiling Mutation run showed the unfragmented receive path of the RUDP session was undefended (only fragmented reassembly was tested). Co-Authored-By: Claude Sonnet 5.5 --- .../cultnet-rs/tests/media_stream_wire.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/cultnet-rs/tests/media_stream_wire.rs b/packages/cultnet-rs/tests/media_stream_wire.rs index 2b06e94e..9a3ae1be 100644 --- a/packages/cultnet-rs/tests/media_stream_wire.rs +++ b/packages/cultnet-rs/tests/media_stream_wire.rs @@ -653,3 +653,22 @@ fn the_encoder_refuses_a_record_over_the_ceiling() { let error = encode_media_wire_record(&wrap(at + 1), provenance()).unwrap_err(); assert!(error.to_string().contains("ceiling"), "{error}"); } + +/// A record that arrives unfragmented is held to the same ceiling. +#[test] +fn a_session_refuses_an_unfragmented_media_packet_over_the_ceiling() { + let packets = connected_session() + .send_many( + "schema", + vec![0; GAMECULT_MEDIA_MAX_WIRE_BYTES + 1], + CultNetRudpSendOptions::default(), + None, + ) + .unwrap(); + assert_eq!(packets.len(), 1, "sent whole"); + let mut packet = packets[0].clone(); + assert!(connected_session().receive(&packet, 0).is_ok()); + packet.channel_id = GAMECULT_MEDIA_CHANNEL.to_string(); + let error = connected_session().receive(&packet, 0).unwrap_err(); + assert!(error.to_string().contains("max_payload_bytes"), "{error}"); +}