feat(vdisplay): compositor-embedded pointer for sessions without the cursor channel (Phase B)

Since the cursor-channel work, every Linux virtual output was created in
metadata pointer mode — making ALL sessions depend on host-side cursor
compositing, including the ones that can never use the channel (Moonlight/
GameStream, legacy clients, capture-mode starts). Those sessions paid the
blend bring-up per session and, whenever a visible cursor was composited,
the loss of NVENC's stream-ordered submit — for a strictly worse cursor
than the compositor's own.

Mirror the Windows no-regression gate: the already-wired per-session
set_hw_cursor(cursor_forward) now drives the Linux backends too. A
cursor-channel session gets metadata (shapes forwarded, composite flip
blends host-side — today's validated path, unchanged); every other session
gets the pointer compositor-EMBEDDED at creation (KWin zkde pointer=2,
Mutter cursor-mode=1, wlroots/hyprland portal CursorMode::Embedded — their
pre-channel default; the portal pair also gains the Metadata arm for
channel sessions, wired but untested on-glass). SessionPlan.cursor_blend
narrows to cursor-forward sessions and rides into the direct-SDK NVENC
open, which skips the Vulkan slot-blend bring-up entirely when off —
embedded sessions ring on plain CUDA surfaces and pay zero cursor cost,
per-session or per-frame.

The keep-alive registry's reuse key grows the created pointer mode (new
VirtualDisplay::hw_cursor getter): a kept embedded display has no cursor
metadata for a channel session to forward, and a kept metadata display
would leave a channel-less session with no pointer in its frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:47:53 +02:00
co-authored by Claude Fable 5
parent d2c46eaf3c
commit 925130d1f9
10 changed files with 205 additions and 46 deletions
+18 -6
View File
@@ -142,13 +142,25 @@ pub trait VirtualDisplay: Send {
/// the backend's default EDID. Default: no-op — only the Windows pf-vdisplay backend can mint
/// per-monitor EDIDs today (the Linux compositors' virtual outputs take no EDID from us).
fn set_client_hdr(&mut self, _hdr: Option<punktfunk_core::quic::HdrMeta>) {}
/// Ask the backend for an out-of-band HARDWARE CURSOR on the created output (the M2c cursor
/// channel): the compositor/OS stops compositing the pointer into captured frames and the
/// capture layer surfaces shape/position separately. Carried on the backend instance; set
/// once before [`create`](Self::create). Default: no-op — only the Windows pf-vdisplay
/// backend (IddCx hardware cursor, driver proto v5) implements it; the Linux portal path
/// gets the same split from `SPA_META_Cursor` without asking.
/// Ask the backend for an OUT-OF-BAND cursor on the created output (the cursor channel):
/// the compositor/OS stops compositing the pointer into captured frames and the capture
/// layer surfaces shape/position separately. Carried on the backend instance; set once
/// before [`create`](Self::create) (both session paths pass `cursor_forward`). Off = the
/// compositor EMBEDS the pointer into frames — zero host-side cursor work, the pre-channel
/// path — which is what every session without the negotiated cursor cap gets (Moonlight /
/// GameStream / legacy clients / capture-mode starts), mirroring the Windows no-regression
/// gate. Implementations: Windows pf-vdisplay (IddCx hardware cursor, driver proto v5);
/// KWin (zkde `pointer` metadata vs embedded); Mutter (`cursor-mode` metadata vs embedded);
/// wlroots/hyprland (portal `CursorMode`). Default: no-op (gamescope has no cursor either
/// way — see the Phase C source).
fn set_hw_cursor(&mut self, _on: bool) {}
/// The out-of-band-cursor request currently set (see [`set_hw_cursor`](Self::set_hw_cursor)).
/// The registry includes it in the keep-alive REUSE key: a kept embedded-pointer display can
/// never serve a cursor-channel session (its stream has no cursor metadata to forward) nor
/// vice versa (the pointer would be missing from frames).
fn hw_cursor(&self) -> bool {
false
}
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
/// registry reads it right after `create` to key the display's group **arrangement** (manual
@@ -92,11 +92,19 @@ fn next_output_name() -> String {
/// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one
/// named headless output and spins up a portal thread owning the cast on it.
pub struct HyprlandDisplay;
pub struct HyprlandDisplay {
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
hw_cursor: bool,
}
impl HyprlandDisplay {
pub fn new() -> Result<Self> {
Ok(HyprlandDisplay)
Ok(HyprlandDisplay { hw_cursor: false })
}
}
@@ -141,6 +149,14 @@ impl VirtualDisplay for HyprlandDisplay {
"hyprland"
}
fn set_hw_cursor(&mut self, on: bool) {
self.hw_cursor = on;
}
fn hw_cursor(&self) -> bool {
self.hw_cursor
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Log the permission-system caveat once per process (silent black frames otherwise).
preflight_once();
@@ -167,9 +183,10 @@ impl VirtualDisplay for HyprlandDisplay {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let hw_cursor = self.hw_cursor;
thread::Builder::new()
.name("punktfunk-hypr-vout".into())
.spawn(move || portal_thread(setup_tx, stop_thread))
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn hyprland portal thread")?;
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
@@ -491,7 +508,17 @@ fn ensure_xdph_config() -> Result<()> {
/// stopped (the zbus connection is the cast's lifetime). xdph answers source selection via our
/// custom picker, no dialog. (Kept separate from wlroots' copy so each wlr-family backend stays
/// self-owned per D1; unify if they ever diverge no further.)
fn portal_thread(setup_tx: Sender<Result<(OwnedFd, u32), String>>, stop: Arc<AtomicBool>) {
fn portal_thread(
setup_tx: Sender<Result<(OwnedFd, u32), String>>,
stop: Arc<AtomicBool>,
hw_cursor: bool,
) {
// Portal cursor mode per the session's channel negotiation (see the struct doc).
let cursor_mode = if hw_cursor {
CursorMode::Metadata
} else {
CursorMode::Embedded
};
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
@@ -524,7 +551,7 @@ fn portal_thread(setup_tx: Sender<Result<(OwnedFd, u32), String>>, stop: Arc<Ato
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(CursorMode::Embedded)
.set_cursor_mode(cursor_mode)
// xdph offers MONITOR; the custom picker selects our output.
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
+40 -10
View File
@@ -56,12 +56,14 @@ use zkde::zkde_screencast_stream_unstable_v1::{
};
use zkde::zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1 as Screencast;
/// `pointer` attachment mode (the protocol enum): ship the cursor as `SPA_META_Cursor` metadata
/// (the capturer always negotiates the meta). Embedded mode would leave the cursor channel with
/// nothing to forward AND the encoder blend with nothing to composite — the same trap as the
/// Mutter backend's cursor-mode (see mutter.rs `CURSOR_METADATA`); the blend draws the pointer
/// for sessions where the client does not render it itself.
/// `pointer` attachment modes (the protocol enum), chosen per session by `set_hw_cursor`
/// (Phase B — the Windows no-regression gate mirrored): a CURSOR-CHANNEL session gets METADATA
/// (`SPA_META_Cursor` on the stream — shapes forwarded to the client, the composite flip blends
/// host-side; embedded would leave both with nothing, the round-1 mutter trap), every other
/// session gets EMBEDDED — KWin composites the pointer into frames itself, zero host-side
/// cursor work, the pre-channel path Moonlight/legacy clients always had.
const POINTER_METADATA: u32 = 4;
const POINTER_EMBEDDED: u32 = 2;
/// The name we give the created output; KWin exposes it to output-management as `Virtual-<name>`.
const VOUT_NAME: &str = "punktfunk";
@@ -92,6 +94,9 @@ pub struct KwinDisplay {
/// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's.
/// A backstop [`Drop`] runs it if the registry never took it (so a physical is never left dark).
pending_restore: Option<Box<dyn FnOnce() + Send>>,
/// Out-of-band cursor request (`set_hw_cursor`, i.e. the session negotiated the cursor
/// channel): METADATA pointer mode at creation; off = EMBEDDED (see the consts above).
hw_cursor: bool,
}
impl Drop for KwinDisplay {
@@ -128,6 +133,14 @@ impl VirtualDisplay for KwinDisplay {
self.pending_restore.take()
}
fn set_hw_cursor(&mut self, on: bool) {
self.hw_cursor = on;
}
fn hw_cursor(&self) -> bool {
self.hw_cursor
}
fn apply_position(&mut self, x: i32, y: i32) {
// `last_name` holds the RESOLVED kscreen address (numeric output id, or the
// `Virtual-<name>` fallback) — never re-derive from the name: during a supersede two
@@ -166,6 +179,11 @@ impl VirtualDisplay for KwinDisplay {
};
self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout)
let (width, height) = (mode.width, mode.height);
let pointer_mode = if self.hw_cursor {
POINTER_METADATA
} else {
POINTER_EMBEDDED
};
let spawn_vout = |w: u32, h: u32| -> Result<(u32, Arc<AtomicBool>)> {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false));
@@ -173,7 +191,9 @@ impl VirtualDisplay for KwinDisplay {
let name_thread = name.clone();
thread::Builder::new()
.name("punktfunk-kwin-vout".into())
.spawn(move || virtual_output_thread(w, h, name_thread, setup_tx, stop_thread))
.spawn(move || {
virtual_output_thread(w, h, name_thread, pointer_mode, setup_tx, stop_thread)
})
.context("spawn KWin virtual-output thread")?;
match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => Ok((v, stop)),
@@ -201,7 +221,14 @@ impl VirtualDisplay for KwinDisplay {
let want_high = mode.refresh_hz > 60;
let birth_h = if want_high { height + 16 } else { height };
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
tracing::info!(node_id, width, height, birth_h, "KWin virtual output ready");
tracing::info!(
node_id,
width,
height,
birth_h,
embedded_pointer = !self.hw_cursor,
"KWin virtual output ready"
);
// ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede (mode switch) creates the
// replacement output — SAME per-slot name, deliberately, for KWin's per-name config
// persistence — while the superseded sibling is still alive (create-before-drop). Every
@@ -716,10 +743,11 @@ fn virtual_output_thread(
width: u32,
height: u32,
name: String,
pointer_mode: u32,
setup_tx: Sender<Result<u32, String>>,
stop: Arc<AtomicBool>,
) {
if let Err(e) = run(width, height, &name, &setup_tx, &stop) {
if let Err(e) = run(width, height, &name, pointer_mode, &setup_tx, &stop) {
// If we never delivered a node id, report the failure to the waiting opener.
let _ = setup_tx.send(Err(format!("{e:#}")));
}
@@ -760,6 +788,7 @@ fn run(
width: u32,
height: u32,
name: &str,
pointer_mode: u32,
setup_tx: &Sender<Result<u32, String>>,
stop: &AtomicBool,
) -> Result<()> {
@@ -780,13 +809,14 @@ fn run(
)
})?;
// Create the virtual output sized to the client, cursor riding as stream metadata.
// Create the virtual output sized to the client; the pointer rides as stream metadata
// (cursor-channel session) or KWin embeds it into frames (everyone else — see the consts).
let stream = screencast.stream_virtual_output(
name.to_string(),
width as i32,
height as i32,
1.0, // scale (logical == physical)
POINTER_METADATA,
pointer_mode,
&qh,
(),
);
@@ -53,6 +53,11 @@ const APPLY_TEMPORARY: u32 = 1;
/// Embedded mode would leave BOTH paths blind: no metadata means nothing to forward AND nothing
/// to blend, and Mutter's own embedded painting is what the pre-channel path relied on.
const CURSOR_METADATA: u32 = 2;
/// `cursor-mode` embedded (mutter enum 1): Mutter composites the pointer into frames itself —
/// zero host-side cursor work, the pre-channel path. Chosen for every session WITHOUT the
/// negotiated cursor channel (`set_hw_cursor` off — Phase B, the Windows no-regression gate
/// mirrored); metadata stays the cursor-channel sessions' mode (shapes forwarded / host blend).
const CURSOR_EMBEDDED: u32 = 1;
/// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies
/// a monitor configuration. Each of these makes Mutter rebuild its monitor topology, and
@@ -74,6 +79,9 @@ pub struct MutterDisplay {
/// sole-monitor config (which would disable the first session's virtual). Defaults true (a lone
/// session establishes topology as before).
first_in_group: bool,
/// Out-of-band cursor request (`set_hw_cursor`): metadata cursor-mode at creation; off =
/// embedded (see [`CURSOR_EMBEDDED`]).
hw_cursor: bool,
/// The connecting client's cert fingerprint (set before [`create`](VirtualDisplay::create)) —
/// keys the per-client persisted **scale** (GNOME can't persist it itself: Mutter mints a fresh
/// EDID serial per `RecordVirtual` monitor, so `monitors.xml` never rematches; see
@@ -89,6 +97,7 @@ impl MutterDisplay {
pub fn new() -> Result<Self> {
Ok(MutterDisplay {
first_in_group: true,
hw_cursor: false,
client_fp: None,
last_slot: None,
})
@@ -117,6 +126,14 @@ impl VirtualDisplay for MutterDisplay {
self.client_fp = fingerprint;
}
fn set_hw_cursor(&mut self, on: bool) {
self.hw_cursor = on;
}
fn hw_cursor(&self) -> bool {
self.hw_cursor
}
fn last_identity_slot(&self) -> Option<u32> {
self.last_slot
}
@@ -146,6 +163,7 @@ impl VirtualDisplay for MutterDisplay {
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let first_in_group = self.first_in_group;
let hw_cursor = self.hw_cursor;
thread::Builder::new()
.name("punktfunk-mutter-vout".into())
.spawn(move || {
@@ -154,6 +172,7 @@ impl VirtualDisplay for MutterDisplay {
stop_thread,
mode,
first_in_group,
hw_cursor,
scale_key,
remembered_scale,
)
@@ -208,6 +227,7 @@ fn session_thread(
stop: Arc<AtomicBool>,
mode: Mode,
first_in_group: bool,
hw_cursor: bool,
scale_key: String,
remembered_scale: Option<f64>,
) {
@@ -267,7 +287,7 @@ fn session_thread(
}
};
let session = match connect(mode, remembered_scale).await {
let session = match connect(mode, hw_cursor, remembered_scale).await {
Ok(s) => s,
Err(e) => {
let _ = setup_tx.send(Err(format!("{e:#}")));
@@ -368,7 +388,11 @@ struct MutterSession {
/// desktop scale, passed as the virtual mode's `preferred-scale` so Mutter creates the monitor
/// already scaled (Mutter ≥ 48; older Mutter ignores unknown mode keys) — this covers the
/// `extend` topology, where we never issue our own ApplyMonitorsConfig.
async fn connect(mode: Mode, preferred_scale: Option<f64>) -> Result<MutterSession> {
async fn connect(
mode: Mode,
hw_cursor: bool,
preferred_scale: Option<f64>,
) -> Result<MutterSession> {
let conn = zbus::Connection::session()
.await
.context("connect session D-Bus")?;
@@ -429,7 +453,14 @@ async fn connect(mode: Mode, preferred_scale: Option<f64>) -> Result<MutterSessi
// once gated behind PUNKTFUNK_MUTTER_VIRTUAL_REFRESH; the stop-screencast-before-any-monitor-
// reconfig teardown below fixed the crash, so pinning the client's refresh is now the default.)
let mut rec: HashMap<&str, Value> = HashMap::new();
rec.insert("cursor-mode", Value::from(CURSOR_METADATA));
rec.insert(
"cursor-mode",
Value::from(if hw_cursor {
CURSOR_METADATA
} else {
CURSOR_EMBEDDED
}),
);
if mode.refresh_hz > 60 || preferred_scale.is_some() {
let mut vmode: HashMap<&str, Value> = HashMap::new();
vmode.insert("size", Value::from((mode.width, mode.height)));
@@ -54,11 +54,19 @@ chooser_cmd=cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'\n",
/// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create)
/// adds one headless output and spins up a portal thread owning the cast on it.
pub struct WlrootsDisplay;
pub struct WlrootsDisplay {
/// Out-of-band cursor request (`set_hw_cursor`, the negotiated cursor channel): portal
/// `CursorMode::Metadata` — shapes/positions ride `SPA_META_Cursor` for the channel + the
/// composite blend. Off (every non-channel session): `Embedded` — the compositor paints the
/// pointer into frames, zero host-side cursor work (the pre-channel default this backend
/// always had). ⚠️ Metadata is UNTESTED on-glass for this backend (Phase B wired it so the
/// channel isn't silently dead here; KWin/Mutter are the validated legs).
hw_cursor: bool,
}
impl WlrootsDisplay {
pub fn new() -> Result<Self> {
Ok(WlrootsDisplay)
Ok(WlrootsDisplay { hw_cursor: false })
}
}
@@ -73,6 +81,14 @@ impl VirtualDisplay for WlrootsDisplay {
"wlroots"
}
fn set_hw_cursor(&mut self, on: bool) {
self.hw_cursor = on;
}
fn hw_cursor(&self) -> bool {
self.hw_cursor
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
let before = output_names()
.context("swaymsg get_outputs (is the host inside the sway session env — SWAYSOCK?)")?;
@@ -104,9 +120,10 @@ impl VirtualDisplay for WlrootsDisplay {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let hw_cursor = self.hw_cursor;
thread::Builder::new()
.name("punktfunk-wlr-vout".into())
.spawn(move || portal_thread(setup_tx, stop_thread))
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
.context("spawn wlroots portal thread")?;
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
@@ -255,7 +272,17 @@ fn ensure_xdpw_config() -> Result<()> {
/// The ScreenCast portal handshake (same shape as the capture module's portal thread, but it
/// reports the fd + node id and parks until stopped — the zbus connection is the cast's
/// lifetime). xdpw answers the source selection via the chooser, no dialog.
fn portal_thread(setup_tx: Sender<Result<(OwnedFd, u32), String>>, stop: Arc<AtomicBool>) {
fn portal_thread(
setup_tx: Sender<Result<(OwnedFd, u32), String>>,
stop: Arc<AtomicBool>,
hw_cursor: bool,
) {
// Portal cursor mode per the session's channel negotiation (see the struct doc).
let cursor_mode = if hw_cursor {
CursorMode::Metadata
} else {
CursorMode::Embedded
};
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
@@ -288,7 +315,7 @@ fn portal_thread(setup_tx: Sender<Result<(OwnedFd, u32), String>>, stop: Arc<Ato
.select_sources(
&session,
SelectSourcesOptions::default()
.set_cursor_mode(CursorMode::Embedded)
.set_cursor_mode(cursor_mode)
// xdpw offers MONITOR only; the chooser picks our output.
.set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false)
@@ -268,6 +268,11 @@ mod linux {
/// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease
/// — its entry was reused + re-stamped — is a no-op).
gen: u64,
/// The out-of-band-cursor mode this display was CREATED with (Phase B): metadata-pointer
/// (cursor-channel session) vs compositor-embedded. Reuse requires an exact match — a kept
/// embedded display has no cursor metadata for a channel session to forward, and a kept
/// metadata display would leave a channel-less session with no pointer in its frames.
hw_cursor: bool,
}
/// A per-group topology-restore action (see [`Entry::topology_restore`]).
@@ -473,6 +478,7 @@ mod linux {
) && e.backend == backend
&& e.mode == mode
&& e.launch == launch
&& e.hw_cursor == vd.hw_cursor()
&& epoch_matches(e.backend, e.epoch, cur_epoch)
})
.map(|e| (e.gen, e.node_id))
@@ -608,6 +614,7 @@ mod linux {
launch: launch.clone(),
epoch: cur_epoch,
gen,
hw_cursor: vd.hw_cursor(),
};
// Compute this new display's position in its group (design §6.2) BEFORE pushing, then push
@@ -1029,6 +1036,7 @@ mod linux {
launch: None,
epoch: 0,
gen,
hw_cursor: false,
}
}
@@ -775,6 +775,10 @@ impl VirtualDisplay for PfVdisplayDisplay {
self.hw_cursor = on;
}
fn hw_cursor(&self) -> bool {
self.hw_cursor
}
fn set_quit_flag(&mut self, quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {
self.quit = Some(quit);
}