diff --git a/crates/pf-encode/src/enc/linux/mod.rs b/crates/pf-encode/src/enc/linux/mod.rs index 79306a53..dd50e1a7 100644 --- a/crates/pf-encode/src/enc/linux/mod.rs +++ b/crates/pf-encode/src/enc/linux/mod.rs @@ -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::().ok()) - .filter(|v| *v >= 2) - .unwrap_or_else(|| (fps.max(16) / 2) as i32) + super::policy::intra_refresh_period(fps) as i32 } impl NvencEncoder { diff --git a/crates/pf-encode/src/enc/linux/pyrowave.rs b/crates/pf-encode/src/enc/linux/pyrowave.rs index d382a588..b220d8e3 100644 --- a/crates/pf-encode/src/enc/linux/pyrowave.rs +++ b/crates/pf-encode/src/enc/linux/pyrowave.rs @@ -118,12 +118,7 @@ pub(crate) fn capture_modifiers(fourcc: u32) -> Vec { /// **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 diff --git a/crates/pf-encode/src/enc/linux/pyrowave_remote.rs b/crates/pf-encode/src/enc/linux/pyrowave_remote.rs index f5bdfd34..d8c8f8a4 100644 --- a/crates/pf-encode/src/enc/linux/pyrowave_remote.rs +++ b/crates/pf-encode/src/enc/linux/pyrowave_remote.rs @@ -369,7 +369,7 @@ fn handshake(mut link: Link, p: &Params, bitrate_bps: u64) -> Result 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, diff --git a/crates/pf-encode/src/enc/policy.rs b/crates/pf-encode/src/enc/policy.rs new file mode 100644 index 00000000..391d8c21 --- /dev/null +++ b/crates/pf-encode/src/enc/policy.rs @@ -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::().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 { + std::env::var("PUNKTFUNK_LTR_INTERVAL_FRAMES") + .ok() + .and_then(|v| v.parse::().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 { + std::env::var("PUNKTFUNK_LTR_FORCE_AT") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|v| *v > 0) +} diff --git a/crates/pf-encode/src/enc/windows/amf.rs b/crates/pf-encode/src/enc/windows/amf.rs index 7170e772..2a1ca1a6 100644 --- a/crates/pf-encode/src/enc/windows/amf.rs +++ b/crates/pf-encode/src/enc/windows/amf.rs @@ -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::().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::().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 { - std::env::var("PUNKTFUNK_LTR_FORCE_AT") - .ok() - .and_then(|s| s.parse::().ok()) - .filter(|v| *v > 0) + super::policy::ltr_interval_env().unwrap_or_else(|| (fps.max(2) / 2).max(1) as i64) } // --------------------------------------------------------------------------------------------- diff --git a/crates/pf-encode/src/enc/windows/qsv.rs b/crates/pf-encode/src/enc/windows/qsv.rs index 19e9f719..470a037b 100644 --- a/crates/pf-encode/src/enc/windows/qsv.rs +++ b/crates/pf-encode/src/enc/windows/qsv.rs @@ -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::().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 { - std::env::var("PUNKTFUNK_LTR_FORCE_AT") - .ok() - .and_then(|v| v.parse::().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::().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 } // --------------------------------------------------------------------------------------------- diff --git a/crates/pf-encode/src/lib.rs b/crates/pf-encode/src/lib.rs index 5e6d89eb..fd2a703c 100644 --- a/crates/pf-encode/src/lib.rs +++ b/crates/pf-encode/src/lib.rs @@ -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")))] diff --git a/crates/pf-gpu/src/lib.rs b/crates/pf-gpu/src/lib.rs index bdcb18a6..de87edc4 100644 --- a/crates/pf-gpu/src/lib.rs +++ b/crates/pf-gpu/src/lib.rs @@ -616,6 +616,20 @@ pub fn manual_selection() -> Option { 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 { + 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 diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 063a97f6..1f7db25f 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -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::() == 96); + #[cfg(target_pointer_width = "32")] + assert!(core::mem::size_of::() == 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::()); + // 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::(), + std::ptr::addr_of_mut!(o).cast::(), + 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::() 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. diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 08b8cbd0..08f74f5c 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -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** diff --git a/crates/punktfunk-core/tests/c/harness.c b/crates/punktfunk-core/tests/c/harness.c index 37e0476b..c60c9986 100644 --- a/crates/punktfunk-core/tests/c/harness.c +++ b/crates/punktfunk-core/tests/c/harness.c @@ -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); diff --git a/crates/punktfunk-core/tests/c_abi.rs b/crates/punktfunk-core/tests/c_abi.rs index 88d3d7a2..2234fe00 100644 --- a/crates/punktfunk-core/tests/c_abi.rs +++ b/crates/punktfunk-core/tests/c_abi.rs @@ -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()) diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 3cabc717..1179952c 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -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::(); - 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. diff --git a/crates/punktfunk-host/src/native/control.rs b/crates/punktfunk-host/src/native/control.rs index c1a9fb09..2a6f7d20 100644 --- a/crates/punktfunk-host/src/native/control.rs +++ b/crates/punktfunk-host/src/native/control.rs @@ -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, - encoder_ceiling_kbps: Arc, - cadence_degraded: Arc, - cadence_behind_score: Arc, - // 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, - fec_target_ctl: Arc, - // 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, - reconfig_tx: std::sync::mpsc::Sender, - keyframe_tx: std::sync::mpsc::Sender<()>, - rfi_tx: std::sync::mpsc::Sender<(u32, u32)>, - bitrate_tx: std::sync::mpsc::Sender, - probe_tx: std::sync::mpsc::Sender, - mut probe_result_rx: tokio::sync::mpsc::UnboundedReceiver, - mut reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver, - // 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, - // 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, - // 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, - shard_ack_tx: tokio::sync::mpsc::UnboundedSender, - mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver, - cursor_client_draws: Arc, - clip_enabled: Arc, - 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, - // `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, -) { +/// 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, + /// See [`Self::live_bitrate`]. + pub(super) encoder_ceiling_kbps: Arc, + /// See [`Self::live_bitrate`]. + pub(super) cadence_degraded: Arc, + /// See [`Self::live_bitrate`]. + pub(super) cadence_behind_score: Arc, + /// 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, + pub(super) fec_target_ctl: Arc, + /// 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, + pub(super) reconfig_tx: std::sync::mpsc::Sender, + 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, + pub(super) probe_tx: std::sync::mpsc::Sender, + pub(super) probe_result_rx: tokio::sync::mpsc::UnboundedReceiver, + pub(super) reconfig_result_rx: tokio::sync::mpsc::UnboundedReceiver, + /// 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, + /// 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, + /// 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, + pub(super) shard_ack_tx: tokio::sync::mpsc::UnboundedSender, + pub(super) cursor_shape_rx: + tokio::sync::mpsc::UnboundedReceiver, + pub(super) cursor_client_draws: Arc, + pub(super) clip_enabled: Arc, + 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, + /// `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, +} + +/// 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, diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index b2526763..f9d1fcb7 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -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) -> 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: Optionstruct_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