Five places where one fact lived twice now state it once (maintainability sweep) #415

Merged
enricobuehler merged 5 commits from worktree-refactor-sweep into main 2026-08-27 16:42:57 +00:00
17 changed files with 673 additions and 211 deletions
+5 -12
View File
@@ -260,22 +260,15 @@ static IR_UNSUPPORTED: std::sync::atomic::AtomicBool = std::sync::atomic::Atomic
/// loss — the cascade). The session glue then rate-limits client keyframe requests
/// ([`EncoderCaps::intra_refresh`](super::EncoderCaps)).
fn intra_refresh_requested() -> bool {
std::env::var("PUNKTFUNK_INTRA_REFRESH")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
super::policy::intra_refresh_requested()
&& !IR_UNSUPPORTED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The intra-refresh wave length in frames — ffmpeg derives `intraRefreshPeriod`/`Cnt` from
/// `gop_size` before forcing the real GOP infinite, so this is what `gop_size` is set to in IR
/// mode. Default = half a second of frames (heals fast, spreads the intra cost to ~2-3% per
/// frame); `PUNKTFUNK_IR_PERIOD_FRAMES` overrides.
/// The intra-refresh wave length in frames ([`super::policy::intra_refresh_period`]) — ffmpeg
/// derives `intraRefreshPeriod`/`Cnt` from `gop_size` before forcing the real GOP infinite, so
/// this is what `gop_size` is set to in IR mode.
fn intra_refresh_period(fps: u32) -> i32 {
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
.ok()
.and_then(|s| s.parse::<i32>().ok())
.filter(|v| *v >= 2)
.unwrap_or_else(|| (fps.max(16) / 2) as i32)
super::policy::intra_refresh_period(fps) as i32
}
impl NvencEncoder {
+1 -6
View File
@@ -118,12 +118,7 @@ pub(crate) fn capture_modifiers(fourcc: u32) -> Vec<u64> {
/// **Log-only** — see [`select_physical_device`] for why no oracle, this one included, is
/// allowed to CHANGE the selection.
fn capture_anchor_node() -> std::path::PathBuf {
std::env::var("PUNKTFUNK_RENDER_NODE")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("/dev/dri/renderD128"))
pf_gpu::render_node_env().unwrap_or_else(|| std::path::PathBuf::from("/dev/dri/renderD128"))
}
/// `(major, minor)` of a device node, split the way `VkPhysicalDeviceDrmPropertiesEXT` reports
@@ -369,7 +369,7 @@ fn handshake(mut link: Link, p: &Params, bitrate_bps: u64) -> Result<Handshake>
let hello = ToWorker::Hello {
proto: worker::PROTO_VERSION,
workspace_version: worker::WORKSPACE_VERSION.to_string(),
drm_node: std::env::var("PUNKTFUNK_RENDER_NODE").ok(),
drm_node: pf_gpu::render_node_env().map(|p| p.to_string_lossy().into_owned()),
width: p.width,
height: p.height,
fps: p.fps,
+62
View File
@@ -0,0 +1,62 @@
// Loss-recovery env-knob PARSING shared by the native backends (Linux NVENC/libav, Windows
// AMF/QSV) — extracted from three hand-copies that had already diverged twice: QSV's
// `ltr_disabled` dropped the trim + `yes`/`on` spellings (a `set VAR=1 ` with a trailing space
// silently left LTR enabled on Intel while the identical value worked on AMD), and QSV's
// `intra_refresh_period` ignored the env var entirely. Both were fixed in place — and stayed
// three copies, so the next drift was a matter of time. Parse each knob ONCE.
//
// Parsing only: DEFAULTS stay with their backend where they differ (QSV marks LTR ~1/4 s, AMF
// ~1/2 s — deliberate tuning, not drift), and API-bound clamps stay at the call site (QSV's
// `mfxU16` 8..=240). Sibling of `rfi.rs`, which did the same for the slot-recovery policy.
/// Truthy env opt-in: `1` / `true` / `yes` / `on`, trimmed (see the QSV trailing-space incident
/// above — every backend must accept the same spellings).
pub(crate) fn env_flag(name: &str) -> bool {
std::env::var(name)
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
}
/// `PUNKTFUNK_INTRA_REFRESH` — opt into the intra-refresh loss-recovery wave: a moving intra
/// band with recovery-point signalling refreshes the whole picture every
/// [`intra_refresh_period`] frames, so FEC-unrecoverable loss heals without the 20-40× full-IDR
/// spike (which under loss causes more loss — the cascade). Linux ANDs its runtime
/// `IR_UNSUPPORTED` latch on top; on Windows this is also the LTR↔IR selector (mutually
/// exclusive — the wave sweeps the picture, LTR pins references).
pub(crate) fn intra_refresh_requested() -> bool {
env_flag("PUNKTFUNK_INTRA_REFRESH")
}
/// `PUNKTFUNK_IR_PERIOD_FRAMES` — the intra-refresh wave length in frames (>= 2 to be a wave);
/// default half a second of frames (heals fast, spreads the intra cost to ~2-3 % per frame).
/// Backends narrow to their API's field type at the call site.
pub(crate) fn intra_refresh_period(fps: u32) -> u32 {
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|v| *v >= 2)
.unwrap_or_else(|| fps.max(16) / 2)
}
/// `PUNKTFUNK_LTR_INTERVAL_FRAMES` — explicit LTR mark-cadence override (>= 1 frame); `None`
/// leaves the backend's tuned default in charge.
#[cfg(target_os = "windows")]
pub(crate) fn ltr_interval_env() -> Option<i64> {
std::env::var("PUNKTFUNK_LTR_INTERVAL_FRAMES")
.ok()
.and_then(|v| v.parse::<i64>().ok())
.filter(|v| *v >= 1)
}
/// Validation hook (`PUNKTFUNK_LTR_FORCE_AT=N`, spike-only): at `frame_idx == N` the encoder
/// self-triggers its real `invalidate_ref_frames` path, so a headless spike run exercises LTR
/// recovery end-to-end (mark → force → recovery-anchor tag) without a live client. `None`
/// normally; N must be positive — frame 0 is the opening IDR. (QSV's hand-copy skipped that
/// filter, so `=0` behaved differently per vendor.)
#[cfg(target_os = "windows")]
pub(crate) fn ltr_test_force_at() -> Option<i64> {
std::env::var("PUNKTFUNK_LTR_FORCE_AT")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.filter(|v| *v > 0)
}
+3 -40
View File
@@ -50,6 +50,7 @@
// contract, not wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]
use super::policy::{intra_refresh_period, intra_refresh_requested, ltr_test_force_at};
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{anyhow, bail, Context, Result};
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
@@ -536,27 +537,6 @@ fn usage_from_env(codec: Codec) -> i64 {
}
}
/// Whether this session should run the **intra-refresh** loss-recovery mode (`PUNKTFUNK_INTRA_REFRESH`
/// truthy — the same opt-in the Linux NVENC path uses): a moving intra wave refreshes the whole
/// picture every [`intra_refresh_period`] frames, so FEC-unrecoverable loss heals without the
/// 20-40× full-IDR spike, and the session glue rate-limits client keyframe requests
/// ([`EncoderCaps::intra_refresh`]).
fn intra_refresh_requested() -> bool {
std::env::var("PUNKTFUNK_INTRA_REFRESH")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
}
/// Intra-refresh wave length in frames (default half a second, `PUNKTFUNK_IR_PERIOD_FRAMES`
/// overrides) — same knob and default as the Linux NVENC intra-refresh mode.
fn intra_refresh_period(fps: u32) -> u32 {
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|v| *v >= 2)
.unwrap_or_else(|| (fps.max(16) / 2).max(2))
}
/// Number of user-controlled LTR slots. AMD exposes up to 2; two rotating slots hold a sliding pair
/// of recent long-term references, so a loss can re-reference the newest one *before* the loss point.
const NUM_LTR_SLOTS: usize = 2;
@@ -568,9 +548,7 @@ const NUM_LTR_SLOTS: usize = 2;
/// LTR is mutually exclusive with it, so LTR wins). `PUNKTFUNK_NO_AMF_LTR=1` forces the old full-IDR
/// recovery for debugging.
fn ltr_disabled() -> bool {
std::env::var("PUNKTFUNK_NO_AMF_LTR")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
super::policy::env_flag("PUNKTFUNK_NO_AMF_LTR")
}
/// Cadence (frames) between LTR marks — a fresh long-term reference roughly every half second by
@@ -578,22 +556,7 @@ fn ltr_disabled() -> bool {
/// second of recent references, so a loss up to ~1 s old still has a known-good frame to force; a
/// smaller interval means the forced reference is more recent (a smaller recovery-frame residual).
fn ltr_mark_interval(fps: u32) -> i64 {
std::env::var("PUNKTFUNK_LTR_INTERVAL_FRAMES")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.filter(|v| *v >= 1)
.unwrap_or_else(|| (fps.max(2) / 2).max(1) as i64)
}
/// Validation hook (`PUNKTFUNK_LTR_FORCE_AT=N`, spike-only): at `frame_idx == N` the encoder
/// self-triggers its real [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) path, so a
/// headless spike run can exercise LTR recovery end-to-end (mark → force → recovery-anchor tag)
/// without a live client sending an [`RfiRequest`](punktfunk_core::quic::RfiRequest). `None` normally.
fn ltr_test_force_at() -> Option<i64> {
std::env::var("PUNKTFUNK_LTR_FORCE_AT")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.filter(|v| *v > 0)
super::policy::ltr_interval_env().unwrap_or_else(|| (fps.max(2) / 2).max(1) as i64)
}
// ---------------------------------------------------------------------------------------------
+7 -39
View File
@@ -37,6 +37,7 @@
//! it stays behind the same gate and falls back to IDR wherever the driver declines. 4:4:4 stays
//! `false` until probed on real hardware (design §8.6).
use super::policy::{intra_refresh_requested, ltr_test_force_at};
use super::{ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{anyhow, bail, Context, Result};
use libvpl_sys as vpl;
@@ -143,52 +144,19 @@ const NUM_LTR_SLOTS: usize = 2;
/// `PUNKTFUNK_NO_QSV_LTR` — defeat switch for the LTR-RFI path (parity with
/// `PUNKTFUNK_NO_AMF_LTR`); loss recovery then always falls back to IDR.
fn ltr_disabled() -> bool {
// Same accepted spellings as AMF's `ltr_disabled` — this had dropped the `trim()` and the
// `yes`/`on` forms, so a value with stray whitespace (easy to produce with `set VAR=1 `)
// silently left LTR enabled on Intel while the identical value worked on AMD.
std::env::var("PUNKTFUNK_NO_QSV_LTR")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
super::policy::env_flag("PUNKTFUNK_NO_QSV_LTR")
}
/// Frames between LTR marks (`PUNKTFUNK_LTR_INTERVAL_FRAMES`, shared with AMF); default ~1/4 s
/// Frames between LTR marks ([`super::policy::ltr_interval_env`] overrides); default ~1/4 s
/// so a loss usually finds a slot only a few frames old.
fn ltr_mark_interval(fps: u32) -> i64 {
std::env::var("PUNKTFUNK_LTR_INTERVAL_FRAMES")
.ok()
.and_then(|v| v.parse::<i64>().ok())
.filter(|&v| v > 0)
.unwrap_or_else(|| (fps as i64 / 4).max(1))
super::policy::ltr_interval_env().unwrap_or_else(|| (fps as i64 / 4).max(1))
}
/// Spike-only validation hook (`PUNKTFUNK_LTR_FORCE_AT=N`, shared with AMF): self-trigger the
/// real `invalidate_ref_frames` path at frame N so a headless run exercises mark → force →
/// recovery-anchor without a live client.
fn ltr_test_force_at() -> Option<i64> {
std::env::var("PUNKTFUNK_LTR_FORCE_AT")
.ok()
.and_then(|v| v.parse::<i64>().ok())
}
/// Mirrors [`super::amf`]'s `PUNKTFUNK_INTRA_REFRESH` opt-in: request the intra-refresh wave
/// instead of LTR (mutually exclusive — the wave sweeps the whole picture, LTR pins references).
fn intra_refresh_requested() -> bool {
// Spelling parity with AMF (see `ltr_disabled` above).
std::env::var("PUNKTFUNK_INTRA_REFRESH")
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
}
/// The wave period in frames (~0.5 s), `PUNKTFUNK_IR_PERIOD_FRAMES` overrides — the same knob and
/// default as AMF / Linux NVENC. (This claimed parity while ignoring the env var entirely, so the
/// knob silently did nothing on Intel; the clamp is kept because `mfxU16` bounds the field.)
/// The intra-refresh wave period, narrowed to the `mfxU16` field's useful 8..=240
/// ([`super::policy::intra_refresh_period`] parses the shared knob).
fn intra_refresh_period(fps: u32) -> u16 {
std::env::var("PUNKTFUNK_IR_PERIOD_FRAMES")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.filter(|v| *v >= 2)
.unwrap_or(fps / 2)
.clamp(8, 240) as u16
super::policy::intra_refresh_period(fps).clamp(8, 240) as u16
}
// ---------------------------------------------------------------------------------------------
+6
View File
@@ -2039,6 +2039,12 @@ mod nvenc_core;
))]
#[path = "enc/rfi.rs"]
mod rfi;
// Loss-recovery env-knob parsing (IR/LTR opt-ins, periods, spike hooks) shared by the Linux and
// Windows backends — rfi.rs's sibling: three hand-copies of the same env reads had diverged
// twice before this extraction (see the module header). Defaults and API clamps stay per-backend.
#[cfg(any(target_os = "linux", target_os = "windows"))]
#[path = "enc/policy.rs"]
mod policy;
// Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux
// NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2).
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]
+15 -5
View File
@@ -616,6 +616,20 @@ pub fn manual_selection() -> Option<GpuInfo> {
gpus.into_iter().nth(i)
}
/// The raw `PUNKTFUNK_RENDER_NODE` override (the house DRM-node knob), parsed ONCE: trimmed,
/// empty = unset. Three call sites used to read the env independently and disagreed on trim and
/// empty handling. Callers that must NOT consult the console's manual GPU preference (PyroWave's
/// oracle rules — no oracle may change ITS device selection) read this directly;
/// [`linux_render_node`] layers the preference on top.
#[cfg(target_os = "linux")]
pub fn render_node_env() -> Option<PathBuf> {
std::env::var("PUNKTFUNK_RENDER_NODE")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
/// The VAAPI/DRM render node for this host: matched manual preference > `PUNKTFUNK_RENDER_NODE`
/// (a deliberate live env read — see `config.rs` module docs) > `/dev/dri/renderD128`.
#[cfg(target_os = "linux")]
@@ -625,11 +639,7 @@ pub fn linux_render_node() -> PathBuf {
{
return node;
}
std::env::var("PUNKTFUNK_RENDER_NODE")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/dev/dri/renderD128"))
render_node_env().unwrap_or_else(|| PathBuf::from("/dev/dri/renderD128"))
}
/// NVIDIA-presence probe (same device-node check as `encode::nvidia_present` — duplicated two
+216 -1
View File
@@ -2584,7 +2584,192 @@ fn clamp_device_name(s: &str) -> String {
s[..end].to_string()
}
/// Shared body of [`punktfunk_connect_ex7`] / [`punktfunk_connect_ex8`]: `status_out`
/// Every connect option in one **growable** struct — the terminal form of the
/// [`punktfunk_connect`] … [`punktfunk_connect_ex11`] chain, consumed by
/// [`punktfunk_connect_opts`]. Eleven generations each minted a new exported symbol to add a
/// field or two (`ex11` over `ex10`: exactly `audio_rate_hz` + `audio_bits`, for a 24-parameter
/// signature and a full forwarding shim). This struct ends that: a new option is a new field
/// appended HERE, guarded by `struct_size` exactly like [`PunktfunkConfig`].
///
/// Usage: zero-initialize the whole struct, set `struct_size = sizeof(PunktfunkConnectOpts)`,
/// then set the fields you mean. Every zero field keeps the auto/legacy behaviour of the `ex`
/// chain (null pointer = absent, `0` = auto/unspecified) — `audio_rate_hz = 0` is `ex10`'s
/// UNSPECIFIED audio format, an explicit pair is `ex11`'s hi-res request, so the load-bearing
/// `ex11`-vs-`ex10` symbol choice becomes a field value.
///
/// Growth discipline (for this crate): append only — never reorder, never widen an existing
/// field; a new field's zero value must mean "unspecified/auto"; keep the struct free of TAIL
/// padding on both pointer widths (the const asserts below lock 96/68 bytes), so an appended
/// field can never land inside bytes an older caller's `sizeof` already covered; bump
/// [`crate::ABI_VERSION`].
#[cfg(feature = "quic")]
#[repr(C)]
pub struct PunktfunkConnectOpts {
/// `sizeof(PunktfunkConnectOpts)` as THIS caller was compiled — the skew guard
/// ([`punktfunk_connect_opts`] rejects smaller than the v26 introduction size, and when the
/// struct grows, an older caller's shorter size defaults the tail instead of misreading it).
pub struct_size: u32,
/// Required: NUL-terminated UTF-8 IP or hostname (the one non-nullable pointer here).
pub host: *const std::os::raw::c_char,
/// Library id to auto-launch, or null ([`punktfunk_connect_ex4`]).
pub launch_id: *const std::os::raw::c_char,
/// Null (trust on first use) or the host certificate's expected 32-byte SHA-256
/// ([`punktfunk_connect`]'s trust contract).
pub pin_sha256: *const u8,
/// TLS client identity: both null (anonymous) or both NUL-terminated PEM
/// ([`punktfunk_generate_identity`]).
pub client_cert_pem: *const std::os::raw::c_char,
/// See `client_cert_pem`.
pub client_key_pem: *const std::os::raw::c_char,
/// The label this device knocks with, or null for the OS default
/// ([`punktfunk_connect_ex10`]).
pub device_name: *const std::os::raw::c_char,
/// Requested mode ([`punktfunk_connect`]).
pub width: u32,
/// See `width`.
pub height: u32,
/// See `width`.
pub refresh_hz: u32,
/// `PUNKTFUNK_COMPOSITOR_*`; `0`/unrecognized = auto ([`punktfunk_connect_ex`]).
pub compositor: u32,
/// `PUNKTFUNK_GAMEPAD_*`; `0`/unrecognized = auto ([`punktfunk_connect_ex2`]).
pub gamepad: u32,
/// Session wire budget in kbps; `0` = the host default ([`punktfunk_connect_ex3`]).
pub bitrate_kbps: u32,
/// Audio format ask; `0`/`0` = UNSPECIFIED (the legacy Opus path), an explicit pair is a
/// hi-res request that derives `PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES`
/// ([`punktfunk_connect_ex11`] — including why explicit 48000/16 is a genuine lossless ask).
pub audio_rate_hz: u32,
/// Connect timeout in milliseconds.
pub timeout_ms: u32,
/// Required: the host's UDP port.
pub port: u16,
/// `PUNKTFUNK_VIDEO_CAP_*` bits ([`punktfunk_connect_ex5`]).
pub video_caps: u8,
/// Channel ask: 2 / 6 / 8; `0` = stereo ([`punktfunk_connect_ex6`]).
pub audio_channels: u8,
/// See `audio_rate_hz`.
pub audio_bits: u8,
/// `PUNKTFUNK_CODEC_*` bits the client can decode ([`punktfunk_connect_ex7`]).
pub video_codecs: u8,
/// The one `PUNKTFUNK_CODEC_*` bit to prefer; `0` = host's choice
/// ([`punktfunk_connect_ex7`]).
pub preferred_codec: u8,
/// `PUNKTFUNK_CLIENT_CAP_*` bits ([`punktfunk_connect_ex8`]).
pub client_caps: u8,
}
// The no-tail-padding lock the growth contract rests on (see the struct doc): if either width's
// size moves under an edit that meant to change nothing, fields were reordered/widened or tail
// padding appeared — all append-contract breaks. On APPENDING a field: keep
// `CONNECT_OPTS_MIN_SIZE` frozen at these v26 values and update these literals to the new
// (still padding-free) sizes.
#[cfg(feature = "quic")]
const _: () = {
#[cfg(target_pointer_width = "64")]
assert!(core::mem::size_of::<PunktfunkConnectOpts>() == 96);
#[cfg(target_pointer_width = "32")]
assert!(core::mem::size_of::<PunktfunkConnectOpts>() == 68);
};
/// The v26 introduction size of [`PunktfunkConnectOpts`] — the MINIMUM `struct_size`
/// [`punktfunk_connect_opts`] ever accepts. FROZEN: when the struct grows, this stays put so
/// v26-built callers keep connecting; only the const asserts above move.
#[cfg(all(feature = "quic", target_pointer_width = "64"))]
const CONNECT_OPTS_MIN_SIZE: usize = 96;
#[cfg(all(feature = "quic", target_pointer_width = "32"))]
const CONNECT_OPTS_MIN_SIZE: usize = 68;
/// Connect with every option in one growable [`PunktfunkConnectOpts`] (ABI v26) — semantics are
/// exactly [`punktfunk_connect_ex11`]'s, field for field. The `ex` chain stays supported and
/// byte-identical forever, but it is CLOSED: new options land only in the struct.
///
/// `status_out` (nullable) is written on every path, like the whole connect family;
/// `observed_sha256_out` (null or 32 bytes) receives the host certificate's fingerprint on
/// success, per [`punktfunk_connect`]'s trust contract.
///
/// # Safety
/// `opts` is null or points to at least `opts->struct_size` readable bytes laid out as its
/// declared version of [`PunktfunkConnectOpts`]; its pointer fields follow
/// [`punktfunk_connect_ex11`]'s contract; `observed_sha256_out` is null or valid for 32 bytes.
#[cfg(feature = "quic")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn punktfunk_connect_opts(
opts: *const PunktfunkConnectOpts,
observed_sha256_out: *mut u8,
status_out: *mut i32,
) -> *mut PunktfunkConnection {
let set_status = |s: crate::error::PunktfunkStatus| {
if !status_out.is_null() {
// SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path,
// written once by value.
unsafe { *status_out = s as i32 };
}
};
if opts.is_null() {
set_status(crate::error::PunktfunkStatus::NullPointer);
return std::ptr::null_mut();
}
// Read only the 4-byte size prefix first to bound the subsequent read — `config_from_ptr`'s
// guard, with the growth direction added: older (shorter, but never shorter than v26)
// callers get their missing tail defaulted instead of misread.
// SAFETY: `addr_of!` forms a raw pointer WITHOUT creating a reference, which is the point:
// the caller's struct may be a different size than ours, so the field is read by offset
// rather than through a `&`.
let declared = unsafe { std::ptr::addr_of!((*opts).struct_size).read_unaligned() } as usize;
if declared < CONNECT_OPTS_MIN_SIZE {
set_status(crate::error::PunktfunkStatus::InvalidArg);
return std::ptr::null_mut();
}
// Copy the known prefix over an all-zero struct: today that is the whole struct; once the
// struct has grown past a caller's vintage, the copy stops at THEIR `struct_size` and the
// appended fields stay zero = unspecified (zeroed raw pointers are null). A NEWER caller's
// extra tail is ignored the same way.
// SAFETY: all-zero is a valid `PunktfunkConnectOpts` — null pointers and zero scalars.
let mut o: PunktfunkConnectOpts = unsafe { std::mem::zeroed() };
let take = declared.min(std::mem::size_of::<PunktfunkConnectOpts>());
// SAFETY: per the ABI contract `opts` is readable for `declared >= take` bytes; `o` is a
// local of at least `take` bytes; a local cannot overlap a caller-owned region.
unsafe {
std::ptr::copy_nonoverlapping(
opts.cast::<u8>(),
std::ptr::addr_of_mut!(o).cast::<u8>(),
take,
);
}
// SAFETY: the pointer fields are forwarded UNCHANGED to the shared body, which applies the
// same ABI contract to them; the copy above dereferenced nothing they point at.
unsafe {
connect_ex_impl(
o.host,
o.port,
o.client_caps,
o.width,
o.height,
o.refresh_hz,
o.compositor,
o.gamepad,
o.bitrate_kbps,
o.video_caps,
o.audio_channels,
o.video_codecs,
o.preferred_codec,
o.launch_id,
o.pin_sha256,
observed_sha256_out,
o.client_cert_pem,
o.client_key_pem,
o.device_name,
o.audio_rate_hz,
o.audio_bits,
o.timeout_ms,
status_out,
)
}
}
/// Shared body of the whole connect family — [`punktfunk_connect`] through
/// [`punktfunk_connect_ex11`] and [`punktfunk_connect_opts`]: `status_out`
/// (nullable) is written on EVERY path — `Ok`, the mapped [`PunktfunkError`],
/// `InvalidArg` for bad arguments, `Panic` if the connect panicked. `device_name` (nullable,
/// [`punktfunk_connect_ex10`]) is the label this device knocks with; null = the OS default.
@@ -6044,6 +6229,36 @@ mod log_sink_tests {
mod tests {
use super::*;
/// The [`PunktfunkConnectOpts`] size-prefix guard: null and undersized structs come back as
/// status codes, not reads — and a well-sized struct takes the copy path all the way into
/// the shared body (null `host` = `InvalidArg`, before any dialing). The growth half of the
/// contract (an older, shorter caller gets its tail defaulted) cannot be exercised until the
/// struct actually grows; what CAN regress today is this guard and the no-tail-padding
/// layout the const asserts beside the struct lock.
#[test]
fn connect_opts_guards_size_prefix() {
let mut status = 0i32;
// SAFETY: null `opts` is the documented reported-not-UB case.
let c =
unsafe { punktfunk_connect_opts(std::ptr::null(), std::ptr::null_mut(), &mut status) };
assert!(c.is_null());
assert_eq!(status, PunktfunkStatus::NullPointer as i32);
// SAFETY: an all-zero struct is a valid value (null pointers, zero scalars).
let mut o: PunktfunkConnectOpts = unsafe { std::mem::zeroed() };
o.struct_size = 4; // an impossible, pre-v26 size
// SAFETY: `o` outlives the call; out-params are null or a live local.
let c = unsafe { punktfunk_connect_opts(&o, std::ptr::null_mut(), &mut status) };
assert!(c.is_null());
assert_eq!(status, PunktfunkStatus::InvalidArg as i32);
o.struct_size = std::mem::size_of::<PunktfunkConnectOpts>() as u32;
// SAFETY: as above; the null `host` field is the documented InvalidArg path.
let c = unsafe { punktfunk_connect_opts(&o, std::ptr::null_mut(), &mut status) };
assert!(c.is_null());
assert_eq!(status, PunktfunkStatus::InvalidArg as i32);
}
/// The `ex10` device name is cut to the Hello's BYTE budget on a CHARACTER boundary — the
/// naive `s[..HELLO_NAME_MAX]` panics on any multi-byte name that straddles it, and an
/// operator naming a device in German or Japanese is not an edge case.
+10 -1
View File
@@ -256,7 +256,16 @@ pub use stats::Stats;
/// never sees it and [`WIRE_VERSION`] is unchanged. It relies on tracing's `log` feature, now
/// declared explicitly by this crate (it was on transitively through quinn's defaults, which is
/// not a thing an ABI promise should rest on).
pub const ABI_VERSION: u32 = 25;
/// **v26** adds [`abi::punktfunk_connect_opts`] + [`abi::PunktfunkConnectOpts`] — the whole
/// connect surface in ONE size-prefixed, growable struct, closing the eleven-generation
/// `punktfunk_connect_ex*` chain (each new option used to mint a new exported symbol plus a
/// 20-something-parameter forwarding shim; `ex11` over `ex10` was two fields). ADDED, not
/// widened: every `ex` variant keeps its symbol, signature and byte-identical behaviour, and an
/// embedder that never calls the new form behaves exactly as on v25. New connect options land
/// only in the struct from here on — appended behind its `struct_size` guard, zero meaning
/// unspecified/auto — so they stop being ABI events at all. Client-local; [`WIRE_VERSION`] is
/// unchanged.
pub const ABI_VERSION: u32 = 26;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+23
View File
@@ -32,6 +32,29 @@ static PunktfunkConfig make_config(uint32_t role, uint32_t drop_period) {
int main(void) {
printf("punktfunk-core C ABI harness (abi_version=%u)\n", punktfunk_abi_version());
/* PunktfunkConnectOpts (v26): the C compiler must agree with Rust's const-asserted layout —
* 96 bytes on 64-bit / 68 on 32-bit, NO tail padding (the growth contract: an appended field
* may never land in bytes an older caller's sizeof already covered) and the size-prefix
* guard must reject an undersized struct as a status, not a read. The declaration sits
* behind the header's quic guard; the staticlib this harness links always carries quic
* (see the -lopus/Security link line), so the check only needs the define. */
#ifdef PUNKTFUNK_FEATURE_QUIC
if (sizeof(PunktfunkConnectOpts) != (sizeof(void *) == 8 ? 96u : 68u)) {
fprintf(stderr, "FAIL: PunktfunkConnectOpts is %zu bytes\n", sizeof(PunktfunkConnectOpts));
return 1;
}
{
PunktfunkConnectOpts o;
int32_t st = 0;
memset(&o, 0, sizeof(o));
o.struct_size = 4; /* an impossible, pre-v26 size */
if (punktfunk_connect_opts(&o, NULL, &st) != NULL || st != PUNKTFUNK_STATUS_INVALID_ARG) {
fprintf(stderr, "FAIL: undersized connect opts accepted (st=%d)\n", (int)st);
return 1;
}
}
#endif
const uint32_t DROP_PERIOD = 8; /* drop 1 of every 8 packets */
PunktfunkConfig host_cfg = make_config(0, DROP_PERIOD);
PunktfunkConfig client_cfg = make_config(1, DROP_PERIOD);
+28 -10
View File
@@ -43,14 +43,16 @@ fn native_libs() -> &'static [&'static str] {
fn ensure_staticlib(profile_dir: &Path) -> PathBuf {
let staticlib = profile_dir.join("libpunktfunk_core.a");
if !staticlib.exists() {
// `cargo test` doesn't always emit the standalone staticlib; build it. The
// outer cargo's build lock is released during test execution, so this is safe.
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let _ = Command::new(cargo)
.args(["build", "-p", "punktfunk-core"])
.status();
}
// `cargo test` doesn't always emit the standalone staticlib; build it — WITH `quic`, the
// surface the harness declares (`-DPUNKTFUNK_FEATURE_QUIC`) and this test's link line
// already pays for (`-lopus`, Security). Unconditional, because a featureless `.a` left by
// an earlier plain `cargo build` would otherwise be reused and fail the link on the quic
// symbols; when the artifact is already right this is an incremental no-op. The outer
// cargo's build lock is released during test execution, so this is safe.
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let _ = Command::new(cargo)
.args(["build", "-p", "punktfunk-core", "--features", "quic"])
.status();
staticlib
}
@@ -84,8 +86,24 @@ fn c_abi_harness_round_trips() {
let mut compile = Command::new(&cc);
compile
.args(["-std=c11", "-Wall", "-Wextra", "-O2", "-I"])
.arg(&include)
// The staticlib is built with workspace-unified features, quic included (that's what the
// -lopus / Security link line below pays for) — so expose the header's quic surface and
// let the harness exercise it (the `PunktfunkConnectOpts` layout check).
.args([
"-std=c11",
"-Wall",
"-Wextra",
"-O2",
"-DPUNKTFUNK_FEATURE_QUIC",
"-I",
])
.arg(&include);
// Apple Silicon: `cc` does not search homebrew's prefix on its own, and `-lopus` lives
// there — without this the harness never linked on a Mac dev box at all.
if cfg!(target_os = "macos") && Path::new("/opt/homebrew/lib").is_dir() {
compile.arg("-L/opt/homebrew/lib");
}
compile
.arg(&harness)
.arg(&staticlib)
.args(native_libs())
+11 -11
View File
@@ -1785,21 +1785,21 @@ async fn serve_session(
// `clip_offer_rx` shape). The sender lives in the access lifecycle task below; for a session
// with no fingerprint it's dropped instead and the arm disables itself.
let (access_tx, access_rx) = tokio::sync::mpsc::unbounded_channel::<AccessUpdate>();
tokio::spawn(control::run(
tokio::spawn(control::run(control::Task {
ctrl_send,
ctrl_recv,
hello.mode,
initial_mode: hello.mode,
codec,
live_reconfig_ok,
adaptive_fec,
session_bitrate_kbps,
live_bitrate.clone(),
encoder_ceiling_kbps.clone(),
cadence_degraded.clone(),
cadence_behind_score.clone(),
client_packets_received_ctl,
live_bitrate: live_bitrate.clone(),
encoder_ceiling_kbps: encoder_ceiling_kbps.clone(),
cadence_degraded: cadence_degraded.clone(),
cadence_behind_score: cadence_behind_score.clone(),
client_packets_received: client_packets_received_ctl,
fec_target_ctl,
phase_ctl_control,
phase_ctl: phase_ctl_control,
reconfig_tx,
keyframe_tx,
rfi_tx,
@@ -1813,11 +1813,11 @@ async fn serve_session(
shard_ack_tx,
cursor_shape_rx,
cursor_client_draws,
clip_enabled.clone(),
clip_enabled: clip_enabled.clone(),
clip,
session_grants.clone(),
session_grants: session_grants.clone(),
access_rx,
));
}));
// The access lifecycle task (WP3): owns the session's expiry deadline and folds every watch
// edit into the live mask. Only sessions with a fingerprint have a record to watch; dropping
// `access_tx` otherwise retires the control task's update arm.
+103 -61
View File
@@ -9,67 +9,109 @@ use super::*;
use pf_clipboard::ClipCoordCmd;
use punktfunk_core::quic::{ClipControl, ClipOffer, ClipState};
/// Run the control task for one live session. Owns the control streams (`serve_session` hands them
/// off after negotiation) plus every channel end that bridges to the data-plane thread, and the
/// [`pf_clipboard::ClipCoord`] handle bridging to the clipboard coordinator. Returns when the
/// control stream closes or a data-plane channel drops.
#[allow(clippy::too_many_arguments)]
pub(super) async fn run(
mut ctrl_send: quinn::SendStream,
ctrl_recv: quinn::RecvStream,
initial_mode: punktfunk_core::Mode,
codec: crate::encode::Codec,
live_reconfig_ok: bool,
adaptive_fec: bool,
session_bitrate_kbps: u32,
// Encoder-truth bridge (data plane → here, §ABR overdrive): the encoder's live applied rate,
// its discovered codec-level ceiling (0 = unknown), and the "encode can't hold cadence"
// flag. Read at `SetBitrate`-resolve time so the ack — the base the client's controller
// climbs from — never promises a rate the encoder won't run at.
live_bitrate: Arc<AtomicU32>,
encoder_ceiling_kbps: Arc<AtomicU32>,
cadence_degraded: Arc<AtomicBool>,
cadence_behind_score: Arc<AtomicU32>,
// Delivery truth, published from every `DeliveryReport` for the data plane's stall diagnosis:
// the packets the client says it has received all session (`u32::MAX` = a client too old to
// send one, the pre-seeded value).
client_packets_received: Arc<AtomicU32>,
fec_target_ctl: Arc<AtomicU8>,
// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode loop's
// controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
phase_ctl: Arc<super::stream::PhaseCtl>,
reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
keyframe_tx: std::sync::mpsc::Sender<()>,
rfi_tx: std::sync::mpsc::Sender<(u32, u32)>,
bitrate_tx: std::sync::mpsc::Sender<u32>,
probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
// Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to
// the client as a `BitrateChanged` so its controller's climb base tracks the real encoder.
mut retarget_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
// Pipeline-gap announcements (see `gap_tx`): a rebuild that kept the session up stopped the
// stream for this many ms, forwarded to the client as a `PipelineGap` so its bitrate
// controller discards the report window that straddled our own stall.
mut gap_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
// Mid-session shard renegotiation (design/shard-payload-reneg.md): the wire-MTU watcher
// asks for a `ShardPayloadChanged` here (this task is the control stream's sole writer),
// and the client's `ShardPayloadAck`s flow back on `shard_ack_tx` — the grow gate.
mut shard_change_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
shard_ack_tx: tokio::sync::mpsc::UnboundedSender<u16>,
mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::CursorShape>,
cursor_client_draws: Arc<AtomicBool>,
clip_enabled: Arc<AtomicBool>,
clip: pf_clipboard::ClipCoord,
// Per-client access (design/per-client-access.md §5): the session's LIVE grant mask — the
// same atomic the datagram filter reads; the deadline/watch task folds console edits into
// it, so a `ClipControl` or `ClipOffer` arriving after a mid-session revoke resolves against
// the new mask.
session_grants: Arc<AtomicU32>,
// `AccessUpdate`s from the session's deadline/watch task (expiry warnings + mid-session
// grant edits) — this task is the control stream's sole writer, so they cross here.
mut access_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::AccessUpdate>,
) {
/// Everything [`run`] owns for one live session the control streams (`serve_session` hands
/// them off after negotiation), every channel end that bridges to the data-plane thread, and the
/// [`pf_clipboard::ClipCoord`] handle bridging to the clipboard coordinator. A named-field
/// struct rather than the parameter list it grew from: at ~30 positionals the spawn site had
/// stopped saying anything, and its same-typed neighbours (`retarget_rx` / `gap_rx` both carry
/// bare `u32`s) were one silent transposition away from a runtime puzzle.
pub(super) struct Task {
pub(super) ctrl_send: quinn::SendStream,
pub(super) ctrl_recv: quinn::RecvStream,
pub(super) initial_mode: punktfunk_core::Mode,
pub(super) codec: crate::encode::Codec,
pub(super) live_reconfig_ok: bool,
pub(super) adaptive_fec: bool,
pub(super) session_bitrate_kbps: u32,
/// Encoder-truth bridge (data plane → here, §ABR overdrive): the encoder's live applied
/// rate, its discovered codec-level ceiling (0 = unknown), and the "encode can't hold
/// cadence" flag. Read at `SetBitrate`-resolve time so the ack — the base the client's
/// controller climbs from — never promises a rate the encoder won't run at.
pub(super) live_bitrate: Arc<AtomicU32>,
/// See [`Self::live_bitrate`].
pub(super) encoder_ceiling_kbps: Arc<AtomicU32>,
/// See [`Self::live_bitrate`].
pub(super) cadence_degraded: Arc<AtomicBool>,
/// See [`Self::live_bitrate`].
pub(super) cadence_behind_score: Arc<AtomicU32>,
/// Delivery truth, published from every `DeliveryReport` for the data plane's stall
/// diagnosis: the packets the client says it has received all session (`u32::MAX` = a
/// client too old to send one, the pre-seeded value).
pub(super) client_packets_received: Arc<AtomicU32>,
pub(super) fec_target_ctl: Arc<AtomicU8>,
/// Phase-locked capture bridge: client PhaseReports land here latest-wins; the encode
/// loop's controller drains at its own ~1 Hz cadence (design/phase-locked-capture.md).
pub(super) phase_ctl: Arc<super::stream::PhaseCtl>,
pub(super) reconfig_tx: std::sync::mpsc::Sender<punktfunk_core::Mode>,
pub(super) keyframe_tx: std::sync::mpsc::Sender<()>,
pub(super) rfi_tx: std::sync::mpsc::Sender<(u32, u32)>,
pub(super) bitrate_tx: std::sync::mpsc::Sender<u32>,
pub(super) probe_tx: std::sync::mpsc::Sender<ProbeRequest>,
pub(super) probe_result_rx: tokio::sync::mpsc::UnboundedReceiver<ProbeResult>,
pub(super) reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver<Reconfigured>,
/// Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to
/// the client as a `BitrateChanged` so its controller's climb base tracks the real encoder.
pub(super) retarget_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
/// Pipeline-gap announcements (see `gap_tx`): a rebuild that kept the session up stopped
/// the stream for this many ms, forwarded to the client as a `PipelineGap` so its bitrate
/// controller discards the report window that straddled our own stall.
pub(super) gap_rx: tokio::sync::mpsc::UnboundedReceiver<u32>,
/// Mid-session shard renegotiation (design/shard-payload-reneg.md): the wire-MTU watcher
/// asks for a `ShardPayloadChanged` here (this task is the control stream's sole writer),
/// and the client's `ShardPayloadAck`s flow back on `shard_ack_tx` — the grow gate.
pub(super) shard_change_rx: tokio::sync::mpsc::UnboundedReceiver<u16>,
pub(super) shard_ack_tx: tokio::sync::mpsc::UnboundedSender<u16>,
pub(super) cursor_shape_rx:
tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::CursorShape>,
pub(super) cursor_client_draws: Arc<AtomicBool>,
pub(super) clip_enabled: Arc<AtomicBool>,
pub(super) clip: pf_clipboard::ClipCoord,
/// Per-client access (design/per-client-access.md §5): the session's LIVE grant mask — the
/// same atomic the datagram filter reads; the deadline/watch task folds console edits into
/// it, so a `ClipControl` or `ClipOffer` arriving after a mid-session revoke resolves
/// against the new mask.
pub(super) session_grants: Arc<AtomicU32>,
/// `AccessUpdate`s from the session's deadline/watch task (expiry warnings + mid-session
/// grant edits) — this task is the control stream's sole writer, so they cross here.
pub(super) access_rx: tokio::sync::mpsc::UnboundedReceiver<punktfunk_core::quic::AccessUpdate>,
}
/// Run the control task for one live session (owning everything in its [`Task`]). Returns when
/// the control stream closes or a data-plane channel drops.
pub(super) async fn run(task: Task) {
let Task {
mut ctrl_send,
ctrl_recv,
initial_mode,
codec,
live_reconfig_ok,
adaptive_fec,
session_bitrate_kbps,
live_bitrate,
encoder_ceiling_kbps,
cadence_degraded,
cadence_behind_score,
client_packets_received,
fec_target_ctl,
phase_ctl,
reconfig_tx,
keyframe_tx,
rfi_tx,
bitrate_tx,
probe_tx,
mut probe_result_rx,
mut reconfig_result_rx,
mut retarget_rx,
mut gap_rx,
mut shard_change_rx,
shard_ack_tx,
mut cursor_shape_rx,
cursor_client_draws,
clip_enabled,
clip,
session_grants,
mut access_rx,
} = task;
let pf_clipboard::ClipCoord {
available: clip_available,
cmd_tx: clip_cmd_tx,
+48 -19
View File
@@ -1640,6 +1640,32 @@ fn settle_portal_cursor(
false
}
/// The host-composite pair for the live compositor — `(gamescope_composite,
/// metadata_composite)`: ONE derivation shared by bring-up and the mid-stream compositor
/// retarget so the two cannot drift ([`settle_portal_cursor`]'s discipline). They had drifted:
/// bring-up keyed the gamescope arm on the compositor alone, while the retarget read
/// [`gamescope_cursor`] — which also folds in `gamescope_composites_cursor()`, the "the spawned
/// gamescope paints the pointer into its node itself" capability. On such a gamescope the two
/// answers differed at bring-up: a host composite planned for a pointer the node already
/// carries, with no XFixes reader attached to feed it (`session_plan.rs` documents the pair
/// contract — attach-without-blend wastes an X11 connection, blend-without-attach streams no
/// pointer, and blending a self-painting node would draw the pointer twice).
///
/// `gamescope` is whether the LIVE compositor is gamescope — the retarget passes the
/// newly-detected one, not the session's original.
///
/// [`gamescope_cursor`]: crate::session_plan::SessionPlan::gamescope_cursor
fn composite_plan(
plan: &crate::session_plan::SessionPlan,
has_cursor_channel: bool,
gamescope: bool,
) -> (bool, bool) {
(
plan.gamescope_cursor && !has_cursor_channel,
!has_cursor_channel && plan.cursor_blend && !gamescope,
)
}
pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDisplay>) -> Result<()> {
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
@@ -1806,16 +1832,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
tracing::info!("cursor channel negotiated — forwarding shape/state, encoder blend off");
}
// gamescope (Phase C): no channel for a plain capture-mode client and no compositor-embedded
// pointer, so the host ALWAYS composites the XFixes-sourced cursor into the video. Active only
// when there's no cursor-forward channel (a future desktop-mode gamescope client takes the
// `cursor_fwd` path instead). See `plan.gamescope_cursor`.
// `mut`: a mid-stream Gaming↔Desktop switch (the capture-loss rebuild below) retargets the
// compositor, so this is recomputed there against the live compositor.
let mut gamescope_composite =
compositor == pf_vdisplay::Compositor::Gamescope && cursor_fwd.is_none();
if gamescope_composite {
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
}
// pointer, so the host composites the XFixes-sourced cursor into the video — unless the
// spawned gamescope paints the pointer into its node itself (`plan.gamescope_cursor` folds
// that capability in; keying on the compositor alone here used to plan a composite the
// reader never feeds on such a node). Active only when there's no cursor-forward channel (a
// future desktop-mode gamescope client takes the `cursor_fwd` path instead).
//
// No-channel metadata composite: the client never draws the pointer (it did not advertise
// the cursor channel — e.g. a capture-latched client, `console.rs` `latched_mouse`), and
// the compositor-EMBEDS fallback is a fiction on a Mutter virtual stream (the software
@@ -1824,10 +1846,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// schedules no update either, mutter#4939). So the plan asked the backend for
// cursor-as-metadata and the HOST composites, permanently — the same arm a channel
// session lands in after its capture-model flip, minus the channel.
// `mut`: recomputed with `gamescope_composite` on a mid-stream compositor retarget.
let mut metadata_composite = cursor_fwd.is_none()
&& plan.cursor_blend
&& compositor != pf_vdisplay::Compositor::Gamescope;
//
// `mut` on both: a mid-stream Gaming↔Desktop switch (the capture-loss rebuild below)
// retargets the compositor and re-derives the pair through the same [`composite_plan`].
let (mut gamescope_composite, mut metadata_composite) = composite_plan(
&plan,
cursor_fwd.is_some(),
compositor == pf_vdisplay::Compositor::Gamescope,
);
if gamescope_composite {
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
}
if metadata_composite {
tracing::info!(
"no cursor channel — compositing the metadata cursor into the video (embedded \
@@ -3528,11 +3557,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
crate::session_plan::gamescope_cursor_for(
c == crate::vdisplay::Compositor::Gamescope,
);
gamescope_composite =
plan.gamescope_cursor && cursor_fwd.is_none();
metadata_composite = cursor_fwd.is_none()
&& plan.cursor_blend
&& c != crate::vdisplay::Compositor::Gamescope;
(gamescope_composite, metadata_composite) = composite_plan(
&plan,
cursor_fwd.is_some(),
c == crate::vdisplay::Compositor::Gamescope,
);
// The retargeted backend starts with `hw_cursor`
// unset — without re-applying the session's
// out-of-band cursor request, the rebuilt display
+31 -4
View File
@@ -253,9 +253,31 @@ and capture `observed_sha256_out` (trust-on-first-use), then pin it thereafter.
## 5. Connecting
There is a **ladder** of `connect` variants; each adds parameters and defaults the rest to the prior
variant's behavior. Use the highest one you need — they all return `PunktfunkConnection *` (NULL on
failure) and block up to `timeout_ms` for the handshake, so **call off your UI thread.**
**The recommended call is `punktfunk_connect_opts` (ABI ≥ 26)** — every option in one
size-prefixed struct, so a new option is a new field instead of a new symbol:
```c
PunktfunkConnectOpts o = {0}; /* zero = auto/legacy for every option */
o.struct_size = sizeof(PunktfunkConnectOpts);
o.host = "192.168.1.50";
o.port = 9777;
o.width = 1920; o.height = 1080; o.refresh_hz = 60;
o.bitrate_kbps = 20000;
o.video_codecs = PUNKTFUNK_CODEC_HEVC; /* OR-in _H264 (software hosts) / _AV1 */
o.preferred_codec = PUNKTFUNK_CODEC_HEVC;
o.pin_sha256 = host_fp; /* or NULL for TOFU */
o.client_cert_pem = cert; o.client_key_pem = key;
o.timeout_ms = 8000;
int32_t status; uint8_t observed[32];
PunktfunkConnection *c = punktfunk_connect_opts(&o, observed, &status);
if (!c) { /* status says why — a PunktfunkStatus, including the typed host-rejection block */ }
```
Before it there is a **ladder** of `connect` variants; each adds parameters and defaults the rest
to the prior variant's behavior. Every rung keeps its symbol and behavior forever, but the ladder
is **closed** — new options land only in the struct. All of these return `PunktfunkConnection *`
(NULL on failure) and block up to `timeout_ms` for the handshake, so **call off your UI thread.**
| Variant | Adds |
|------------------|------|
@@ -266,7 +288,12 @@ failure) and block up to `timeout_ms` for the handshake, so **call off your UI t
| `punktfunk_connect_ex4` | `launch_id` (auto-launch a library title) |
| `punktfunk_connect_ex5` | `video_caps` (10-bit / HDR / 4:4:4 / host-timing) |
| `punktfunk_connect_ex6` | `audio_channels` (2 / 6 / 8) |
| **`punktfunk_connect_ex7`** | **`video_codecs` + `preferred_codec`** — the recommended full call |
| `punktfunk_connect_ex7` | `video_codecs` + `preferred_codec` — the full positional call |
| `punktfunk_connect_ex8` | `status_out` (typed WHY on a failed connect — `PunktfunkStatus`, incl. host rejections) |
| `punktfunk_connect_ex9` | `client_caps` (client-drawn cursor, …) |
| `punktfunk_connect_ex10` | `device_name` (the label this device knocks with) |
| `punktfunk_connect_ex11` | `audio_rate_hz` + `audio_bits` (hi-res / lossless audio ask) |
| **`punktfunk_connect_opts`** | **terminal: one growable struct** — new options stop being new symbols |
```c
uint8_t caps = 0; // add PUNKTFUNK_VIDEO_CAP_10BIT | _HDR | _444 as supported
+103 -1
View File
@@ -185,7 +185,16 @@
// never sees it and [`WIRE_VERSION`] is unchanged. It relies on tracing's `log` feature, now
// declared explicitly by this crate (it was on transitively through quinn's defaults, which is
// not a thing an ABI promise should rest on).
#define PUNKTFUNK_ABI_VERSION 25
// **v26** adds [`abi::punktfunk_connect_opts`] + [`abi::PunktfunkConnectOpts`] — the whole
// connect surface in ONE size-prefixed, growable struct, closing the eleven-generation
// `punktfunk_connect_ex*` chain (each new option used to mint a new exported symbol plus a
// 20-something-parameter forwarding shim; `ex11` over `ex10` was two fields). ADDED, not
// widened: every `ex` variant keeps its symbol, signature and byte-identical behaviour, and an
// embedder that never calls the new form behaves exactly as on v25. New connect options land
// only in the struct from here on — appended behind its `struct_size` guard, zero meaning
// unspecified/auto — so they stop being ABI events at all. Client-local; [`WIRE_VERSION`] is
// unchanged.
#define PUNKTFUNK_ABI_VERSION 26
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -2258,6 +2267,81 @@ typedef struct {
uint64_t bytes_received;
} PunktfunkStats;
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Every connect option in one **growable** struct — the terminal form of the
// [`punktfunk_connect`] … [`punktfunk_connect_ex11`] chain, consumed by
// [`punktfunk_connect_opts`]. Eleven generations each minted a new exported symbol to add a
// field or two (`ex11` over `ex10`: exactly `audio_rate_hz` + `audio_bits`, for a 24-parameter
// signature and a full forwarding shim). This struct ends that: a new option is a new field
// appended HERE, guarded by `struct_size` exactly like [`PunktfunkConfig`].
//
// Usage: zero-initialize the whole struct, set `struct_size = sizeof(PunktfunkConnectOpts)`,
// then set the fields you mean. Every zero field keeps the auto/legacy behaviour of the `ex`
// chain (null pointer = absent, `0` = auto/unspecified) — `audio_rate_hz = 0` is `ex10`'s
// UNSPECIFIED audio format, an explicit pair is `ex11`'s hi-res request, so the load-bearing
// `ex11`-vs-`ex10` symbol choice becomes a field value.
//
// Growth discipline (for this crate): append only — never reorder, never widen an existing
// field; a new field's zero value must mean "unspecified/auto"; keep the struct free of TAIL
// padding on both pointer widths (the const asserts below lock 96/68 bytes), so an appended
// field can never land inside bytes an older caller's `sizeof` already covered; bump
// [`crate::ABI_VERSION`].
typedef struct {
// `sizeof(PunktfunkConnectOpts)` as THIS caller was compiled — the skew guard
// ([`punktfunk_connect_opts`] rejects smaller than the v26 introduction size, and when the
// struct grows, an older caller's shorter size defaults the tail instead of misreading it).
uint32_t struct_size;
// Required: NUL-terminated UTF-8 IP or hostname (the one non-nullable pointer here).
const char *host;
// Library id to auto-launch, or null ([`punktfunk_connect_ex4`]).
const char *launch_id;
// Null (trust on first use) or the host certificate's expected 32-byte SHA-256
// ([`punktfunk_connect`]'s trust contract).
const uint8_t *pin_sha256;
// TLS client identity: both null (anonymous) or both NUL-terminated PEM
// ([`punktfunk_generate_identity`]).
const char *client_cert_pem;
// See `client_cert_pem`.
const char *client_key_pem;
// The label this device knocks with, or null for the OS default
// ([`punktfunk_connect_ex10`]).
const char *device_name;
// Requested mode ([`punktfunk_connect`]).
uint32_t width;
// See `width`.
uint32_t height;
// See `width`.
uint32_t refresh_hz;
// `PUNKTFUNK_COMPOSITOR_*`; `0`/unrecognized = auto ([`punktfunk_connect_ex`]).
uint32_t compositor;
// `PUNKTFUNK_GAMEPAD_*`; `0`/unrecognized = auto ([`punktfunk_connect_ex2`]).
uint32_t gamepad;
// Session wire budget in kbps; `0` = the host default ([`punktfunk_connect_ex3`]).
uint32_t bitrate_kbps;
// Audio format ask; `0`/`0` = UNSPECIFIED (the legacy Opus path), an explicit pair is a
// hi-res request that derives `PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES`
// ([`punktfunk_connect_ex11`] — including why explicit 48000/16 is a genuine lossless ask).
uint32_t audio_rate_hz;
// Connect timeout in milliseconds.
uint32_t timeout_ms;
// Required: the host's UDP port.
uint16_t port;
// `PUNKTFUNK_VIDEO_CAP_*` bits ([`punktfunk_connect_ex5`]).
uint8_t video_caps;
// Channel ask: 2 / 6 / 8; `0` = stereo ([`punktfunk_connect_ex6`]).
uint8_t audio_channels;
// See `audio_rate_hz`.
uint8_t audio_bits;
// `PUNKTFUNK_CODEC_*` bits the client can decode ([`punktfunk_connect_ex7`]).
uint8_t video_codecs;
// The one `PUNKTFUNK_CODEC_*` bit to prefer; `0` = host's choice
// ([`punktfunk_connect_ex7`]).
uint8_t preferred_codec;
// `PUNKTFUNK_CLIENT_CAP_*` bits ([`punktfunk_connect_ex8`]).
uint8_t client_caps;
} PunktfunkConnectOpts;
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// One audio packet pulled off a `punktfunk/1` connection — an Opus frame (48 kHz, 5 ms) on
// every ordinary session, or one lossless PCM frame on a session that resolved the `0xD3` plane.
@@ -3145,6 +3229,24 @@ PunktfunkConnection *punktfunk_connect_ex11(const char *host,
int32_t *status_out);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Connect with every option in one growable [`PunktfunkConnectOpts`] (ABI v26) — semantics are
// exactly [`punktfunk_connect_ex11`]'s, field for field. The `ex` chain stays supported and
// byte-identical forever, but it is CLOSED: new options land only in the struct.
//
// `status_out` (nullable) is written on every path, like the whole connect family;
// `observed_sha256_out` (null or 32 bytes) receives the host certificate's fingerprint on
// success, per [`punktfunk_connect`]'s trust contract.
//
// # Safety
// `opts` is null or points to at least `opts->struct_size` readable bytes laid out as its
// declared version of [`PunktfunkConnectOpts`]; its pointer fields follow
// [`punktfunk_connect_ex11`]'s contract; `observed_sha256_out` is null or valid for 32 bytes.
PunktfunkConnection *punktfunk_connect_opts(const PunktfunkConnectOpts *opts,
uint8_t *observed_sha256_out,
int32_t *status_out);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Generate a persistent client identity: a self-signed certificate + private key, both
// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both