aedee2a4e3
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 54s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 1m23s
ci / rust (push) Failing after 4m15s
arch / build-publish (push) Failing after 4m35s
deb / build-publish (push) Failing after 4m4s
docker / deploy-docs (push) Successful in 24s
ci / bench (push) Successful in 5m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 3m45s
windows-host / package (push) Failing after 9m8s
release / apple (push) Successful in 5m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 5m25s
flatpak / build-publish (push) Failing after 8m3s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m58s
android / android (push) Successful in 14m9s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 9m28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 12m26s
design/latency-reduction-2026-07.md T0.1/T0.2/T1.2/T1.3:
- T1.2 rate-capped front-loaded pacing: the paced overflow's budget is now
min(0.9x slack, overflow wire time at ~3x the live encoder bitrate)
(PUNKTFUNK_PACE_FACTOR, 0 = legacy deadline-only spread). A 300 KB-1 MB
frame's tail leaves in ~2-5 ms instead of smearing across ~15 ms at 60 fps;
GameStream schedule byte-identical (pins unchanged).
- T1.3 data-first wire order: packetize emits every block's data shards before
any parity (per-block parity pools keep all blocks' parity alive for the
second pass), so lossless completion stops waiting behind the parity tail.
EOF = last emitted packet; receiver already order-agnostic.
- T0.1 staged 0xCF: HostTiming gains an append-extensible per-stage tail
(queue/encode/pace us; seal+channel-wait derived as residual) - no cap bit
needed, old peers read the 13-byte prefix. Joined client-side into
Stats::host_{queue,encode,xfer,pace}_ms, the OSD detailed tier, and the
probe's report.
- T0.2 true on-glass present timing: VK_KHR_present_id/present_wait enabled
when supported; a PresentTimer waiter thread resolves each present id to
real visibility, replacing the submit-time display stamp (which undercounts
by up to a refresh and hides a silent-FIFO standing queue).
Validated on .21: core 185 + host 185 tests, pf-presenter 19, clippy
-D warnings across all five touched crates; loss-harness recovery curve
unchanged; C ABI harness round-trips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
260 lines
12 KiB
Rust
260 lines
12 KiB
Rust
//! Host side: split an access unit into FEC-protected shard packets.
|
|
|
|
use super::*;
|
|
use crate::config::Config;
|
|
use crate::error::{PunktfunkError, Result};
|
|
use crate::fec::ErasureCoder;
|
|
use zerocopy::IntoBytes;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Host side: packetization
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Splits encoded access units into FEC-protected shard packets. Host-side only.
|
|
///
|
|
/// Frame numbering: a caller can pass an **explicit** `frame_index` to
|
|
/// [`packetize_each`](Self::packetize_each) (the punktfunk/1 encode loop owns the video numbering
|
|
/// so the encoder's reference-frame-invalidation bookkeeping stays 1:1 with the wire across
|
|
/// encoder rebuilds/resets), or pass `None` to draw from the internal counter (the legacy path —
|
|
/// synthetic/spike/ABI sessions where no encoder cares). Speed-test probe filler draws from a
|
|
/// **separate** index space ([`alloc_probe_index`](Self::alloc_probe_index)) so a burst never
|
|
/// consumes video indexes — see [`crate::quic::VIDEO_CAP_PROBE_SEQ`].
|
|
pub struct Packetizer {
|
|
next_frame_index: u32,
|
|
/// Probe-space frame counter (see [`alloc_probe_index`](Self::alloc_probe_index)).
|
|
next_probe_index: u32,
|
|
next_seq: u32,
|
|
shard_payload: usize,
|
|
fec: crate::config::FecConfig,
|
|
version: u8,
|
|
/// Reusable zero-padded scratch for the frame's final data shard when the frame isn't an
|
|
/// exact `shard_payload` multiple (and for the single all-zero shard of an empty frame).
|
|
/// Every other data shard is a `shard_payload`-sized slice straight into the frame buffer —
|
|
/// blocks are consecutive shard ranges, so only the frame's last shard can be partial.
|
|
tail: Vec<u8>,
|
|
/// Reusable PER-BLOCK parity buffers for [`ErasureCoder::encode_into`] (plan Phase 1.4):
|
|
/// one pool per block index, each growing once to its high-water recovery count, then
|
|
/// every frame's parity is generated into them with zero allocations. Per-block (not one
|
|
/// shared pool) because the data-first wire order (latency plan T1.3) emits every block's
|
|
/// DATA shards before any block's parity — all blocks' parity must stay alive until the
|
|
/// frame's second emission pass.
|
|
recovery: Vec<Vec<Vec<u8>>>,
|
|
}
|
|
|
|
impl Packetizer {
|
|
pub fn new(config: &Config) -> Self {
|
|
Packetizer {
|
|
next_frame_index: 0,
|
|
next_probe_index: 0,
|
|
next_seq: 0,
|
|
shard_payload: config.shard_payload,
|
|
fec: config.fec,
|
|
version: config.phase as u8,
|
|
tail: Vec::new(),
|
|
recovery: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Allocate the next **probe-space** frame index (speed-test filler). A separate counter from
|
|
/// the video `frame_index`es so a multi-thousand-AU probe burst never advances the video
|
|
/// numbering — the client routes [`FLAG_PROBE`]-flagged shards into its own reassembly window
|
|
/// (see [`Reassembler`]), so the two spaces never collide. Only used against clients that
|
|
/// advertise [`crate::quic::VIDEO_CAP_PROBE_SEQ`].
|
|
pub fn alloc_probe_index(&mut self) -> u32 {
|
|
let i = self.next_probe_index;
|
|
self.next_probe_index = i.wrapping_add(1);
|
|
i
|
|
}
|
|
|
|
/// Live-adjust the FEC recovery percentage (adaptive FEC). Takes effect on the next
|
|
/// [`packetize`](Self::packetize); the wire is self-describing (each packet carries its block's
|
|
/// data/recovery counts), so the receiver needs no notification. Clamped to ≤ 90.
|
|
pub fn set_fec_percent(&mut self, pct: u8) {
|
|
self.fec.fec_percent = pct.min(90);
|
|
}
|
|
|
|
/// The current FEC recovery percentage.
|
|
pub fn fec_percent(&self) -> u8 {
|
|
self.fec.fec_percent
|
|
}
|
|
|
|
/// Packetize one access unit into owned wire packets (header ++ shard payload each).
|
|
/// Thin wrapper over [`packetize_each`](Self::packetize_each) — the allocation-free
|
|
/// streaming path's reference implementation (tests and the loss harness use this).
|
|
pub fn packetize(
|
|
&mut self,
|
|
frame: &[u8],
|
|
pts_ns: u64,
|
|
user_flags: u32,
|
|
coder: &dyn ErasureCoder,
|
|
) -> Result<Vec<Vec<u8>>> {
|
|
let mut packets = Vec::new();
|
|
self.packetize_each(frame, pts_ns, user_flags, None, coder, |hdr, body| {
|
|
let mut pkt = Vec::with_capacity(HEADER_LEN + body.len());
|
|
pkt.extend_from_slice(hdr.as_bytes());
|
|
pkt.extend_from_slice(body);
|
|
packets.push(pkt);
|
|
Ok(())
|
|
})?;
|
|
Ok(packets)
|
|
}
|
|
|
|
/// Packetize one access unit, yielding each packet to `emit` as a `(header, shard bytes)`
|
|
/// pair — in exact wire order, which is also the order the session's nonce counter
|
|
/// advances. No per-packet allocation happens here, so the caller can write header and
|
|
/// shard straight into a pooled wire buffer and seal in place
|
|
/// ([`Session::seal_frame`](crate::session::Session::seal_frame)). An `emit` error aborts
|
|
/// the frame mid-way (packet numbering has already advanced — callers treat it as fatal).
|
|
///
|
|
/// Wire order is DATA-FIRST (latency plan T1.3): every block's data shards in block order,
|
|
/// then every block's parity shards in block order. In the lossless case a frame completes
|
|
/// the moment its last DATA shard arrives, so the completion-gating packet no longer sits
|
|
/// behind the parity tail of the paced spread (~fec% of the spread saved). The receiver is
|
|
/// order-agnostic (headers are self-describing; the reassembler completes each block on
|
|
/// `data + recovery ≥ k`), so this is not a wire-format change. `FLAG_SOF` stays on the
|
|
/// first emitted packet (block 0, shard 0); `FLAG_EOF` marks the last emitted packet —
|
|
/// the final parity shard, or the final data shard of a FEC-free frame.
|
|
///
|
|
/// `frame_index`: `Some(i)` stamps the AU with the caller's index — the punktfunk/1 encode
|
|
/// loop numbers video AUs itself so the encoder's RFI bookkeeping (LTR marks, DPB timestamps)
|
|
/// is 1:1 with what the client sees, surviving encoder rebuilds/resets that restart internal
|
|
/// counters. `None` draws from the internal counter (the legacy/self-numbering path). A
|
|
/// session must not mix the two styles for the same index space.
|
|
pub fn packetize_each(
|
|
&mut self,
|
|
frame: &[u8],
|
|
pts_ns: u64,
|
|
user_flags: u32,
|
|
frame_index: Option<u32>,
|
|
coder: &dyn ErasureCoder,
|
|
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
|
|
) -> Result<()> {
|
|
let payload = self.shard_payload;
|
|
let frame_index = frame_index.unwrap_or_else(|| {
|
|
let i = self.next_frame_index;
|
|
self.next_frame_index = i.wrapping_add(1);
|
|
i
|
|
});
|
|
|
|
// At least one (zero-padded) data shard even for an empty frame.
|
|
let total_data = frame.len().div_ceil(payload).max(1);
|
|
let max_block = self.fec.max_data_per_block as usize;
|
|
let block_count = total_data.div_ceil(max_block).max(1);
|
|
let frame_bytes = frame.len() as u32;
|
|
|
|
// Defend the u16 wire fields against silent truncation. `Config::validate`
|
|
// already rejects configs that could reach these for valid frame sizes; this is
|
|
// the belt-and-suspenders for a frame larger than the negotiated maximum.
|
|
if payload > u16::MAX as usize {
|
|
return Err(PunktfunkError::InvalidArg("shard_payload exceeds u16"));
|
|
}
|
|
if block_count > u16::MAX as usize {
|
|
return Err(PunktfunkError::Unsupported(
|
|
"frame too large: block count exceeds u16",
|
|
));
|
|
}
|
|
|
|
// Stage the frame's one possibly-partial shard (the last) in the reusable
|
|
// zero-padded scratch; every full shard is referenced in place below.
|
|
let full_shards = frame.len() / payload;
|
|
self.tail.clear();
|
|
self.tail.resize(payload, 0);
|
|
let rem = frame.len() % payload;
|
|
if rem > 0 {
|
|
self.tail[..rem].copy_from_slice(&frame[full_shards * payload..]);
|
|
}
|
|
let tail = &self.tail;
|
|
let shard_at = |s: usize| -> &[u8] {
|
|
if s < full_shards {
|
|
&frame[s * payload..(s + 1) * payload]
|
|
} else {
|
|
tail.as_slice()
|
|
}
|
|
};
|
|
// Per-block shard geometry (deterministic — recomputed in both passes).
|
|
let block_data_count = |b: usize| ((b + 1) * max_block).min(total_data) - b * max_block;
|
|
|
|
// One parity pool per block, reused across frames (steady-state zero-alloc).
|
|
if self.recovery.len() < block_count {
|
|
self.recovery.resize_with(block_count, Vec::new);
|
|
}
|
|
|
|
// Total parity across the frame decides where FLAG_EOF lands (the last emitted packet).
|
|
let mut total_recovery = 0usize;
|
|
for b in 0..block_count {
|
|
let k = block_data_count(b);
|
|
let m = self.fec.recovery_for(k);
|
|
if k + m > u16::MAX as usize {
|
|
return Err(PunktfunkError::Unsupported("block shard count exceeds u16"));
|
|
}
|
|
total_recovery += m;
|
|
}
|
|
|
|
let mut emit_one =
|
|
|next_seq: &mut u32, b: usize, shard_index: usize, body: &[u8], flags: u8| {
|
|
let seq = *next_seq;
|
|
*next_seq = next_seq.wrapping_add(1);
|
|
let k = block_data_count(b);
|
|
let hdr = PacketHeader {
|
|
pts_ns,
|
|
frame_index,
|
|
stream_seq: seq,
|
|
frame_bytes,
|
|
user_flags,
|
|
block_index: b as u16,
|
|
block_count: block_count as u16,
|
|
data_shards: k as u16,
|
|
recovery_shards: self.fec.recovery_for(k) as u16,
|
|
shard_index: shard_index as u16,
|
|
shard_bytes: payload as u16,
|
|
magic: PUNKTFUNK_MAGIC,
|
|
version: self.version,
|
|
fec_scheme: coder.scheme() as u8,
|
|
flags,
|
|
};
|
|
emit(&hdr, body)
|
|
};
|
|
let mut next_seq = self.next_seq;
|
|
|
|
// Pass 1 — per block: generate parity into the block's pool, emit the DATA shards.
|
|
for b in 0..block_count {
|
|
let first = b * max_block;
|
|
let k = block_data_count(b);
|
|
|
|
// This block's data shards: references into `frame` (plus the staged tail).
|
|
let data_shards: Vec<&[u8]> = (first..first + k).map(shard_at).collect();
|
|
let recovery_count = self.fec.recovery_for(k);
|
|
coder.encode_into(&data_shards, recovery_count, &mut self.recovery[b])?;
|
|
|
|
for (shard_index, body) in data_shards.iter().enumerate() {
|
|
let mut flags = FLAG_PIC;
|
|
if b == 0 && shard_index == 0 {
|
|
flags |= FLAG_SOF;
|
|
}
|
|
if total_recovery == 0 && b + 1 == block_count && shard_index + 1 == k {
|
|
flags |= FLAG_EOF;
|
|
}
|
|
emit_one(&mut next_seq, b, shard_index, body, flags)?;
|
|
}
|
|
}
|
|
|
|
// Pass 2 — per block: emit the parity shards (the frame's tail on the wire).
|
|
let mut parity_left = total_recovery;
|
|
for b in 0..block_count {
|
|
let k = block_data_count(b);
|
|
let recovery_count = self.fec.recovery_for(k);
|
|
for r in 0..recovery_count {
|
|
parity_left -= 1;
|
|
let mut flags = FLAG_PIC;
|
|
if parity_left == 0 {
|
|
flags |= FLAG_EOF;
|
|
}
|
|
let body: &[u8] = &self.recovery[b][r];
|
|
emit_one(&mut next_seq, b, k + r, body, flags)?;
|
|
}
|
|
}
|
|
self.next_seq = next_seq;
|
|
Ok(())
|
|
}
|
|
}
|