feat(devtools): reproduce the capture-model cursor path without a real client
punktfunk-probe grows --cursor-capture: advertise CLIENT_CAP_CURSOR, flip the channel to the capture model (CursorRenderMode client_draws= false), and wiggle RELATIVE pointer motion for the whole dump — decode the .h265 and the host-composited pointer must be in the pixels. Plus --codec pyrowave (advertised only on request so the dump format of existing recipes never changes). tools/cursor-probe stands up the capture side alone: virtual output with the out-of-band cursor, production PipeWire capturer, production injector, absolute then relative motion — and reports whether SPA_META_Cursor ever yields an overlay. It is how Mutter's pointer-in-stream metadata gate was isolated on-glass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+13
@@ -945,6 +945,19 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-probe"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pf-capture",
|
||||
"pf-inject",
|
||||
"pf-vdisplay",
|
||||
"punktfunk-core",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
|
||||
@@ -29,6 +29,7 @@ members = [
|
||||
"clients/session",
|
||||
"clients/windows",
|
||||
"clients/android/native",
|
||||
"tools/cursor-probe",
|
||||
"tools/display-disturb",
|
||||
"tools/latency-probe",
|
||||
"tools/loss-harness",
|
||||
|
||||
@@ -53,8 +53,9 @@ use punktfunk_core::config::Role;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use punktfunk_core::packet::FLAG_PROBE;
|
||||
use punktfunk_core::quic::{
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, Hello, LossReport, ProbeRequest, ProbeResult,
|
||||
Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start, Welcome,
|
||||
endpoint, io, window_loss_ppm, BitrateChanged, CursorRenderMode, Hello, LossReport,
|
||||
ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, SetBitrate, Start,
|
||||
Welcome,
|
||||
};
|
||||
use punktfunk_core::transport::UdpTransport;
|
||||
use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session};
|
||||
@@ -115,6 +116,12 @@ struct Args {
|
||||
/// `--speed-test KBPS:MS` — after the stream starts, ask the host for a `MS`-millisecond
|
||||
/// bandwidth probe burst at `KBPS`, then report measured throughput + loss.
|
||||
speed_test: Option<(u32, u32)>,
|
||||
/// `--cursor-capture` — negotiate the cursor channel (`CLIENT_CAP_CURSOR`) and immediately
|
||||
/// flip it to the capture model (`CursorRenderMode { client_draws: false }`), then wiggle the
|
||||
/// pointer with RELATIVE motion for the whole stream: the headless reproduction of a
|
||||
/// pointer-lock client expecting the HOST to composite the cursor into the video. Decode the
|
||||
/// dump and look for the pointer — a cursorless dump is the bug this flag was built to catch.
|
||||
cursor_capture: bool,
|
||||
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
|
||||
/// seconds (default 4), print what's found, and exit. No connection is made.
|
||||
discover: Option<u64>,
|
||||
@@ -278,6 +285,7 @@ fn parse_args() -> Args {
|
||||
"h264" | "avc" => punktfunk_core::quic::CODEC_H264,
|
||||
"hevc" | "h265" => punktfunk_core::quic::CODEC_HEVC,
|
||||
"av1" => punktfunk_core::quic::CODEC_AV1,
|
||||
"pyrowave" => punktfunk_core::quic::CODEC_PYROWAVE,
|
||||
_ => 0, // auto — no preference
|
||||
},
|
||||
launch: get("--launch").map(str::to_string),
|
||||
@@ -292,6 +300,7 @@ fn parse_args() -> Args {
|
||||
.any(|a| a == "--discover")
|
||||
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
|
||||
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
|
||||
cursor_capture: argv.iter().any(|a| a == "--cursor-capture"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,6 +317,7 @@ fn codec_ext(codec: u8) -> &'static str {
|
||||
match codec {
|
||||
punktfunk_core::quic::CODEC_H264 => "h264",
|
||||
punktfunk_core::quic::CODEC_AV1 => "av1",
|
||||
punktfunk_core::quic::CODEC_PYROWAVE => "pyrowave",
|
||||
_ => "h265",
|
||||
}
|
||||
}
|
||||
@@ -517,16 +527,29 @@ async fn session(args: Args) -> Result<()> {
|
||||
// `Welcome::codec`; the dump extension follows that.
|
||||
video_codecs: punktfunk_core::quic::CODEC_H264
|
||||
| punktfunk_core::quic::CODEC_HEVC
|
||||
| punktfunk_core::quic::CODEC_AV1,
|
||||
| punktfunk_core::quic::CODEC_AV1
|
||||
// PyroWave only on request: a PyroWave-default host would otherwise pick it
|
||||
// for every probe run, changing the dump format under long-standing recipes.
|
||||
| if args.preferred_codec == punktfunk_core::quic::CODEC_PYROWAVE {
|
||||
punktfunk_core::quic::CODEC_PYROWAVE
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// `--codec` soft preference (0 = auto). The host honors it when it can emit it.
|
||||
preferred_codec: args.preferred_codec,
|
||||
// PUNKTFUNK_CLIENT_PEAK_NITS=<nits> advertises a synthetic display volume — the host
|
||||
// writes it into the virtual display's EDID (CTA HDR block), so the EDID-forwarding
|
||||
// path can be validated headlessly (check the host's monitor caps / ADD log line).
|
||||
display_hdr: punktfunk_core::client::display_hdr_env_override(),
|
||||
// No CLIENT_CAP_CURSOR: this headless tool renders nothing — advertising it would
|
||||
// just strip the pointer from the dumped bitstream.
|
||||
client_caps: 0,
|
||||
// No CLIENT_CAP_CURSOR by default: this headless tool renders nothing — advertising
|
||||
// it would just strip the pointer from the dumped bitstream. `--cursor-capture`
|
||||
// advertises it deliberately and then flips the channel to the capture model, so the
|
||||
// HOST composites and the dump is where the pointer must appear.
|
||||
client_caps: if args.cursor_capture {
|
||||
punktfunk_core::quic::CLIENT_CAP_CURSOR
|
||||
} else {
|
||||
0
|
||||
},
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
@@ -822,6 +845,64 @@ async fn session(args: Args) -> Result<()> {
|
||||
"SPEED TEST complete",
|
||||
);
|
||||
});
|
||||
} else if args.cursor_capture {
|
||||
// Capture-model cursor repro: flip the negotiated cursor channel to "host composites"
|
||||
// and drive RELATIVE pointer motion, exactly like a pointer-lock client. Owns BOTH
|
||||
// control halves: the host may send CursorShape (0x50) messages while the channel is
|
||||
// still in its initial client-draws state, and dropping our recv half would fail those
|
||||
// writes host-side.
|
||||
let mut cs = send;
|
||||
let mut cr = recv;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
match io::write_msg(
|
||||
&mut cs,
|
||||
&CursorRenderMode {
|
||||
client_draws: false,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => tracing::info!(
|
||||
"cursor-capture: CursorRenderMode {{ client_draws: false }} sent — the host \
|
||||
must now composite the pointer into the video"
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "cursor-capture: render-mode write failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Drain (and just log) whatever the host still sends on the control stream.
|
||||
while let Ok(b) = cr.read_msg().await {
|
||||
tracing::debug!(
|
||||
len = b.len(),
|
||||
ty = b.get(4).copied().unwrap_or(0),
|
||||
"cursor-capture: control message drained"
|
||||
);
|
||||
}
|
||||
});
|
||||
let wiggle_conn = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
// Relative circles, forever: keeps the host pointer moving (and, on metadata-cursor
|
||||
// compositors, keeps cursor updates flowing) for the whole dump.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
tracing::info!("cursor-capture: relative pointer wiggle running");
|
||||
let mut t = 0.0f64;
|
||||
loop {
|
||||
let e = InputEvent {
|
||||
kind: InputKind::MouseMove,
|
||||
_pad: [0; 3],
|
||||
code: 0,
|
||||
x: (10.0 * t.cos()) as i32,
|
||||
y: (10.0 * t.sin()) as i32,
|
||||
flags: 0,
|
||||
};
|
||||
let _ = wiggle_conn.send_datagram(e.encode().to_vec().into());
|
||||
t += 0.2;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Normal stream mode: relay the data loop's windowed loss estimate to the host as periodic
|
||||
// LossReports, so it can size FEC to the link (adaptive FEC), and — like the real clients —
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# On-glass probe for the Linux cursor-as-metadata capture path: creates a virtual output with the
|
||||
# out-of-band cursor requested (exactly like a cursor-channel session), attaches the production
|
||||
# PipeWire capturer, wiggles the pointer over it via the production injector, and reports whether
|
||||
# `SPA_META_Cursor` ever yields an overlay (position + bitmap). Answers "does THIS compositor
|
||||
# deliver cursor metadata on a virtual output" without needing a client.
|
||||
[package]
|
||||
name = "cursor-probe"
|
||||
description = "On-glass probe: does this compositor's virtual output deliver SPA_META_Cursor?"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
pf-vdisplay = { path = "../../crates/pf-vdisplay" }
|
||||
pf-capture = { path = "../../crates/pf-capture" }
|
||||
pf-inject = { path = "../../crates/pf-inject" }
|
||||
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,217 @@
|
||||
//! 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 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 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!("---");
|
||||
if embedded {
|
||||
println!(
|
||||
"cursor-probe: EMBEDDED run — no metadata expected; check the stream visually. \
|
||||
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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user