Files
punktfunk/tools/cursor-probe/src/main.rs
T
enricobuehlerandClaude Fable 5 1f59498c5c fix(host/linux): a no-channel session composites the metadata cursor — Mutter never embeds on a virtual stream
The capture-latched client (console.rs latched_mouse) never advertises
CLIENT_CAP_CURSOR, so its session resolved cursor_blend=false and asked
Mutter to EMBED the pointer. On a Mutter virtual stream that is a
fiction: since Mutter 48 (7ff5334a, hw-cursor inhibition removed) the
software cursor overlay is suppressed stage-globally whenever any
physical head realizes a HW cursor — dmabuf-recorded frames blit the
view without a pointer, and cursor-only motion schedules no re-record
either (mutter#4939). Probed on-glass on Mutter 50.3: embedded +
relative motion = frozen frame counter; SPA_META_Cursor positions kept
flowing in the same setup.

So the no-channel session now takes the path that was verified end to
end: cursor-as-metadata + the host composites, permanently — the same
arm a channel session lands in after its capture-model flip. Embedded
remains only the can't-blend fallback (libav VAAPI/NVENC, software).

- session_plan::cursor_blend_for grows the no-channel arm (codec +
  depth in, the same CUDA-payload prediction handshake makes);
  gamescope excluded so patch-2+ keeps its native-NV12 zero-copy shape
- the encode loop's composite refresh + one-shot breadcrumbs now cover
  the no-channel session; the park schedule keeps retrying while its
  composite is starved (relative-only clients cannot park themselves)
- the compositor retarget re-applies set_hw_cursor — the rebuilt
  display used to come up EMBEDDED even for channel sessions
- the GameStream virtual source takes the same rule (it never has a
  channel); its stream_body blend flag mirrors the request
- punktfunk-probe grows --cursor-nochannel (the latched-capture client,
  headless); cursor-probe grows --dump (PPM frames + a content-change
  counter, the pixel evidence the embedded A/B lacked)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 00:35:25 +02:00

275 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,
)
.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(())
}
}