The codec owns the erasure math for both ends: protect_video_frame and protect_audio_block on the producer, recover_video_block and recover_audio_block on the consumer. Each block is systematic Reed-Solomon over GF(2^8) (reed-solomon-erasure 6, default features), named on every parity record by the scheme id rs-gf256-v1. Any <= m erasures recover exactly; more report BeyondRepair. Video frames split into ceil(n/16) near-equal blocks with m = max(2, ceil(k/4)); audio is 4+2. A frame's records come back in send order, rotating one record from each block so a burst lands as one loss per block. Deleted first: gamecult.media_video_parity_shard.v2 and its XOR-stripe validator, and the reliable "audio" channel (profile entry, send-options arm, constant, tests). Audio now rides "media" with parity. Added: gamecult.media_video_parity_shard.v3 (block geometry, scheme id, shard length), gamecult.media_audio_parity_shard.v1, the AudioParity wire variant with validation, and the parity record key now names the block. Every shard is bounded at MEDIA_FEC_MAX_SHARD_PAYLOAD_BYTES (720) so its wrapped record fits MEDIA_FEC_MAX_WIRE_BYTES (1431), measured with identifiers of up to 32 bytes. Tests pin: exhaustive recovery for every shipped (k, m) and 4+2, m+1 refusal, known-answer parity bytes for both paths, the datagram bound on every record kind, send order, deadline carriage and decoder validation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
10 KiB
Rust
272 lines
10 KiB
Rust
//! The CultMesh Media Stream records are a wire contract shared by producers and
|
|
//! consumers that never link each other. These tests pin the properties a
|
|
//! consumer is entitled to rely on: positional layout, declared field order, and
|
|
//! tolerance of records written before an appended field existed.
|
|
|
|
use cultcache_rs::DatabaseEntry;
|
|
use cultnet_rs::{
|
|
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;
|
|
|
|
fn access_unit() -> GameCultMediaVideoAccessUnitRecord {
|
|
GameCultMediaVideoAccessUnitRecord {
|
|
stream_id: "raven-primary-av".to_string(),
|
|
session_id: "session-1".to_string(),
|
|
frame_id: 4242,
|
|
codec: "h264".to_string(),
|
|
pts_ticks: 90_000,
|
|
duration_ticks: 3_000,
|
|
timebase_num: 1,
|
|
timebase_den: 90_000,
|
|
keyframe: true,
|
|
dependency_frame_id: None,
|
|
deadline_ticks: 108_000,
|
|
chunk_index: 2,
|
|
chunk_count: 5,
|
|
payload: vec![0xDE, 0xAD, 0xBE, 0xEF],
|
|
}
|
|
}
|
|
|
|
fn audio_packet() -> GameCultMediaAudioPacketRecord {
|
|
GameCultMediaAudioPacketRecord {
|
|
stream_id: "raven-primary-av".to_string(),
|
|
session_id: "session-1".to_string(),
|
|
packet_id: 77,
|
|
codec: "opus".to_string(),
|
|
pts_ticks: 48_000,
|
|
duration_ticks: 960,
|
|
timebase_num: 1,
|
|
timebase_den: 48_000,
|
|
deadline_ticks: 96_000,
|
|
payload: vec![1, 2, 3],
|
|
}
|
|
}
|
|
|
|
fn decode_as_value(bytes: &[u8]) -> Value {
|
|
rmp_serde::from_slice(bytes).expect("records decode as generic MessagePack")
|
|
}
|
|
|
|
/// The declared `key` indices are the wire layout, not documentation. A consumer
|
|
/// in another language reads position 2 for `frame_id` and position 13 for
|
|
/// `payload`; reordering the struct would silently break it.
|
|
#[test]
|
|
fn video_access_unit_encodes_as_a_positional_array() {
|
|
let encoded = rmp_serde::to_vec(&access_unit()).expect("encodes");
|
|
let Value::Array(slots) = decode_as_value(&encoded) else {
|
|
panic!("access units must encode as a positional array");
|
|
};
|
|
|
|
assert_eq!(slots.len(), 14, "field count is wire contract");
|
|
assert_eq!(slots[0].as_str(), Some("raven-primary-av"));
|
|
assert_eq!(slots[2].as_u64(), Some(4242), "slot 2 is frame_id");
|
|
assert_eq!(slots[3].as_str(), Some("h264"));
|
|
assert_eq!(slots[8].as_bool(), Some(true), "slot 8 is keyframe");
|
|
assert!(slots[9].is_nil(), "an absent dependency stays nil, not 0");
|
|
assert_eq!(slots[12].as_u64(), Some(5), "slot 12 is chunk_count");
|
|
assert_eq!(
|
|
slots[13].as_slice(),
|
|
Some([0xDE, 0xAD, 0xBE, 0xEF].as_slice()),
|
|
"slot 13 is the payload, carried as bin"
|
|
);
|
|
}
|
|
|
|
/// Payloads must reach the wire as MessagePack `bin`, not as an array of
|
|
/// integers. The C#, TypeScript and Python runtimes all read byte fields as
|
|
/// `bin`, so a payload serialized as a sequence is unreadable by the reference
|
|
/// implementation, and costs 1.45x besides: 848 bytes encode to 1234 as an
|
|
/// integer array against 851 as `bin`.
|
|
///
|
|
/// This is what the derive's `bytes` attribute buys. `#[serde(with =
|
|
/// "serde_bytes")]` will not do it — `DatabaseEntry` emits its own `Serialize`
|
|
/// and reads only its own `cultcache` attribute namespace.
|
|
#[test]
|
|
fn payload_should_encode_as_messagepack_bin() {
|
|
let encoded = rmp_serde::to_vec(&access_unit()).expect("encodes");
|
|
let Value::Array(slots) = decode_as_value(&encoded) else {
|
|
panic!("access units must encode as a positional array");
|
|
};
|
|
|
|
assert_eq!(
|
|
slots[13].as_slice(),
|
|
Some([0xDE, 0xAD, 0xBE, 0xEF].as_slice()),
|
|
"payloads belong on the wire as bin, not as an array of integers"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn audio_packets_are_whole_and_carry_their_own_timebase() {
|
|
let encoded = rmp_serde::to_vec(&audio_packet()).expect("encodes");
|
|
let Value::Array(slots) = decode_as_value(&encoded) else {
|
|
panic!("audio packets must encode as a positional array");
|
|
};
|
|
|
|
assert_eq!(slots.len(), 10, "field count is wire contract");
|
|
assert_eq!(slots[2].as_u64(), Some(77), "slot 2 is packet_id");
|
|
assert_eq!(slots[6].as_u64(), Some(1), "slot 6 is timebase_num");
|
|
assert_eq!(slots[7].as_u64(), Some(48_000), "slot 7 is timebase_den");
|
|
|
|
let decoded: GameCultMediaAudioPacketRecord =
|
|
rmp_serde::from_slice(&encoded).expect("round-trips");
|
|
assert_eq!(decoded, audio_packet());
|
|
}
|
|
|
|
#[test]
|
|
fn parity_shards_carry_the_geometry_recovery_needs() {
|
|
let shard = GameCultMediaVideoParityShardRecord {
|
|
stream_id: "raven-primary-av".to_string(),
|
|
session_id: "session-1".to_string(),
|
|
frame_id: 4242,
|
|
codec: "h264".to_string(),
|
|
pts_ticks: 90_000,
|
|
duration_ticks: 3_000,
|
|
timebase_num: 1,
|
|
timebase_den: 90_000,
|
|
keyframe: false,
|
|
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,
|
|
shard_payload_bytes: 1024,
|
|
last_chunk_payload_bytes: 512,
|
|
payload: vec![9, 9, 9],
|
|
};
|
|
|
|
let encoded = rmp_serde::to_vec(&shard).expect("encodes");
|
|
let Value::Array(slots) = decode_as_value(&encoded) else {
|
|
panic!("parity shards must encode as a positional array");
|
|
};
|
|
|
|
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[20].as_u64(),
|
|
Some(512),
|
|
"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 =
|
|
rmp_serde::from_slice(&encoded).expect("round-trips");
|
|
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.
|
|
#[test]
|
|
fn receiver_feedback_decodes_records_written_before_its_last_field() {
|
|
let without_appended_field = Value::Array(vec![
|
|
Value::from("raven-primary-av"),
|
|
Value::from("session-1"),
|
|
Value::from("starfire-obs"),
|
|
Value::from(4200u64),
|
|
Value::Array(vec![Value::from(4198u64)]),
|
|
Value::Array(vec![Value::from(4199u64)]),
|
|
Value::from(true),
|
|
Value::from(1_500i64),
|
|
Value::from(9_000i64),
|
|
Value::from("2026-09-09T00:00:00Z"),
|
|
]);
|
|
|
|
let mut bytes = Vec::new();
|
|
rmpv::encode::write_value(&mut bytes, &without_appended_field).expect("writes legacy record");
|
|
|
|
let decoded: GameCultMediaReceiverFeedbackRecord =
|
|
rmp_serde::from_slice(&bytes).expect("a pre-append record must still decode");
|
|
|
|
assert_eq!(decoded.receiver_id, "starfire-obs");
|
|
assert_eq!(decoded.highest_decodable_frame_id, Some(4200));
|
|
assert!(decoded.requested_keyframe);
|
|
assert!(
|
|
decoded.missing_video_chunk_keys.is_empty(),
|
|
"the appended field defaults rather than failing the decode"
|
|
);
|
|
}
|
|
|
|
/// The published constants and the derived type identity must not drift apart;
|
|
/// a consumer resolving by schema id and a producer registering by type would
|
|
/// then disagree without either failing to compile.
|
|
#[test]
|
|
fn published_schema_constants_match_the_derived_identity() {
|
|
assert_eq!(
|
|
GameCultMediaVideoAccessUnitRecord::SCHEMA_NAME,
|
|
GAMECULT_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA
|
|
);
|
|
assert_eq!(
|
|
GameCultMediaVideoParityShardRecord::SCHEMA_NAME,
|
|
GAMECULT_MEDIA_VIDEO_PARITY_SHARD_SCHEMA
|
|
);
|
|
assert_eq!(
|
|
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
|
|
);
|
|
|
|
assert_eq!(
|
|
GameCultMediaAudioPacketRecord::TYPE,
|
|
"gamecult.media_audio_packet"
|
|
);
|
|
}
|