Files
punktfunk/tools/cursor-probe/src/main.rs
T
enricobuehlerandClaude Fable 5 5174a59832 fix(capture/kwin): a hidden cursor leaves the stream — KWin's id-0 meta is the hide
Since the 0.22.0 cursor work (the seat-pointer park + the metadata
composite), a KWin capture-model stream always has a cursor — and it never
went away again: not in game, not in Big Picture, not with a controller in
hand (field report, 2026-08-01). The host blended the arrow forever because
pf-capture deliberately ignores SPA_META_Cursor id 0, and once `visible`
latched true nothing on Linux ever cleared it.

Two producer contracts meet on id 0, and one flag now carries which one a
stream follows. KWin rewrites the cursor meta on EVERY enqueued buffer and
writes id 0 whenever Cursor::isOnOutput says the pointer is not in this
stream — which covers both a globally hidden cursor and a client null-cursor
surface (empty geometry intersects nothing). There id 0 IS the hide, and
honoring it is what lets a game hide the pointer mid-stream. Mutter only
rewrites a buffer's meta when the cursor changed, so recycled buffers carry
stale id-0 regions between damage frames — honoring those flickered the
cursor off between hovers (on-glass round 5), and that path keeps its
last-known-state behavior.

The flag rides from the backend that created the output (correct for
registry-pooled reuse too — a kept display only ever matches its own
backend) through capture_virtual_output into the parser's CursorState. The
portal-monitor path stays on the stale-meta contract: the only thing routed
through it today is Mutter's HDR mirror.

Verified on .25: pf-capture 45/45, punktfunk-host 369/369, clippy
-D warnings clean (pf-capture, punktfunk-host, cursor-probe), fmt clean.
On-glass KDE validation still owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 21:54:09 +02:00

276 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Cursor-as-metadata on-glass probe (see Cargo.toml). Run inside the target user session:
//!
//! ```sh
//! DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus cursor-probe [--seconds 20] [--embedded] [--gpu]
//! ```
//!
//! `--embedded` A/Bs the pre-channel path (compositor embeds the pointer, no metadata expected);
//! `--gpu` takes the zero-copy dmabuf negotiation a real session uses instead of the CPU mmap path.
#[cfg(target_os = "linux")]
fn main() -> anyhow::Result<()> {
linux::run()
}
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("cursor-probe is Linux-only (compositor virtual outputs)");
std::process::exit(1);
}
#[cfg(target_os = "linux")]
mod linux {
use anyhow::{Context, Result};
use punktfunk_core::input::{InputEvent, InputKind};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
const W: u32 = 1280;
const H: u32 = 800;
pub fn run() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let args: Vec<String> = std::env::args().collect();
let arg = |name: &str| {
args.iter()
.skip_while(|a| a.as_str() != name)
.nth(1)
.cloned()
};
let secs: u64 = arg("--seconds").and_then(|s| s.parse().ok()).unwrap_or(20);
let embedded = args.iter().any(|a| a == "--embedded");
let gpu = args.iter().any(|a| a == "--gpu");
let compositor = pf_vdisplay::detect()?;
println!("cursor-probe: compositor = {compositor:?}");
let mut vd = pf_vdisplay::open(compositor)?;
// The cursor-channel session's request: out-of-band cursor (Mutter/KWin metadata mode).
vd.set_hw_cursor(!embedded);
println!(
"cursor-probe: virtual output with {} cursor",
if embedded {
"EMBEDDED (compositor paints it)"
} else {
"METADATA (out-of-band)"
}
);
let vout = vd
.create(pf_vdisplay::Mode {
width: W,
height: H,
refresh_hz: 60,
})
.context("create the virtual output")?;
println!("cursor-probe: node_id = {}", vout.node_id);
// Encode-backend facts don't matter to the metadata question; all-off = CPU-friendly.
let policy = pf_capture::ZeroCopyPolicy {
backend_is_vaapi: false,
backend_is_gpu: gpu,
pyrowave_session: false,
native_nv12_session: false,
pyrowave_modifiers: Vec::new(),
hdr_cuda_ok: false,
};
let mut cap = pf_capture::open_virtual_output(
vout.remote_fd,
vout.node_id,
vout.preferred_mode,
vout.keepalive,
gpu,
false,
false,
policy,
vout.expect_exact_dims,
compositor == pf_vdisplay::Compositor::Kwin,
)
.context("attach the PipeWire capturer")?;
cap.set_active(true);
// A cursor-channel session in capture mode: the HOST composites, so the capturer must
// keep surfacing the overlay (this is a no-op on the Linux portal capturer, but state it
// the way the encode loop does).
cap.set_cursor_forward(false);
// Wiggle the pointer over the virtual output through the production injector (libei on
// GNOME/KWin). The region ladder maps the absolute events onto the output by size match —
// W×H is deliberately distinctive.
let backend = pf_inject::default_backend();
println!("cursor-probe: input backend = {backend:?} (4 s device-resume wait)");
let stop = Arc::new(AtomicBool::new(false));
let stop_inj = stop.clone();
// `dyn InputInjector` isn't Send — build it on the thread that drives it.
let injector = std::thread::spawn(move || {
let mut inj = match pf_inject::open(backend) {
Ok(i) => i,
Err(e) => {
tracing::error!(error = %format!("{e:#}"), "cursor-probe: injector open failed");
return;
}
};
std::thread::sleep(Duration::from_secs(4));
let flags = (W << 16) | (H & 0xffff);
let (cx, cy, r) = ((W / 2) as f64, (H / 2) as f64, 200.0f64);
let mut t = 0.0f64;
let started = Instant::now();
let mut announced_rel = false;
while !stop_inj.load(Ordering::Relaxed) {
// Phase 1 (8 s): ABSOLUTE wiggle — parks the pointer on the virtual output and
// proves the metadata path. Phase 2: RELATIVE circles — what a capture-mode
// (pointer-lock) client sends; watch whether the live meta position keeps moving.
let e = if started.elapsed() < Duration::from_secs(8) {
InputEvent {
kind: InputKind::MouseMoveAbs,
_pad: [0; 3],
code: 0,
x: (cx + r * t.cos()) as i32,
y: (cy + r * t.sin()) as i32,
flags,
}
} else {
if !announced_rel {
announced_rel = true;
println!("cursor-probe: switching to RELATIVE motion (capture-mode model)");
}
InputEvent {
kind: InputKind::MouseMove,
_pad: [0; 3],
code: 0,
x: (12.0 * t.cos()) as i32,
y: (12.0 * t.sin()) as i32,
flags: 0,
}
};
if let Err(err) = inj.inject(&e) {
tracing::warn!(error = %format!("{err:#}"), "cursor-probe: inject failed");
}
t += 0.15;
std::thread::sleep(Duration::from_millis(25));
}
});
let dump = args.iter().any(|a| a == "--dump");
let dump_dir = format!(
"/tmp/pf-probe-frames-{}",
if embedded { "embedded" } else { "metadata" }
);
if dump {
let _ = std::fs::remove_dir_all(&dump_dir);
let _ = std::fs::create_dir_all(&dump_dir);
}
let mut prev: Vec<u8> = Vec::new();
let mut changed = 0u64;
let mut dumped = 0u32;
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut frames, mut with_overlay) = (0u64, 0u64);
let mut last_report = Instant::now();
let mut live_seen = false;
while Instant::now() < deadline {
// Damage-driven source: timeouts (gaps) are normal, hence the missing error arm.
if let Ok(f) = cap.next_frame_within(Duration::from_millis(500)) {
frames += 1;
if let pf_frame::FramePayload::Cpu(data) = &f.payload {
if !prev.is_empty()
&& prev.len() == data.len()
&& prev.as_slice() != data.as_slice()
{
changed += 1;
}
prev.clear();
prev.extend_from_slice(data);
if dump && frames % 15 == 1 && dumped < 16 {
dumped += 1;
let path = format!("{dump_dir}/frame-{frames:05}.ppm");
match dump_ppm(&path, f.width, f.height, f.format, data) {
Ok(()) => println!("cursor-probe: dumped {path}"),
Err(e) => eprintln!("cursor-probe: dump failed: {e:#}"),
}
}
}
if let Some(c) = &f.cursor {
with_overlay += 1;
if with_overlay == 1 {
println!(
"cursor-probe: FIRST frame-attached overlay: pos=({}, {}) \
{}x{} serial={} visible={}",
c.x, c.y, c.w, c.h, c.serial, c.visible
);
}
}
}
if last_report.elapsed() >= Duration::from_secs(2) {
last_report = Instant::now();
let live = cap.cursor();
if live.is_some() {
live_seen = true;
}
match live {
Some(c) => println!(
"cursor-probe: frames={frames} with_overlay={with_overlay} live: \
pos=({}, {}) {}x{} serial={} visible={}",
c.x, c.y, c.w, c.h, c.serial, c.visible
),
None => println!(
"cursor-probe: frames={frames} with_overlay={with_overlay} live: NONE \
(no SPA_META_Cursor bitmap yet)"
),
}
}
}
stop.store(true, Ordering::Relaxed);
let _ = injector.join();
println!("---");
println!(
"cursor-probe: content-changed frames: {changed}/{frames} (a moving embedded \
cursor over a static desktop should change nearly every frame)"
);
if embedded {
println!(
"cursor-probe: EMBEDDED run — no metadata expected; check the dumped frames. \
frames={frames}"
);
} else if live_seen || with_overlay > 0 {
println!(
"cursor-probe: VERDICT: metadata cursor DELIVERED ({with_overlay}/{frames} \
frames carried it; live overlay seen: {live_seen})"
);
} else {
println!(
"cursor-probe: VERDICT: NO cursor metadata after {secs}s of pointer motion over \
the virtual output ({frames} frames) — the compositor is not delivering \
SPA_META_Cursor on this stream"
);
}
Ok(())
}
/// Write a packed 4-bpp frame as a PPM, swapping B/R for the BGR layouts. Enough fidelity
/// to answer "is the pointer in the pixels".
fn dump_ppm(
path: &str,
w: u32,
h: u32,
format: pf_frame::PixelFormat,
data: &[u8],
) -> Result<()> {
use std::io::Write;
let mut out = std::io::BufWriter::new(std::fs::File::create(path)?);
write!(out, "P6\n{w} {h}\n255\n")?;
let (ri, gi, bi) = match format {
pf_frame::PixelFormat::Rgbx | pf_frame::PixelFormat::Rgba => (0usize, 1usize, 2usize),
_ => (2usize, 1usize, 0usize),
};
for px in data.chunks_exact(4).take((w as usize) * (h as usize)) {
out.write_all(&[px[ri], px[gi], px[bi]])?;
}
Ok(())
}
}