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
+12 -3
View File
@@ -573,6 +573,11 @@ pub struct NvencCudaEncoder {
/// allocations and composite mode degrades to no cursor. `cursor_serial` tracks the /// allocations and composite mode degrades to no cursor. `cursor_serial` tracks the
/// uploaded bitmap. /// uploaded bitmap.
vk_blend: Option<VkSlotBlend>, vk_blend: Option<VkSlotBlend>,
/// The session may hand this encoder cursor overlays (`SessionPlan.cursor_blend` — only
/// cursor-channel sessions since Phase B). Off = skip the Vulkan bring-up entirely and ring
/// on plain CUDA surfaces: embedded-pointer sessions never carry an overlay, so they pay
/// zero blend cost, per-session or per-frame.
blend_wanted: bool,
cursor_tried: bool, cursor_tried: bool,
cursor_serial: u64, cursor_serial: u64,
/// Suppress-until-success latch for the per-frame blend warn: a persistent failure sits in /// Suppress-until-success latch for the per-frame blend warn: a persistent failure sits in
@@ -645,6 +650,7 @@ impl NvencCudaEncoder {
_cuda: bool, _cuda: bool,
bit_depth: u8, bit_depth: u8,
chroma: ChromaFormat, chroma: ChromaFormat,
cursor_blend: bool,
) -> Result<Self> { ) -> Result<Self> {
// The runtime `.so` load is the real "is NVENC possible here" gate: fail the open with a // The runtime `.so` load is the real "is NVENC possible here" gate: fail the open with a
// clear reason instead of an opaque session error on the first frame. // clear reason instead of an opaque session error on the first frame.
@@ -684,6 +690,7 @@ impl NvencCudaEncoder {
force_kf: false, force_kf: false,
pending_anchor: false, pending_anchor: false,
vk_blend: None, vk_blend: None,
blend_wanted: cursor_blend,
cursor_tried: false, cursor_tried: false,
cursor_serial: u64::MAX, cursor_serial: u64::MAX,
cursor_blend_warned: false, cursor_blend_warned: false,
@@ -1174,7 +1181,7 @@ impl NvencCudaEncoder {
// SPIR-V cursor blend can composite into the very bytes NVENC encodes; any bring-up // SPIR-V cursor blend can composite into the very bytes NVENC encodes; any bring-up
// or per-slot failure falls back to plain pitched CUDA allocations (sessions always // or per-slot failure falls back to plain pitched CUDA allocations (sessions always
// encode — composite mode just loses the cursor, warned below). // encode — composite mode just loses the cursor, warned below).
if !self.cursor_tried { if !self.cursor_tried && self.blend_wanted {
self.cursor_tried = true; self.cursor_tried = true;
match VkSlotBlend::new() { match VkSlotBlend::new() {
Ok(v) => self.vk_blend = Some(v), Ok(v) => self.vk_blend = Some(v),
@@ -1576,8 +1583,10 @@ impl Encoder for NvencCudaEncoder {
} else if !self.cursor_blend_warned { } else if !self.cursor_blend_warned {
self.cursor_blend_warned = true; self.cursor_blend_warned = true;
tracing::warn!( tracing::warn!(
"NVENC (Linux): cursor overlay present but no Vulkan blend (bring-up failed \ blend_wanted = self.blend_wanted,
earlier) — cursor not composited" "NVENC (Linux): cursor overlay present but no Vulkan blend (bring-up failed, \
or a non-blend session unexpectedly carried an overlay) — cursor not \
composited"
); );
} }
} }
+6 -1
View File
@@ -270,7 +270,7 @@ fn open_video_backend(
chroma: ChromaFormat, chroma: ChromaFormat,
cursor_blend: bool, cursor_blend: bool,
) -> Result<(Box<dyn Encoder>, &'static str)> { ) -> Result<(Box<dyn Encoder>, &'static str)> {
let _ = cursor_blend; // consumed only by the Linux vulkan-encode arm below let _ = cursor_blend; // consumed only by the Linux vulkan-encode + direct-NVENC arms below
validate_dimensions(codec, width, height)?; validate_dimensions(codec, width, height)?;
// Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`) // Refresh/fps must be positive and sane: fps feeds the encoder time_base (`Rational(1, fps)`)
// and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero. // and the pts→ns conversion (`pts * 1e9 / fps`), so 0 builds a 1/0 rational / divides by zero.
@@ -394,6 +394,7 @@ fn open_video_backend(
cuda, cuda,
bit_depth, bit_depth,
chroma, chroma,
cursor_blend,
) )
.map(|e| (e, "nvenc")) .map(|e| (e, "nvenc"))
}; };
@@ -729,7 +730,10 @@ fn open_nvenc_probed(
cuda: bool, cuda: bool,
bit_depth: u8, bit_depth: u8,
chroma: ChromaFormat, chroma: ChromaFormat,
cursor_blend: bool,
) -> Result<Box<dyn Encoder>> { ) -> Result<Box<dyn Encoder>> {
#[cfg(not(feature = "nvenc"))]
let _ = cursor_blend; // consumed by the direct-SDK arm below
// Direct-SDK NVENC (design/linux-direct-nvenc.md): the DEFAULT on NVIDIA, and only for a CUDA // Direct-SDK NVENC (design/linux-direct-nvenc.md): the DEFAULT on NVIDIA, and only for a CUDA
// capture payload (it registers CUDADEVICEPTR inputs — a CPU/dmabuf frame can't feed it, so those // capture payload (it registers CUDADEVICEPTR inputs — a CPU/dmabuf frame can't feed it, so those
// keep the libav path; and `cuda` is false on AMD/Intel, so they stay on VAAPI). Set // keep the libav path; and `cuda` is false on AMD/Intel, so they stay on VAAPI). Set
@@ -751,6 +755,7 @@ fn open_nvenc_probed(
cuda, cuda,
bit_depth, bit_depth,
chroma, chroma,
cursor_blend,
)?) as Box<dyn Encoder>); )?) as Box<dyn Encoder>);
} }
// The silent-degrade trap: a build without `--features nvenc` compiles the direct-SDK // The silent-degrade trap: a build without `--features nvenc` compiles the direct-SDK
+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 /// 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). /// 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>) {} 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 /// Ask the backend for an OUT-OF-BAND cursor on the created output (the cursor channel):
/// channel): the compositor/OS stops compositing the pointer into captured frames and the /// the compositor/OS stops compositing the pointer into captured frames and the capture
/// capture layer surfaces shape/position separately. Carried on the backend instance; set /// layer surfaces shape/position separately. Carried on the backend instance; set once
/// once before [`create`](Self::create). Default: no-op — only the Windows pf-vdisplay /// before [`create`](Self::create) (both session paths pass `cursor_forward`). Off = the
/// backend (IddCx hardware cursor, driver proto v5) implements it; the Linux portal path /// compositor EMBEDS the pointer into frames — zero host-side cursor work, the pre-channel
/// gets the same split from `SPA_META_Cursor` without asking. /// 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) {} 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 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 /// 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 /// 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 /// 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. /// 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 { impl HyprlandDisplay {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
Ok(HyprlandDisplay) Ok(HyprlandDisplay { hw_cursor: false })
} }
} }
@@ -141,6 +149,14 @@ impl VirtualDisplay for HyprlandDisplay {
"hyprland" "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> { fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Log the permission-system caveat once per process (silent black frames otherwise). // Log the permission-system caveat once per process (silent black frames otherwise).
preflight_once(); preflight_once();
@@ -167,9 +183,10 @@ impl VirtualDisplay for HyprlandDisplay {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>(); let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone(); let stop_thread = stop.clone();
let hw_cursor = self.hw_cursor;
thread::Builder::new() thread::Builder::new()
.name("punktfunk-hypr-vout".into()) .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")?; .context("spawn hyprland portal thread")?;
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) { 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 /// 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 /// 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.) /// 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::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode; use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags; use ashpd::enumflags2::BitFlags;
@@ -524,7 +551,7 @@ fn portal_thread(setup_tx: Sender<Result<(OwnedFd, u32), String>>, stop: Arc<Ato
.select_sources( .select_sources(
&session, &session,
SelectSourcesOptions::default() SelectSourcesOptions::default()
.set_cursor_mode(CursorMode::Embedded) .set_cursor_mode(cursor_mode)
// xdph offers MONITOR; the custom picker selects our output. // xdph offers MONITOR; the custom picker selects our output.
.set_sources(BitFlags::from_flag(SourceType::Monitor)) .set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false) .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; use zkde::zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1 as Screencast;
/// `pointer` attachment mode (the protocol enum): ship the cursor as `SPA_META_Cursor` metadata /// `pointer` attachment modes (the protocol enum), chosen per session by `set_hw_cursor`
/// (the capturer always negotiates the meta). Embedded mode would leave the cursor channel with /// (Phase B — the Windows no-regression gate mirrored): a CURSOR-CHANNEL session gets METADATA
/// nothing to forward AND the encoder blend with nothing to composite — the same trap as the /// (`SPA_META_Cursor` on the stream — shapes forwarded to the client, the composite flip blends
/// Mutter backend's cursor-mode (see mutter.rs `CURSOR_METADATA`); the blend draws the pointer /// host-side; embedded would leave both with nothing, the round-1 mutter trap), every other
/// for sessions where the client does not render it itself. /// 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_METADATA: u32 = 4;
const POINTER_EMBEDDED: u32 = 2;
/// The name we give the created output; KWin exposes it to output-management as `Virtual-<name>`. /// The name we give the created output; KWin exposes it to output-management as `Virtual-<name>`.
const VOUT_NAME: &str = "punktfunk"; 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. /// 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). /// 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>>, 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 { impl Drop for KwinDisplay {
@@ -128,6 +133,14 @@ impl VirtualDisplay for KwinDisplay {
self.pending_restore.take() 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) { fn apply_position(&mut self, x: i32, y: i32) {
// `last_name` holds the RESOLVED kscreen address (numeric output id, or the // `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 // `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) self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout)
let (width, height) = (mode.width, mode.height); 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 spawn_vout = |w: u32, h: u32| -> Result<(u32, Arc<AtomicBool>)> {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>(); let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
@@ -173,7 +191,9 @@ impl VirtualDisplay for KwinDisplay {
let name_thread = name.clone(); let name_thread = name.clone();
thread::Builder::new() thread::Builder::new()
.name("punktfunk-kwin-vout".into()) .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")?; .context("spawn KWin virtual-output thread")?;
match setup_rx.recv_timeout(Duration::from_secs(20)) { match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => Ok((v, stop)), Ok(Ok(v)) => Ok((v, stop)),
@@ -201,7 +221,14 @@ impl VirtualDisplay for KwinDisplay {
let want_high = mode.refresh_hz > 60; let want_high = mode.refresh_hz > 60;
let birth_h = if want_high { height + 16 } else { height }; let birth_h = if want_high { height + 16 } else { height };
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?; 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 // ⚠️ 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 // 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 // persistence — while the superseded sibling is still alive (create-before-drop). Every
@@ -716,10 +743,11 @@ fn virtual_output_thread(
width: u32, width: u32,
height: u32, height: u32,
name: String, name: String,
pointer_mode: u32,
setup_tx: Sender<Result<u32, String>>, setup_tx: Sender<Result<u32, String>>,
stop: Arc<AtomicBool>, 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. // If we never delivered a node id, report the failure to the waiting opener.
let _ = setup_tx.send(Err(format!("{e:#}"))); let _ = setup_tx.send(Err(format!("{e:#}")));
} }
@@ -760,6 +788,7 @@ fn run(
width: u32, width: u32,
height: u32, height: u32,
name: &str, name: &str,
pointer_mode: u32,
setup_tx: &Sender<Result<u32, String>>, setup_tx: &Sender<Result<u32, String>>,
stop: &AtomicBool, stop: &AtomicBool,
) -> Result<()> { ) -> 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( let stream = screencast.stream_virtual_output(
name.to_string(), name.to_string(),
width as i32, width as i32,
height as i32, height as i32,
1.0, // scale (logical == physical) 1.0, // scale (logical == physical)
POINTER_METADATA, pointer_mode,
&qh, &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 /// 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. /// to blend, and Mutter's own embedded painting is what the pre-channel path relied on.
const CURSOR_METADATA: u32 = 2; 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 /// 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 /// 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 /// sole-monitor config (which would disable the first session's virtual). Defaults true (a lone
/// session establishes topology as before). /// session establishes topology as before).
first_in_group: bool, 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)) — /// 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 /// 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 /// EDID serial per `RecordVirtual` monitor, so `monitors.xml` never rematches; see
@@ -89,6 +97,7 @@ impl MutterDisplay {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
Ok(MutterDisplay { Ok(MutterDisplay {
first_in_group: true, first_in_group: true,
hw_cursor: false,
client_fp: None, client_fp: None,
last_slot: None, last_slot: None,
}) })
@@ -117,6 +126,14 @@ impl VirtualDisplay for MutterDisplay {
self.client_fp = fingerprint; 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> { fn last_identity_slot(&self) -> Option<u32> {
self.last_slot self.last_slot
} }
@@ -146,6 +163,7 @@ impl VirtualDisplay for MutterDisplay {
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone(); let stop_thread = stop.clone();
let first_in_group = self.first_in_group; let first_in_group = self.first_in_group;
let hw_cursor = self.hw_cursor;
thread::Builder::new() thread::Builder::new()
.name("punktfunk-mutter-vout".into()) .name("punktfunk-mutter-vout".into())
.spawn(move || { .spawn(move || {
@@ -154,6 +172,7 @@ impl VirtualDisplay for MutterDisplay {
stop_thread, stop_thread,
mode, mode,
first_in_group, first_in_group,
hw_cursor,
scale_key, scale_key,
remembered_scale, remembered_scale,
) )
@@ -208,6 +227,7 @@ fn session_thread(
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
mode: Mode, mode: Mode,
first_in_group: bool, first_in_group: bool,
hw_cursor: bool,
scale_key: String, scale_key: String,
remembered_scale: Option<f64>, 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, Ok(s) => s,
Err(e) => { Err(e) => {
let _ = setup_tx.send(Err(format!("{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 /// 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 /// already scaled (Mutter ≥ 48; older Mutter ignores unknown mode keys) — this covers the
/// `extend` topology, where we never issue our own ApplyMonitorsConfig. /// `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() let conn = zbus::Connection::session()
.await .await
.context("connect session D-Bus")?; .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- // 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.) // reconfig teardown below fixed the crash, so pinning the client's refresh is now the default.)
let mut rec: HashMap<&str, Value> = HashMap::new(); 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() { if mode.refresh_hz > 60 || preferred_scale.is_some() {
let mut vmode: HashMap<&str, Value> = HashMap::new(); let mut vmode: HashMap<&str, Value> = HashMap::new();
vmode.insert("size", Value::from((mode.width, mode.height))); 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) /// 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. /// 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 { impl WlrootsDisplay {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
Ok(WlrootsDisplay) Ok(WlrootsDisplay { hw_cursor: false })
} }
} }
@@ -73,6 +81,14 @@ impl VirtualDisplay for WlrootsDisplay {
"wlroots" "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> { fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
let before = output_names() let before = output_names()
.context("swaymsg get_outputs (is the host inside the sway session env — SWAYSOCK?)")?; .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 (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone(); let stop_thread = stop.clone();
let hw_cursor = self.hw_cursor;
thread::Builder::new() thread::Builder::new()
.name("punktfunk-wlr-vout".into()) .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")?; .context("spawn wlroots portal thread")?;
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) { 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 /// 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 /// 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. /// 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::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode; use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags; use ashpd::enumflags2::BitFlags;
@@ -288,7 +315,7 @@ fn portal_thread(setup_tx: Sender<Result<(OwnedFd, u32), String>>, stop: Arc<Ato
.select_sources( .select_sources(
&session, &session,
SelectSourcesOptions::default() SelectSourcesOptions::default()
.set_cursor_mode(CursorMode::Embedded) .set_cursor_mode(cursor_mode)
// xdpw offers MONITOR only; the chooser picks our output. // xdpw offers MONITOR only; the chooser picks our output.
.set_sources(BitFlags::from_flag(SourceType::Monitor)) .set_sources(BitFlags::from_flag(SourceType::Monitor))
.set_multiple(false) .set_multiple(false)
@@ -268,6 +268,11 @@ mod linux {
/// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease /// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease
/// — its entry was reused + re-stamped — is a no-op). /// — its entry was reused + re-stamped — is a no-op).
gen: u64, 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`]). /// A per-group topology-restore action (see [`Entry::topology_restore`]).
@@ -473,6 +478,7 @@ mod linux {
) && e.backend == backend ) && e.backend == backend
&& e.mode == mode && e.mode == mode
&& e.launch == launch && e.launch == launch
&& e.hw_cursor == vd.hw_cursor()
&& epoch_matches(e.backend, e.epoch, cur_epoch) && epoch_matches(e.backend, e.epoch, cur_epoch)
}) })
.map(|e| (e.gen, e.node_id)) .map(|e| (e.gen, e.node_id))
@@ -608,6 +614,7 @@ mod linux {
launch: launch.clone(), launch: launch.clone(),
epoch: cur_epoch, epoch: cur_epoch,
gen, gen,
hw_cursor: vd.hw_cursor(),
}; };
// Compute this new display's position in its group (design §6.2) BEFORE pushing, then push // Compute this new display's position in its group (design §6.2) BEFORE pushing, then push
@@ -1029,6 +1036,7 @@ mod linux {
launch: None, launch: None,
epoch: 0, epoch: 0,
gen, gen,
hw_cursor: false,
} }
} }
@@ -775,6 +775,10 @@ impl VirtualDisplay for PfVdisplayDisplay {
self.hw_cursor = on; 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>) { fn set_quit_flag(&mut self, quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {
self.quit = Some(quit); self.quit = Some(quit);
} }
+14 -8
View File
@@ -1000,12 +1000,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
ctx.bit_depth, ctx.bit_depth,
ctx.chroma, ctx.chroma,
ctx.codec, ctx.codec,
// Blend CAPABILITY wherever the capture HAS a pointer (not gamescope) — including // Blend CAPABILITY only for cursor-FORWARD sessions (Phase B, the Windows gate
// cursor-forward sessions: the client can flip to the capture mouse model mid-stream // mirrored): their client can flip to the capture mouse model mid-stream
// (`CursorRenderMode`), and the composite side of that flip must not need an encoder // (`CursorRenderMode`), and the composite side of that flip must not need an encoder
// rebuild. WHETHER a frame's pointer is drawn is per-tick: the encode loop strips // rebuild WHETHER a frame's pointer is drawn stays per-tick (the encode loop strips
// `frame.cursor` while the client draws locally (see the forwarder tick). // `frame.cursor` while the client draws locally, see the forwarder tick). Every OTHER
ctx.compositor != pf_vdisplay::Compositor::Gamescope, // session's output is created with the pointer compositor-EMBEDDED
// (`vd.set_hw_cursor(false)` → no cursor metadata ever arrives, nothing to blend), so
// it keeps the zero-cost pre-channel path — and gamescope never has a pointer either
// way.
ctx.compositor != pf_vdisplay::Compositor::Gamescope && ctx.cursor_forward,
ctx.cursor_forward, ctx.cursor_forward,
); );
// PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens // PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens
@@ -2788,9 +2792,11 @@ pub(super) fn prepare_display(
bit_depth, bit_depth,
chroma, chroma,
codec, codec,
// Blend capability regardless of cursor_forward — must MATCH virtual_stream's resolve // Blend capability only for cursor-forward sessions — must MATCH virtual_stream's
// (the mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick). // resolve (Phase B: non-channel sessions get the pointer compositor-EMBEDDED, nothing
compositor != pf_vdisplay::Compositor::Gamescope, // to blend; the mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per
// tick for channel sessions).
compositor != pf_vdisplay::Compositor::Gamescope && cursor_forward,
cursor_forward, cursor_forward,
); );
if codec == crate::encode::Codec::PyroWave { if codec == crate::encode::Codec::PyroWave {