feat: media bitrate control, HDR (#346)

Fixes: #335 

Still a work-in-progress.

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Wanjohi <elviswanjohi47@gmail.com>
This commit is contained in:
Kristian Ollikainen
2026-09-25 12:13:34 +03:00
committed by GitHub
co-authored by DatCaptainHorse Claude Opus 5 Wanjohi
parent 1c721962f4
commit 0811f57f1a
64 changed files with 15151 additions and 2702 deletions
+327
View File
@@ -0,0 +1,327 @@
//! How much a delay varies, measured the same way at both ends.
//!
//! # What this measures
//!
//! Every frame carries the timestamp the sender stamped on it at capture. The
//! two clocks are unrelated, so the difference between that and the moment the
//! frame arrives here is meaningless on its own -- it contains an unknown,
//! roughly constant offset. Its *variation* is not meaningless, and variation is
//! the only thing a playout buffer exists to absorb: a path that delivers every
//! frame exactly 300 ms late needs no buffer at all, while one that alternates
//! between 10 ms and 60 ms needs 50 ms whatever its average.
//!
//! So this tracks the smallest difference seen recently -- the best the path has
//! managed, which stands in for the unknown offset -- and reports how far above
//! it each frame lands.
//!
//! # What it deliberately does not do
//!
//! It does not distinguish the sender's contribution from the network's. The
//! sender reports its own pipeline delay separately, and the two are compared
//! rather than subtracted: a frame that was late because the encoder stalled
//! should not raise a buffer, because buffering is latency spent hiding a fault
//! that should be fixed instead.
//!
//! # Why this is shared
//!
//! Both ends measure a delay and the two are compared: the hub reports how much
//! its own pipeline varied before a frame left, the client reports how much the
//! total varied by the time it arrived. A comparison between two different
//! measures would be meaningless, so there is one measure, defined once.
//!
//! It also has to be *variation* on both sides rather than absolute delay,
//! because neither side can know the absolute. The hub's `timestamp_ms` counts
//! from the encoder's own start, not from any epoch, so even on one machine the
//! difference to wall clock contains an unknown constant -- and across two
//! machines there is no shared clock at all.
//!
//! Nothing here adapts anything yet. It measures, so that the decision about
//! what to adapt is made against a distribution rather than an intuition.
use std::collections::VecDeque;
use std::time::{Duration, Instant};
/// How long a best-case observation stays authoritative.
///
/// The minimum has to expire. Paths change -- a relay is dropped for a direct
/// connection, a phone moves between cells -- and a minimum from the old path
/// makes every frame on the new one look permanently late, which would pin a
/// buffer at a size nothing needs. Long enough that an ordinary quiet spell does
/// not reset the baseline, short enough that a genuine change is noticed.
const BASELINE_WINDOW: Duration = Duration::from_secs(30);
/// Ceiling on retained observations, so a stalled consumer cannot grow this.
const MAX_SAMPLES: usize = 4096;
/// One second's worth of lateness, summarised.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DelaySummary {
/// Frames measured.
pub frames: u32,
/// Median lateness above the best the path has managed, in milliseconds.
pub p50_ms: u16,
/// The 95th percentile of the same.
pub p95_ms: u16,
/// The worst single frame.
pub max_ms: u16,
}
/// Tracks arrival lateness for one media stream.
#[derive(Debug)]
pub struct DelayTracker {
/// Best-case delay observations, each with when it was taken, oldest first.
///
/// A deque rather than a single value because a minimum that can only fall
/// never recovers from a path that improved, and one that is simply reset on
/// a timer throws away a good baseline for no reason. Holding the recent
/// candidates lets the oldest expire while a better one is still standing.
baseline: VecDeque<(Instant, i32)>,
/// Lateness of each frame this second, in milliseconds.
samples: Vec<u16>,
/// The first delay seen, as an anchor for every later one.
///
/// Delays are compared *relative to this*, in wrapping arithmetic. Both
/// clocks are unrelated and the sender's stamp is a `u32` of milliseconds
/// that wraps every 49 days, so an absolute subtraction is a number with no
/// meaning and a wrap in the middle of a session makes it jump by 2^32 --
/// which, taken as lateness, reads as every frame being weeks late until the
/// baseline expires. Anchoring and then interpreting the difference as
/// signed makes the wrap a non-event, because the quantity that matters is
/// only ever a few hundred milliseconds wide.
anchor: Option<u32>,
}
impl Default for DelayTracker {
fn default() -> Self {
Self::new()
}
}
impl DelayTracker {
pub fn new() -> Self {
Self {
baseline: VecDeque::new(),
samples: Vec::new(),
anchor: None,
}
}
/// Record one frame's arrival.
///
/// `stamp_ms` is the timestamp the frame carries; `observed_ms` is the local
/// clock at the point being measured -- arrival, for a receiver; the moment
/// the frame is handed to the transport, for a sender. See [`anchor`](Self::anchor) for why the difference is taken
/// in wrapping arithmetic rather than widened.
pub fn observe(&mut self, stamp_ms: u32, observed_ms: u64, now: Instant) {
let raw = (observed_ms as u32).wrapping_sub(stamp_ms);
let anchor = *self.anchor.get_or_insert(raw);
let delay = raw.wrapping_sub(anchor) as i32;
self.expire(now);
// Anything at or below the running minimum becomes the new baseline, and
// supersedes the candidates it beats -- they can only be worse, so
// keeping them would let a stale, higher value resurface on expiry.
while self.baseline.back().is_some_and(|(_, d)| *d >= delay) {
self.baseline.pop_back();
}
self.baseline.push_back((now, delay));
let best = self.baseline.front().map(|(_, d)| *d).unwrap_or(delay);
let lateness = i64::from(delay)
.saturating_sub(i64::from(best))
.clamp(0, i64::from(u16::MAX)) as u16;
if self.samples.len() < MAX_SAMPLES {
self.samples.push(lateness);
}
}
/// Summarise and clear the frames seen since the last call.
///
/// Returns `None` for a second in which nothing arrived. That is not the
/// same as a second with no lateness, and reporting zero would say the path
/// is behaving perfectly at the moment it has stopped delivering anything.
pub fn take(&mut self) -> Option<DelaySummary> {
if self.samples.is_empty() {
return None;
}
self.samples.sort_unstable();
let at = |q: f64| self.samples[((self.samples.len() - 1) as f64 * q) as usize];
let summary = DelaySummary {
frames: self.samples.len() as u32,
p50_ms: at(0.5),
p95_ms: at(0.95),
max_ms: self.samples[self.samples.len() - 1],
};
self.samples.clear();
Some(summary)
}
/// Forget the baseline entirely.
///
/// For a path change, where the old best case says nothing about the new
/// path and keeping it would make every frame look late until it expired.
pub fn reset_baseline(&mut self) {
self.baseline.clear();
}
fn expire(&mut self, now: Instant) {
while self
.baseline
.front()
.is_some_and(|(seen, _)| now.duration_since(*seen) > BASELINE_WINDOW)
{
self.baseline.pop_front();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A frame sent at `ts_ms` arriving `transit` milliseconds later, where the
/// receiver's clock is offset from the sender's by an arbitrary amount.
const OFFSET: u64 = 1_700_000_000_000;
fn at(j: &mut DelayTracker, ts_ms: u32, transit: u64, now: Instant) {
j.observe(ts_ms, OFFSET + u64::from(ts_ms) + transit, now);
}
#[test]
fn a_constant_delay_is_not_jitter() {
// The point of the whole measure. A path that delivers every frame
// exactly 300 ms late needs no buffer at all; only variation does.
let mut j = DelayTracker::new();
let now = Instant::now();
for i in 0..120u32 {
at(
&mut j,
i * 16,
300,
now + Duration::from_millis(u64::from(i) * 16),
);
}
let s = j.take().expect("frames arrived");
assert_eq!(s.max_ms, 0, "a constant offset was read as lateness");
assert_eq!(s.frames, 120);
}
#[test]
fn lateness_is_measured_against_the_best_the_path_managed() {
let mut j = DelayTracker::new();
let now = Instant::now();
at(&mut j, 0, 20, now);
at(&mut j, 16, 70, now + Duration::from_millis(16));
at(&mut j, 32, 20, now + Duration::from_millis(32));
let s = j.take().expect("frames arrived");
assert_eq!(
s.max_ms, 50,
"the 70 ms frame is 50 ms above the 20 ms best"
);
}
#[test]
fn a_silent_second_reports_nothing_rather_than_no_jitter() {
// Zero would say the path is behaving perfectly at the moment it has
// stopped delivering anything at all.
let mut j = DelayTracker::new();
assert_eq!(j.take(), None);
}
#[test]
fn the_baseline_expires_so_an_improved_path_is_noticed() {
// A minimum that can only fall never recovers: one lucky early frame
// would make every later frame look late for the rest of the session.
let mut j = DelayTracker::new();
let now = Instant::now();
at(&mut j, 0, 10, now);
j.take();
// Much later, the path settles at a steady 200 ms.
let later = now + BASELINE_WINDOW + Duration::from_secs(1);
for i in 0..10u32 {
at(
&mut j,
1000 + i * 16,
200,
later + Duration::from_millis(u64::from(i) * 16),
);
}
let s = j.take().expect("frames arrived");
assert_eq!(
s.max_ms, 0,
"a stale best case from a previous path made a steady path look late",
);
}
#[test]
fn a_better_observation_supersedes_worse_ones_still_in_the_window() {
// Otherwise a higher candidate resurfaces when the better one expires,
// and the baseline walks upwards for no reason the path can account for.
let mut j = DelayTracker::new();
let now = Instant::now();
at(&mut j, 0, 90, now);
at(&mut j, 16, 10, now + Duration::from_millis(16));
// The 90 ms candidate is gone, so this sits 40 ms above the 10 ms best.
at(&mut j, 32, 50, now + Duration::from_millis(32));
let s = j.take().expect("frames arrived");
assert_eq!(s.max_ms, 40);
}
#[test]
fn a_reset_forgets_the_old_path_entirely() {
let mut j = DelayTracker::new();
let now = Instant::now();
at(&mut j, 0, 10, now);
j.take();
j.reset_baseline();
at(&mut j, 16, 400, now + Duration::from_millis(16));
let s = j.take().expect("frames arrived");
assert_eq!(s.max_ms, 0, "the new path was judged against the old one");
}
#[test]
fn the_tail_is_kept_apart_from_the_middle() {
// The number that sizes a buffer is the tail. A run of prompt frames
// with one bad one must not average into "slightly late".
let mut j = DelayTracker::new();
let now = Instant::now();
for i in 0..99u32 {
at(
&mut j,
i * 16,
10,
now + Duration::from_millis(u64::from(i) * 16),
);
}
at(&mut j, 99 * 16, 260, now + Duration::from_millis(99 * 16));
let s = j.take().expect("frames arrived");
assert_eq!(s.p50_ms, 0);
assert_eq!(s.max_ms, 250);
}
#[test]
fn taking_clears_so_each_answer_describes_one_second() {
let mut j = DelayTracker::new();
let now = Instant::now();
at(&mut j, 0, 10, now);
assert!(j.take().is_some());
assert_eq!(
j.take(),
None,
"a second reported the previous second again"
);
}
#[test]
fn a_wrapped_sender_timestamp_does_not_poison_the_baseline() {
// ts_ms wraps every 49 days. A negative delay taken as the best case
// would make every subsequent frame appear weeks late.
let mut j = DelayTracker::new();
let now = Instant::now();
j.observe(u32::MAX - 10, OFFSET, now);
j.observe(5, OFFSET + 16, now + Duration::from_millis(16));
let s = j.take().expect("frames arrived");
assert!(s.max_ms < u16::MAX, "a wrap produced a nonsense lateness");
}
}
+719 -4
View File
@@ -3,13 +3,64 @@
// can drift from each other silently.
pub mod datagram;
pub mod delay;
pub mod input;
#[cfg(feature = "lifecycle")]
pub mod lifecycle;
pub mod reliable;
pub mod stats;
pub const ALPN: &[u8] = b"/nestri/stream/1";
/// One connection per kind of traffic, not one connection for everything.
///
/// A QUIC connection is the unit that congestion control, pacing and the
/// datagram send buffer all operate on, so everything sharing one shares a
/// queue. Measured over a 1000-mile link: a video backlog delayed audio and
/// input with it, because a backlog is a property of the connection and video
/// is the only flow large enough to build one. Audio behind a deep video queue
/// was twenty-five times worse for timing than audio behind a shallow one --
/// the same audio, on the same path, ruined by what it was queued behind.
///
/// Splitting them gives each its own congestion controller and its own send
/// buffer, so video can only ever delay video. They compete at a shared
/// bottleneck rather than cooperating, which is the point: audio and input are
/// small and need a share, not a place in line behind a keyframe.
pub const ALPN_VIDEO: &[u8] = b"/nestri/video/1";
pub const ALPN_AUDIO: &[u8] = b"/nestri/audio/1";
pub const ALPN_INPUT: &[u8] = b"/nestri/input/1";
pub const ALPN_CONTROL: &[u8] = b"/nestri/control/1";
/// Every ALPN a hub accepts, for the endpoint builder.
pub const ALPNS: [&[u8]; 4] = [ALPN_VIDEO, ALPN_AUDIO, ALPN_INPUT, ALPN_CONTROL];
/// Which connection an accepted one is, by its ALPN.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Carrier {
Video,
Audio,
Input,
Control,
}
impl Carrier {
pub fn from_alpn(alpn: &[u8]) -> Option<Self> {
match alpn {
a if a == ALPN_VIDEO => Some(Self::Video),
a if a == ALPN_AUDIO => Some(Self::Audio),
a if a == ALPN_INPUT => Some(Self::Input),
a if a == ALPN_CONTROL => Some(Self::Control),
_ => None,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Video => "video",
Self::Audio => "audio",
Self::Input => "input",
Self::Control => "control",
}
}
}
pub const IPC_MAGIC: [u8; 4] = [b'N', b'S', b'T', b'R'];
@@ -38,8 +89,21 @@ pub const FRAME_HDR_LEN: usize = 7;
/// For single-purpose streams (video, audio) the type is redundant but included
/// for uniformity.
pub const MSG_DATA: u8 = 0; // generic data frame (video / audio)
pub const MSG_IDR_REQUEST: u8 = 0x10; // request a keyframe (desktop → hub → hudless)
pub const MSG_ENCODE_SETTINGS: u8 = 0x12; // change encoder settings (desktop → hub → hudless)
pub const MSG_IDR_REQUEST: u8 = 0x10; // request a keyframe (desktop → hub → nescapture)
pub const MSG_ENCODE_SETTINGS: u8 = 0x12; // change encoder settings (desktop → hub → nescapture)
pub const MSG_CLIENT_CAPS: u8 = 0x15; // what the client can decode (desktop → hub → nescapture)
pub const MSG_SURFACE_COLOR: u8 = 0x16; // what the compositor was told a surface is (nescope → nescapture)
/// What the receiver actually got, once a second (desktop → hub).
///
/// The hub cannot see this. Its own view of the path -- RTT, congestion window,
/// whether a datagram send returned an error -- was measured saying the path was
/// healthy while the client was receiving almost nothing, and one reason is
/// structural: `send_datagram` evicts the oldest queued datagrams and returns
/// `Ok`, so the send side has no backpressure signal at all. Only the far end
/// knows what arrived.
pub const MSG_RECEIVER_REPORT: u8 = 0x13;
/// Who decides the bitrate, and the ceiling to decide within (desktop → hub).
pub const MSG_CONTROL_MODE: u8 = 0x14;
pub const MSG_INPUT_BATCH: u8 = 0xFE; // batched input events (desktop → hub)
/// Build a frame body: `[u8 type] [u16 LE seq] [payload]`.
@@ -75,7 +139,7 @@ pub fn decode_frame(frame: &[u8]) -> Option<(u8, u16, &[u8])> {
// ── Stream types ────────────────────────────────────────────────
//
// These name a media kind, not a transport. `STREAM_VIDEO` and `STREAM_AUDIO`
// still tag IPC frames on the hudless→hub unix sockets, but over QUIC their
// still tag IPC frames on the nescapture→hub unix sockets, but over QUIC their
// media now travels as datagrams; only cursor and stats still open a uni
// stream and send this as its first byte.
@@ -90,6 +154,13 @@ pub const STREAM_KEYFRAME: u8 = 5;
// ── Bidi stream types (desktop ↔ hub over QUIC bidi) ───────────
pub const BIDI_INPUT: u8 = 2; // desktop → hub → nescope (input events)
/// Everything the client says that is not an input event: keyframe requests,
/// receiver reports, encode settings, control mode.
///
/// Its own stream on its own connection. Input is small and latency-critical
/// and must not wait behind a receiver report, and neither must wait behind
/// video, which is why these live apart from the media connections entirely.
pub const BIDI_CONTROL: u8 = 6;
// Codec IDs
pub const CODEC_H264: u8 = 0;
@@ -101,6 +172,13 @@ pub const CODEC_KEEP: u8 = 0xFF; // "keep current" sentinel for dynamic encoder
// Rate control modes (encode settings)
pub const RC_CBR: u8 = 0;
pub const RC_CQP: u8 = 1;
/// "Keep current" sentinel, the rate-control counterpart of [`CODEC_KEEP`].
///
/// A settings message says four things at once, and until this existed there
/// was no way to say only one of them: a client wanting a different bit depth
/// had to name a rate control mode and a value too, which in Auto mode means
/// overruling the controller that owns the bitrate.
pub const RC_KEEP: u8 = 0xFF;
// Bit-depth (encode settings)
pub const DEPTH_8: u8 = 0;
@@ -208,6 +286,207 @@ pub fn encode_encode_settings(
buf.push(bit_depth);
}
/// A settings payload that changes the bitrate and nothing else.
///
/// Six bytes rather than seven: the bit-depth byte is *omitted*, which
/// [`decode_encode_settings`] reports as `None`. That matters at the far end,
/// where a change carrying a depth has to be treated as a possible depth change
/// and rebuild the video session -- and a rebuild costs a keyframe. A controller
/// adjusting the bitrate every second must not do that, so it says nothing it
/// does not mean: keep the codec, keep the depth, this bitrate.
pub fn encode_bitrate_only(buf: &mut Vec<u8>, kbps: u32) {
buf.reserve(6);
buf.push(CODEC_KEEP);
buf.push(RC_CBR);
buf.extend_from_slice(&kbps.to_le_bytes());
}
// ── What colour the compositor was told a surface is ────────────────────
/// SDR: BT.709 primaries, sRGB transfer.
pub const SURFACE_COLOR_SRGB: u8 = 0;
/// HDR10: BT.2020 primaries, PQ transfer.
pub const SURFACE_COLOR_BT2020_PQ: u8 = 1;
/// What a Wayland client declared about its surface's colour.
///
/// Capture normally reads the colour space from the game's Vulkan swapchain,
/// and that is the right source when the swapchain names one. It does not
/// always: `VK_COLOR_SPACE_PASS_THROUGH_EXT` means "do not convert my values"
/// and carries no colour information at all, while the surface's real colour
/// space is declared separately, over `wp_color_manager_v1`, to the
/// compositor. A Windows title turning on HDR through wine arrives exactly
/// that way -- the pixels are BT.2020 PQ and the swapchain says nothing.
///
/// So the compositor, which is told, passes it to capture, which is not.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SurfaceColor {
/// One of the `SURFACE_COLOR_*` constants.
pub space: u8,
/// Mastering metadata, as the client gave it. Zero where it said nothing.
///
/// Carried now and not yet applied: it belongs in the stream's own
/// metadata, and sending it from the start means that can be wired up
/// without a second protocol change and a second pin.
pub max_cll: u32,
pub max_fall: u32,
pub min_luminance: u32,
pub max_luminance: u32,
}
/// Encode a surface colour declaration:
/// `[1B space] [4B max_cll] [4B max_fall] [4B min_lum] [4B max_lum]`, LE.
pub fn encode_surface_color(buf: &mut Vec<u8>, colour: &SurfaceColor) {
buf.reserve(17);
buf.push(colour.space);
buf.extend_from_slice(&colour.max_cll.to_le_bytes());
buf.extend_from_slice(&colour.max_fall.to_le_bytes());
buf.extend_from_slice(&colour.min_luminance.to_le_bytes());
buf.extend_from_slice(&colour.max_luminance.to_le_bytes());
}
/// Decode one. `None` when the payload is too short to be one.
pub fn decode_surface_color(payload: &[u8]) -> Option<SurfaceColor> {
if payload.len() < 17 {
return None;
}
let u32_at =
|i: usize| u32::from_le_bytes([payload[i], payload[i + 1], payload[i + 2], payload[i + 3]]);
Some(SurfaceColor {
space: payload[0],
max_cll: u32_at(1),
max_fall: u32_at(5),
min_luminance: u32_at(9),
max_luminance: u32_at(13),
})
}
// ── What the client can decode ──────────────────────────────────────────
/// The codec and depth combinations a client can decode, as one bitmask.
///
/// A host that picks something the far end cannot decode produces a black
/// screen and no error, so it needs the client's whole set rather than its
/// favourite: knowing only "this one prefers AV1" leaves nowhere to fall back
/// to when the host cannot encode AV1 either.
///
/// Sent once, on connect, before any picture. That is early enough that the
/// host never sends a codec the client cannot read, which reacting to the
/// first frame could not manage.
///
/// One bit per pair, at `codec * 2 + depth`, so the layout follows from the
/// codec ids rather than from a table that can disagree with them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ClientCaps(u16);
/// Best first. The same order the host uses to pick among its own encoders,
/// stated once so the two cannot drift apart.
pub const CODEC_PREFERENCE: [u8; 3] = [CODEC_AV1, CODEC_H265, CODEC_H264];
impl ClientCaps {
/// Nothing supported. What a client that never spoke is assumed to have,
/// which is why [`Self::best`] treats an empty set as "no opinion" rather
/// than as "decodes nothing".
pub fn empty() -> Self {
Self(0)
}
pub fn from_bits(bits: u16) -> Self {
Self(bits)
}
pub fn bits(self) -> u16 {
self.0
}
pub fn is_empty(self) -> bool {
self.0 == 0
}
fn bit(codec: u8, depth: u8) -> Option<u16> {
// `CODEC_KEEP` and the audio codec have no place in a video capability
// set, and shifting by them would be nonsense rather than a small
// error.
if !matches!(codec, CODEC_H264 | CODEC_H265 | CODEC_AV1) {
return None;
}
if !matches!(depth, DEPTH_8 | DEPTH_10) {
return None;
}
Some(1u16 << (codec * 2 + depth))
}
/// Add one pair. Unknown codecs and depths are ignored rather than
/// panicking: this is built from what a device probe reported.
#[must_use]
pub fn with(mut self, codec: u8, depth: u8) -> Self {
if let Some(bit) = Self::bit(codec, depth) {
self.0 |= bit;
}
self
}
pub fn supports(self, codec: u8, depth: u8) -> bool {
Self::bit(codec, depth).is_some_and(|bit| self.0 & bit != 0)
}
/// Whether this set can decode `codec` at any depth.
pub fn supports_codec(self, codec: u8) -> bool {
self.supports(codec, DEPTH_8) || self.supports(codec, DEPTH_10)
}
/// The best codec and depth both ends can manage.
///
/// Walks [`CODEC_PREFERENCE`], takes the first codec present in both sets,
/// and within it prefers ten bits -- deeper coefficients carry less
/// rounding error through the transform, so it is usually a small win on
/// efficiency rather than a trade against it.
///
/// `None` when nothing overlaps, which is a real possibility rather than a
/// theoretical one: an old client that sends no capabilities at all reads
/// as empty. The caller keeps whatever it was already doing.
pub fn best(self, host: Self) -> Option<(u8, u8)> {
if self.is_empty() || host.is_empty() {
return None;
}
for codec in CODEC_PREFERENCE {
for depth in [DEPTH_10, DEPTH_8] {
if self.supports(codec, depth) && host.supports(codec, depth) {
return Some((codec, depth));
}
}
}
None
}
}
/// Encode a capability set: `[2B bits LE]`.
pub fn encode_client_caps(buf: &mut Vec<u8>, caps: ClientCaps) {
buf.extend_from_slice(&caps.bits().to_le_bytes());
}
/// Decode a capability set. `None` when the payload is too short to be one.
pub fn decode_client_caps(payload: &[u8]) -> Option<ClientCaps> {
if payload.len() < 2 {
return None;
}
Some(ClientCaps::from_bits(u16::from_le_bytes([
payload[0], payload[1],
])))
}
/// A settings payload that changes the bit depth and nothing else.
///
/// What a client sends once, on connect, to say what it can actually decode.
/// The codec and the rate control are both left alone, so this is safe to send
/// in Auto mode, where the controller owns the bitrate.
///
/// Seven bytes, because the depth byte is the seventh: see
/// [`encode_bitrate_only`] for why its absence means something.
pub fn encode_depth_only(buf: &mut Vec<u8>, bit_depth: u8) {
encode_encode_settings(buf, CODEC_KEEP, RC_KEEP, 0, bit_depth);
}
/// Decode an encode-settings payload. Returns `(codec_id, rate_control_mode, value, bit_depth)`.
/// bit_depth is None for 6-byte (old client) payloads, Some(n) for 7+ byte payloads.
pub fn decode_encode_settings(payload: &[u8]) -> Option<(u8, u8, u32, Option<u8>)> {
@@ -224,3 +503,439 @@ pub fn decode_encode_settings(payload: &[u8]) -> Option<(u8, u8, u32, Option<u8>
};
Some((codec_id, rc, value, depth))
}
// ── Receiver report ─────────────────────────────────────────────
/// What one second looked like from the receiving end.
///
/// Counts are per-second deltas, not totals: a controller wants to know what is
/// happening now, and a total makes every reading depend on how long the session
/// has been running.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ReceiverReport {
/// Video bits per second actually reassembled and released to the decoder.
///
/// Not what was sent, and not what arrived -- what *completed*. A frame
/// missing one fragment contributes nothing here, which is right: it
/// contributed nothing to the picture either. When the sender is saturating
/// the path this is the measured capacity of it.
pub goodput_bps: u64,
/// Frames released to the decoder.
pub released: u32,
/// Frames that started arriving and never completed.
pub incomplete: u32,
/// Frames no fragment of which ever arrived.
pub never_arrived: u32,
/// The receiver's own round-trip estimate, in milliseconds.
pub rtt_ms: u32,
}
impl ReceiverReport {
/// The fraction of frames that did not make it, in `0.0..=1.0`.
///
/// `None` when no frames were accounted for at all, which is not the same
/// as no loss -- a second in which nothing was sent and a second in which
/// nothing arrived look identical here, and only the caller knows which it
/// is expecting.
pub fn loss(&self) -> Option<f32> {
let total = self.released + self.incomplete + self.never_arrived;
if total == 0 {
return None;
}
Some((self.incomplete + self.never_arrived) as f32 / total as f32)
}
}
/// `[8B goodput_bps][4B released][4B incomplete][4B never_arrived][4B rtt_ms]`,
/// all little-endian.
pub const RECEIVER_REPORT_LEN: usize = 24;
pub fn encode_receiver_report(buf: &mut Vec<u8>, report: &ReceiverReport) {
buf.reserve(RECEIVER_REPORT_LEN);
buf.extend_from_slice(&report.goodput_bps.to_le_bytes());
buf.extend_from_slice(&report.released.to_le_bytes());
buf.extend_from_slice(&report.incomplete.to_le_bytes());
buf.extend_from_slice(&report.never_arrived.to_le_bytes());
buf.extend_from_slice(&report.rtt_ms.to_le_bytes());
}
/// Decode a receiver report. `None` when the payload is short.
///
/// A payload *longer* than expected is accepted and its tail ignored, so a newer
/// client that appends a field still reports usefully to an older hub.
pub fn decode_receiver_report(payload: &[u8]) -> Option<ReceiverReport> {
if payload.len() < RECEIVER_REPORT_LEN {
return None;
}
let u32_at = |o: usize| u32::from_le_bytes(payload[o..o + 4].try_into().unwrap());
Some(ReceiverReport {
goodput_bps: u64::from_le_bytes(payload[0..8].try_into().unwrap()),
released: u32_at(8),
incomplete: u32_at(12),
never_arrived: u32_at(16),
rtt_ms: u32_at(20),
})
}
// ── Control mode ────────────────────────────────────────────────
/// Who is choosing the bitrate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ControlMode {
/// The hub's controller decides, within the ceiling.
#[default]
Auto,
/// A person decided, and the controller stands down until told otherwise.
///
/// Kept because it is how this class of bug gets diagnosed at all: the
/// original "the bitrate is already lowered" report was wrong, and the only
/// way anyone established that was by setting one by hand and watching the
/// picture come back.
Manual,
}
pub const CONTROL_MODE_AUTO: u8 = 0;
pub const CONTROL_MODE_MANUAL: u8 = 1;
/// `[1B mode][4B ceiling_kbps LE]`. A ceiling of 0 means "no opinion, keep
/// whatever the hub was given".
pub const CONTROL_MODE_LEN: usize = 5;
pub fn encode_control_mode(buf: &mut Vec<u8>, mode: ControlMode, ceiling_kbps: u32) {
buf.reserve(CONTROL_MODE_LEN);
buf.push(match mode {
ControlMode::Auto => CONTROL_MODE_AUTO,
ControlMode::Manual => CONTROL_MODE_MANUAL,
});
buf.extend_from_slice(&ceiling_kbps.to_le_bytes());
}
/// Returns `(mode, ceiling_kbps)`; the ceiling is `None` when it was left at 0.
pub fn decode_control_mode(payload: &[u8]) -> Option<(ControlMode, Option<u32>)> {
if payload.len() < CONTROL_MODE_LEN {
return None;
}
let mode = match payload[0] {
CONTROL_MODE_AUTO => ControlMode::Auto,
CONTROL_MODE_MANUAL => ControlMode::Manual,
// An unknown mode is not a reason to stop controlling the bitrate, and
// guessing "manual" would silently disable the controller.
_ => return None,
};
let ceiling = u32::from_le_bytes(payload[1..5].try_into().unwrap());
Some((mode, (ceiling != 0).then_some(ceiling)))
}
#[cfg(test)]
mod media_control_tests {
use super::*;
fn report() -> ReceiverReport {
ReceiverReport {
goodput_bps: 2_850_000,
released: 47,
incomplete: 12,
never_arrived: 1,
rtt_ms: 182,
}
}
#[test]
fn a_report_survives_the_wire() {
let mut buf = Vec::new();
encode_receiver_report(&mut buf, &report());
assert_eq!(buf.len(), RECEIVER_REPORT_LEN);
assert_eq!(decode_receiver_report(&buf), Some(report()));
}
#[test]
fn a_short_report_is_refused_rather_than_guessed() {
let mut buf = Vec::new();
encode_receiver_report(&mut buf, &report());
for n in 0..RECEIVER_REPORT_LEN {
assert_eq!(decode_receiver_report(&buf[..n]), None, "{n} bytes");
}
}
#[test]
fn a_longer_report_is_read_and_its_tail_ignored() {
// So a newer client that appends a field still reports usefully to a
// hub that predates it.
let mut buf = Vec::new();
encode_receiver_report(&mut buf, &report());
buf.extend_from_slice(&[0xAA; 8]);
assert_eq!(decode_receiver_report(&buf), Some(report()));
}
#[test]
fn loss_counts_every_frame_that_did_not_arrive_whole() {
// An incomplete frame is a lost frame. It cost bandwidth and produced no
// picture, which is worse than never having been sent.
let r = ReceiverReport {
released: 90,
incomplete: 8,
never_arrived: 2,
..Default::default()
};
assert_eq!(r.loss(), Some(0.1));
}
#[test]
fn a_silent_second_has_no_loss_figure() {
// Nothing sent and nothing arrived look identical from here. Reporting
// 0% would tell a controller the path is healthy; reporting 100% would
// tell it to collapse the bitrate. Neither is known, so neither is said.
assert_eq!(ReceiverReport::default().loss(), None);
}
#[test]
fn total_loss_is_reported_as_total() {
let r = ReceiverReport {
released: 0,
incomplete: 46,
never_arrived: 14,
..Default::default()
};
assert_eq!(r.loss(), Some(1.0));
}
#[test]
fn a_control_mode_survives_the_wire() {
for (mode, ceiling) in [
(ControlMode::Auto, 8_000u32),
(ControlMode::Manual, 1_000),
(ControlMode::Auto, 0),
] {
let mut buf = Vec::new();
encode_control_mode(&mut buf, mode, ceiling);
assert_eq!(buf.len(), CONTROL_MODE_LEN);
assert_eq!(
decode_control_mode(&buf),
Some((mode, (ceiling != 0).then_some(ceiling))),
);
}
}
#[test]
fn an_unknown_mode_is_refused_rather_than_defaulted() {
// Defaulting to manual would silently switch the controller off, which
// is the failure this whole change exists to remove.
let mut buf = vec![0x7F];
buf.extend_from_slice(&8_000u32.to_le_bytes());
assert_eq!(decode_control_mode(&buf), None);
}
#[test]
fn a_surface_colour_survives_the_wire() {
let colour = SurfaceColor {
space: SURFACE_COLOR_BT2020_PQ,
max_cll: 1000,
max_fall: 400,
min_luminance: 0,
max_luminance: 1000,
};
let mut buf = Vec::new();
encode_surface_color(&mut buf, &colour);
assert_eq!(buf.len(), 17);
assert_eq!(decode_surface_color(&buf), Some(colour));
}
/// A truncated payload is not a surface that is suddenly SDR. Reading one
/// as though it were would turn a dropped byte into a wrong picture.
#[test]
fn a_short_surface_colour_is_not_read() {
let mut buf = Vec::new();
encode_surface_color(&mut buf, &SurfaceColor::default());
for len in 0..17 {
assert_eq!(decode_surface_color(&buf[..len]), None, "len {len}");
}
}
#[test]
fn every_message_type_is_its_own_number() {
// Every type byte that travels on a stream, as `(name, value)`. Listed
// by hand because the point is to catch a new one colliding with an
// existing one, and anything derived from the constants would agree
// with them by construction.
//
// `MSG_CLIENT_CAPS` was 0x13 when it was added, which is
// `MSG_RECEIVER_REPORT`. The hub matches on the type byte and the caps
// arm came first, so every receiver report would have been read as
// capabilities - taking away the only measurement the bitrate
// controller has, silently, on a message sent once per connection.
let types = [
("MSG_DATA", MSG_DATA),
("MSG_IDR_REQUEST", MSG_IDR_REQUEST),
("MSG_ENCODE_SETTINGS", MSG_ENCODE_SETTINGS),
("MSG_CLIENT_CAPS", MSG_CLIENT_CAPS),
("MSG_SURFACE_COLOR", MSG_SURFACE_COLOR),
("MSG_RECEIVER_REPORT", MSG_RECEIVER_REPORT),
("MSG_CONTROL_MODE", MSG_CONTROL_MODE),
("MSG_INPUT_BATCH", MSG_INPUT_BATCH),
];
for (i, (name, value)) in types.iter().enumerate() {
for (other_name, other_value) in &types[i + 1..] {
assert_ne!(
value, other_value,
"{name} and {other_name} are both {value:#04x}"
);
}
}
}
#[test]
fn every_codec_and_depth_has_its_own_bit() {
let all = [CODEC_H264, CODEC_H265, CODEC_AV1]
.into_iter()
.flat_map(|c| [DEPTH_8, DEPTH_10].map(move |d| (c, d)));
let mut seen = Vec::new();
for (codec, depth) in all {
let caps = ClientCaps::empty().with(codec, depth);
assert!(caps.supports(codec, depth));
assert!(!seen.contains(&caps.bits()), "{codec}/{depth} collides");
seen.push(caps.bits());
}
}
/// Nothing outside the video codecs belongs in a capability set, and a
/// shift by `CODEC_KEEP` would be nonsense rather than a small error.
#[test]
fn nonsense_pairs_are_ignored_rather_than_stored() {
let caps = ClientCaps::empty()
.with(CODEC_KEEP, DEPTH_8)
.with(CODEC_OPUS, DEPTH_8)
.with(CODEC_AV1, 7);
assert!(caps.is_empty());
assert!(!caps.supports(CODEC_KEEP, DEPTH_8));
}
fn host_all() -> ClientCaps {
ClientCaps::empty()
.with(CODEC_AV1, DEPTH_8)
.with(CODEC_AV1, DEPTH_10)
.with(CODEC_H265, DEPTH_8)
.with(CODEC_H265, DEPTH_10)
.with(CODEC_H264, DEPTH_8)
}
#[test]
fn the_best_shared_codec_wins_at_the_deeper_depth() {
assert_eq!(host_all().best(host_all()), Some((CODEC_AV1, DEPTH_10)));
}
/// The case this exists for: a client with no AV1 decoder must not be sent
/// AV1 just because the host prefers it.
#[test]
fn a_client_without_av1_gets_h265() {
let client = ClientCaps::empty()
.with(CODEC_H265, DEPTH_8)
.with(CODEC_H265, DEPTH_10)
.with(CODEC_H264, DEPTH_8);
assert_eq!(client.best(host_all()), Some((CODEC_H265, DEPTH_10)));
}
#[test]
fn a_client_with_only_h264_gets_h264() {
let client = ClientCaps::empty().with(CODEC_H264, DEPTH_8);
assert_eq!(client.best(host_all()), Some((CODEC_H264, DEPTH_8)));
}
/// Ten bits is preferred, not required: a client that decodes H.265 at
/// eight bits only still gets H.265 rather than being pushed to H.264.
#[test]
fn eight_bit_is_taken_when_that_is_all_there_is() {
let client = ClientCaps::empty()
.with(CODEC_H265, DEPTH_8)
.with(CODEC_H264, DEPTH_8);
assert_eq!(client.best(host_all()), Some((CODEC_H265, DEPTH_8)));
}
/// A host that can only encode AV1 and a client that cannot decode it
/// share nothing. The caller keeps what it was doing rather than picking
/// something neither end asked for.
#[test]
fn no_overlap_is_no_answer() {
let host = ClientCaps::empty().with(CODEC_AV1, DEPTH_8);
let client = ClientCaps::empty().with(CODEC_H264, DEPTH_8);
assert_eq!(client.best(host), None);
}
/// A client that never sent capabilities reads as empty, which must mean
/// "said nothing" and not "decodes nothing".
#[test]
fn silence_is_not_an_answer_either() {
assert_eq!(ClientCaps::empty().best(host_all()), None);
assert_eq!(host_all().best(ClientCaps::empty()), None);
}
#[test]
fn capabilities_survive_the_wire() {
let caps = ClientCaps::empty()
.with(CODEC_AV1, DEPTH_10)
.with(CODEC_H264, DEPTH_8);
let mut buf = Vec::new();
encode_client_caps(&mut buf, caps);
assert_eq!(decode_client_caps(&buf), Some(caps));
assert_eq!(decode_client_caps(&buf[..1]), None, "too short to read");
}
/// The host walks its own encoders in this order; stating it once is what
/// keeps the two ends agreeing about what "best" means.
#[test]
fn the_preference_order_is_av1_first() {
assert_eq!(CODEC_PREFERENCE, [CODEC_AV1, CODEC_H265, CODEC_H264]);
}
#[test]
fn a_depth_only_change_touches_nothing_else() {
let mut buf = Vec::new();
encode_depth_only(&mut buf, DEPTH_10);
let (codec, rc, value, depth) = decode_encode_settings(&buf).expect("readable");
assert_eq!(codec, CODEC_KEEP, "the codec is the host's business");
assert_eq!(rc, RC_KEEP, "the controller keeps the bitrate it chose");
assert_eq!(value, 0, "and there is no value to read");
assert_eq!(depth, Some(DEPTH_10), "the depth is the whole message");
}
/// The two sentinels have to be distinguishable from real values, or a
/// "keep this" reads as a request for something.
#[test]
fn the_keep_sentinels_are_not_real_settings() {
assert_ne!(RC_KEEP, RC_CBR);
assert_ne!(RC_KEEP, RC_CQP);
assert_ne!(CODEC_KEEP, CODEC_H264);
assert_ne!(CODEC_KEEP, CODEC_H265);
assert_ne!(CODEC_KEEP, CODEC_AV1);
}
#[test]
fn a_bitrate_only_change_carries_no_depth_and_no_codec() {
// The far end rebuilds its video session -- and spends a keyframe -- for
// anything that might be a codec or depth change. A controller nudging
// the bitrate every second must say neither.
let mut buf = Vec::new();
encode_bitrate_only(&mut buf, 2_500);
let (codec, rc, value, depth) = decode_encode_settings(&buf).expect("readable");
assert_eq!(codec, CODEC_KEEP);
assert_eq!(rc, RC_CBR);
assert_eq!(value, 2_500);
assert_eq!(depth, None, "a depth byte would force a rebuild");
}
#[test]
fn the_new_message_types_do_not_collide() {
let all = [
MSG_DATA,
MSG_IDR_REQUEST,
MSG_ENCODE_SETTINGS,
MSG_RECEIVER_REPORT,
MSG_CONTROL_MODE,
MSG_INPUT_BATCH,
];
for (i, a) in all.iter().enumerate() {
for b in &all[i + 1..] {
assert_ne!(a, b, "two message types share a value");
}
}
}
}
+122 -10
View File
@@ -59,7 +59,10 @@ pub const CONTROL_PORT: u32 = 7000;
/// messages, so a version-2 peer and a version-3 peer do not talk at all.
/// There is deliberately no shim: nothing is deployed, and a shim would be the
/// second definition of this wire that one shared crate exists to prevent.
pub const CONTROL_VERSION: u32 = 3;
///
/// Version 4 replaced the descriptor's `drives` with `overlays`: a build is a
/// read-only image now, and a box writes into a layer of its own over it.
pub const CONTROL_VERSION: u32 = 4;
/// The command to run, and who runs it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -101,15 +104,29 @@ pub struct Mount {
pub ro: bool,
}
/// A block device the guest mounts, rather than a share it is handed.
/// A read-only block device with a writable one layered over it.
///
/// There are no mount options on this and that is deliberate: what the guest
/// mounts a build volume with is a property of how the volume was built --
/// journal-less ext4, `nosuid`, `nodev` -- and not something a descriptor is in
/// a position to know. A field for options was here and was never read.
/// This is how a game's install reaches a box. The build is an EROFS image
/// every box on the host shares, and the box's own writes land on an ext4 of
/// its own that is thrown away with it; the guest stacks the two with
/// overlayfs, so a game that writes into its install directory works and the
/// build under it cannot change.
///
/// Stacked in the guest rather than on the host because the host has no
/// filesystem it can do it on: the host-side equivalent was a ZFS clone, and a
/// host that needs ZFS is what this replaced.
///
/// There are no mount options on this and that is deliberate: how each layer
/// is mounted is a property of how it was built -- EROFS read-only, a
/// journal-less ext4 upper, `nosuid`, `nodev` -- and not something a
/// descriptor is in a position to know.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Drive {
pub dev: String,
pub struct Overlay {
/// The read-only build image, as the guest names the device.
pub lower: String,
/// The box's writable layer, as the guest names the device.
pub upper: String,
/// Where the stacked result lands.
pub at: String,
}
@@ -178,7 +195,40 @@ pub struct BootDescriptor {
#[serde(default)]
pub mounts: Vec<Mount>,
#[serde(default)]
pub drives: Vec<Drive>,
pub overlays: Vec<Overlay>,
/// What the box may spend on video.
///
/// Here rather than on a launch because its consumer is `neshub`, which is
/// a service and comes up with the box. Geometry went the other way for the
/// same reason: its consumer is the compositor, which is started per launch.
/// A number travels to where the thing that reads it is started.
#[serde(default)]
pub video: VideoLimits,
}
/// Ceilings on what a box's video may cost.
///
/// A struct rather than a bare number so the next video-shaped limit joins it
/// instead of arriving loose alongside it.
///
/// Note what `deny_unknown_fields` on [`BootDescriptor`] means for this: a host
/// that sends `video` to a guest too old to know the field is *refused*, not
/// quietly accepted. That is the intended direction of failure -- the
/// alternative is a box that comes up, streams, and ignores the ceiling it was
/// given, which is exactly the shape of failure this stack produces too easily.
/// The guest image is rebuilt before a host starts sending it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VideoLimits {
/// Ceiling on the video bitrate, in kbps.
///
/// `None` means nobody said, which is not the same as zero and is not the
/// same as unlimited. A reader that was told nothing should say so and pick
/// a conservative default of its own; a reader that treats "unsaid" as
/// "unlimited" reproduces the bug this exists to fix, where every session
/// offered 10 Mbps because nothing had ever set a number.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub bitrate_kbps: Option<u32>,
}
/// How a workload ended.
@@ -390,7 +440,8 @@ mod tests {
at: "/mnt/install".into(),
ro: true,
}],
drives: Vec::new(),
overlays: Vec::new(),
video: VideoLimits::default(),
}
}
@@ -633,3 +684,64 @@ mod tests {
assert!(!on_exit.terminal);
}
}
#[cfg(test)]
mod video_limits_tests {
use super::*;
#[test]
fn a_descriptor_without_video_still_reads() {
// An older host says nothing about video. That has to keep working, and
// it has to be distinguishable from a host that said "no limit".
let d: BootDescriptor = serde_json::from_str(r#"{"mounts":[],"overlays":[]}"#).unwrap();
assert_eq!(d.video.bitrate_kbps, None);
}
#[test]
fn an_unsaid_ceiling_is_not_serialised() {
// So a host that has nothing to say produces the same bytes it always
// did, and an older guest keeps accepting it.
let d = BootDescriptor {
mounts: Vec::new(),
overlays: Vec::new(),
video: VideoLimits::default(),
};
let json = serde_json::to_string(&d).unwrap();
assert!(!json.contains("bitrate"), "{json}");
}
#[test]
fn a_ceiling_survives_the_round_trip() {
let d = BootDescriptor {
mounts: Vec::new(),
overlays: Vec::new(),
video: VideoLimits {
bitrate_kbps: Some(8_000),
},
};
let back: BootDescriptor =
serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
assert_eq!(back, d);
}
#[test]
fn zero_is_not_the_same_as_unsaid() {
// A reader that conflates them cannot tell "the host wants no video" from
// "the host never mentioned it", and the second must not be read as a
// licence to send whatever it likes.
let said: BootDescriptor = serde_json::from_str(r#"{"video":{"bitrate_kbps":0}}"#).unwrap();
let unsaid: BootDescriptor = serde_json::from_str("{}").unwrap();
assert_eq!(said.video.bitrate_kbps, Some(0));
assert_eq!(unsaid.video.bitrate_kbps, None);
}
#[test]
fn an_unknown_video_field_is_refused() {
// Same reasoning as the descriptor's own `deny_unknown_fields`: a limit
// this build does not understand is one it would otherwise ignore while
// reporting success.
let r: Result<BootDescriptor, _> =
serde_json::from_str(r#"{"video":{"bitrate_kbps":8000,"fps_cap":30}}"#);
assert!(r.is_err(), "an unknown video limit must not be ignored");
}
}
+204 -10
View File
@@ -13,9 +13,9 @@ pub fn encode_nescope_stats(buf: &mut Vec<u8>, game_fps: u8, frame_count: u32) {
buf.extend_from_slice(&frame_count.to_le_bytes());
}
/// Hudless stats: capture FPS, encode time, dropped frames, diagnostics, capture latency.
/// nescapture stats: capture FPS, encode time, dropped frames, diagnostics, capture latency.
/// [1][capture_fps: u8][encode_avg_ms: f32 LE][dropped: u32 LE][present_attempts: u32 LE][capture_attempts: u32 LE][capture_ms: f32 LE]
pub fn encode_hudless_stats(
pub fn encode_nescapture_stats(
buf: &mut Vec<u8>,
capture_fps: u8,
encode_avg_ms: f32,
@@ -60,15 +60,98 @@ pub fn encode_hub_stats(
buf.push(audio_channels);
}
/// What the video bitrate is made of, and who chose it.
///
/// Appended to a hub stats packet rather than replacing anything, so an older
/// reader keeps working on the part it understands -- `decode_stats` already
/// guards each field on the length it needs.
///
/// **The split is the point.** One combined byte counter cannot distinguish an
/// encoder ignoring its bitrate target from a stream that is mostly keyframes,
/// and those have opposite fixes. A session overshooting its target by ten times
/// looked identical either way, which is why the cause stayed ambiguous for
/// weeks.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct VideoBreakdown {
/// Keyframe bits per second, measured over the last second.
pub key_bps: u32,
/// Everything else, measured the same way.
pub delta_bps: u32,
/// Keyframes in the last second.
pub keyframes: u8,
/// What the controller is asking the encoder for.
pub target_kbps: u32,
/// The ceiling it is choosing within, after any client lowered it.
pub ceiling_kbps: u32,
/// How much the box's own pipeline delay *varied* over the last second, in
/// milliseconds, at the median and the 95th percentile.
///
/// Variation rather than absolute cost, and necessarily so: the encoder
/// stamps a frame with milliseconds since its own start, not since any
/// epoch, so the difference to wall clock holds an unknown constant even
/// though both run on the same machine. It is also the comparable
/// quantity -- the client measures the same thing about the total arrival
/// delay, with the same code, so the gap between the two is what the
/// network added.
///
/// The point is attribution: a client seeing late frames cannot otherwise
/// tell a stalled encoder from a jittery path, and buffering against the
/// first is latency spent hiding a fault that should be fixed instead.
pub pipeline_p50_ms: u16,
pub pipeline_p95_ms: u16,
/// The worst single frame in that second.
pub pipeline_max_ms: u16,
/// The ceiling the box itself was given, which no client may exceed.
///
/// Separate from `ceiling_kbps` because a client that lowers the ceiling
/// would otherwise have nothing left to raise it against: the only number
/// it can see is the one it just lowered. A control that can be turned down
/// and not back up is worse than no control.
pub box_ceiling_kbps: u32,
/// Why the target is what it is; see the hub's control module.
pub reason: u8,
/// 0 when the controller is deciding, 1 when a person set it by hand.
pub manual: u8,
/// How far behind the send queue is, in milliseconds of video.
///
/// Bytes handed to the transport and not yet gone, over the rate they are
/// leaving at. This is the number that was invisible during the session
/// where every frame arrived, nothing was lost, and the picture was still
/// eight seconds old: loss cannot show a queue, only its overflow. Anything
/// but near-zero here means latency is being spent on backlog.
pub backlog_ms: u16,
}
/// `[4B key_bps][4B delta_bps][1B keyframes][4B target][4B ceiling][1B reason]
/// [1B manual][4B box_ceiling][2B pipeline_p50][2B pipeline_p95][2B pipeline_max]
/// [2B backlog_ms]`
pub const VIDEO_BREAKDOWN_LEN: usize = 31;
pub fn encode_video_breakdown(buf: &mut Vec<u8>, b: &VideoBreakdown) {
buf.reserve(VIDEO_BREAKDOWN_LEN);
buf.extend_from_slice(&b.key_bps.to_le_bytes());
buf.extend_from_slice(&b.delta_bps.to_le_bytes());
buf.push(b.keyframes);
buf.extend_from_slice(&b.target_kbps.to_le_bytes());
buf.extend_from_slice(&b.ceiling_kbps.to_le_bytes());
buf.push(b.reason);
buf.push(b.manual);
buf.extend_from_slice(&b.box_ceiling_kbps.to_le_bytes());
buf.extend_from_slice(&b.pipeline_p50_ms.to_le_bytes());
buf.extend_from_slice(&b.pipeline_p95_ms.to_le_bytes());
buf.extend_from_slice(&b.pipeline_max_ms.to_le_bytes());
buf.extend_from_slice(&b.backlog_ms.to_le_bytes());
}
/// Decoded stats from any source.
#[derive(Debug, Clone, Default)]
pub struct PipelineStats {
pub nescope_fps: u8,
pub nescope_frames: u32,
pub hudless_fps: u8,
pub hudless_encode_ms: f32,
pub hudless_capture_ms: f32,
pub hudless_dropped: u32,
pub nescapture_fps: u8,
pub nescapture_encode_ms: f32,
pub nescapture_capture_ms: f32,
pub nescapture_dropped: u32,
pub hub_clients: u8,
pub hub_video_mb: u32,
pub hub_relay_ms: f32,
@@ -76,6 +159,8 @@ pub struct PipelineStats {
pub capture_attempts: u32,
pub audio_bitrate_kbps: u32,
pub audio_channels: u8,
/// `None` from a hub that predates the breakdown.
pub video: Option<VideoBreakdown>,
}
/// Try to decode a single stats packet. The `msg_type` is the frame-level
@@ -87,13 +172,13 @@ pub fn decode_stats(msg_type: u8, data: &[u8], stats: &mut PipelineStats) {
stats.nescope_frames = u32::from_le_bytes([data[1], data[2], data[3], data[4]]);
}
STATS_HUDLESS if data.len() >= 17 => {
stats.hudless_fps = data[0];
stats.hudless_encode_ms = f32::from_le_bytes([data[1], data[2], data[3], data[4]]);
stats.hudless_dropped = u32::from_le_bytes([data[5], data[6], data[7], data[8]]);
stats.nescapture_fps = data[0];
stats.nescapture_encode_ms = f32::from_le_bytes([data[1], data[2], data[3], data[4]]);
stats.nescapture_dropped = u32::from_le_bytes([data[5], data[6], data[7], data[8]]);
stats.present_attempts = u32::from_le_bytes([data[9], data[10], data[11], data[12]]);
stats.capture_attempts = u32::from_le_bytes([data[13], data[14], data[15], data[16]]);
if data.len() >= 21 {
stats.hudless_capture_ms =
stats.nescapture_capture_ms =
f32::from_le_bytes([data[17], data[18], data[19], data[20]]);
}
}
@@ -110,7 +195,116 @@ pub fn decode_stats(msg_type: u8, data: &[u8], stats: &mut PipelineStats) {
if data.len() >= 14 {
stats.audio_channels = data[13];
}
if data.len() >= 14 + VIDEO_BREAKDOWN_LEN {
let d = &data[14..];
let u32_at = |o: usize| u32::from_le_bytes(d[o..o + 4].try_into().unwrap());
stats.video = Some(VideoBreakdown {
key_bps: u32_at(0),
delta_bps: u32_at(4),
keyframes: d[8],
target_kbps: u32_at(9),
ceiling_kbps: u32_at(13),
reason: d[17],
manual: d[18],
box_ceiling_kbps: u32_at(19),
pipeline_p50_ms: u16::from_le_bytes([d[23], d[24]]),
pipeline_p95_ms: u16::from_le_bytes([d[25], d[26]]),
pipeline_max_ms: u16::from_le_bytes([d[27], d[28]]),
backlog_ms: u16::from_le_bytes([d[29], d[30]]),
});
}
}
_ => {}
}
}
#[cfg(test)]
mod breakdown_tests {
use super::*;
fn breakdown() -> VideoBreakdown {
VideoBreakdown {
key_bps: 3_200_000,
delta_bps: 6_800_000,
keyframes: 2,
target_kbps: 6_000,
ceiling_kbps: 8_000,
reason: 1,
manual: 0,
box_ceiling_kbps: 8_000,
pipeline_p50_ms: 9,
pipeline_p95_ms: 24,
pipeline_max_ms: 61,
backlog_ms: 40,
}
}
fn hub_packet(with_breakdown: bool) -> Vec<u8> {
let mut buf = Vec::new();
encode_hub_stats(&mut buf, 1, 10_000_000, 0.0, 128, 2);
if with_breakdown {
encode_video_breakdown(&mut buf, &breakdown());
}
buf
}
#[test]
fn a_breakdown_survives_the_wire() {
let packet = hub_packet(true);
let mut stats = PipelineStats::default();
decode_stats(STATS_HUB, &packet[1..], &mut stats);
assert_eq!(stats.video, Some(breakdown()));
// The fields that were always there still read correctly beside it.
assert_eq!(stats.hub_clients, 1);
assert_eq!(stats.audio_bitrate_kbps, 128);
assert_eq!(stats.audio_channels, 2);
}
#[test]
fn a_hub_without_the_breakdown_still_reads() {
// The reason this is appended rather than folded into the layout: a hub
// that predates it keeps working, and says so by omission rather than by
// reporting zeroes that look like a stream carrying nothing.
let packet = hub_packet(false);
let mut stats = PipelineStats::default();
decode_stats(STATS_HUB, &packet[1..], &mut stats);
assert_eq!(stats.video, None);
assert_eq!(stats.hub_clients, 1);
assert_eq!(stats.audio_channels, 2);
}
#[test]
fn a_truncated_breakdown_is_left_out_rather_than_half_read() {
let full = hub_packet(true);
for n in 15..full.len() - 1 {
let mut stats = PipelineStats::default();
decode_stats(STATS_HUB, &full[1..n], &mut stats);
assert_eq!(stats.video, None, "{n} bytes produced a partial breakdown");
}
}
#[test]
fn the_split_distinguishes_the_two_ways_a_stream_overshoots() {
// The whole reason for the split. Same total, opposite causes: an
// encoder ignoring its target, and a stream that is nearly all
// keyframes. One counter cannot tell them apart.
let ignoring_target = VideoBreakdown {
key_bps: 200_000,
delta_bps: 9_800_000,
keyframes: 1,
..breakdown()
};
let keyframe_storm = VideoBreakdown {
key_bps: 9_000_000,
delta_bps: 1_000_000,
keyframes: 30,
..breakdown()
};
assert_eq!(
ignoring_target.key_bps + ignoring_target.delta_bps,
keyframe_storm.key_bps + keyframe_storm.delta_bps,
);
assert!(ignoring_target.delta_bps > ignoring_target.key_bps);
assert!(keyframe_storm.key_bps > keyframe_storm.delta_bps);
}
}