The daily-driver five: 10-bit SDR, system keys for remote desktop, keep-host-audio, mode variables for prep, and a cursor sized to the client #412

Merged
enricobuehler merged 2 commits from worktree-daily-driver-five into main 2026-08-27 11:34:51 +00:00
38 changed files with 716 additions and 131 deletions
+35
View File
@@ -640,6 +640,9 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
if touched.has("enable_444") {
o.enable_444 = Some(values.enable_444);
}
if touched.has("ten_bit_sdr") {
o.ten_bit_sdr = Some(values.ten_bit_sdr);
}
if touched.has("compositor") {
o.compositor = Some(values.compositor.clone());
}
@@ -649,6 +652,9 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings)
if touched.has("audio_format") {
o.audio_format = Some(values.audio_format.clone());
}
if touched.has("keep_host_audio") {
o.keep_host_audio = Some(values.keep_host_audio);
}
if touched.has("mic_enabled") {
o.mic_enabled = Some(values.mic_enabled);
}
@@ -1281,6 +1287,13 @@ pub fn show_scoped(
only, and only where the host can encode it.",
)
.build();
let ten_bit_sdr_row = adw::SwitchRow::builder()
.title("10-bit SDR")
.subtitle(
"Smoother gradients without HDR \u{2014} 10-bit encoding precision. Needs an \
NVIDIA host; HDR takes over when it engages.",
)
.build();
let decoder_row = ChoiceRow::new(
&dialog,
inline,
@@ -1466,6 +1479,10 @@ pub fn show_scoped(
w.set_sensitive(surround_row.selected() == 0);
surround_row.connect_changed(move |i| w.set_sensitive(i == 0));
}
let keep_host_audio_row = adw::SwitchRow::builder()
.title("Keep host audio playing")
.subtitle("The host's speakers or headphones keep playing while you stream — needs a host on 0.32+")
.build();
let mic_row = adw::SwitchRow::builder()
.title("Stream microphone")
.subtitle("Sends your microphone to the host's virtual mic — Ctrl+Alt+Shift+V mutes it mid-stream")
@@ -1711,10 +1728,12 @@ pub fn show_scoped(
wake_row.set_active(s.auto_wake);
inhibit_row.set_active(s.inhibit_shortcuts);
invert_row.set_active(s.invert_scroll);
keep_host_audio_row.set_active(s.keep_host_audio);
mic_row.set_active(s.mic_enabled);
echo_row.set_active(s.echo_cancel);
hdr_row.set_active(s.hdr_enabled);
chroma_row.set_active(s.enable_444);
ten_bit_sdr_row.set_active(s.ten_bit_sdr);
surround_row.set_selected(index::surround(s));
audio_format_row.set_selected(index::audio_format(s));
// `set_selected` never fires the changed hook, so mirror the stereo gate here — the same
@@ -1970,6 +1989,12 @@ pub fn show_scoped(
toggle!(vrr_row, "allow_vrr", o.allow_vrr.is_some(), allow_vrr);
toggle!(hdr_row, "hdr_enabled", o.hdr_enabled.is_some(), hdr_enabled);
toggle!(chroma_row, "enable_444", o.enable_444.is_some(), enable_444);
toggle!(
ten_bit_sdr_row,
"ten_bit_sdr",
o.ten_bit_sdr.is_some(),
ten_bit_sdr
);
toggle!(
fullscreen_row,
"fullscreen_on_stream",
@@ -1988,6 +2013,12 @@ pub fn show_scoped(
o.invert_scroll.is_some(),
invert_scroll
);
toggle!(
keep_host_audio_row,
"keep_host_audio",
o.keep_host_audio.is_some(),
keep_host_audio
);
toggle!(mic_row, "mic_enabled", o.mic_enabled.is_some(), mic_enabled);
toggle!(
echo_row,
@@ -2059,6 +2090,7 @@ pub fn show_scoped(
quality_group.add(codec_row.widget());
quality_group.add(&hdr_row);
quality_group.add(&chroma_row);
quality_group.add(&ten_bit_sdr_row);
// Decoder and GPU are facts about THIS device's hardware — never per profile (tier G).
if !profile_mode {
quality_group.add(decoder_row.widget());
@@ -2097,6 +2129,7 @@ pub fn show_scoped(
let audio_group = group("", "Applies from the next session.");
audio_group.add(surround_row.widget());
audio_group.add(audio_format_row.widget());
audio_group.add(&keep_host_audio_row);
// The speaker/mic endpoint pickers below are this device's audio routing (tier G) — they
// render only in the defaults scope; the surround/format + mic-uplink rows above are
// profileable.
@@ -2242,10 +2275,12 @@ pub fn show_scoped(
if want_speaker != pf_client_core::pad_audio::speaker_active(&s.pad_speaker) {
s.pad_speaker = if want_speaker { "pad" } else { "off" }.to_string();
}
s.keep_host_audio = keep_host_audio_row.is_active();
s.mic_enabled = mic_row.is_active();
s.echo_cancel = echo_row.is_active();
s.hdr_enabled = hdr_row.is_active();
s.enable_444 = chroma_row.is_active();
s.ten_bit_sdr = ten_bit_sdr_row.is_active();
s.audio_channels = match surround_row.selected() {
1 => 6,
2 => 8,
+6 -1
View File
@@ -414,7 +414,11 @@ mod session_main {
// The cost stays VISIBLE, not silent: the Detailed stats overlay prints the
// resolved chroma ("4:4:4→4:2:0" when the host declined) and the decode path
// frames actually took.
video_caps: pf_client_core::video::video_caps_for(settings.hdr_enabled, want_444),
video_caps: pf_client_core::video::video_caps_for(
settings.hdr_enabled,
settings.ten_bit_sdr,
want_444,
),
// This panel's HDR colour volume → the host's virtual-display EDID, so host
// apps tone-map to the real glass. Windows reads it from DXGI (the
// `--window-pos` monitor; advanced-color outputs only) — gated on the HDR
@@ -442,6 +446,7 @@ mod session_main {
pad_haptics: settings.pad_haptics,
pad_speaker: settings.pad_speaker.clone(),
clipboard,
keep_host_audio: settings.keep_host_audio,
// The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs).
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
+33
View File
@@ -487,9 +487,11 @@ struct OverrideFlags {
codec: bool,
hdr_enabled: bool,
enable_444: bool,
ten_bit_sdr: bool,
compositor: bool,
audio_channels: bool,
audio_format: bool,
keep_host_audio: bool,
mic_enabled: bool,
echo_cancel: bool,
touch_mode: bool,
@@ -523,9 +525,11 @@ impl OverrideFlags {
codec: o.codec.is_some(),
hdr_enabled: o.hdr_enabled.is_some(),
enable_444: o.enable_444.is_some(),
ten_bit_sdr: o.ten_bit_sdr.is_some(),
compositor: o.compositor.is_some(),
audio_channels: o.audio_channels.is_some(),
audio_format: o.audio_format.is_some(),
keep_host_audio: o.keep_host_audio.is_some(),
mic_enabled: o.mic_enabled.is_some(),
echo_cancel: o.echo_cancel.is_some(),
touch_mode: o.touch_mode.is_some(),
@@ -919,6 +923,9 @@ pub(crate) fn settings_page(
let hdr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.hdr_enabled, |s, on| {
s.hdr_enabled = on
});
let ten_bit_sdr_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.ten_bit_sdr, |s, on| {
s.ten_bit_sdr = on
});
let chroma_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.enable_444, |s, on| {
s.enable_444 = on
});
@@ -1066,6 +1073,10 @@ pub(crate) fn settings_page(
let format_combo = setting_combo(ctx, scope, (rev, set_rev), af_names, af_i, |s, i| {
s.audio_format = AUDIO_FORMATS[i].0.to_string();
});
let keep_host_audio_toggle =
setting_toggle(ctx, scope, (rev, set_rev), s.keep_host_audio, |s, on| {
s.keep_host_audio = on
});
let mic_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.mic_enabled, |s, on| {
s.mic_enabled = on
});
@@ -1231,6 +1242,17 @@ pub(crate) fn settings_page(
bandwidth. Requires an NVIDIA host (NVENC) or the PyroWave \
codec \u{2014} other encoders stream 4:2:0.",
),
described_overridable(
(rev, set_rev),
scope,
"ten_bit_sdr",
"10-bit SDR",
over.ten_bit_sdr,
ten_bit_sdr_toggle,
"Smoother gradients without HDR \u{2014} the picture is encoded at \
10-bit precision. Needs an NVIDIA host; HDR takes over when it \
engages.",
),
],
None,
));
@@ -1541,6 +1563,17 @@ pub(crate) fn settings_page(
rate; the stats overlay names what the session actually got.",
)
}),
Some(described_overridable(
(rev, set_rev),
scope,
"keep_host_audio",
"Keep host audio playing",
over.keep_host_audio,
keep_host_audio_toggle,
"The host\u{2019}s own speakers or headphones keep playing while you \
stream \u{2014} both ends hear the same audio. Needs a host on 0.32 \
or newer.",
)),
// The endpoint picks are facts about THIS device's hardware — never
// per profile, like Decoder/GPU.
(!profile_mode)
+4 -2
View File
@@ -667,7 +667,8 @@ pub fn open_virtual_output(
pub fn open_idd_push(
target: pf_frame::dxgi::WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_hdr: bool,
ten_bit_sdr: bool,
want_444: bool,
pyrowave: bool,
keepalive: Box<dyn Send>,
@@ -678,7 +679,8 @@ pub fn open_idd_push(
idd_push::IddPushCapturer::open(
target,
preferred,
client_10bit,
want_hdr,
ten_bit_sdr,
want_444,
pyrowave,
keepalive,
+28 -1
View File
@@ -390,14 +390,41 @@ float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
}
";
/// The 10-bit **SDR** pass PS ([`HdrRgb10Converter::new_sdr_expand`]) — full-res, samples the
/// 8-bit BGRA slot and writes the SAME sRGB values into the packed 10-bit target. No colour math
/// on purpose: the UNORM sample→write roundtrip IS the 8→10 expansion (code 255/255 lands on
/// 1023/1023), and the transfer stays sRGB/BT.709 exactly as the 8-bit SDR path treats BGRA —
/// the depth gain is the ENCODER's (Main10 coding precision), not the source's.
const SDR_RGB10_PS: &str = r"
Texture2D<float4> tx : register(t0);
SamplerState sm : register(s0);
float4 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
return float4(tx.Sample(sm, uv).rgb, 1.0);
}
";
impl HdrRgb10Converter {
/// The HDR pass: FP16 scRGB in, PQ-encoded BT.2020 RGB out.
pub(crate) fn new(device: &ID3D11Device) -> Result<Self> {
Self::from_ps(
device,
HDR_RGB10_PS.replace("#include_common", HDR_P010_COMMON),
)
}
/// The 10-bit **SDR** pass: BGRA in, the same sRGB values out at 10-bit UNORM (see
/// [`SDR_RGB10_PS`]). Identical plumbing — only the pixel shader differs — so the two
/// depth paths share the VS/sampler/draw and cannot drift.
pub(crate) fn new_sdr_expand(device: &ID3D11Device) -> Result<Self> {
Self::from_ps(device, SDR_RGB10_PS.to_string())
}
fn from_ps(device: &ID3D11Device, src: String) -> Result<Self> {
// SAFETY: every call is a `?`-checked D3D11 method on the live `device` borrow, over
// fully-initialized stack descriptors and live `Option` out-params; `compile_shader`
// receives `s!()` literals (its contract). Each created COM interface owns its own
// reference, and no raw pointer outlives the call that produced it.
unsafe {
let src = HDR_RGB10_PS.replace("#include_common", HDR_P010_COMMON);
let vsb = compile_shader(HDR_VS, s!("main"), s!("vs_5_0"))?;
let psb = compile_shader(&src, s!("main"), s!("ps_5_0"))?;
let mut vs = None;
+50 -15
View File
@@ -416,13 +416,20 @@ pub struct IddPushCapturer {
/// display's HDR mode flipped). Stamped into the header + each delivery so the driver re-attaches
/// (and so stale-ring publishes are rejected).
generation: u32,
/// The CLIENT's advertised 10-bit capability (= negotiated `bit_depth >= 10`). Gates the
/// composition depth: a 10-bit client PROACTIVELY enables advanced color at `open` (HDR without a
/// manual toggle); an SDR-only client forces it OFF and the descriptor poller PINS it there, so a
/// client that advertised SDR ("HDR off") is never handed the in-band PQ upgrade the pixel-format-
/// driven encoder would otherwise stamp from an HDR composition. (An HDR-negotiated H.26x session
/// still follows a host-side "Use HDR" flip; all clients decode Main10 + auto-detect PQ from the VUI.)
client_10bit: bool,
/// The session negotiated **HDR** (client advertised `VIDEO_CAP_HDR` and the handshake said
/// yes — no longer merely `bit_depth >= 10`, which the 10-bit SDR path below also reaches).
/// Gates the composition depth: an HDR session PROACTIVELY enables advanced color at `open`
/// (HDR without a manual toggle); any other session forces it OFF and the descriptor poller
/// PINS it there, so a client that did not ask for HDR is never handed the in-band PQ
/// upgrade the pixel-format-driven encoder would otherwise stamp from an HDR composition.
/// (An HDR-negotiated H.26x session still follows a host-side "Use HDR" flip; all clients
/// decode Main10 + auto-detect PQ from the VUI.)
want_hdr: bool,
/// The session negotiated 10-bit WITHOUT HDR (`OutputFormat::ten_bit_sdr`): the BGRA slot is
/// expanded 8→10 bit into the packed RGB10 output ([`PixelFormat::Rgb10a2Sdr`]) so NVENC
/// encodes Main10 under the ordinary BT.709 SDR VUI. The display's colour state is never
/// touched — `want_hdr` above stays false, advanced colour stays pinned off.
ten_bit_sdr: bool,
/// The DISPLAY's CURRENT HDR state (from `advanced_color_enabled`) — the user can flip "Use HDR" in
/// Windows mid-session. Drives the ring format (HDR → FP16 surfaces, SDR → BGRA) and the conversion.
/// Polled in the capture loop; a change recreates the ring (see [`Self::recreate_ring`]).
@@ -532,6 +539,11 @@ pub struct IddPushCapturer {
/// session negotiated 4:4:4 — the full-chroma twin of [`Self::hdr_p010_conv`]. Rebuilt with the
/// ring on a mode/HDR flip.
hdr_rgb10_conv: Option<HdrRgb10Converter>,
/// BGRA slot → packed 10-bit RGB by plain 8→10 expansion (`HdrRgb10Converter::new_sdr_expand`
/// — same sRGB values at UNORM precision), used on a 10-bit **SDR** session
/// ([`Self::ten_bit_sdr`], both 4:2:0 and 4:4:4 — NVENC does the CSC/subsampling either
/// way). Built lazily like its HDR twin above.
sdr_rgb10_conv: Option<HdrRgb10Converter>,
last_seq: u64,
last_present: Option<(ID3D11Texture2D, PixelFormat)>,
status_logged: bool,
@@ -655,8 +667,8 @@ impl IddPushCapturer {
/// `Nv12` (BT.709 8-bit limited), or full-chroma `Bgra` passthrough on a 4:4:4 session (NVENC
/// CSCs RGB→YUV444 itself, following the BT.709 VUI — the one path that deliberately pays the
/// SM-side CSC, because the video processor can only produce subsampled output). The
/// composition depth DOES follow the session's negotiated `client_10bit` — pinned at open
/// (`open.rs`, the `!client_10bit` force-off and the 10-bit enable) and re-pinned every sample
/// composition depth DOES follow the session's negotiated `want_hdr` — pinned at open
/// (`open.rs`, the `!want_hdr` force-off and the 10-bit enable) and re-pinned every sample
/// by [`Self::poll_display_hdr`], because a PQ stream sent to a client that advertised SDR-only
/// lands on an SDR desktop and blows out. (The older note here claimed the opposite — that the
/// advertised `VIDEO_CAP_10BIT` was ignored because clients under-report it. That reasoning
@@ -684,6 +696,11 @@ impl IddPushCapturer {
return (DXGI_FORMAT_R10G10B10A2_UNORM, PixelFormat::Rgb10a2);
}
(DXGI_FORMAT_P010, PixelFormat::P010)
} else if self.ten_bit_sdr {
// 10-bit SDR (either chroma): the BGRA slot expanded 8→10 into packed RGB. The
// format is the SDR twin of `Rgb10a2` — NVENC ingests it as ABGR10 and encodes
// Main10 under the BT.709 VUI its CSC follows (the SDR 4:4:4 precedent below).
(DXGI_FORMAT_R10G10B10A2_UNORM, PixelFormat::Rgb10a2Sdr)
} else if self.want_444 {
(DXGI_FORMAT_B8G8R8A8_UNORM, PixelFormat::Bgra)
} else {
@@ -816,9 +833,10 @@ impl IddPushCapturer {
self.video_conv = None; // converters are sized + HDR-specific → rebuild at the new mode
self.hdr_p010_conv = None;
self.hdr_rgb10_conv = None;
self.sdr_rgb10_conv = None;
// The PyroWave CSC is mode-baked too (BgraToYuvPlanes picks different SDR vs HDR shaders
// and R8/R8G8 vs R16/R16G16 outputs). Without this, a display_hdr flip (Downgrade point D:
// client_10bit=true but HDR couldn't enable at open) reused the stale SDR converter against
// want_hdr=true but HDR couldn't enable at open) reused the stale SDR converter against
// the freshly HDR-formatted pyro ring — every frame corrupted. `ensure_pyro_conv` only
// builds when None, so it must be reset here like its siblings.
self.pyro_conv = None;
@@ -859,12 +877,12 @@ impl IddPushCapturer {
// is never recreated at the wrong format):
// - a PyroWave session: its encoder was opened for fixed plane formats (R8 SDR / R16 HDR),
// so it can't follow a flip the way H.26x re-inits do;
// - ANY SDR-negotiated session (`!client_10bit`, either codec): a host-side flip to HDR
// - ANY SDR-negotiated session (`!want_hdr`, either codec): a host-side flip to HDR
// must not promote the stream to P010 PQ behind a client that advertised SDR-only.
// An HDR-negotiated H.26x session is NOT pinned — it still follows a host "Use HDR" flip in
// either direction (its encoder re-inits on the depth change).
if (self.pyrowave || !self.client_10bit) && now.hdr != self.client_10bit {
let want = self.client_10bit;
if (self.pyrowave || !self.want_hdr) && now.hdr != self.want_hdr {
let want = self.want_hdr;
// A display that refuses the pin refuses it on every 250 ms sample — past
// [`Self::HDR_PIN_EAGER`] consecutive failures the write+read-back re-fires only on
// every [`Self::HDR_PIN_RETRY_EVERY`]th sample (~4 s), instead of 4 CCD writes +
@@ -876,7 +894,7 @@ impl IddPushCapturer {
|| self.desc_seq % Self::HDR_PIN_RETRY_EVERY == 0
{
// OBSERVE the flip; never assert it. This used to discard `set_advanced_color`'s
// `bool` and then write `now.hdr = self.client_10bit` — substituting the DESIRED
// `bool` and then write `now.hdr = self.want_hdr` — substituting the DESIRED
// state for the observed one, which broke in both directions on a display that
// cannot be flipped (the state this file already logs as "Downgrade point D" at
// open):
@@ -907,7 +925,7 @@ impl IddPushCapturer {
observed_hdr = ?observed,
set_advanced_color_returned = requested,
pyrowave = self.pyrowave,
client_10bit = self.client_10bit,
want_hdr = self.want_hdr,
"IDD push: could not pin the display to the NEGOTIATED depth — following what \
it actually composes instead (a physical display forcing HDR, or a driver that \
refuses the flip). The stream's depth will not match the negotiation; the \
@@ -1113,6 +1131,12 @@ impl IddPushCapturer {
self.height,
)?);
}
} else if self.ten_bit_sdr {
// 10-bit SDR (4:2:0 AND 4:4:4): one full-res 8→10 expansion pass to packed RGB;
// NVENC does the RGB→YUV CSC + any subsampling under the BT.709 VUI.
if self.sdr_rgb10_conv.is_none() {
self.sdr_rgb10_conv = Some(HdrRgb10Converter::new_sdr_expand(&self.device)?);
}
} else if self.want_444 {
// Full-chroma passthrough — no conversion resources to build.
} else if self.video_conv.is_none() {
@@ -1631,6 +1655,17 @@ impl IddPushCapturer {
let (y_rtv, uv_rtv) = rtvs.as_ref().expect("P010 out slot has plane RTVs");
conv.convert(&self.context, src, y_rtv, uv_rtv, self.width, self.height)?;
}
} else if self.ten_bit_sdr {
// 10-bit SDR: BGRA slot → packed 10-bit RGB, a plain 8→10 expansion (same
// sRGB values at UNORM precision). NVENC ingests it as ABGR10 and encodes
// Main10 under the BT.709 VUI — the CSC and any subsampling are NVENC's,
// exactly like the SDR 4:4:4 passthrough below, one bit-depth up.
if let Some(conv) = self.sdr_rgb10_conv.as_ref() {
let src = blended.as_ref().map(|(_, srv)| srv).unwrap_or(&slot_srv);
let (_, _, rtv) = out.as_ref().expect("out ring");
let rtv = rtv.as_ref().expect("Rgb10a2Sdr out slot has an RTV");
conv.convert(&self.context, src, rtv, self.width, self.height)?;
}
} else if self.want_444 {
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma
// RGB and CSCs to YUV 4:4:4 itself (per the always-written BT.709 VUI). Plain
+23 -14
View File
@@ -162,7 +162,8 @@ impl IddPushCapturer {
pub fn open(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_hdr: bool,
ten_bit_sdr: bool,
want_444: bool,
pyrowave: bool,
keepalive: Box<dyn Send>,
@@ -176,7 +177,8 @@ impl IddPushCapturer {
match Self::open_inner(
target,
preferred,
client_10bit,
want_hdr,
ten_bit_sdr,
want_444,
pyrowave,
sender,
@@ -195,7 +197,8 @@ impl IddPushCapturer {
fn open_inner(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_hdr: bool,
ten_bit_sdr: bool,
want_444: bool,
pyrowave: bool,
sender: crate::FrameChannelSender,
@@ -218,7 +221,8 @@ impl IddPushCapturer {
match Self::open_on(
target.clone(),
preferred,
client_10bit,
want_hdr,
ten_bit_sdr,
want_444,
pyrowave,
luid,
@@ -253,7 +257,8 @@ impl IddPushCapturer {
Self::open_on(
target,
preferred,
client_10bit,
want_hdr,
ten_bit_sdr,
want_444,
pyrowave,
drv,
@@ -270,7 +275,8 @@ impl IddPushCapturer {
fn open_on(
target: WinCaptureTarget,
preferred: Option<(u32, u32, u32)>,
client_10bit: bool,
want_hdr: bool,
ten_bit_sdr: bool,
want_444: bool,
pyrowave: bool,
luid: LUID,
@@ -338,7 +344,7 @@ impl IddPushCapturer {
// lands on an SDR desktop and blows out — the composition must honor the negotiation.
// An HDR-negotiated (10-bit) session instead enables HDR below and rides the FP16 scRGB
// ring (design/pyrowave-444-hdr.md Phase 3 for PyroWave; the H.26x P010 path otherwise).
if !client_10bit {
if !want_hdr {
let _ = pf_win_display::win_display::set_advanced_color(target.target_id, false);
let settle = Instant::now();
while settle.elapsed() < Duration::from_millis(250) {
@@ -372,8 +378,8 @@ impl IddPushCapturer {
// size the ring FP16 directly — don't race the advanced_color_enabled poll, which may not have
// settled within 250 ms and would size the ring SDR while the driver composes FP16 → a format
// mismatch → an immediate ring recreate + dropped first frames (audit §5.4).
let enabled_hdr = client_10bit
&& pf_win_display::win_display::set_advanced_color(target.target_id, true);
let enabled_hdr =
want_hdr && pf_win_display::win_display::set_advanced_color(target.target_id, true);
if enabled_hdr {
// Let the colorspace change settle before the driver composes + we size the ring:
// poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4),
@@ -399,7 +405,7 @@ impl IddPushCapturer {
// A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) —
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
// An SDR-negotiated session (either codec) forced advanced color OFF above and composes
// SDR unconditionally: `client_10bit` gates HDR so a client that advertised SDR-only is
// SDR unconditionally: `want_hdr` gates HDR so a client that advertised SDR-only is
// never handed a PQ stream, even if a physical display forces HDR on (the descriptor
// poller re-asserts OFF; PyroWave's format guard/stash absorbs any lingering FP16 compose).
// Keep the raw observation so Downgrade point D below can say whether the read reported
@@ -407,13 +413,13 @@ impl IddPushCapturer {
// causes and different fixes.
let observed_hdr =
pf_win_display::win_display::advanced_color_enabled(target.target_id);
let display_hdr = client_10bit && (enabled_hdr || observed_hdr.unwrap_or(false));
let display_hdr = want_hdr && (enabled_hdr || observed_hdr.unwrap_or(false));
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
// BT.709, so the client's label overstates the stream until the descriptor poller sees
// HDR come on. Loud, because every frame of this session is affected.
if client_10bit && !display_hdr {
if want_hdr && !display_hdr {
tracing::error!(
target = target.target_id,
want_hdr = true,
@@ -586,7 +592,8 @@ impl IddPushCapturer {
render_luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart),
mode = format!("{w}x{h}"),
display_hdr,
client_10bit,
want_hdr,
ten_bit_sdr,
want_444,
ring_fp16 = display_hdr,
// Whether DXGI ever reached the win32u GPU-preference hook. By this point the
@@ -610,7 +617,8 @@ impl IddPushCapturer {
height: h,
slots,
generation,
client_10bit,
want_hdr,
ten_bit_sdr,
display_hdr,
hdr_pin_warned: false,
hdr_pin_failures: 0,
@@ -648,6 +656,7 @@ impl IddPushCapturer {
video_conv: None,
hdr_p010_conv: None,
hdr_rgb10_conv: None,
sdr_rgb10_conv: None,
last_seq: 0,
last_present: None,
status_logged: false,
+18
View File
@@ -56,6 +56,8 @@ pub struct SettingsOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_444: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ten_bit_sdr: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compositor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_channels: Option<u8>,
@@ -67,6 +69,8 @@ pub struct SettingsOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
pub audio_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_host_audio: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mic_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub echo_cancel: Option<bool>,
@@ -140,6 +144,9 @@ impl SettingsOverlay {
if let Some(v) = self.enable_444 {
s.enable_444 = v;
}
if let Some(v) = self.ten_bit_sdr {
s.ten_bit_sdr = v;
}
if let Some(v) = &self.compositor {
s.compositor = v.clone();
}
@@ -149,6 +156,9 @@ impl SettingsOverlay {
if let Some(v) = &self.audio_format {
s.audio_format = v.clone();
}
if let Some(v) = self.keep_host_audio {
s.keep_host_audio = v;
}
if let Some(v) = self.mic_enabled {
s.mic_enabled = v;
}
@@ -242,6 +252,9 @@ impl SettingsOverlay {
if after.enable_444 != before.enable_444 {
self.enable_444 = Some(after.enable_444);
}
if after.ten_bit_sdr != before.ten_bit_sdr {
self.ten_bit_sdr = Some(after.ten_bit_sdr);
}
if after.compositor != before.compositor {
self.compositor = Some(after.compositor.clone());
}
@@ -251,6 +264,9 @@ impl SettingsOverlay {
if after.audio_format != before.audio_format {
self.audio_format = Some(after.audio_format.clone());
}
if after.keep_host_audio != before.keep_host_audio {
self.keep_host_audio = Some(after.keep_host_audio);
}
if after.mic_enabled != before.mic_enabled {
self.mic_enabled = Some(after.mic_enabled);
}
@@ -321,9 +337,11 @@ impl SettingsOverlay {
"codec" => self.codec = None,
"hdr_enabled" => self.hdr_enabled = None,
"enable_444" => self.enable_444 = None,
"ten_bit_sdr" => self.ten_bit_sdr = None,
"compositor" => self.compositor = None,
"audio_channels" => self.audio_channels = None,
"audio_format" => self.audio_format = None,
"keep_host_audio" => self.keep_host_audio = None,
"mic_enabled" => self.mic_enabled = None,
"echo_cancel" => self.echo_cancel = None,
"touch_mode" => self.touch_mode = None,
+11
View File
@@ -83,6 +83,11 @@ pub struct SessionParams {
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
pub clipboard: bool,
/// Advertise `quic::CLIENT_CAP_KEEP_HOST_AUDIO`: ask the host to capture its default
/// playback device as-is instead of re-routing audio onto a silent endpoint, so the
/// host keeps playing while it streams ([`crate::trust::Settings::keep_host_audio`]).
/// Request-only — an older host ignores it.
pub keep_host_audio: bool,
/// Advertise `quic::CLIENT_CAP_CURSOR`: this embedder renders the host cursor locally
/// (the presenter's cursor channel, design/remote-desktop-sweep.md M2), so the host may
/// stop compositing the pointer into the video. Only set when the embedder actually
@@ -889,6 +894,12 @@ fn pump(
// PAD_AUDIO: the embedder can render per-pad DualSense haptics/speaker (see above).
}) | (if pad_audio_on {
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
// KEEP_HOST_AUDIO: the user's per-host "keep playing on the host" ask rides the
// Hello so the host's wiring pass can honour it before audio capture opens.
}) | (if params.keep_host_audio {
punktfunk_core::quic::CLIENT_CAP_KEEP_HOST_AUDIO
} else {
0
}),
+22 -5
View File
@@ -1163,10 +1163,10 @@ pub struct Settings {
#[serde(default = "default_mouse_mode")]
pub mouse_mode: String,
/// Send system chords (Alt+Tab, Super / the Windows key) to the host while input is
/// captured under the `capture` mouse model; off leaves them with the local shell.
/// Read at connect into the presenter's session opts, which turns it into an SDL
/// keyboard grab (a low-level hook on Windows, shortcuts-inhibit or `XGrabKeyboard`
/// on Linux). The `desktop` mouse model never grabs, whatever this says.
/// captured; off leaves them with the local shell. Read at connect into the presenter's
/// session opts, which turns it into an SDL keyboard grab (a low-level hook on Windows,
/// shortcuts-inhibit or `XGrabKeyboard` on Linux). Applies in BOTH mouse models — the
/// `desktop` model's unlocked pointer clicking another window is its way back.
pub inhibit_shortcuts: bool,
/// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool,
@@ -1210,6 +1210,14 @@ pub struct Settings {
/// ending a session over a dropdown. `default` so pre-existing stores load on the Opus plane.
#[serde(default = "default_audio_format")]
pub audio_format: String,
/// Ask the host to leave its own audio devices alone for this session
/// (`CLIENT_CAP_KEEP_HOST_AUDIO`): the host captures whatever its default playback device
/// already is, so audio keeps playing there — the headphones plugged into the host PC stay
/// live — as well as here. Off (the default), the host parks playback on a silent endpoint
/// and the host goes quiet while streaming. Best-effort: an older host ignores the ask and
/// re-routes as it always did. `default` so pre-existing stores load with today's behavior.
#[serde(default)]
pub keep_host_audio: bool,
/// Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
/// preference — the host honors it when it can emit it, else falls back to the best shared codec.
#[serde(default = "default_codec")]
@@ -1243,10 +1251,17 @@ pub struct Settings {
pub enable_444: bool,
/// Advertise 10-bit + HDR10 so the host upgrades HDR content to a Main10/PQ stream.
/// The presenter handles the display side dynamically either way (HDR10 swapchain
/// where offered, tonemap where not) — off means "never send me 10-bit".
/// where offered, tonemap where not) — off means "never send me HDR".
/// `default = true`: the Linux stores never carried this and always advertised.
#[serde(default = "default_true")]
pub hdr_enabled: bool,
/// Advertise 10-bit WITHOUT HDR (`VIDEO_CAP_10BIT` alone): the host encodes the SDR
/// desktop at Main10 precision — less encode banding on gradients, the display's colour
/// state untouched at both ends. Subsumed by `hdr_enabled`; the host additionally gates
/// (today: a Windows host on direct NVENC, HEVC), so elsewhere the session stays 8-bit.
/// `default` so pre-existing stores load with today's behavior.
#[serde(default)]
pub ten_bit_sdr: bool,
/// Presentation intent: `"latency"` (default) or `"smooth"` — the Apple/Android
/// clients' shared `present_priority` profile key, resolved with
/// [`PresentPriority::resolve`] (via [`Settings::present_priority`]). Anything
@@ -1523,11 +1538,13 @@ impl Default for Settings {
echo_cancel: true,
audio_channels: 2,
audio_format: default_audio_format(),
keep_host_audio: false,
codec: "auto".into(),
decoder: "auto".into(),
adapter: String::new(),
enable_444: false,
hdr_enabled: true,
ten_bit_sdr: false,
present_priority: "latency".into(),
smooth_buffer: 0,
vsync: true,
+39 -9
View File
@@ -1610,12 +1610,18 @@ const CHROMA_444: u8 = 3;
/// ⚠ The mobile/TV embedders must NOT copy this blindly — Amlogic MediaCodec wedges on
/// multi-slice AUs (see `VIDEO_CAP_MULTI_SLICE`), so they advertise per-decoder.
///
/// HDR off means 10-bit is not advertised either, so the host never upgrades depth.
pub fn video_caps_for(hdr_enabled: bool, want_444: bool) -> u8 {
/// `ten_bit_sdr` is the "10-bit SDR" setting: it advertises `VIDEO_CAP_10BIT` WITHOUT the HDR
/// bit, asking the host for Main10 coding precision (less encode banding) under an SDR stream —
/// the display's colour state untouched at both ends. With HDR on it is subsumed (HDR already
/// advertises the depth); with both off the host never upgrades depth.
pub fn video_caps_for(hdr_enabled: bool, ten_bit_sdr: bool, want_444: bool) -> u8 {
let mut caps = punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE;
if hdr_enabled {
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR;
}
if ten_bit_sdr {
caps |= punktfunk_core::quic::VIDEO_CAP_10BIT;
}
if want_444 {
caps |= punktfunk_core::quic::VIDEO_CAP_444;
}
@@ -2804,31 +2810,55 @@ mod tests {
const V444: u8 = punktfunk_core::quic::VIDEO_CAP_444;
// The regression itself: setting on, device can't → the bit must NOT go out.
assert_eq!(
video_caps_for(true, false) & V444,
video_caps_for(true, false, false) & V444,
0,
"a 4:4:4 promise this device cannot keep costs HEVC entirely"
);
// ...and the feature still works where it can be honoured.
assert_ne!(video_caps_for(true, true) & V444, 0);
assert_ne!(video_caps_for(true, false, true) & V444, 0);
// Never advertised unasked, whatever the device can do.
assert_eq!(video_caps_for(true, false) & V444, 0);
assert_eq!(video_caps_for(false, false) & V444, 0);
assert_eq!(video_caps_for(true, false, false) & V444, 0);
assert_eq!(video_caps_for(false, false, false) & V444, 0);
// The 4:4:4 gate must not disturb the other two bits (10-bit/HDR is deliberately
// NOT probe-gated — see `hevc_444_hardware_decodable`'s docs for why).
const HDR_BITS: u8 =
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR;
for want_444 in [false, true] {
assert_eq!(video_caps_for(true, want_444) & HDR_BITS, HDR_BITS);
assert_eq!(video_caps_for(false, want_444) & HDR_BITS, 0);
assert_eq!(video_caps_for(true, false, want_444) & HDR_BITS, HDR_BITS);
assert_eq!(video_caps_for(false, false, want_444) & HDR_BITS, 0);
assert_ne!(
video_caps_for(false, want_444) & punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE,
video_caps_for(false, false, want_444)
& punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE,
0,
"MULTI_SLICE is unconditional for this embedder"
);
}
}
/// The 10-bit SDR setting advertises the DEPTH bit alone — never HDR — and is subsumed
/// by the HDR setting (which already advertises both).
#[test]
fn ten_bit_sdr_advertises_depth_without_hdr() {
const TEN: u8 = punktfunk_core::quic::VIDEO_CAP_10BIT;
const HDR: u8 = punktfunk_core::quic::VIDEO_CAP_HDR;
// The new combination: depth without the HDR label.
assert_eq!(video_caps_for(false, true, false) & (TEN | HDR), TEN);
// Off = today's 8-bit SDR.
assert_eq!(video_caps_for(false, false, false) & (TEN | HDR), 0);
// HDR on subsumes it — both bits, with or without the SDR-10 switch.
assert_eq!(video_caps_for(true, true, false) & (TEN | HDR), TEN | HDR);
// ...and it never disturbs 4:4:4 or MULTI_SLICE.
assert_eq!(
video_caps_for(false, true, false) & punktfunk_core::quic::VIDEO_CAP_444,
0
);
assert_ne!(
video_caps_for(false, true, false) & punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE,
0
);
}
/// No presenter Vulkan device ⇒ no 4:4:4, and that is an ANSWER rather than a missing
/// one: the native Vulkan rung is the only one in this build that implements 4:4:4 at
/// all (`pf_vaadec::profile_for` errors on `chroma_format_idc == 3`, pf-dxvadec refuses
+34 -3
View File
@@ -42,6 +42,9 @@ enum RowId {
Decoder,
Hdr,
Chroma444,
/// The 10-bit SDR opt-in (`VIDEO_CAP_10BIT` without HDR). Desktop-only, like
/// [`RowId::Chroma444`]: the Android session derives its depth bits from the panel.
TenBitSdr,
PresentPriority,
SmoothBuffer,
Vsync,
@@ -51,6 +54,9 @@ enum RowId {
/// tied to the channel count above it for a reason that is NOT the one the design doc gives;
/// see the `enabled` note in [`row_spec`].
AudioFormat,
/// The per-session `CLIENT_CAP_KEEP_HOST_AUDIO` ask — the host keeps playing on its own
/// output while it streams. Desktop-only until the Android session advertises the bit.
KeepHostAudio,
Mic,
EchoCancel,
PadForward,
@@ -210,6 +216,7 @@ const TABS: [(&str, &[RowId]); 7] = [
RowId::LowLatency,
RowId::Hdr,
RowId::Chroma444,
RowId::TenBitSdr,
RowId::PresentPriority,
RowId::SmoothBuffer,
RowId::Vsync,
@@ -221,6 +228,7 @@ const TABS: [(&str, &[RowId]); 7] = [
&[
RowId::Audio,
RowId::AudioFormat,
RowId::KeepHostAudio,
RowId::Mic,
RowId::EchoCancel,
],
@@ -977,10 +985,14 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
id,
RowId::Decoder
| RowId::Chroma444
| RowId::TenBitSdr
| RowId::Vsync
| RowId::AllowVrr
| RowId::Fullscreen
| RowId::Shortcuts
// Desktop-only until the Android session advertises CLIENT_CAP_KEEP_HOST_AUDIO —
// a row whose bit never goes out would be a dead toggle.
| RowId::KeepHostAudio
);
match platform {
Platform::Desktop => !android_only,
@@ -1140,6 +1152,7 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
),
RowId::Hdr => (None, "10-bit HDR", on_off(s.hdr_enabled).into()),
RowId::Chroma444 => (None, "Full chroma (4:4:4)", on_off(s.enable_444).into()),
RowId::TenBitSdr => (None, "10-bit SDR", on_off(s.ten_bit_sdr).into()),
RowId::PresentPriority => (
Some("Presentation"),
"Prioritize",
@@ -1170,6 +1183,11 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
"Audio quality",
audio_format_label(&s.audio_format).into(),
),
RowId::KeepHostAudio => (
None,
"Keep host audio playing",
on_off(s.keep_host_audio).into(),
),
RowId::Mic => (None, "Microphone", on_off(s.mic_enabled).into()),
RowId::EchoCancel => (None, "Echo cancellation", on_off(s.echo_cancel).into()),
RowId::PadForward => (
@@ -1347,6 +1365,10 @@ fn detail(id: RowId, ctx: &Ctx) -> &'static str {
Needs an NVIDIA host (NVENC) or the PyroWave codec other encoders \
stream 4:2:0 and the session falls back silently."
}
RowId::TenBitSdr => {
"Smoother gradients without HDR — the picture is encoded at 10-bit \
precision. Needs an NVIDIA host; HDR takes over when it engages."
}
RowId::PresentPriority => {
"Lowest latency shows each frame the moment the display can take it — a \
network hiccup becomes an occasional repeated or skipped frame. Smoothness \
@@ -1371,6 +1393,10 @@ fn detail(id: RowId, ctx: &Ctx) -> &'static str {
link. The host has its own switch and stays on Opus if it can't deliver the rate; \
the stats overlay names what the session got. Stereo only."
}
RowId::KeepHostAudio => {
"The host's own speakers or headphones keep playing while you stream. \
Both ends hear the same audio; needs a host on 0.32 or newer."
}
RowId::Mic => {
"Send this device's microphone to the host's virtual mic. \
Ctrl+Alt+Shift+V mutes and unmutes it while streaming."
@@ -1623,6 +1649,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
}
RowId::Hdr => toggle(&mut s.hdr_enabled, delta, wrap),
RowId::Chroma444 => toggle(&mut s.enable_444, delta, wrap),
RowId::TenBitSdr => toggle(&mut s.ten_bit_sdr, delta, wrap),
RowId::PresentPriority => {
let cur = PRESENT_PRIORITIES
.iter()
@@ -1661,6 +1688,7 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
None
}
}
RowId::KeepHostAudio => toggle(&mut s.keep_host_audio, delta, wrap),
RowId::Mic => toggle(&mut s.mic_enabled, delta, wrap),
// Inert while the mic is off — a boundary thud, matching what the dimmed row shows.
RowId::EchoCancel => {
@@ -2634,8 +2662,10 @@ pub(super) mod tests {
vec![
RowId::Decoder,
RowId::Chroma444,
RowId::TenBitSdr,
RowId::Vsync,
RowId::AllowVrr,
RowId::KeepHostAudio,
RowId::Shortcuts,
RowId::Fullscreen,
]
@@ -2714,9 +2744,10 @@ pub(super) mod tests {
// 2026-08 sweep found them bridged but unreachable) later passes added, minus the
// game-library toggle: this screen never read it, and the library is offered on any
// paired host now.
// 35 desktop rows + the ten Android-only ones (design android-skia-console-port.md
// D3): eight `extra`-backed settings and two platform-screen action rows.
assert_eq!(seen.len(), 45, "{seen:?}");
// 37 desktop rows (the daily-driver batch added 10-bit SDR and Keep host audio) +
// the ten Android-only ones (design android-skia-console-port.md D3): eight
// `extra`-backed settings and two platform-screen action rows.
assert_eq!(seen.len(), 47, "{seen:?}");
assert!(seen.contains(&RowId::Palette));
assert!(seen.contains(&RowId::ReduceMotion));
assert!(seen.contains(&RowId::ReduceUiResolution));
+4 -1
View File
@@ -33,7 +33,10 @@ use pf_frame::CapturedFrame;
#[cfg(target_os = "windows")]
pub(crate) fn ten_bit_input(format: pf_frame::PixelFormat, negotiated_depth: u8) -> bool {
use pf_frame::PixelFormat;
let ten = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
let ten = matches!(
format,
PixelFormat::P010 | PixelFormat::Rgb10a2 | PixelFormat::Rgb10a2Sdr
);
if negotiated_depth >= 10 && !ten {
tracing::warn!(
?format,
+9 -5
View File
@@ -44,7 +44,11 @@ fn sws_src_pixel(format: PixelFormat) -> Result<Pixel> {
// swscale source for the X2RGB10→P010 conversion.
PixelFormat::X2Rgb10 => Pixel::X2RGB10LE,
PixelFormat::X2Bgr10 => Pixel::X2BGR10LE,
PixelFormat::Nv12 | PixelFormat::P010 | PixelFormat::Rgb10a2 | PixelFormat::Yuv444 => {
PixelFormat::Nv12
| PixelFormat::P010
| PixelFormat::Rgb10a2
| PixelFormat::Rgb10a2Sdr
| PixelFormat::Yuv444 => {
bail!("NVENC CPU-input conversion supports packed RGB/BGR only; got {format:?}")
}
})
@@ -164,10 +168,10 @@ fn nvenc_input(format: PixelFormat) -> (Pixel, bool) {
// Planar YUV444 from the zero-copy worker's GPU convert (a 4:4:4 session) — native
// full-chroma YUV in, `hevc_nvenc` emits Range-Extensions 4:4:4.
PixelFormat::Yuv444 => (Pixel::YUV444P, false),
// Rgb10a2 (HDR) and P010 (the Windows 10-bit video-processor output) are produced only by
// the Windows paths; the Linux capturer never emits them. Map to BGRA so the match is
// exhaustive — unreachable here.
PixelFormat::Rgb10a2 | PixelFormat::P010 => (Pixel::BGRA, false),
// Rgb10a2/Rgb10a2Sdr (the Windows packed-10 outputs) and P010 (the Windows 10-bit
// video-processor output) are produced only by the Windows paths; the Linux capturer
// never emits them. Map to BGRA so the match is exhaustive — unreachable here.
PixelFormat::Rgb10a2 | PixelFormat::Rgb10a2Sdr | PixelFormat::P010 => (Pixel::BGRA, false),
// The Linux HDR capture formats never take the RGB-passthrough input: `open` intercepts
// them onto the X2RGB10→P010 swscale path before consulting this mapping (like 4:4:4).
PixelFormat::X2Rgb10 | PixelFormat::X2Bgr10 => (Pixel::BGRA, false),
+5 -1
View File
@@ -60,7 +60,11 @@ fn vaapi_sws_src(format: PixelFormat) -> Result<Pixel> {
// swscale source for the X2RGB10→P010 conversion.
PixelFormat::X2Rgb10 => Pixel::X2RGB10LE,
PixelFormat::X2Bgr10 => Pixel::X2BGR10LE,
PixelFormat::Nv12 | PixelFormat::P010 | PixelFormat::Rgb10a2 | PixelFormat::Yuv444 => {
PixelFormat::Nv12
| PixelFormat::P010
| PixelFormat::Rgb10a2
| PixelFormat::Rgb10a2Sdr
| PixelFormat::Yuv444 => {
bail!("VAAPI CPU-input path supports packed RGB/BGR only; got {format:?}")
}
})
+8
View File
@@ -132,6 +132,14 @@ impl From<PixelFormat> for WireFormat {
PixelFormat::Rgb => WireFormat::Rgb,
PixelFormat::Bgr => WireFormat::Bgr,
PixelFormat::Rgb10a2 => WireFormat::Rgb10a2,
// A Windows-only capture format (the IDD-push 10-bit SDR expansion): the Linux
// encode worker can never be handed one, and the wire deliberately grows no
// variant for it.
PixelFormat::Rgb10a2Sdr => {
unreachable!(
"Rgb10a2Sdr is a Windows capture format — the Linux worker never sees it"
)
}
PixelFormat::Nv12 => WireFormat::Nv12,
PixelFormat::P010 => WireFormat::P010,
PixelFormat::Yuv444 => WireFormat::Yuv444,
+8 -4
View File
@@ -213,11 +213,15 @@ impl Encoder for OpenH264Encoder {
PixelFormat::Bgr => (3, 2, 1, 0),
PixelFormat::Rgba | PixelFormat::Rgbx => (4, 0, 1, 2),
PixelFormat::Bgra | PixelFormat::Bgrx => (4, 2, 1, 0),
// 10-bit HDR comes only from the GPU paths; the software 8-bit H.264 encoder can't
// represent it (and never receives it — HDR is never negotiated on a software host).
PixelFormat::Rgb10a2 | PixelFormat::X2Rgb10 | PixelFormat::X2Bgr10 => {
// 10-bit comes only from the GPU paths; the software 8-bit H.264 encoder can't
// represent it (and never receives it — neither HDR nor 10-bit SDR is negotiated on
// a software host).
PixelFormat::Rgb10a2
| PixelFormat::Rgb10a2Sdr
| PixelFormat::X2Rgb10
| PixelFormat::X2Bgr10 => {
anyhow::bail!(
"software H.264 encoder cannot encode 10-bit HDR ({:?})",
"software H.264 encoder cannot encode 10-bit ({:?})",
self.src_format
)
}
@@ -234,6 +234,7 @@ fn sws_src(format: PixelFormat) -> Result<Pixel> {
PixelFormat::Nv12
| PixelFormat::P010
| PixelFormat::Rgb10a2
| PixelFormat::Rgb10a2Sdr
| PixelFormat::Yuv444
| PixelFormat::X2Rgb10
| PixelFormat::X2Bgr10 => {
@@ -250,7 +251,12 @@ fn sws_src(format: PixelFormat) -> Result<Pixel> {
/// `submit_d3d11` then failed the depth check below, forever, with `reset()` unable to help
/// because the rebuild re-derived the same wrong answer.
fn is_10bit_format(format: PixelFormat) -> bool {
matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
// Rgb10a2Sdr joins for honesty, though it can't arrive here: the 10-bit SDR chain is
// gated to the direct-NVENC backend at the handshake.
matches!(
format,
PixelFormat::P010 | PixelFormat::Rgb10a2 | PixelFormat::Rgb10a2Sdr
)
}
/// Which lane the system-memory path routes a captured D3D11 format through. Device-free — the
@@ -1689,6 +1689,14 @@ impl Encoder for NvencD3d11Encoder {
self.bit_depth = 10;
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
}
PixelFormat::Rgb10a2Sdr => {
// The 10-bit SDR capture: same packed layout as Rgb10a2, but plain sRGB
// values — `hdr` above is false, so the session opens Main10 with the
// ordinary BT.709 SDR VUI (the encoder's CSC follows it, the SDR 4:4:4
// precedent one bit-depth up).
self.bit_depth = 10;
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
}
PixelFormat::Nv12 => {
// NV12 is 8-bit 4:2:0. Force 8-bit so a transition from a prior P010 (10-bit) session
// — or a 10-bit-negotiated client on an SDR display — re-inits at the matching depth.
+20 -2
View File
@@ -43,6 +43,13 @@ pub enum PixelFormat {
/// produces this: scRGB FP16 desktop pixels are converted to BT.2020 PQ and written here, then
/// handed to NVENC as `ABGR10` for an HEVC Main10 / HDR10 encode.
Rgb10a2,
/// [`Rgb10a2`](Self::Rgb10a2)'s **SDR** twin: the same `R10G10B10A2` memory, but the values
/// are the plain sRGB desktop pixels expanded 8→10 bit — NOT PQ/BT.2020. The Windows 10-bit
/// SDR capture path produces this so NVENC encodes Main10 (finer coding precision = less
/// encode banding) under the ordinary BT.709 SDR VUI. A separate variant on purpose: the
/// encoder derives its colour signalling from the pixels that arrive, and one format
/// carrying two transfers would put PQ labels on SDR frames.
Rgb10a2Sdr,
/// `NV12` (DXGI `NV12`): 8-bit BT.709 limited-range YUV 4:2:0. Produced by the D3D11 **video
/// processor** (video engine, not the 3D engine) so the per-frame colour conversion doesn't fight a
/// GPU-saturating game; handed to NVENC as `NV12` (it encodes YUV natively — no internal RGB→YUV).
@@ -111,8 +118,9 @@ pub fn drm_fourcc(format: PixelFormat) -> Option<u32> {
X2Rgb10 => drm_fourcc_code(b"XR30"), // DRM_FORMAT_XRGB2101010
X2Bgr10 => drm_fourcc_code(b"XB30"), // DRM_FORMAT_XBGR2101010
// 24-bit packed RGB/BGR have no straightforward dmabuf import here; use the CPU path.
// Rgb10a2/P010 are Windows formats; Yuv444 is OUR convert output, never a capture source.
Rgb | Bgr | Rgb10a2 | P010 | Yuv444 => return None,
// Rgb10a2/Rgb10a2Sdr/P010 are Windows formats; Yuv444 is OUR convert output, never a
// capture source.
Rgb | Bgr | Rgb10a2 | Rgb10a2Sdr | P010 | Yuv444 => return None,
})
}
@@ -130,6 +138,13 @@ pub struct OutputFormat {
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `P010`, or `Rgb10a2` for a 4:4:4 source).
/// `false` = 8-bit SDR.
pub hdr: bool,
/// 10-bit **SDR** session (`bit_depth == 10` negotiated, HDR not): on Windows the IDD-push
/// capturer expands the BGRA slot 8→10 bit into the packed [`PixelFormat::Rgb10a2Sdr`]
/// output so NVENC encodes Main10 under the BT.709 SDR VUI — finer coding precision without
/// touching the display's colour state (advanced colour stays off). Mutually exclusive with
/// `hdr` (the handshake resolves 10-bit HDR onto the `hdr` path). Ignored on Linux — no
/// SDR-10 capture chain exists there yet, and the handshake never negotiates it.
pub ten_bit_sdr: bool,
/// Full-chroma 4:4:4 session: the capturer must keep full chroma. On Windows the IDD-push
/// capturer hands the **BGRA** slot through (skipping the subsampling BGRA→NV12
/// VideoConverter) so NVENC ingests full-chroma RGB and CSCs to 4:4:4 itself — measured
@@ -170,6 +185,9 @@ impl OutputFormat {
OutputFormat {
gpu,
hdr,
// 10-bit SDR is punktfunk/1-native only (Moonlight's HDR checkbox is the whole
// depth story on the GameStream plane).
ten_bit_sdr: false,
// The GameStream + spike paths are always 4:2:0 (4:4:4 is punktfunk/1-native only).
chroma_444: false,
// GameStream never negotiates PyroWave (native punktfunk/1 only).
+10 -6
View File
@@ -8,10 +8,12 @@
//!
//! The host sends the bitmap in host-FRAMEBUFFER pixels, whose size tracks the host virtual
//! display's DPI scaling (32 px at 100%, 96 px at 300%). Drawn 1:1 it balloons on a high-DPI
//! host; instead we scale it by the SAME aspect-fit factor the video is drawn at
//! (`min(window_px/mode)`), so the pointer stays sized to the streamed desktop at any host
//! scaling. SDL cursors are fixed-size from their surface (no draw-time scaling), so we cache
//! shapes RAW and resample per install — rebuilding when the serial OR the fit changes.
//! host; instead we scale it by the aspect-fit factor the video is drawn at
//! (`min(window_px/mode)`) TIMES the client display's content scale (SDL shows the surface at
//! ~1:1 physical pixels on every backend, so without the second factor a 200 % client's
//! pointer came out half the size of its native ones — see the run loop's `cursor_scale`).
//! SDL cursors are fixed-size from their surface (no draw-time scaling), so we cache
//! shapes RAW and resample per install — rebuilding when the serial OR the scale changes.
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::{CursorState, HOST_CAP_CURSOR};
@@ -81,8 +83,10 @@ impl CursorChannel {
/// `desktop_active` = the desktop mouse model is engaged (captured + desktop): only then
/// do we own the local cursor's shape/visibility; under capture SDL's relative mode owns
/// it, and released the system cursor must look normal. `fit_scale` is host-framebuffer
/// pixels → window pixels (the aspect-fit factor the video is drawn at); the shape is
/// resampled by it so the pointer matches the streamed desktop at any host DPI.
/// pixels → cursor-surface pixels (the run loop's `cursor_scale`: the aspect-fit factor
/// the video is drawn at, times the client display's content scale); the shape is
/// resampled by it so the pointer matches the streamed desktop at any host DPI *and*
/// the client's native pointers at any client DPI.
pub fn pump(
&mut self,
connector: &NativeClient,
+28 -12
View File
@@ -74,8 +74,10 @@ pub struct SessionOpts {
pub invert_scroll: bool,
/// Send system chords (Alt+Tab, the Windows key / Super) to the host while input is
/// captured ([`Settings::inhibit_shortcuts`], default on). Off keeps them local — the
/// work profile that streams on a second screen and still Alt-Tabs here. Never applies
/// under the `desktop` mouse model, which is something you Alt-Tab *away* from.
/// work profile that streams on a second screen and still Alt-Tabs here. Applies in
/// BOTH mouse models (a remote desktop needs the host's Start menu too — see
/// [`apply_capture`]); the desktop model's unlocked pointer clicking another window
/// is the always-available way back.
pub inhibit_shortcuts: bool,
/// Presentation intent ([`Settings::present_priority`] resolved): `Latency` keeps the
/// shipped arrival pacing (newest-wins, present the moment a frame can go out);
@@ -1217,20 +1219,30 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// Cursor channel (M2): drain forwarded shape/state and drive the local OS cursor —
// only meaningful in the desktop mouse model (capture's relative lock hides it).
if let Some(st) = stream.as_mut() {
// Host-framebuffer px → window px: the aspect-fit factor the video is drawn at
// (same `min(surface/content)` as `finger_to_content`). The forwarded pointer is
// resampled by it so a high-DPI host's oversized bitmap lands sized to the streamed
// desktop rather than ballooning. 1:1 until the first frame gives `last_video`.
let fit_scale = st.last_video.map_or(1.0, |(vw, vh)| {
// Host-framebuffer px → cursor-surface px: the aspect-fit factor the video is
// drawn at (same `min(surface/content)` as `finger_to_content`) — times the
// client display's own content scale. Fit alone sizes the pointer to the
// STREAMED desktop (a high-DPI host's oversized bitmap lands proportional
// instead of ballooning) but ignores the panel it lands on: SDL shows a custom
// cursor's surface at ~1:1 physical pixels on every backend (Windows HCURSOR,
// XcursorImage, Wayland buffer-scale/viewport), so on a 200 % client every
// native pointer is 2× and ours came out half their size (2026-08 field
// report). The Apple client never had this hole — NSCursor is sized in POINTS,
// so its fit-only scale rides the backing factor for free; multiplying by
// `display_scale` is the same model spelled in pixels. 1:1 until the first
// frame gives `last_video`; 0 is SDL's display-scale error value, treated as 1.
let cursor_scale = st.last_video.map_or(1.0, |(vw, vh)| {
let (pw, ph) = window.size_in_pixels();
(pw as f32 / vw.max(1) as f32).min(ph as f32 / vh.max(1) as f32)
let fit = (pw as f32 / vw.max(1) as f32).min(ph as f32 / vh.max(1) as f32);
let density = window.display_scale();
fit * if density > 0.0 { density } else { 1.0 }
});
if let (Some(chan), Some(c)) = (st.cursor_chan.as_mut(), st.connector.as_ref()) {
let desktop_active = st
.capture
.as_ref()
.is_some_and(|cap| cap.captured() && cap.desktop());
chan.pump(c, &mouse, desktop_active, fit_scale);
chan.pump(c, &mouse, desktop_active, cursor_scale);
// §8 mid-stream render flip: tell the host who renders the pointer whenever the
// local model changes. The host may composite one ONLY while we hold a grabbed,
// hidden pointer — the capture model, engaged — because that is the one state
@@ -2666,8 +2678,12 @@ impl ResizeIndicator {
/// The `desktop` mouse model never locks: the pointer roams (and leaves the window)
/// freely, the local cursor is hidden over the window — the host's composited cursor,
/// tracking our absolute sends, is the one you see (until the M2 cursor channel flips
/// who draws it) — and system chords stay local (a remote desktop is something you
/// Alt-Tab away from, not into). `desktop` only matters while `on`.
/// who draws it). The keyboard grab follows `inhibit` in BOTH models: the earlier
/// "desktop is something you Alt-Tab away from" rule made the setting inert exactly for
/// the remote-desktop use it matters most for (Win opens the HOST's Start menu or it's
/// not a desktop), and desktop mode keeps a way out capture mode doesn't have — the
/// unlocked pointer clicks any other window, focus is lost, and the chords come back.
/// `desktop` only matters while `on`.
///
/// `grants` is the session's effective access mask (per-client access §7 "not capture
/// what can't land"): no pointer lock without the POINTER bit, no keyboard grab without
@@ -2690,7 +2706,7 @@ fn apply_capture(
// POINTER grant no absolute/relative send lands, so hiding it would leave a
// keyboard-only session with no cursor at all.
mouse.show_cursor(!(on && pointer));
let grab = on && !desktop && inhibit && grants & GRANT_KEYBOARD != 0;
let grab = on && inhibit && grants & GRANT_KEYBOARD != 0;
if !window.set_keyboard_grab(grab) && grab {
// The one refusal SDL reports is a missing mechanism — a Wayland compositor with no
// shortcuts-inhibit global. Said once per process: the answer never changes
+37 -3
View File
@@ -1,8 +1,12 @@
//! Client/host video capability bits, codec + chroma negotiation, and colour signalling.
/// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
/// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) stream. Alone — without
/// [`VIDEO_CAP_HDR`] — it is the **10-bit SDR** ask (0.32, the client's "10-bit SDR" setting):
/// the host encodes the SDR desktop at Main10 precision under a BT.709 SDR VUI, and neither
/// display's colour state is touched. Every pre-0.32 client sets the two bits together.
pub const VIDEO_CAP_10BIT: u8 = 0x01;
/// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
/// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit — set
/// together with [`VIDEO_CAP_10BIT`]).
pub const VIDEO_CAP_HDR: u8 = 0x02;
/// [`Hello::video_caps`] bit: the client can decode a full-chroma **4:4:4** HEVC stream (HEVC
/// Range Extensions / Rec.ITU-T H.265 `chroma_format_idc = 3`) AND its user turned 4:4:4 on (a
@@ -187,9 +191,25 @@ pub const HOST_CAP_PAD_AUDIO: u8 = 0x40;
/// requesting must not set this bit.
///
/// `0x10` — `0x08` is [`CLIENT_CAP_PAD_AUDIO`], `0x04` is [`CLIENT_CAP_AUDIO_RED`], `0x02` is
/// [`CLIENT_CAP_PHASE_LOCK`], `0x01` is [`CLIENT_CAP_CURSOR`]. `0x20`/`0x40`/`0x80` remain free.
/// [`CLIENT_CAP_PHASE_LOCK`], `0x01` is [`CLIENT_CAP_CURSOR`].
pub const CLIENT_CAP_AUDIO_HIRES: u8 = 0x10;
/// [`Hello::client_caps`] bit: this session asks the host to leave the host's OWN audio devices
/// alone — capture whatever the operator's default playback device already is, instead of
/// re-routing the desktop mix onto a silent (or preferred) endpoint. A loopback/monitor tap
/// doesn't silence the device it taps, so the host keeps playing (the headphones plugged into
/// the host PC stay live) and the client hears the same audio — Moonlight's "Mute host PC
/// speakers" box, unchecked, as a per-session client choice.
///
/// The user's-setting precedent ([`CLIENT_CAP_AUDIO_HIRES`]), and REQUEST-only — no `HOST_CAP`
/// echo: an older host ignores the bit and re-routes as it always did, which degrades to
/// "audio still works, host went quiet", not a broken session. Per-session best-effort on the
/// host: with several concurrent sessions the wiring is host-global, so any live session that
/// asked wins for all of them until it ends. Composes with the host-wide
/// `PUNKTFUNK_AUDIO_OUTPUT_MODE=follow_default`, which is this behaviour for every session.
/// `0x20` — `0x10` is [`CLIENT_CAP_AUDIO_HIRES`]; `0x40`/`0x80` remain free.
pub const CLIENT_CAP_KEEP_HOST_AUDIO: u8 = 0x20;
/// [`Welcome::host_caps`] bit: the host resolved the session onto the lossless audio plane
/// ([`AUDIO_PCM_MAGIC`](super::datagram::AUDIO_PCM_MAGIC), `0xD3`). Like [`HOST_CAP_AUDIO_RED`]
/// this is a statement about the WIRE rather than an offer: with the bit set the client decodes
@@ -436,6 +456,20 @@ mod tests {
assert_eq!(HOST_CAP_PAD_AUDIO.count_ones(), 1);
}
#[test]
fn keep_host_audio_cap_bit_is_distinct() {
assert_eq!(
CLIENT_CAP_KEEP_HOST_AUDIO
& (CLIENT_CAP_CURSOR
| CLIENT_CAP_PHASE_LOCK
| CLIENT_CAP_AUDIO_RED
| CLIENT_CAP_PAD_AUDIO
| CLIENT_CAP_AUDIO_HIRES),
0
);
assert_eq!(CLIENT_CAP_KEEP_HOST_AUDIO.count_ones(), 1);
}
#[test]
fn resolve_codec_canonicalizes_a_multi_bit_preference() {
// A non-conformant peer may stuff its capability MASK into `preferred` — the result
@@ -8,8 +8,37 @@
//! * [`CaptureStats`] — the audio plane's vitals, so a log can tell a quiet host from a broken
//! endpoint from one we are damaging ourselves.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
/// Live sessions that asked the host to leave its audio devices alone
/// (`CLIENT_CAP_KEEP_HOST_AUDIO` — the client's "keep playing on the host" setting). A count,
/// not a flag, because sessions overlap; host-global because the wiring is: with several
/// concurrent sessions any live asker wins for all of them until it ends (best-effort, as the
/// cap documents). Windows reads it in `audio_control::keep_default_devices`; Linux in the
/// capturer's topology pick (Monitor — tap the operator's default sink instead of claiming it).
static KEEP_HOST_AUDIO_SESSIONS: AtomicUsize = AtomicUsize::new(0);
/// RAII for one session's `CLIENT_CAP_KEEP_HOST_AUDIO` ask — hold it for exactly the session's
/// lifetime (every exit path decrements, panic-unwind included).
pub(crate) struct KeepHostAudioGuard(());
pub(crate) fn keep_host_audio_guard() -> KeepHostAudioGuard {
KEEP_HOST_AUDIO_SESSIONS.fetch_add(1, Ordering::Relaxed);
KeepHostAudioGuard(())
}
impl Drop for KeepHostAudioGuard {
fn drop(&mut self) {
KEEP_HOST_AUDIO_SESSIONS.fetch_sub(1, Ordering::Relaxed);
}
}
/// Whether any live session asked for the operator's audio devices to be left alone.
pub(crate) fn session_keeps_default() -> bool {
KEEP_HOST_AUDIO_SESSIONS.load(Ordering::Relaxed) > 0
}
/// Default-playback re-assertions inside [`FIGHT_WINDOW`] before we stop fighting.
pub(crate) const FIGHT_LIMIT: u32 = 4;
pub(crate) const FIGHT_WINDOW: Duration = Duration::from_secs(20);
@@ -1074,4 +1103,22 @@ mod tests {
);
assert_eq!(s.late, 0);
}
/// The per-session keep-host-audio ask is a COUNT (sessions overlap), and every guard
/// drop hands the devices back — the RAII contract the wiring pass depends on. This test
/// is the static's only toucher, so it owns the counter for its run.
#[test]
fn keep_host_audio_guard_counts_overlapping_sessions() {
assert!(!session_keeps_default());
let a = keep_host_audio_guard();
assert!(session_keeps_default());
let b = keep_host_audio_guard();
drop(a);
assert!(
session_keeps_default(),
"the second session still holds the ask"
);
drop(b);
assert!(!session_keeps_default());
}
}
@@ -100,6 +100,14 @@ impl CaptureMode {
/// An unrecognised value resolves to the default rather than failing: this is a field-debugging
/// lever, and a typo in it must not cost a session its audio.
fn capture_mode() -> CaptureMode {
if crate::audio::capture_policy::session_keeps_default() {
// A live session asked for the host's audio devices to be left alone
// (`CLIENT_CAP_KEEP_HOST_AUDIO`): follow the operator's default sink and tap its
// monitor — the sink keeps playing on the host, no default-sink claim. Wins over
// `PUNKTFUNK_STREAM_SINK` for the same reason `follow_default` beats a stale
// `PUNKTFUNK_HOST_AUDIO`: "don't touch my devices" is the more restrictive promise.
return CaptureMode::Monitor;
}
capture_mode_from(std::env::var("PUNKTFUNK_STREAM_SINK").ok().as_deref())
}
@@ -197,9 +197,12 @@ pub(crate) fn host_audio_requested() -> bool {
}
/// The operator's default playback/recording devices must not be touched at all — the
/// `follow_default` mode, formerly `PUNKTFUNK_KEEP_DEFAULT`.
/// `follow_default` mode (formerly `PUNKTFUNK_KEEP_DEFAULT`), or a live session's
/// `CLIENT_CAP_KEEP_HOST_AUDIO` ask (the client's "keep playing on the host" setting,
/// held per-session by [`crate::audio::capture_policy::keep_host_audio_guard`]).
pub(crate) fn keep_default_devices() -> bool {
pf_host_config::config().audio_output_mode.keeps_default()
|| crate::audio::capture_policy::session_keeps_default()
}
/// One wiring pass plus the inputs the desktop-audio capture loop's failure handling needs:
@@ -354,7 +357,9 @@ pub(crate) fn wire_now_full(park_defaults: bool) -> WiredPlan {
if changed {
tracing::info!(
mode = %pf_host_config::config().audio_output_mode.as_str(),
"audio output mode is follow_default — leaving the audio default devices untouched"
session_asked = crate::audio::capture_policy::session_keeps_default(),
"leaving the audio default devices untouched (follow_default mode, or a \
session's keep-host-audio ask)"
);
}
return done(wiring);
+1
View File
@@ -305,6 +305,7 @@ pub fn capture_virtual_output(
target,
pref,
want.hdr,
want.ten_bit_sdr,
want.chroma_444,
want.pyrowave,
keep,
@@ -283,10 +283,15 @@ fn run(
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
prep_cmds.extend(crate::library::prep_for(lib_id));
}
let prep_env = [(
let mut prep_env = vec![(
"PF_APP_TITLE".to_string(),
app.map(|a| a.title.clone()).unwrap_or_default(),
)];
// The negotiated mode, same `PF_STREAM_*` names as the native plane's prep env and the
// marker file — one vocabulary for a script whichever client connected.
prep_env.extend(crate::hooks::prep_mode_env(
cfg.width, cfg.height, cfg.fps, cfg.hdr,
));
let _prep = (!prep_cmds.is_empty()).then(|| crate::hooks::run_prep(&prep_cmds, &prep_env));
// Open the virtual-display source: pick the live compositor, normalize the session env
// (apply_session_env + input/gamescope routing — ATTACH/resize + KWin/Mutter retargeting,
+28
View File
@@ -1010,6 +1010,21 @@ pub struct PrepCmd {
pub undo: Option<String>,
}
/// The negotiated stream mode as env for prep `do`/`undo` commands — the same `PF_STREAM_*`
/// vocabulary as the [`crate::stream_marker`] file (and the same rule: keys only ever get
/// added), so a script written against either sees one spelling. Exists because prep commands
/// are how operators do per-mode setup (an RTSS/driver FPS cap wants the refresh rate), and
/// until now their whole environment was the app identity — every mode value had to be
/// hard-coded per device. One definition, used by BOTH serving planes, so they can't drift.
pub fn prep_mode_env(width: u32, height: u32, refresh_hz: u32, hdr: bool) -> [(String, String); 4] {
[
("PF_STREAM_WIDTH".to_string(), width.to_string()),
("PF_STREAM_HEIGHT".to_string(), height.to_string()),
("PF_STREAM_REFRESH".to_string(), refresh_hz.to_string()),
("PF_STREAM_HDR".to_string(), u8::from(hdr).to_string()),
]
}
/// Holds the armed `undo` commands for one session's prep steps; dropping it (session end,
/// error return, panic-unwind) runs them in reverse order on a detached thread — teardown
/// never blocks on operator code.
@@ -1289,6 +1304,19 @@ mod tests {
assert_eq!(v, "evilname");
}
#[test]
fn prep_mode_env_speaks_the_marker_vocabulary() {
// The names are the stream-marker file's — a script written against either sees one
// spelling — and HDR is 1/0 like the marker, not true/false like PF_EVENT_*.
let env = prep_mode_env(2560, 1440, 120, true);
let get = |k: &str| env.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str());
assert_eq!(get("PF_STREAM_WIDTH"), Some("2560"));
assert_eq!(get("PF_STREAM_HEIGHT"), Some("1440"));
assert_eq!(get("PF_STREAM_REFRESH"), Some("120"));
assert_eq!(get("PF_STREAM_HDR"), Some("1"));
assert_eq!(prep_mode_env(1, 1, 1, false)[3].1, "0");
}
#[cfg(unix)]
#[test]
fn exec_runs_with_stdin_and_env_and_timeout_kills() {
+25 -3
View File
@@ -2246,10 +2246,28 @@ async fn serve_session(
// the closure only runs when the title actually has prep steps.
let _prep = hello.launch.as_deref().and_then(|id| {
let cmds = crate::library::prep_for(id);
let env = [("PF_APP_ID".to_string(), id.to_string())];
// The app identity plus the negotiated mode (`prep_mode_env` — the marker file's
// `PF_STREAM_*` vocabulary), so a prep step can set a per-mode FPS cap instead of
// hard-coding one per device.
let mut env = vec![("PF_APP_ID".to_string(), id.to_string())];
env.extend(crate::hooks::prep_mode_env(
hello.mode.width,
hello.mode.height,
hello.mode.refresh_hz,
welcome.color.is_hdr(),
));
(!cmds.is_empty())
.then(|| tokio::task::block_in_place(|| crate::hooks::run_prep(&cmds, &env)))
});
// The client asked for this box's audio devices to be left alone
// (`CLIENT_CAP_KEEP_HOST_AUDIO` — its "keep playing on the host" setting): hold the
// wiring override for exactly this session's lifetime, BEFORE the data plane opens the
// audio capture. Windows then skips the IPolicyConfig default parking and loopbacks the
// operator's own default device; Linux taps the default sink's monitor instead of
// claiming the default. RAII — every exit path hands the devices back.
let _keep_host_audio = (hello.client_caps & punktfunk_core::quic::CLIENT_CAP_KEEP_HOST_AUDIO
!= 0)
.then(crate::audio::capture_policy::keep_host_audio_guard);
// The resolved number is the session's TOTAL WIRE budget (RFC §5.1): the Welcome, every
// ack, the HUD and this whole control plane speak budget — only the encoder opens are
// handed the derived video rate (`EncDerive` in the stream loop: budget minus the audio
@@ -2262,8 +2280,11 @@ async fn serve_session(
// unconditionally (`resolve_bitrate_kbps_for` overrode any explicit rate — RFC §5.2).
let bitrate_auto = hello.bitrate_kbps == 0 || codec == crate::encode::Codec::PyroWave;
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
// Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the
// session uses exactly what the client was told). `Yuv444` only when the handshake gate passed.
// The session's HDR verdict — read back from the Welcome's colour label (what the client
// was told) rather than re-derived from the depth: a 10-bit SDR session says 10 + SDR.
let hdr = welcome.color.is_hdr();
// Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the
// session uses exactly what the client was told). `Yuv444` only when the handshake gate passed.
let chroma = if welcome.chroma_format == punktfunk_core::quic::CHROMA_IDC_444 {
crate::encode::ChromaFormat::Yuv444
} else {
@@ -2455,6 +2476,7 @@ async fn serve_session(
client_packets_received,
bitrate_auto,
bit_depth,
hdr,
chroma,
codec,
probe_rx,
+49 -18
View File
@@ -618,6 +618,11 @@ pub(super) async fn negotiate(
// label that matches the stream.
let host_wants_10bit = pf_host_config::config().ten_bit;
let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0;
// The two bits split (0.32): `VIDEO_CAP_HDR` is the client's HDR (BT.2020 PQ) ask;
// `VIDEO_CAP_10BIT` alone is the 10-bit **SDR** ask — Main10 coding precision (less encode
// banding on gradients) without touching the display's colour state. Every pre-0.32 client
// sets both together, so the split changes nothing for them.
let client_wants_hdr = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_HDR != 0;
// The capture side must be able to deliver a 10-bit HDR source for the NATIVE plane's
// virtual-output capture — the honest-downgrade gate, mirroring `capturer_supports_444`.
// SOURCE-AWARE, because on Linux the answer depends on which compositor we just resolved:
@@ -632,26 +637,43 @@ pub(super) async fn negotiate(
// path makes separately in rtsp.rs has no twin here: that latch is per-source, and this gate
// already consulted the one belonging to the source this session will drive.
let capture_supports_hdr = crate::capture::capturer_supports_hdr_for(compositor);
// The 10-bit SDR chain: the Windows IDD-push capturer expands its BGRA slot 8→10 bit
// (`Rgb10a2Sdr`), and only the direct-NVENC backend ingests that packed RGB — the same
// ingest fact the 4:4:4 gate keys on below (`resolved_backend_ingests_rgb_444` is `false`
// off Windows, which is also the whole Linux story: no SDR-10 capture chain exists there
// yet, so an SDR-10 ask resolves 8-bit — the honest downgrade, told in the Welcome before
// the client builds its decoder). HEVC only for now: NVENC's packed-RGB → 10-bit AV1
// ingest is unverified on glass — widen once measured. PyroWave is excluded by the same
// term (its capture composition is pinned to the display's HDR state, not this path).
let sdr10_chain_ok =
codec == crate::encode::Codec::H265 && crate::encode::resolved_backend_ingests_rgb_444();
// 10-bit is reachable through EITHER path: the HDR one (client asked HDR + the capturer
// can deliver a 10-bit HDR source) or the SDR-10 one above.
let depth_reachable = (client_wants_hdr && capture_supports_hdr) || sdr10_chain_ok;
// The GPU probe may open a tiny encoder on first use, so run it off the reactor like the
// 4:4:4 probe below (blocking probes → spawn_blocking), short-circuited behind the cheap
// gates. The result is cached process-wide per (GPU, codec).
let gpu_can_10bit = if host_wants_10bit
&& client_supports_10bit
&& codec.supports_10bit()
&& capture_supports_hdr
{
tokio::task::spawn_blocking(move || crate::encode::can_encode_10bit(codec))
.await
.context("10-bit capability probe task")?
} else {
false
};
let gpu_can_10bit =
if host_wants_10bit && client_supports_10bit && codec.supports_10bit() && depth_reachable {
tokio::task::spawn_blocking(move || crate::encode::can_encode_10bit(codec))
.await
.context("10-bit capability probe task")?
} else {
false
};
let bit_depth: u8 = if gpu_can_10bit { 10 } else { 8 };
// The session's HDR verdict — the Welcome's colour label, the capturer's advanced-colour
// mandate, and the virtual display's HDR bring-up all read THIS, never `bit_depth >= 10`:
// a 10-bit session without it is the 10-bit SDR session (BT.709 VUI, display untouched).
let session_hdr = gpu_can_10bit && client_wants_hdr && capture_supports_hdr;
tracing::info!(
bit_depth,
session_hdr,
host_wants_10bit,
client_supports_10bit,
client_wants_hdr,
capture_supports_hdr,
sdr10_chain_ok,
codec = ?codec,
gpu_can_10bit,
client_video_caps = hello.video_caps,
@@ -764,6 +786,9 @@ pub(super) async fn negotiate(
} else {
bit_depth
};
// The HDR verdict follows any depth clamp (the Linux 4:4:4 one above): an 8-bit stream is
// never labelled HDR. A no-op wherever the depth survived.
let session_hdr = session_hdr && bit_depth == 10;
// Resolve the encoder bitrate (client request clamped to a sane range, or a codec-aware
// host default). Resolved AFTER depth + chroma: PyroWave's Automatic rate is a ~bpp pin
@@ -942,12 +967,13 @@ pub(super) async fn negotiate(
gamepad,
bitrate_kbps,
bit_depth,
// Colour signalling the client configures its decoder/presenter from. A negotiated
// 10-bit session is our HDR path (BT.2020 PQ — what the NVENC HEVC VUI emits from a
// 10-bit capture format); 8-bit stays BT.709 SDR. The mastering metadata (ST.2086 +
// CLL) rides the 0xCE datagram below. (A future step can refine this to the capturer's
// actual monitor HDR state and announce a mid-stream flip.)
color: if bit_depth >= 10 {
// Colour signalling the client configures its decoder/presenter from — the session's
// HDR verdict, NOT the bit depth: a 10-bit SDR session (VIDEO_CAP_10BIT without
// VIDEO_CAP_HDR) encodes Main10 under a BT.709 SDR VUI and must say SDR here. The
// mastering metadata (ST.2086 + CLL) rides the 0xCE datagram below. (A future step can
// refine this to the capturer's actual monitor HDR state and announce a mid-stream
// flip.)
color: if session_hdr {
ColorInfo::HDR10_BT2020_PQ
} else {
ColorInfo::SDR_BT709
@@ -1136,8 +1162,13 @@ pub(super) async fn negotiate(
multi_slice,
bitrate_kbps,
bitrate_auto,
enc_of,
bit_depth,
session_hdr,
// ⚠ order: `enc_of` comes AFTER the depth pair — #408 (`bbc01cdd`)
// landed it before `bit_depth`, which cannot compile on Windows and
// sat unnoticed because Linux CI never builds this cfg(windows) block
// and windows-host.yml only runs on dispatch/release.
enc_of,
chroma,
codec,
shard_payload,
+23 -9
View File
@@ -1429,8 +1429,13 @@ pub(super) struct SessionContext {
/// the NEGOTIATED MODE (`resolve_bitrate_kbps_for`) — a mid-stream mode switch re-resolves it
/// for the new mode (the pin follows the resolution; an explicit client rate stays put).
pub(super) bitrate_auto: bool,
/// Negotiated encode bit depth (8, or 10 = HEVC Main10).
/// Negotiated encode bit depth (8, or 10 = HEVC Main10 / 10-bit AV1). Does NOT imply HDR —
/// `hdr` below carries that separately (the 10-bit SDR path).
pub(super) bit_depth: u8,
/// The session's HDR verdict — the Welcome's colour label (`welcome.color.is_hdr()`), which
/// the handshake resolved from the client's `VIDEO_CAP_HDR` ask + the capturer's HDR
/// capability. Drives the virtual display's HDR bring-up and the capturer's want-HDR flag.
pub(super) hdr: bool,
/// Negotiated chroma subsampling (4:2:0, or 4:4:4 when the client + host + GPU all support it).
pub(super) chroma: crate::encode::ChromaFormat,
/// Negotiated video codec the encoder emits (HEVC by default; H.264 / AV1 when the client
@@ -1650,6 +1655,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`.
let mut plan = crate::session_plan::SessionPlan::resolve(
ctx.bit_depth,
ctx.hdr,
ctx.chroma,
ctx.codec,
// Blend CAPABILITY (the single rule in `cursor_blend_for`): cursor-FORWARD sessions
@@ -1708,6 +1714,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
client_packets_received,
bitrate_auto,
bit_depth,
hdr,
// The resolved chroma is already captured in `plan` (above); ignore the duplicate here.
chroma: _,
// Likewise the codec — `plan.codec` (resolved from `ctx.codec`) is the source of truth below.
@@ -1910,11 +1917,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// panel instead of the driver's built-in ~1000-nit placeholder. No-op on Linux
// backends and for older/SDR clients.
vd.set_client_hdr(client_hdr);
// THIS SESSION's colourimetry (distinct from the client panel's volume above): a
// 10-bit session needs the output brought up HDR, which on gamescope means spawning
// THIS SESSION's colourimetry (distinct from the client panel's volume above): an
// HDR session needs the output brought up HDR, which on gamescope means spawning
// it with the HDR flags so nested games get HDR surfaces at all. Decided in the
// Welcome (`capture::capturer_supports_hdr_for`), so it cannot change under us.
vd.set_hdr(bit_depth >= 10);
// The HDR verdict, not the depth — a 10-bit SDR session leaves the output SDR.
vd.set_hdr(hdr);
// Out-of-band cursor request: cursor-forward sessions (Windows pf-vdisplay /
// IddCx hardware cursor; Linux metadata mode) AND no-channel host-composite
// sessions (Linux only — `metadata_composite` is `plan.cursor_blend`-gated, so
@@ -4815,6 +4823,9 @@ pub(super) fn prepare_display(
// Passed through to [`build_pipeline`] — see its parameter of the same name.
bitrate_auto: bool,
bit_depth: u8,
// The session's HDR verdict (the Welcome's colour label) — NOT derivable from the depth
// since the 10-bit SDR path exists.
hdr: bool,
// The budget→encoder conversion for the prep build (Welcome-time FEC snapshot — the
// session loop's FEC watcher takes over once streaming).
enc_of: super::EncDerive,
@@ -4830,6 +4841,7 @@ pub(super) fn prepare_display(
// Welcome value passed here.
let mut plan = crate::session_plan::SessionPlan::resolve(
bit_depth,
hdr,
chroma,
codec,
// Blend capability — must MATCH virtual_stream's resolve. Windows-only path, where
@@ -4852,7 +4864,8 @@ pub(super) fn prepare_display(
let mut vd = crate::vdisplay::open(compositor)?;
vd.set_client_identity(client_identity);
vd.set_client_hdr(client_hdr);
vd.set_hdr(bit_depth >= 10);
// The session's HDR verdict, not the depth — a 10-bit SDR session leaves the output SDR.
vd.set_hdr(hdr);
vd.set_hw_cursor(cursor_forward);
vd.set_quit_flag(quit.clone());
// Slot-scoped setup serialization + reconnect preempt — see the inline arm in
@@ -5309,10 +5322,11 @@ fn build_pipeline(
// Pace the encoder + frame clock at the session's rate, floored by what the display achieved
// — never above either.
let effective_hz = pacing_hz(mode.refresh_hz, achieved_hz);
// HDR vs SDR for the IDD-push conversion: a negotiated 10-bit session (client advertised
// VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2;
// otherwise the FP16 IDD frames are converted to 8-bit SDR. (Ignored by non-IDD-push backends,
// which auto-detect HDR from the monitor state.)
// HDR vs SDR for the IDD-push conversion: a negotiated HDR session (client advertised
// VIDEO_CAP_10BIT|VIDEO_CAP_HDR + host opted in via PUNKTFUNK_10BIT) is our HDR path →
// BT.2020 PQ; a 10-bit SDR session (`plan.ten_bit_sdr` via `output_format()`) expands
// BGRA 8→10 under the SDR VUI; otherwise the frames convert to 8-bit SDR. (Ignored by
// non-IDD-push backends, which auto-detect HDR from the monitor state.)
//
// KWin rewrites `SPA_META_Cursor` on every buffer, so its id-0 metas are an authoritative
// "pointer hidden" the cursor blend/forward must honor — without this, the composited arrow
+14 -4
View File
@@ -88,9 +88,11 @@ pub struct SessionPlan {
pub capture: CaptureBackend,
pub topology: SessionTopology,
pub encoder: EncoderBackend,
/// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10).
/// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10 / 10-bit AV1). Since the
/// 10-bit SDR path, 10 does NOT imply HDR — `hdr` below carries that separately.
pub bit_depth: u8,
/// The want-HDR flag handed to the capturer (`bit_depth >= 10`): on Windows the IDD-push
/// The want-HDR flag handed to the capturer (the handshake's HDR verdict — the Welcome's
/// colour label, no longer derived from the depth): on Windows the IDD-push
/// capturer proactively enables advanced colour on the virtual display; on Linux it runs the
/// 10-bit PQ/BT.2020 PipeWire offer. It is only ever set where the handshake's source-aware
/// gate said yes (`capture::capturer_supports_hdr_for`) — on Linux that means a gamescope
@@ -145,9 +147,13 @@ pub struct SessionPlan {
impl SessionPlan {
/// Resolve the whole plan once from [`config`](crate::config) + the negotiated `bit_depth`,
/// `chroma`, and `codec`.
/// `hdr`, `chroma`, and `codec`. `hdr` is passed IN (the handshake's verdict — the Welcome's
/// colour label) rather than derived from the depth: since 10-bit SDR exists, `bit_depth ==
/// 10` no longer implies BT.2020 PQ, and deriving it here re-welded the two.
#[allow(clippy::too_many_arguments)]
pub fn resolve(
bit_depth: u8,
hdr: bool,
chroma: crate::encode::ChromaFormat,
codec: crate::encode::Codec,
cursor_blend: bool,
@@ -159,7 +165,7 @@ impl SessionPlan {
topology: resolve_topology(),
encoder: resolve_encoder(),
bit_depth,
hdr: bit_depth >= 10,
hdr,
chroma,
codec,
wire_chunk: None,
@@ -228,6 +234,10 @@ impl SessionPlan {
crate::capture::OutputFormat {
gpu,
hdr: self.hdr,
// 10-bit depth WITHOUT the HDR label = the 10-bit SDR session: the Windows
// capturer expands BGRA 8→10 (`Rgb10a2Sdr`) and the display's colour state is
// never touched.
ten_bit_sdr: self.bit_depth == 10 && !self.hdr,
hw_cursor: self.cursor_forward,
// 4:4:4 needs a full-chroma source: on Windows this keeps the capturer on RGB (not the
// default NV12/P010 video-engine output) so NVENC can CSC to 4:4:4.
+7
View File
@@ -157,6 +157,13 @@ session end in **reverse order**, best-effort, even if the session crashed:
A `do` that fails logs, keeps going, and its own `undo` is skipped (it never took effect).
Every prep command (and its `undo`) runs with the session's negotiated mode in its environment:
`PF_STREAM_WIDTH`, `PF_STREAM_HEIGHT`, `PF_STREAM_REFRESH` and `PF_STREAM_HDR` (`1`/`0`), plus the
app identity — `PF_APP_ID` for a native client's launch, `PF_APP_TITLE` for a Moonlight one. So a
per-mode frame cap is one step for every device —
`{ "do": "rtss-cli property:set Global FramerateLimit $PF_STREAM_REFRESH" }` — instead of one
hard-coded entry per client.
## Reacting to a game, not a stream
`stream.stopped` tells you the *stream* ended; `game.exited` tells you the *game* did. Often the
+23 -3
View File
@@ -80,6 +80,14 @@ HEVC or PyroWave, the host's 4:4:4 policy on, a capture path that delivers full
that can encode it; if any gate fails the host says 4:2:0 before your decoder is built. Apple
(hardware decode probe required), Linux, Windows and the console home; not Android.
**10-bit SDR** — *default: off.* The picture is encoded at 10-bit precision without turning
anything HDR: gradients that band under an 8-bit encode — skies, fog, dark scenes — come through
smooth, and the displays at both ends keep their colour settings untouched. This is about the
*encoder's* precision, not a 10-bit capture: the desktop stays 8-bit, the win is that compression
stops adding banding of its own. Needs a Windows host on an NVIDIA GPU and HEVC; anywhere else the
session stays 8-bit, and the host says so in the handshake. When HDR engages it takes over (HDR is
already 10-bit). Linux, Windows and the desktop console.
**Prioritize** — *default: Lowest latency.* **Lowest latency** shows every frame the moment the
display can take it — a network hiccup becomes an occasional repeated or skipped frame.
**Smoothness** holds a small buffer that evens hiccups out, at that buffer's worth of added delay.
@@ -113,6 +121,17 @@ from *that*. A **Linux** host claims a sink with exactly that many channels (rea
**Windows** host loopback-captures the current output endpoint and lets Windows convert — 5.1 from
a stereo endpoint is an upmix. Offered everywhere.
**Keep host audio playing** — *default: off.* Normally a session parks the host's playback on a
silent endpoint so sound comes out of the client only, and the host PC goes quiet. On, the session
asks the host to capture whatever its default playback device already is instead — the speakers or
headphones plugged into the host keep playing, and both ends hear the same audio (Moonlight's
"Mute host PC speakers" box, unchecked). Per profile, so a laptop-in-the-house profile can keep the
host's headphones live while the TV profile mutes them. Best-effort: it needs a host on 0.32 or
newer, and with several clients streaming at once, any one asking wins for all of them. The
host-wide equivalent is
[`PUNKTFUNK_AUDIO_OUTPUT_MODE=follow_default`](/docs/configuration). Linux, Windows and the
desktop console.
**Microphone** — *default: off on Linux, Windows, Android and the console home; on in the Apple
app.* Sends this device's microphone to the host's virtual mic. Spelled *Stream microphone* on
Linux and Windows; **Ctrl+Alt+Shift+V** mutes it mid-stream — see
@@ -195,9 +214,10 @@ and puts it back afterwards — see
(Alt+Tab, Win, …)*), macOS and the console home; on a Deck it matters only for an attached keyboard
(gamescope holds nothing back). On, Alt+Tab and the Windows/Super key reach the host while input is
captured; off, they act locally. Either way the chords return when you release capture with
**Ctrl+Alt+Shift+Q**, the window loses focus, or the stream ends
[Desktop mouse mode](/docs/input#mouse-modes) never takes them at all. Leaving it on means
**Ctrl+Alt+Shift+Q is your way out**, since Alt+Tab no longer is.
**Ctrl+Alt+Shift+Q**, the window loses focus, or the stream ends. It applies in
[both mouse modes](/docs/input#mouse-modes) — in Desktop mode the unlocked pointer can always
click another window to hand them back. Leaving it on means **Ctrl+Alt+Shift+Q is your way out**
of a captured stream, since Alt+Tab no longer is.
On macOS the chords in question are the **⌘** ones — on, ⌘Q, ⌘W, ⌘H and the rest go to the host
while input is captured (⌘Q arrives as Super+Q); off, they act on the Mac, which means ⌘Q quits
+1 -1
View File
@@ -164,7 +164,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t
| `PUNKTFUNK_MIC_DEVICE` | name substring | **(Windows)** Target mic-uplink device by friendly-name substring (first match wins). |
| `PUNKTFUNK_MIC_LEGACY_BUFFER` | `1` | Restore the fixed pre-adaptive mic buffering (a ~48 ms prime and ~120 ms cap on Windows; a buffer scaled to the recording app's audio quantum on Linux) instead of the adaptive per-client jitter target. One-release escape hatch: if the microphone coming out of the host only sounds right *with* this set, that's a bug — please report it. |
| `PUNKTFUNK_NO_MIC_INSTALL` | set | **(Windows)** Skip installing the virtual-mic driver (e.g. when the host runs as SYSTEM). |
| `PUNKTFUNK_AUDIO_OUTPUT_MODE` | `client_only` *(default)* · `host_and_client` · `follow_default` | **(Windows)** Where desktop audio is audible while a stream runs. `client_only` parks playback on a silent endpoint so sound comes out of the *client* only — that's why the PC goes quiet when a stream starts; everything is put back when it ends. `host_and_client` prefers a real output device, so the host's speakers keep playing too. `follow_default` never touches your default devices at all — the host just captures whatever your default playback device is (the mic uplink still picks a target device; you may have to select it yourself). A misspelled value warns in the log and uses `client_only`. The pre-0.25 flags `PUNKTFUNK_HOST_AUDIO=1` and `PUNKTFUNK_KEEP_DEFAULT=1` still work as aliases for the last two; `follow_default` wins if both are set. |
| `PUNKTFUNK_AUDIO_OUTPUT_MODE` | `client_only` *(default)* · `host_and_client` · `follow_default` | **(Windows)** Where desktop audio is audible while a stream runs. `client_only` parks playback on a silent endpoint so sound comes out of the *client* only — that's why the PC goes quiet when a stream starts; everything is put back when it ends. `host_and_client` prefers a real output device, so the host's speakers keep playing too. `follow_default` never touches your default devices at all — the host just captures whatever your default playback device is (the mic uplink still picks a target device; you may have to select it yourself). A misspelled value warns in the log and uses `client_only`. The pre-0.25 flags `PUNKTFUNK_HOST_AUDIO=1` and `PUNKTFUNK_KEEP_DEFAULT=1` still work as aliases for the last two; `follow_default` wins if both are set. A client can also ask for the `follow_default` behaviour per session — its [**Keep host audio playing**](/docs/client-settings#audio) setting — without touching this host-wide mode. |
| `PUNKTFUNK_NO_AUDIO_MINT` | set | **(Windows)** Don't provision the host's own dedicated virtual audio endpoints at startup (they're minted from Steam's streaming-audio driver where it's installed, and give capture a stable target that renaming or unplugging hardware can't break). With this set — or whenever minting isn't possible — the host picks devices by name instead, exactly as before 0.25. |
## Clipboard
+5 -2
View File
@@ -149,13 +149,16 @@ There are two, and they are a per-client setting called **Mouse input**:
- **Capture (games)** — the pointer locks to the stream and only relative movement is sent. The only
cursor you see is the host's. This is what mouse-look in a game needs. The session window also
grabs the keyboard here, so Alt+Tab and the Windows key (Super on Linux) reach the host rather than
grabs the keyboard, so Alt+Tab and the Windows key (Super on Linux) reach the host rather than
your own desktop — on macOS that is the ⌘ chords, ⌘Q included, with ⌘⎋ kept back as the way out.
Turn **Capture system shortcuts** off in [client settings](/docs/client-settings#input) to keep
them local.
- **Desktop (absolute)** — the pointer is not locked. It moves in and out of the stream freely and
its position is sent as an absolute point — what you want for remote desktop work. Your local
cursor is hidden over the stream; the one you see there is the host's.
cursor is hidden over the stream; the one you see there is the host's. On Linux and Windows,
Alt+Tab and the Windows/Super key go to the host here too while **Capture system shortcuts** is
on — the host's Start menu is part of the desktop you're driving — and clicking any other local
window takes them back. (On a Mac the ⌘ chords stay local in this mode.)
**Capture is the default** on the Linux, Windows and macOS clients. **Android defaults to Desktop**.
+25 -3
View File
@@ -834,12 +834,16 @@
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) stream. Alone — without
// [`VIDEO_CAP_HDR`] — it is the **10-bit SDR** ask (0.32, the client's "10-bit SDR" setting):
// the host encodes the SDR desktop at Main10 precision under a BT.709 SDR VUI, and neither
// display's colour state is touched. Every pre-0.32 client sets the two bits together.
#define PUNKTFUNK_VIDEO_CAP_10BIT 1
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit — set
// together with [`VIDEO_CAP_10BIT`]).
#define PUNKTFUNK_VIDEO_CAP_HDR 2
#endif
@@ -1068,10 +1072,28 @@
// requesting must not set this bit.
//
// `0x10` — `0x08` is [`CLIENT_CAP_PAD_AUDIO`], `0x04` is [`CLIENT_CAP_AUDIO_RED`], `0x02` is
// [`CLIENT_CAP_PHASE_LOCK`], `0x01` is [`CLIENT_CAP_CURSOR`]. `0x20`/`0x40`/`0x80` remain free.
// [`CLIENT_CAP_PHASE_LOCK`], `0x01` is [`CLIENT_CAP_CURSOR`].
#define PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES 16
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::client_caps`] bit: this session asks the host to leave the host's OWN audio devices
// alone — capture whatever the operator's default playback device already is, instead of
// re-routing the desktop mix onto a silent (or preferred) endpoint. A loopback/monitor tap
// doesn't silence the device it taps, so the host keeps playing (the headphones plugged into
// the host PC stay live) and the client hears the same audio — Moonlight's "Mute host PC
// speakers" box, unchecked, as a per-session client choice.
//
// The user's-setting precedent ([`CLIENT_CAP_AUDIO_HIRES`]), and REQUEST-only — no `HOST_CAP`
// echo: an older host ignores the bit and re-routes as it always did, which degrades to
// "audio still works, host went quiet", not a broken session. Per-session best-effort on the
// host: with several concurrent sessions the wiring is host-global, so any live session that
// asked wins for all of them until it ends. Composes with the host-wide
// `PUNKTFUNK_AUDIO_OUTPUT_MODE=follow_default`, which is this behaviour for every session.
// `0x20` — `0x10` is [`CLIENT_CAP_AUDIO_HIRES`]; `0x40`/`0x80` remain free.
#define CLIENT_CAP_KEEP_HOST_AUDIO 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Welcome::host_caps`] bit: the host resolved the session onto the lossless audio plane
// ([`AUDIO_PCM_MAGIC`](super::datagram::AUDIO_PCM_MAGIC), `0xD3`). Like [`HOST_CAP_AUDIO_RED`]