feat: PyroWave Phase 3 — pinned rate, all-intra silencing, opt-in UI, notices, docs
ci / docs-site (push) Successful in 1m0s
ci / web (push) Successful in 2m13s
arch / build-publish (push) Failing after 6m17s
decky / build-publish (push) Successful in 18s
android / android (push) Failing after 6m43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
ci / bench (push) Successful in 5m6s
ci / rust (push) Failing after 9m30s
docker / deploy-docs (push) Successful in 27s
flatpak / build-publish (push) Failing after 4m50s
deb / build-publish (push) Failing after 7m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 6m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 6m2s
windows-host / package (push) Failing after 3m4s
apple / swift (push) Failing after 1m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 3m1s
release / apple (push) Failing after 1m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 2m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 4m36s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 4m43s
apple / screenshots (push) Has been skipped

plan §4.6 + Phase 3 productization:

- Pinned bitrate: an Automatic client (bitrate 0) on a PyroWave session
  resolves to the codec's ~1.6 bpp operating point for the mode (≈200
  Mbps at 1080p60) instead of the 20 Mbps H.26x default; explicit rates
  are honored. Mid-stream SetBitrate retargets are refused with the
  pinned rate acked (guards old/foreign clients), and the client-side
  AIMD controller + startup capacity probe stay off for the codec — no
  rate descent into wavelet mush, no climb probe whose VBV reasoning
  doesn't apply to hard per-frame CBR. Unit-tested.

- All-intra silencing: the data plane drops drained keyframe/RFI
  requests on PyroWave sessions (the next frame IS the recovery), so
  the forced-IDR cooldown, RFI attempt, and storm coalescing never run.

- Opt-in UI: 'PyroWave (wired LAN)' joins the console's Video-codec
  cycler; trust::Settings maps it to CODEC_PYROWAVE. Safe everywhere by
  the negotiation contract — an un-advertised preference falls back
  through the ladder.

- FEC: decision recorded — adaptive FEC (10% start, loss-report driven)
  stays as-is for the MVP opaque-AU mode; the FEC≈0 policy belongs to
  the Phase-4 datagram-aligned mode.

- THIRD-PARTY-NOTICES: the generator now lists third-party trees
  vendored inside first-party crates (pyrowave, Granite subset, volk,
  Vulkan-Headers) with their full license texts; file regenerated.

- docs-site: 'PyroWave (wired-LAN codec)' page — what it is, the
  bandwidth table, how to enable it, current limits.

Validated on .21: 309 host + 148 core + 26 client tests green,
console-ui clean, both feature configs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 09:45:49 +02:00
parent 8dc5d672e2
commit 12148243bd
8 changed files with 1179 additions and 452 deletions
+679 -297
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -280,6 +280,65 @@ pub fn pair_with_host(
) )
} }
/// User-facing sentence for a failed connect / request-access, keyed on the actual cause —
/// shared by every desktop/console surface so "the host declined this device" never renders
/// as "connection timed out". Reason-specific text for a typed host rejection
/// ([`punktfunk_core::reject::RejectReason`]); the caller keeps its own wording for
/// non-rejection errors.
pub fn connect_reject_message(reason: punktfunk_core::reject::RejectReason) -> String {
use punktfunk_core::reject::RejectReason as R;
match reason {
R::Denied => "The host declined this device's request.".into(),
R::ApprovalTimeout => {
"Nobody approved the request on the host in time — approve this device in the \
host's console or web UI, then request access again."
.into()
}
R::Superseded => {
"A newer request from this device replaced this one — approve the latest request \
on the host."
.into()
}
R::IdentityRequired => {
"The host requires pairing — pair this device (PIN or request access) first.".into()
}
R::PairingNotArmed => {
"Pairing isn't armed on the host — arm it on the host's Pairing page, then try \
again."
.into()
}
R::PairingBoundToOtherDevice => {
"The host's pairing window is armed for a different device — arm it for this one."
.into()
}
R::PairingRateLimited => {
"Too many pairing attempts — wait a couple of seconds and try again.".into()
}
R::WireVersionMismatch => {
"Client and host versions don't match — update both to the same release.".into()
}
R::Busy => "The host is busy with another session.".into(),
}
}
/// User-facing sentence for a failed PIN pairing ceremony ([`pair_with_host`]) — distinguishes
/// a wrong PIN (the SPAKE2 proof failed) from an unreachable host and from the host's typed
/// rejections, so a dead network path or a disarmed host is never reported as a bad PIN.
pub fn pair_error_message(err: &punktfunk_core::PunktfunkError) -> String {
use punktfunk_core::PunktfunkError as E;
match err {
E::Crypto => "Wrong PIN — check the PIN on the host's Pairing page and try again.".into(),
E::Rejected(reason) => connect_reject_message(*reason),
E::Timeout => "The host didn't answer. Is it running and reachable?".into(),
E::Io(_) => {
"Couldn't reach the host — check that this device and the host are on the same \
network (no VPN on this device, no guest-Wi-Fi / AP isolation)."
.into()
}
other => format!("Pairing failed: {other:?}"),
}
}
/// Probe several hosts for reachability in parallel — one thread each, so the wall-clock cost is /// Probe several hosts for reachability in parallel — one thread each, so the wall-clock cost is
/// ~one `timeout`, not the sum. Each element of the returned vec corresponds by index to /// ~one `timeout`, not the sum. Each element of the returned vec corresponds by index to
/// `targets`. Wraps the single-host [`NativeClient::probe`] (a bounded, trust-agnostic, /// `targets`. Wraps the single-host [`NativeClient::probe`] (a bounded, trust-agnostic,
@@ -515,6 +574,10 @@ impl Settings {
"h264" | "avc" => punktfunk_core::quic::CODEC_H264, "h264" | "avc" => punktfunk_core::quic::CODEC_H264,
"hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC, "hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC,
"av1" => punktfunk_core::quic::CODEC_AV1, "av1" => punktfunk_core::quic::CODEC_AV1,
// The wired-LAN wavelet codec: preference-only by design (resolve_codec never
// auto-picks it), and harmless on a build/device that doesn't advertise the
// bit — the ladder falls back to HEVC.
"pyrowave" => punktfunk_core::quic::CODEC_PYROWAVE,
_ => 0, _ => 0,
} }
} }
@@ -631,6 +694,9 @@ mod tests {
assert!(s.mic_enabled); assert!(s.mic_enabled);
assert_eq!(s.decoder, "hardware"); assert_eq!(s.decoder, "hardware");
assert_eq!(s.preferred_codec(), punktfunk_core::quic::CODEC_AV1); assert_eq!(s.preferred_codec(), punktfunk_core::quic::CODEC_AV1);
let mut pw = s.clone();
pw.codec = "pyrowave".into();
assert_eq!(pw.preferred_codec(), punktfunk_core::quic::CODEC_PYROWAVE);
assert_eq!(s.adapter, "NVIDIA GeForce RTX 4080"); assert_eq!(s.adapter, "NVIDIA GeForce RTX 4080");
assert!(s.hdr_enabled); assert!(s.hdr_enabled);
// The old shell's `show_hud` lands on `show_stats` (the user's preference survives). // The old shell's `show_hud` lands on `show_stats` (the user's preference survives).
+4 -1
View File
@@ -65,11 +65,14 @@ const COMPOSITORS: [(&str, &str); 5] = [
("mutter", "Mutter"), ("mutter", "Mutter"),
("gamescope", "gamescope"), ("gamescope", "gamescope"),
]; ];
const CODECS: [(&str, &str); 4] = [ const CODECS: [(&str, &str); 5] = [
("auto", "Automatic"), ("auto", "Automatic"),
("hevc", "HEVC"), ("hevc", "HEVC"),
("h264", "H.264"), ("h264", "H.264"),
("av1", "AV1"), ("av1", "AV1"),
// Opt-in wired-LAN low-latency codec (100-400 Mbps class, 8-bit SDR). Only ever
// selected when the host supports it too; anything else falls back to HEVC.
("pyrowave", "PyroWave (wired LAN)"),
]; ];
const DECODERS: [(&str, &str); 4] = [ const DECODERS: [(&str, &str); 4] = [
("auto", "Automatic"), ("auto", "Automatic"),
+158 -116
View File
@@ -862,7 +862,16 @@ impl NativeClient {
.await .await
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?; .map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
let host_fp = observed.lock().unwrap().ok_or(PunktfunkError::Crypto)?; let host_fp = observed.lock().unwrap().ok_or(PunktfunkError::Crypto)?;
let outcome = exchange(conn.clone(), host_fp).await; let outcome = match exchange(conn.clone(), host_fp).await {
// A typed application close from the host (pairing not armed / armed for a
// different device / rate-limited / version mismatch) beats the generic
// transport error the aborted exchange produced — it is the actual answer.
Err(e) => Err(match reject_from_close(&conn) {
Some(r) => PunktfunkError::Rejected(r),
None => e,
}),
ok => ok,
};
// Always tell the host we're done so it never blocks at its read — code 0 on // Always tell the host we're done so it never blocks at its read — code 0 on
// success, 1 on a refused/aborted ceremony. // success, 1 on a refused/aborted ceremony.
let code: u32 = if outcome.is_ok() { 0 } else { 1 }; let code: u32 = if outcome.is_ok() { 0 } else { 1 };
@@ -1355,6 +1364,18 @@ struct WorkerArgs {
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking /// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
/// data-plane pump. /// data-plane pump.
/// The host's stated rejection, if this connection was closed with a typed application code
/// (see [`crate::reject`]) — `None` for local errors, bare/legacy closes (including our own
/// `LocallyClosed`), and transport failures, which keep their original error.
fn reject_from_close(conn: &quinn::Connection) -> Option<crate::reject::RejectReason> {
match conn.close_reason()? {
quinn::ConnectionError::ApplicationClosed(ac) => u32::try_from(u64::from(ac.error_code))
.ok()
.and_then(crate::reject::RejectReason::from_close_code),
_ => None,
}
}
async fn worker_main(args: WorkerArgs) { async fn worker_main(args: WorkerArgs) {
let WorkerArgs { let WorkerArgs {
host, host,
@@ -1417,124 +1438,139 @@ async fn worker_main(args: WorkerArgs) {
} }
})?; })?;
let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]); let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]);
let (mut send, mut recv) = conn // The rest of the handshake runs in an inner future so a failure can consult
.open_bi() // `conn.close_reason()`: a host that turned us away with a typed application close
.await // (pairing not armed / denied / approval timeout / version mismatch / busy) surfaces
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?; // as `PunktfunkError::Rejected` instead of the generic transport error the failed
// read produces — the difference between "not accepted" and the actual cause.
let handshake = async {
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
io::write_msg( io::write_msg(
&mut send, &mut send,
&Hello { &Hello {
abi_version: crate::WIRE_VERSION, abi_version: crate::WIRE_VERSION,
mode, mode,
compositor, compositor,
gamepad, gamepad,
bitrate_kbps, bitrate_kbps,
// No device name yet: the connect ABI has no name parameter (pairing does). The // No device name yet: the connect ABI has no name parameter (pairing does). The
// host falls back to a fingerprint-derived label in its pending-approval list. // host falls back to a fingerprint-derived label in its pending-approval list.
name: None, name: None,
// Library id to launch this session, if the embedder asked for one. // Library id to launch this session, if the embedder asked for one.
launch: launch.clone(), launch: launch.clone(),
// The embedder's decode/present caps (e.g. the Windows client advertises // The embedder's decode/present caps (e.g. the Windows client advertises
// VIDEO_CAP_10BIT | VIDEO_CAP_HDR). The host only upgrades to a 10-bit / HDR encode // VIDEO_CAP_10BIT | VIDEO_CAP_HDR). The host only upgrades to a 10-bit / HDR encode
// when the matching bit is set, so `0` stays an 8-bit BT.709 stream. HOST_TIMING is // when the matching bit is set, so `0` stays an 8-bit BT.709 stream. HOST_TIMING is
// OR'd in unconditionally: every NativeClient build demuxes the 0xCF plane, and the // OR'd in unconditionally: every NativeClient build demuxes the 0xCF plane, and the
// bit only asks the host for observability datagrams (never changes the encode). // bit only asks the host for observability datagrams (never changes the encode).
// PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window // PROBE_SEQ likewise: the shared reassembler keeps probe filler in its own window
// (every embedder inherits it), so the host may burst speed tests without consuming // (every embedder inherits it), so the host may burst speed tests without consuming
// video frame indexes. // video frame indexes.
video_caps: video_caps video_caps: video_caps
| crate::quic::VIDEO_CAP_HOST_TIMING | crate::quic::VIDEO_CAP_HOST_TIMING
| crate::quic::VIDEO_CAP_PROBE_SEQ, | crate::quic::VIDEO_CAP_PROBE_SEQ,
// Requested surround channel count; the host echoes the resolved value in Welcome. // Requested surround channel count; the host echoes the resolved value in Welcome.
audio_channels, audio_channels,
// The codecs this client can decode + its soft preference (0 = auto). The host // The codecs this client can decode + its soft preference (0 = auto). The host
// resolves the emitted codec from these and reports it in `Welcome::codec`. // resolves the emitted codec from these and reports it in `Welcome::codec`.
video_codecs, video_codecs,
preferred_codec, preferred_codec,
// The client display's HDR volume → the host's virtual-display EDID (host apps // The client display's HDR volume → the host's virtual-display EDID (host apps
// tone-map to the client's real panel). `None` = unknown/SDR. // tone-map to the client's real panel). `None` = unknown/SDR.
display_hdr, display_hdr,
}
.encode(),
)
.await?;
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)?;
if welcome.compositor != CompositorPref::Auto {
tracing::info!(
compositor = welcome.compositor.as_str(),
"host resolved compositor"
);
}
if welcome.gamepad != GamepadPref::Auto {
tracing::info!(
gamepad = welcome.gamepad.as_str(),
"host resolved gamepad backend"
);
}
// Reserve our data-plane port, then start the host.
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
let udp_port = probe.local_addr()?.port();
drop(probe);
io::write_msg(
&mut send,
&Start {
client_udp_port: udp_port,
}
.encode(),
)
.await?;
// Wall-clock skew handshake on the control stream (before the session's control task takes
// it): align our clock to the host's so the embedder can express receive/present instants in
// the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock
// assumption, as before). This is the substrate for glass-to-glass present-time measurement.
let (clock_offset_ns, clock_rtt_ns) =
match crate::quic::clock_sync(&mut send, &mut recv).await {
Some(skew) => {
tracing::info!(
offset_ns = skew.offset_ns,
rtt_us = skew.rtt_ns / 1000,
rounds = skew.rounds,
"clock skew estimated (host-client)"
);
(skew.offset_ns, Some(skew.rtt_ns))
} }
None => (0, None), .encode(),
}; )
.await?;
let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)?;
if welcome.compositor != CompositorPref::Auto {
tracing::info!(
compositor = welcome.compositor.as_str(),
"host resolved compositor"
);
}
if welcome.gamepad != GamepadPref::Auto {
tracing::info!(
gamepad = welcome.gamepad.as_str(),
"host resolved gamepad backend"
);
}
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port); // Reserve our data-plane port, then start the host.
let transport = let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?; let udp_port = probe.local_addr()?.port();
// Hole-punch the host's data port so video traverses a NAT / stateful inter-VLAN firewall drop(probe);
// (control + side planes ride the client-initiated QUIC; the raw video UDP needs the client io::write_msg(
// to open the path first). Stops with the session via the shared shutdown flag. &mut send,
if let Ok(sock) = transport.try_clone_socket() { &Start {
crate::transport::spawn_data_punch(sock, shutdown.clone()); client_udp_port: udp_port,
}
.encode(),
)
.await?;
// Wall-clock skew handshake on the control stream (before the session's control task takes
// it): align our clock to the host's so the embedder can express receive/present instants in
// the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock
// assumption, as before). This is the substrate for glass-to-glass present-time measurement.
let (clock_offset_ns, clock_rtt_ns) =
match crate::quic::clock_sync(&mut send, &mut recv).await {
Some(skew) => {
tracing::info!(
offset_ns = skew.offset_ns,
rtt_us = skew.rtt_ns / 1000,
rounds = skew.rounds,
"clock skew estimated (host-client)"
);
(skew.offset_ns, Some(skew.rtt_ns))
}
None => (0, None),
};
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
let transport =
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?;
// Hole-punch the host's data port so video traverses a NAT / stateful inter-VLAN firewall
// (control + side planes ride the client-initiated QUIC; the raw video UDP needs the client
// to open the path first). Stops with the session via the shared shutdown flag.
if let Ok(sock) = transport.try_clone_socket() {
crate::transport::spawn_data_punch(sock, shutdown.clone());
}
let session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
Ok::<_, PunktfunkError>((
session,
send,
recv,
Negotiated {
mode: welcome.mode,
compositor: welcome.compositor,
gamepad: welcome.gamepad,
host_fingerprint: fingerprint,
bitrate_kbps: welcome.bitrate_kbps,
clock_offset_ns,
clock_rtt_ns,
bit_depth: welcome.bit_depth,
color: welcome.color,
chroma_format: welcome.chroma_format,
audio_channels: welcome.audio_channels,
codec: welcome.codec,
},
welcome.host_caps,
))
};
match handshake.await {
Ok((session, send, recv, negotiated, host_caps)) => {
Ok((conn, session, send, recv, negotiated, host_caps))
}
Err(e) => Err(match reject_from_close(&conn) {
Some(r) => PunktfunkError::Rejected(r),
None => e,
}),
} }
let session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
Ok::<_, PunktfunkError>((
conn,
session,
send,
recv,
Negotiated {
mode: welcome.mode,
compositor: welcome.compositor,
gamepad: welcome.gamepad,
host_fingerprint: fingerprint,
bitrate_kbps: welcome.bitrate_kbps,
clock_offset_ns,
clock_rtt_ns,
bit_depth: welcome.bit_depth,
color: welcome.color,
chroma_format: welcome.chroma_format,
audio_channels: welcome.audio_channels,
codec: welcome.codec,
},
welcome.host_caps,
))
}; };
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, host_caps) = match setup.await let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, host_caps) = match setup.await
@@ -1548,6 +1584,7 @@ async fn worker_main(args: WorkerArgs) {
// Copies the pump needs after `negotiated` is handed over to `connect`. // Copies the pump needs after `negotiated` is handed over to `connect`.
let clock_rtt_ns = negotiated.clock_rtt_ns; let clock_rtt_ns = negotiated.clock_rtt_ns;
let resolved_bitrate_kbps = negotiated.bitrate_kbps; let resolved_bitrate_kbps = negotiated.bitrate_kbps;
let negotiated_codec = negotiated.codec;
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the // Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair. // client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed); clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
@@ -1948,7 +1985,11 @@ async fn worker_main(args: WorkerArgs) {
// echoes 0 → controller stays permanently off). Fed once per report window with the same // echoes 0 → controller stays permanently off). Fed once per report window with the same
// deltas the LossReport uses, plus the window's mean skew-corrected one-way delay and // deltas the LossReport uses, plus the window's mean skew-corrected one-way delay and
// whether a jump-to-live flush fired. // whether a jump-to-live flush fired.
let mut abr = BitrateController::new(if bitrate_kbps == 0 { // PyroWave sessions PIN their rate (§4.6): AIMD descent turns wavelets to mush well
// above its floor, and the climb probe's VBV reasoning doesn't apply to hard
// per-frame CBR — controller and capacity probe stay off (0 = permanently off).
let rate_pinned = negotiated_codec == crate::quic::CODEC_PYROWAVE;
let mut abr = BitrateController::new(if bitrate_kbps == 0 && !rate_pinned {
resolved_bitrate_kbps resolved_bitrate_kbps
} else { } else {
0 0
@@ -1965,6 +2006,7 @@ async fn worker_main(args: WorkerArgs) {
const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2); const CAPACITY_PROBE_DELAY: Duration = Duration::from_secs(2);
const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6); const CAPACITY_PROBE_TIMEOUT: Duration = Duration::from_secs(6);
let mut capacity_probe_at: Option<Instant> = (bitrate_kbps == 0 let mut capacity_probe_at: Option<Instant> = (bitrate_kbps == 0
&& !rate_pinned
&& resolved_bitrate_kbps > 0 && resolved_bitrate_kbps > 0
&& std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0")) && std::env::var("PUNKTFUNK_ABR_PROBE").map_or(true, |v| v != "0"))
.then(|| Instant::now() + CAPACITY_PROBE_DELAY); .then(|| Instant::now() + CAPACITY_PROBE_DELAY);
+163 -38
View File
@@ -417,8 +417,18 @@ const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10
/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal, /// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal,
/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct /// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct
/// code lets a client tell "host busy" apart from a transport failure. /// code lets a client tell "host busy" apart from a transport failure. Shared with the clients via
const REJECT_BUSY_CODE: u32 = 0x42; /// `punktfunk_core::reject` so they can decode it (`RejectReason::Busy`).
const REJECT_BUSY_CODE: u32 = punktfunk_core::reject::REJECT_BUSY_CLOSE_CODE;
/// Make a gate rejection legible to the client BEFORE erroring out of the session task: close
/// with the typed application code (`punktfunk_core::reject`) so the client renders the real
/// reason ("pairing not armed", "denied in the console") — the task's `Err` then only logs.
/// Without this the dropped connection closes with a bare code 0, indistinguishable on the
/// client from transport trouble (the "not accepted" support-thread failure mode).
fn close_rejected(conn: &quinn::Connection, reason: punktfunk_core::reject::RejectReason) {
conn.close(reason.close_code().into(), reason.to_string().as_bytes());
}
/// QUIC application error code a client closes with on a **deliberate quit** (a user "stop", not a /// QUIC application error code a client closes with on a **deliberate quit** (a user "stop", not a
/// network drop). The host reads it off the connection's `ApplicationClosed` reason and tears the /// network drop). The host reads it off the connection's `ApplicationClosed` reason and tears the
@@ -456,6 +466,27 @@ fn resolve_bitrate_kbps(requested: u32) -> u32 {
} }
} }
/// [`resolve_bitrate_kbps`] with the codec's floor semantics: PyroWave has no useful
/// low-rate regime (wavelet quality collapses far above the H.26x floor — plan §4.6), so
/// an Automatic client (`0`) gets the codec's ~1.6 bpp operating point for the negotiated
/// mode instead of the 20 Mbps H.26x default. The rate is then PINNED for the session:
/// the client's ABR controller stays off for this codec and the host refuses mid-stream
/// retargets. An explicit client rate is honored unchanged (the operator knows the link).
fn resolve_bitrate_kbps_for(
codec: crate::encode::Codec,
requested: u32,
mode: &punktfunk_core::config::Mode,
) -> u32 {
if requested == 0 && codec == crate::encode::Codec::PyroWave {
let bps =
mode.width as u64 * mode.height as u64 * u64::from(mode.refresh_hz.max(1)) * 16 / 10;
return u32::try_from(bps / 1000)
.unwrap_or(MAX_BITRATE_KBPS)
.clamp(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS);
}
resolve_bitrate_kbps(requested)
}
/// Resolve the audio channel count the session will capture + encode from the client's request. /// Resolve the audio channel count the session will capture + encode from the client's request.
/// Normalizes to one of 2 (stereo) / 6 (5.1) / 8 (7.1); anything else (older client, garbage) /// Normalizes to one of 2 (stereo) / 6 (5.1) / 8 (7.1); anything else (older client, garbage)
/// becomes stereo. Both backends can produce the requested count (PipeWire pads/upmixes positions, /// becomes stereo. Both backends can produce the requested count (PipeWire pads/upmixes positions,
@@ -631,29 +662,46 @@ async fn serve_session(
// The client fingerprint (cert possession is proven by the QUIC handshake) is needed to honor // The client fingerprint (cert possession is proven by the QUIC handshake) is needed to honor
// a fingerprint-bound PIN window (#9): a window the operator armed for a SPECIFIC device must // a fingerprint-bound PIN window (#9): a window the operator armed for a SPECIFIC device must
// not be consumable — or burnable — by any other fingerprint. // not be consumable — or burnable — by any other fingerprint.
let client_fp = endpoint::peer_fingerprint(&conn) let Some(client_fp) = endpoint::peer_fingerprint(&conn) else {
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?; close_rejected(
&conn,
punktfunk_core::reject::RejectReason::IdentityRequired,
);
anyhow::bail!("pairing requires the client to present a certificate");
};
let client_fp_hex = fingerprint_hex(&client_fp); let client_fp_hex = fingerprint_hex(&client_fp);
// Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any // Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any
// fingerprint binding. // fingerprint binding.
let pin = match np.pin_for_attempt(&client_fp_hex) { let pin = match np.pin_for_attempt(&client_fp_hex) {
crate::native_pairing::PinAttempt::Pin(pin) => pin, crate::native_pairing::PinAttempt::Pin(pin) => pin,
crate::native_pairing::PinAttempt::Disarmed => anyhow::bail!( crate::native_pairing::PinAttempt::Disarmed => {
"pairing not armed (arm it in the console, or start with --allow-pairing)" close_rejected(&conn, punktfunk_core::reject::RejectReason::PairingNotArmed);
), anyhow::bail!(
"pairing not armed (arm it in the console, or start with --allow-pairing)"
)
}
// Armed for a DIFFERENT device — reject without running the ceremony, so this attempt does // Armed for a DIFFERENT device — reject without running the ceremony, so this attempt does
// NOT consume (burn) the operator's window for the device they actually selected (#9). // NOT consume (burn) the operator's window for the device they actually selected (#9).
crate::native_pairing::PinAttempt::BoundToOther => anyhow::bail!( crate::native_pairing::PinAttempt::BoundToOther => {
"pairing is armed for a different device — this attempt does not consume the window" close_rejected(
), &conn,
punktfunk_core::reject::RejectReason::PairingBoundToOtherDevice,
);
anyhow::bail!(
"pairing is armed for a different device — this attempt does not consume the window"
)
}
}; };
{ {
let mut last = last_pairing.lock().unwrap(); let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last { if let Some(t) = *last {
anyhow::ensure!( if t.elapsed() < PAIRING_COOLDOWN {
t.elapsed() >= PAIRING_COOLDOWN, close_rejected(
"pairing rate-limited — retry shortly" &conn,
); punktfunk_core::reject::RejectReason::PairingRateLimited,
);
anyhow::bail!("pairing rate-limited — retry shortly");
}
} }
*last = Some(std::time::Instant::now()); *last = Some(std::time::Instant::now());
} }
@@ -670,12 +718,17 @@ async fn serve_session(
// Decode just enough to gate (the Hello carries the device name for the pending label); // Decode just enough to gate (the Hello carries the device name for the pending label);
// the `handshake` future re-decodes for the real session — a few dozen bytes, negligible. // the `handshake` future re-decodes for the real session — a few dozen bytes, negligible.
let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
anyhow::ensure!( if gate_hello.abi_version != punktfunk_core::WIRE_VERSION {
gate_hello.abi_version == punktfunk_core::WIRE_VERSION, close_rejected(
"wire version mismatch: client {} host {}", &conn,
gate_hello.abi_version, punktfunk_core::reject::RejectReason::WireVersionMismatch,
punktfunk_core::WIRE_VERSION );
); anyhow::bail!(
"wire version mismatch: client {} host {}",
gate_hello.abi_version,
punktfunk_core::WIRE_VERSION
);
}
let fp = endpoint::peer_fingerprint(&conn); let fp = endpoint::peer_fingerprint(&conn);
let known = fp let known = fp
.as_ref() .as_ref()
@@ -685,6 +738,10 @@ async fn serve_session(
// An anonymous client (no certificate) has no identity to approve — reject outright // An anonymous client (no certificate) has no identity to approve — reject outright
// (the PIN ceremony is its way in). Mirrors the prior behavior for anonymous knocks. // (the PIN ceremony is its way in). Mirrors the prior behavior for anonymous knocks.
let Some(fp) = fp else { let Some(fp) = fp else {
close_rejected(
&conn,
punktfunk_core::reject::RejectReason::IdentityRequired,
);
anyhow::bail!( anyhow::bail!(
"unpaired anonymous client rejected (this host requires pairing — present a \ "unpaired anonymous client rejected (this host requires pairing — present a \
client identity and approve it in the console, or run the PIN ceremony)" client identity and approve it in the console, or run the PIN ceremony)"
@@ -719,15 +776,24 @@ async fn serve_session(
tracing::info!(name = %label, fingerprint = %fp_hex, tracing::info!(name = %label, fingerprint = %fp_hex,
"device approved in console — admitting session (no reconnect)"); "device approved in console — admitting session (no reconnect)");
} }
PairingDecision::Denied => anyhow::bail!("pairing request denied in the console"), PairingDecision::Denied => {
PairingDecision::TimedOut => anyhow::bail!( close_rejected(&conn, punktfunk_core::reject::RejectReason::Denied);
"pairing request not approved within {PENDING_APPROVAL_WAIT:?} \ anyhow::bail!("pairing request denied in the console")
the device can knock again" }
), PairingDecision::TimedOut => {
PairingDecision::Superseded => anyhow::bail!( close_rejected(&conn, punktfunk_core::reject::RejectReason::ApprovalTimeout);
"parked knock superseded by a newer connection from the same device — \ anyhow::bail!(
only the newest is admitted on approval" "pairing request not approved within {PENDING_APPROVAL_WAIT:?} \
), the device can knock again"
)
}
PairingDecision::Superseded => {
close_rejected(&conn, punktfunk_core::reject::RejectReason::Superseded);
anyhow::bail!(
"parked knock superseded by a newer connection from the same device — \
only the newest is admitted on approval"
)
}
} }
// Re-acquire a session slot for the now-approved session (waits if all slots are busy, // Re-acquire a session slot for the now-approved session (waits if all slots are busy,
// exactly like any freshly accepted client). // exactly like any freshly accepted client).
@@ -747,12 +813,17 @@ async fn serve_session(
let data_port = opts.data_port; let data_port = opts.data_port;
let handshake = async { let handshake = async {
let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
anyhow::ensure!( if hello.abi_version != punktfunk_core::WIRE_VERSION {
hello.abi_version == punktfunk_core::WIRE_VERSION, close_rejected(
"wire version mismatch: client {} host {}", &conn,
hello.abi_version, punktfunk_core::reject::RejectReason::WireVersionMismatch,
punktfunk_core::WIRE_VERSION );
); anyhow::bail!(
"wire version mismatch: client {} host {}",
hello.abi_version,
punktfunk_core::WIRE_VERSION
);
}
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above, // The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
// before this future, so a client reaching here is paired (or the host is `--open`). // before this future, so a client reaching here is paired (or the host is `--open`).
@@ -891,8 +962,9 @@ async fn serve_session(
// needed; the actual pads are created lazily by the input thread). // needed; the actual pads are created lazily by the input thread).
let gamepad = resolve_gamepad(hello.gamepad); let gamepad = resolve_gamepad(hello.gamepad);
// Resolve the encoder bitrate (client request clamped to a sane range, or host default). // Resolve the encoder bitrate (client request clamped to a sane range, or a
let bitrate_kbps = resolve_bitrate_kbps(hello.bitrate_kbps); // codec-aware host default — PyroWave pins ~1.6 bpp for the mode).
let bitrate_kbps = resolve_bitrate_kbps_for(codec, hello.bitrate_kbps, &hello.mode);
tracing::info!( tracing::info!(
requested_kbps = hello.bitrate_kbps, requested_kbps = hello.bitrate_kbps,
resolved_kbps = bitrate_kbps, resolved_kbps = bitrate_kbps,
@@ -1145,6 +1217,8 @@ async fn serve_session(
let adaptive_fec = fec_static_override().is_none(); let adaptive_fec = fec_static_override().is_none();
let fec_target = Arc::new(AtomicU8::new(welcome.fec.fec_percent)); let fec_target = Arc::new(AtomicU8::new(welcome.fec.fec_percent));
let fec_target_ctl = fec_target.clone(); let fec_target_ctl = fec_target.clone();
// The session's negotiated rate — the pin PyroWave retarget-refusals ack (§4.6).
let session_bitrate_kbps = welcome.bitrate_kbps;
tokio::spawn(async move { tokio::spawn(async move {
let mut active = hello.mode; let mut active = hello.mode;
// Host-side switch rate limit (a backstop against a hostile/broken client spamming // Host-side switch rate limit (a backstop against a hostile/broken client spamming
@@ -1248,7 +1322,20 @@ async fn serve_session(
// thread, which rebuilds the encoder in place at the same mode — the fresh // thread, which rebuilds the encoder in place at the same mode — the fresh
// encoder's first frame is an IDR with in-band parameter sets, so the // encoder's first frame is an IDR with in-band parameter sets, so the
// client's decoder follows without a reconnect. // client's decoder follows without a reconnect.
let resolved = resolve_bitrate_kbps(req.bitrate_kbps); // PyroWave: the rate is PINNED (§4.6 — quality collapses under rate
// descent; recovery pressure is answered by codec fallback, not AIMD).
// Our client controller is off for this codec; this guards older or
// foreign clients by acking the unchanged session rate.
let resolved = if codec == crate::encode::Codec::PyroWave {
tracing::info!(
requested_kbps = req.bitrate_kbps,
pinned_kbps = session_bitrate_kbps,
"PyroWave session: mid-stream bitrate retarget refused (pinned)"
);
session_bitrate_kbps
} else {
resolve_bitrate_kbps(req.bitrate_kbps)
};
tracing::info!( tracing::info!(
requested_kbps = req.bitrate_kbps, requested_kbps = req.bitrate_kbps,
resolved_kbps = resolved, resolved_kbps = resolved,
@@ -4389,6 +4476,20 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
None => (first, last), None => (first, last),
}); });
} }
// All-intra (§4.6): every PyroWave AU is a keyframe, so the NEXT frame already is
// the recovery a request asks for — drop the drained requests instead of running
// the forced-IDR cooldown / RFI / storm machinery (whose frame-size reasoning is
// meaningless when frames are uniform). Defense in depth: the backend's
// request_keyframe/invalidate_ref_frames are no-ops anyway.
if plan.codec == crate::encode::Codec::PyroWave && (want_kf || rfi_range.is_some()) {
tracing::debug!(
want_kf,
?rfi_range,
"PyroWave session: recovery request ignored (all-intra — next frame is the recovery)"
);
want_kf = false;
rfi_range = None;
}
if !want_kf { if !want_kf {
if let Some((first, last)) = rfi_range { if let Some((first, last)) = rfi_range {
// Sanity-cap the range before consulting the encoder: RFI can only re-reference // Sanity-cap the range before consulting the encoder: RFI can only re-reference
@@ -5306,6 +5407,30 @@ mod tests {
assert_eq!(dec, snap); assert_eq!(dec, snap);
} }
#[test]
fn pyrowave_bitrate_pins_to_bpp_default() {
use punktfunk_core::config::Mode;
let mode = Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
};
// Automatic (0) on PyroWave → the ~1.6 bpp operating point, not the 20 Mbps H.26x
// default (which would turn wavelets to mush — plan §4.6).
let kbps = resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 0, &mode);
assert_eq!(kbps, 1920 * 1080 * 60 * 16 / 10 / 1000);
// An explicit client rate is honored (clamped like any other codec)...
assert_eq!(
resolve_bitrate_kbps_for(crate::encode::Codec::PyroWave, 130_000, &mode),
130_000
);
// ...and the H.26x codecs keep the legacy default.
assert_eq!(
resolve_bitrate_kbps_for(crate::encode::Codec::H265, 0, &mode),
DEFAULT_BITRATE_KBPS
);
}
#[test] #[test]
fn adapt_fec_maps_loss_to_recovery_band() { fn adapt_fec_maps_loss_to_recovery_band() {
// A perfectly clean window (0 loss) lands on the floor. // A perfectly clean window (0 loss) lands on the floor.
+1
View File
@@ -24,6 +24,7 @@
"sway", "sway",
"running-as-a-service", "running-as-a-service",
"virtual-displays", "virtual-displays",
"pyrowave",
"host-cli", "host-cli",
"---Connecting---", "---Connecting---",
"clients", "clients",
+65
View File
@@ -0,0 +1,65 @@
---
title: PyroWave (wired-LAN codec)
description: The opt-in ultra-low-latency wavelet codec for wired links — what it is, the bandwidth it needs, and how to turn it on.
---
PyroWave is an **opt-in** video codec mode for links that can afford real bandwidth: wired
Ethernet, a docked Steam Deck, a 2.5GbE LAN. It trades bitrate for latency — instead of
H.264/HEVC/AV1 on the GPU's video engine, frames are compressed with
[PyroWave](https://github.com/Themaister/pyrowave), an intra-only wavelet codec running as plain
Vulkan compute. Punktfunk vendors a pinned copy and runs it on both ends.
**It is never selected automatically.** HEVC/AV1 remain the codecs for Wi-Fi and everything
else; PyroWave engages only when *you* pick it on the client **and** the host supports it. If
either side can't, the session silently falls back to the normal codec ladder.
## Why you'd want it
- **Codec latency drops by an order of magnitude.** Encode and decode each take a fraction of a
millisecond of GPU compute (measured ~0.15 ms encode / ~0.07 ms decode at 1080p on an
RTX 5070 Ti), versus one-to-several milliseconds per side for the hardware H.26x pipelines.
- **Every frame is a keyframe.** There is no GOP, no reference chain, no keyframe round-trip
after packet loss — a lost frame costs exactly that frame, and the next one is already a
complete picture. The whole IDR/recovery apparatus that produces loss-time stutter simply
doesn't exist in this mode.
- **Uniform frame sizes.** The rate control hits its per-frame byte budget exactly, so the
pacer sees a flat load instead of 2040× keyframe spikes.
## What it costs
Bandwidth. At the codec's ~1.6 bits-per-pixel operating point (4:2:0, 60 fps):
| Mode | Bitrate |
|---|---|
| 1280×800 @ 60 (Deck) | ≈ 100 Mbps |
| 1920×1080 @ 60 | ≈ 200 Mbps |
| 2560×1440 @ 60 | ≈ 355 Mbps |
| 3840×2160 @ 60 | ≈ 800 Mbps |
120 Hz doubles these. Gigabit Ethernet tops out around 940 Mbps of payload, so 4K60 wants
2.5GbE (or a lower rate). **Do not run this over Wi-Fi** — that's what HEVC/AV1 are for.
Also: PyroWave is **8-bit SDR only**. An HDR session stays on HEVC/AV1 regardless of this
setting.
## Turning it on
1. **Host** (Linux): build/install a host with the `pyrowave` feature. On an NVIDIA host the
capture path additionally needs `PUNKTFUNK_ENCODER=pyrowave` in `host.env` for the codec to
be advertised; AMD/Intel hosts advertise it automatically when the feature is present.
2. **Client** (Linux session client with the `pyrowave` feature): set **Settings → Video
codec → PyroWave (wired LAN)** in the gamepad console, or launch with
`PUNKTFUNK_PREFER_PYROWAVE=1`.
3. Leave the bitrate on Automatic: a PyroWave session pins itself to the ~1.6 bpp rate for
your mode (≈200 Mbps at 1080p60). An explicit bitrate is honored if you set one, but the
adaptive-bitrate controller stays off either way — this codec has no useful low-rate
regime, so under sustained loss the right move is switching back to HEVC, not degrading.
The stats overlay shows `pyrowave` as the decode path when the mode is active.
## Current limits
- Linux host + Linux client (including docked Deck) today; the Windows host and the Apple
native port are tracked on the [roadmap](/docs/roadmap).
- 8-bit SDR, 4:2:0 only.
- Mid-stream resolution changes rebuild the stream rather than switching seamlessly.
+43
View File
@@ -45,6 +45,25 @@ def find_license_files(pkg_dir):
return out return out
# Third-party source trees VENDORED inside first-party workspace crates — the
# workspace-member skip in main() hides them from `cargo metadata`, so they are listed
# here explicitly: (label, license file relative to the repo root, source URL).
VENDORED_TREES = [
("pyrowave (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/LICENSE",
"https://github.com/Themaister/pyrowave"),
("Granite subset (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/Granite/LICENSE",
"https://github.com/Themaister/Granite"),
("volk (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/Granite/third_party/volk/LICENSE.md",
"https://github.com/zeux/volk"),
("Vulkan-Headers (vendored, crates/pyrowave-sys)",
"crates/pyrowave-sys/vendor/pyrowave/Granite/third_party/khronos/vulkan-headers/LICENSE.md",
"https://github.com/KhronosGroup/Vulkan-Headers"),
]
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--out", default="THIRD-PARTY-NOTICES.txt") ap.add_argument("--out", default="THIRD-PARTY-NOTICES.txt")
@@ -78,6 +97,23 @@ def main():
ent = texts.setdefault(h, {"text": txt, "filename": fname, "crates": set()}) ent = texts.setdefault(h, {"text": txt, "filename": fname, "crates": set()})
ent["crates"].add(label) ent["crates"].add(label)
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
vendored = []
for label, lic_path, url in VENDORED_TREES:
full = os.path.join(repo_root, lic_path)
try:
with open(full, encoding="utf-8", errors="replace") as f:
txt = f.read().strip()
except OSError:
print(f"WARNING: vendored license missing: {lic_path}", file=sys.stderr)
continue
vendored.append((label, url))
h = hashlib.sha256(txt.encode("utf-8", "replace")).hexdigest()
ent = texts.setdefault(
h, {"text": txt, "filename": os.path.basename(lic_path), "crates": set()}
)
ent["crates"].add(label)
lines = [] lines = []
w = lines.append w = lines.append
w("THIRD-PARTY SOFTWARE NOTICES") w("THIRD-PARTY SOFTWARE NOTICES")
@@ -91,6 +127,13 @@ def main():
w("") w("")
w(f"Total third-party crates: {len(pkgs)}") w(f"Total third-party crates: {len(pkgs)}")
w("") w("")
if vendored:
w("-" * 76)
w("VENDORED THIRD-PARTY SOURCE (inside first-party crates)")
w("-" * 76)
for label, url in vendored:
w(f" {label}{url}")
w("")
w("-" * 76) w("-" * 76)
w("MANIFEST (crate version — SPDX license — source)") w("MANIFEST (crate version — SPDX license — source)")
w("-" * 76) w("-" * 76)