Files
punktfunk/crates/punktfunk-host/src/devtest.rs
T
enricobuehlerandClaude Opus 5 a98174ebfa feat(host): anchor-test, and libei says which output absolute input landed on
The absolute-region ladder existed for one case a unit test can only
simulate — two heads of the SAME size, where matching a libei region by
the streamed mode is a coin flip — and that case had never been run
against a real compositor. It was also unobservable: the log said which
regions the EIS server offered, never which one a coordinate went into,
so "the pointer is on the wrong monitor" could only be discovered by
looking at a monitor.

libei now reports the chosen region once per distinct answer, beside the
existing miss warning: one covers an anchor that named nothing, this one
covers an anchor that matched something, by saying which.

`anchor-test` is the gate that reads it, in the shape mirror-test
established: it enumerates the heads, SAYS whether the rig actually has
two of the same size (a green run on a single-head box proves nothing),
anchors at a named one (or `--none` for the A/B that makes the anchored
run mean anything), and walks the corners. It refuses to run off libei
rather than emitting a green result about a ladder that isn't there.

On-glass on KWin 6.7.3 with `kwin_wayland --virtual --output-count 2`
(regions 1920x1080+0+0 and +1920+0): unanchored picks +0+0, anchored at
Virtual-1 picks +1920+0, anchored at Virtual-0 picks +0+0. That is open
item 3 of design/per-monitor-portal-capture.md §8b, closed.

Clearing the pin now logs too — setting one always did, and the streamed
screen going back to virtual is the same size of change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 23:51:41 +02:00

730 lines
30 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.
//! Standalone dev/test subcommands that validate a subsystem without a streaming client: the
//! input-injection smoke test and the virtual-gamepad exercisers (Linux UHID DualSense /
//! Switch Pro; Windows UMDF DualSense-family + the Steam Deck devnode spike). Split out of the
//! `main` CLI dispatch (plan §W5 "devtest.rs, land first") so `main.rs`'s match keeps only thin
//! arms that forward here. Each fn owns the full behaviour (and doc) of its former inline arm.
#[cfg(target_os = "linux")]
use anyhow::Context;
use anyhow::Result;
/// Draw a scripted stylus stroke through the REAL pen chain — wire-shaped samples → the core
/// [`PenTracker`](punktfunk_core::quic::PenTracker) → the "Punktfunk Pen" uinput tablet — so
/// full-fidelity pen injection is validated without any client (design/pen-tablet-input.md P1):
/// hover in from the left, tip down, a sine stroke across the mapped output with a pressure
/// ramp + tilt sweep, tip up, hover out. Observe in Krita/GIMP with a pressure brush, or
/// `sudo libinput debug-events` (expect `TABLET_TOOL_PROXIMITY/TIP/AXIS`).
#[cfg(target_os = "linux")]
pub fn pen_test() -> Result<()> {
use punktfunk_core::quic::{
PenBatch, PenSample, PenTracker, PenTransition, PEN_IN_RANGE, PEN_TOUCHING,
};
use std::time::Duration;
let mut dev = crate::inject::pen::VirtualPen::create()?;
let mut tracker = PenTracker::default();
let mut out: Vec<PenTransition> = Vec::new();
// Compositors need a beat to enumerate the new evdev node before events count.
std::thread::sleep(Duration::from_secs(2));
let mut seq = 0u16;
let mut send = |tracker: &mut PenTracker, out: &mut Vec<PenTransition>, s: PenSample| {
out.clear();
tracker.apply(&PenBatch::new(seq, &[s]), out);
seq = seq.wrapping_add(1);
dev.apply_batch(out);
};
tracing::info!("pen-test: hover in, then a 3 s pressure-ramped sine stroke");
let hover = |x: f32| PenSample {
state: PEN_IN_RANGE,
x,
y: 0.5,
distance: 300,
..Default::default()
};
for i in 0..20 {
send(&mut tracker, &mut out, hover(0.05 + i as f32 * 0.005));
std::thread::sleep(Duration::from_millis(10));
}
const STEPS: u32 = 360;
for i in 0..=STEPS {
let t = i as f32 / STEPS as f32;
send(
&mut tracker,
&mut out,
PenSample {
state: PEN_IN_RANGE | PEN_TOUCHING,
x: 0.15 + 0.7 * t,
y: 0.5 + 0.2 * (t * std::f32::consts::TAU * 2.0).sin(),
// Ramp 10 % → 100 % so a pressure brush visibly widens along the stroke.
pressure: (6553.0 + 58982.0 * t) as u16,
distance: 0,
tilt_deg: 25 + (20.0 * t) as u8,
azimuth_deg: ((90.0 + 180.0 * t) as u16) % 360,
roll_deg: ((360.0 * t) as u16) % 360,
..Default::default()
},
);
std::thread::sleep(Duration::from_millis(8));
}
for i in 0..10 {
send(&mut tracker, &mut out, hover(0.85 + i as f32 * 0.005));
std::thread::sleep(Duration::from_millis(10));
}
send(&mut tracker, &mut out, PenSample::default()); // state 0 = out of range
tracing::info!("pen-test: done (stroke drawn, pen out of range) — device destroyed on exit");
Ok(())
}
/// Inject a scripted mouse + keyboard pattern through the session's input backend (libei on
/// KWin/GNOME, wlr on Sway). Lets us validate input injection without a Moonlight client.
#[cfg(target_os = "linux")]
pub fn input_test() -> Result<()> {
use punktfunk_core::input::{InputEvent, InputKind};
use std::time::Duration;
let backend = crate::inject::default_backend();
tracing::info!(?backend, "input-test: opening injector");
let mut inj = crate::inject::open(backend)?;
// An async backend (libei) needs a moment to establish its portal/EIS session + device
// resume; events injected before then are dropped.
std::thread::sleep(Duration::from_secs(4));
let ev = |kind, code, x, y| InputEvent {
kind,
_pad: [0; 3],
code,
x,
y,
flags: 0,
};
// `PUNKTFUNK_INPUT_TEST_ABS=WxH` (e.g. 1280x800): exercise ABSOLUTE pointer moves instead —
// steps through the corners + center of the given surface, 1s apart, so an observer
// (`DISPLAY=:0 xdotool getmouselocation`) can verify each jump. This is the degraded-touch
// path (touch → MouseMoveAbs), so it validates game-mode touch without a client.
if let Ok(dims) = std::env::var("PUNKTFUNK_INPUT_TEST_ABS") {
let (w, h) = dims
.split_once('x')
.and_then(|(w, h)| Some((w.parse::<u32>().ok()?, h.parse::<u32>().ok()?)))
.unwrap_or((1280, 800));
let flags = (w << 16) | (h & 0xffff);
let pts = [
(100, 100),
(w as i32 - 100, 100),
(w as i32 - 100, h as i32 - 100),
(100, h as i32 - 100),
(w as i32 / 2, h as i32 / 2),
];
tracing::info!(w, h, "input-test: ABS mode — corners + center, 1s apart");
for (x, y) in pts {
let mut e = ev(InputKind::MouseMoveAbs, 0, x, y);
e.flags = flags;
if let Err(err) = inj.inject(&e) {
tracing::warn!(error = %format!("{err:#}"), "input-test: abs inject failed");
}
tracing::info!(x, y, "input-test: abs move emitted");
std::thread::sleep(Duration::from_secs(1));
}
tracing::info!("input-test: done (abs)");
return Ok(());
}
tracing::info!(
"input-test: injecting a mouse square + 'A'/click taps for ~8s (watch wev / focused app)"
);
for i in 0..160u32 {
let (dx, dy) = match (i / 10) % 4 {
0 => (12, 0),
1 => (0, 12),
2 => (-12, 0),
_ => (0, -12),
};
if let Err(e) = inj.inject(&ev(InputKind::MouseMove, 0, dx, dy)) {
tracing::warn!(error = %format!("{e:#}"), "input-test: inject failed");
}
if i % 20 == 0 {
let _ = inj.inject(&ev(InputKind::KeyDown, 0x41, 0, 0)); // 'A'
let _ = inj.inject(&ev(InputKind::KeyUp, 0x41, 0, 0));
let _ = inj.inject(&ev(InputKind::MouseButtonDown, 1, 0, 0)); // left click
let _ = inj.inject(&ev(InputKind::MouseButtonUp, 1, 0, 0));
}
std::thread::sleep(Duration::from_millis(50));
}
tracing::info!("input-test: done");
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn input_test() -> Result<()> {
anyhow::bail!("input-test requires Linux")
}
/// Create a virtual DualSense via UHID and exercise it (validation, no streaming session):
/// toggles the Cross button, sweeps the left stick, and prints any HID output the kernel
/// sends back. Verify with `evtest` / `ls /dev/input/by-id/*Punktfunk*` / `wpctl status`.
/// `--edge` creates a DualSense **Edge** (054C:0DF2) instead and additionally cycles the
/// four back/Fn buttons (kernel ≥ 7.2 exposes them as BTN_TRIGGER_HAPPY1..4; on older
/// kernels verify the bind + `hidraw` byte 10 instead).
#[cfg(target_os = "linux")]
pub fn dualsense_test(args: &[String]) -> Result<()> {
use crate::inject::dualsense::{DsUhidIdentity, DualSensePad};
use crate::inject::dualsense_proto::{edge_paddle_bits, DsState};
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(20);
let edge = args.iter().any(|a| a == "--edge");
let (identity, label) = if edge {
(DsUhidIdentity::dualsense_edge(), "DualSense Edge")
} else {
(DsUhidIdentity::dualsense(), "DualSense")
};
use std::time::{Duration, Instant};
let mut pad = DualSensePad::open(0, &identity)
.with_context(|| format!("create virtual {label} via /dev/uhid"))?;
// Answer the kernel's init GET_REPORTs promptly so hid-playstation creates the input
// devices before we start streaming state.
let init = Instant::now() + Duration::from_millis(800);
while Instant::now() < init {
pad.service(0);
std::thread::sleep(Duration::from_millis(10));
}
println!(
"virtual {label} created — check `evtest`, `ls /dev/input/by-id/*Punktfunk*`, \
`ls /sys/class/leds/`. Cycling Cross + sweeping LS for {secs}s."
);
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut i, mut last_write) = (0i32, Instant::now());
while Instant::now() < deadline {
let fb = pad.service(0);
if let Some((low, high)) = fb.rumble {
println!(" rumble from kernel/game: low={low} high={high}");
}
for o in fb.hidout {
println!(" hid output from kernel/game: {o:?}");
}
if last_write.elapsed() >= Duration::from_millis(300) {
last_write = Instant::now();
i += 1;
let mut buttons = if i % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A
} else {
0
};
if edge {
// Cycle one paddle per beat (R4 → L4 → R5 → L5) so all four Edge slots
// are visible in evtest / hidraw.
buttons |= punktfunk_core::input::gamepad::BTN_PADDLE1 << (i % 4);
}
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
let mut st = DsState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
if edge {
st.buttons[2] |= edge_paddle_bits(buttons);
}
pad.write_state(&st).context("write report")?;
}
std::thread::sleep(Duration::from_millis(15));
}
println!("dualsense-test: done");
Ok(())
}
/// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no
/// streaming session): answers the full hid-nintendo probe conversation, then cycles the
/// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player-
/// light feedback. Verify with `evtest` (hid-nintendo input devices), `dmesg | grep
/// nintendo`, SDL identifying a "Nintendo Switch Pro Controller".
#[cfg(target_os = "linux")]
pub fn switchpro_test(args: &[String]) -> Result<()> {
use crate::inject::switch_pro::SwitchProPad;
use crate::inject::switch_proto::SwitchState;
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(20);
use std::time::{Duration, Instant};
let mut pad =
SwitchProPad::open(0).context("create virtual Switch Pro Controller via /dev/uhid")?;
// Answer the driver's probe conversation promptly — every step blocks hid-nintendo
// init until its reply lands; also stream neutral 0x30 reports like real hardware.
println!("virtual Switch Pro created — servicing the hid-nintendo probe…");
let init = Instant::now() + Duration::from_millis(2500);
let mut hb = Instant::now();
while Instant::now() < init {
let fb = pad.service(0);
for o in fb.hidout {
println!(" probe feedback: {o:?}");
}
if hb.elapsed() >= Duration::from_millis(15) {
hb = Instant::now();
let _ = pad.write_state(&SwitchState::neutral());
}
std::thread::sleep(Duration::from_millis(2));
}
println!("probe window over — cycling buttons + stick for {secs}s (check evtest)");
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut i, mut last_write) = (0i32, Instant::now());
while Instant::now() < deadline {
let fb = pad.service(0);
if let Some((low, high)) = fb.rumble {
println!(" rumble from kernel/game: low={low} high={high}");
}
for o in fb.hidout {
println!(" hid output from kernel/game: {o:?}");
}
// ~15 ms cadence = the real controller's report rate (also keeps the driver's
// post-probe subcommand rate limiter fed).
if last_write.elapsed() >= Duration::from_millis(15) {
last_write = Instant::now();
i += 1;
let step = i / 20; // change the pressed button every ~300 ms
let buttons = if step % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A
} else {
punktfunk_core::input::gamepad::BTN_B
};
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
let st = SwitchState::from_gamepad(buttons, lx, 0, 0, 0, 0, 0);
pad.write_state(&st).context("write Switch Pro report")?;
}
std::thread::sleep(Duration::from_millis(2));
}
println!("switchpro-test: done");
Ok(())
}
/// Windows N4 SPIKE (gamepad-new-types §6): hold a software-devnode HID Steam Deck
/// (28DE:1205 via device_type 3) and watch whether Steam Input promotes it. Needs the
/// updated signed driver installed + Steam running. `--seconds N` (default 120).
#[cfg(target_os = "windows")]
pub fn deck_windows_spike(args: &[String]) -> Result<()> {
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(120);
crate::inject::dualsense_windows::deck_spike_hold(0, secs)
}
/// Windows vmouse SPIKE: hold the pf-mouse virtual HID pointer and sweep the REAL cursor via HID
/// reports — proves devnode → INF bind → mshidumdf → mouhid → win32k on-glass, and that a resident
/// virtual pointer makes `SM_MOUSEPRESENT` true (DWM then composites the cursor) with no dongle
/// attached. Run with the host service STOPPED (the resident mouse owns the mailbox otherwise).
/// `--seconds N` (default 30).
#[cfg(target_os = "windows")]
pub fn vmouse_spike(args: &[String]) -> Result<()> {
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(30);
crate::inject::mouse_windows::spike_hold(secs)
}
/// Windows: create a virtual DualSense via the UMDF driver (a SwDeviceCreate per-session
/// devnode plus the shared-memory channel) and hold it, pushing one fixed frame (Cross +
/// LS-right). Drives the real DualSenseWindowsManager, so it validates the device lifecycle
/// end to end. Verify while it holds: `Get-PnpDevice` shows a VID_054C device, and a HID read
/// returns the pushed report (byte1=0xC0, byte8=0x28). On exit the pad drops → SwDeviceClose
/// removes the devnode.
#[cfg(target_os = "windows")]
pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
use punktfunk_core::input::{GamepadEvent, GamepadFrame};
use std::time::{Duration, Instant};
let secs: u64 = args
.iter()
.skip_while(|a| *a != "--seconds")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(20);
// `--index N` creates pad `pf_pad_N` (default 0) — use a spare index (e.g. 1) to test
// alongside a running host that already holds pad 0. `--ds4` drives the DualShock 4
// backend instead of the DualSense one.
let idx: u8 = args
.iter()
.skip_while(|a| *a != "--index")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let ds4 = args.iter().any(|a| a == "--ds4");
let xbox = args.iter().any(|a| a == "--xbox");
// `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds
// the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in
// report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
let edge = args.iter().any(|a| a == "--edge");
let deck = args.iter().any(|a| a == "--deck");
let extra_buttons: u32 = if edge || deck {
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
} else {
0
};
// Same drive loop for either backend (identical method surface): Arrival creates the pad,
// State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback.
macro_rules! drive {
($mgr:expr, $label:expr) => {{
let mut mgr = $mgr;
mgr.handle(&GamepadEvent::Arrival {
index: idx,
kind: 2,
capabilities: 0,
});
println!(
"virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \
it in joy.cpl / Steam / a game; any feedback the game sends prints below.",
$label
);
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut i, mut last) = (0i32, Instant::now());
while Instant::now() < deadline {
mgr.pump(
|pad, lo, hi| println!(" rumble from game: pad={pad} low={lo} high={hi}"),
|o| println!(" hid output from game: {o:?}"),
);
if last.elapsed() >= Duration::from_millis(400) {
last = Instant::now();
i += 1;
let buttons = if i % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A | extra_buttons // Cross (+ Edge paddles)
} else {
0
};
let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X
mgr.handle(&GamepadEvent::State(GamepadFrame {
index: idx as i16,
active_mask: 1 << idx,
buttons,
left_trigger: 0,
right_trigger: 0,
ls_x: lx,
ls_y: 0,
rs_x: 0,
rs_y: 0,
}));
}
std::thread::sleep(Duration::from_millis(15));
}
}};
}
if xbox {
// Xbox 360 via the XUSB companion: a different surface (handle + pump_rumble, no
// HID-output plane), so drive it inline rather than via the macro.
let mut mgr = crate::inject::gamepad::GamepadManager::new();
mgr.handle(&GamepadEvent::Arrival {
index: idx,
kind: 1,
capabilities: 0,
});
println!(
"virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \
an XInput game or xinputtest.exe."
);
let deadline = Instant::now() + Duration::from_secs(secs);
let mut t = 0i32;
while Instant::now() < deadline {
mgr.pump_rumble(|pad, lo, hi| {
println!(" rumble from game: pad={pad} low={lo} high={hi}")
});
t += 1;
let lx = (((t % 200) - 100) * 327).clamp(-32768, 32767) as i16; // sweep ±32700
let buttons = if (t / 67) % 2 == 0 {
punktfunk_core::input::gamepad::BTN_A
} else {
0
};
mgr.handle(&GamepadEvent::State(GamepadFrame {
index: idx as i16,
active_mask: 1 << idx,
buttons,
left_trigger: 0,
right_trigger: 0,
ls_x: lx,
ls_y: 0,
rs_x: 0,
rs_y: 0,
}));
std::thread::sleep(Duration::from_millis(15));
}
} else if ds4 {
drive!(
crate::inject::dualshock4_windows::DualShock4WindowsManager::new(),
"DualShock 4"
);
} else if edge {
drive!(
crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
"DualSense Edge"
);
} else if deck {
drive!(
crate::inject::steam_deck_windows::SteamDeckWindowsManager::new(),
"Steam Deck"
);
} else {
drive!(
crate::inject::dualsense_windows::DualSenseWindowsManager::new(),
"DualSense"
);
}
println!("dualsense-windows-test: done (devnode removed)");
Ok(())
}
/// Mirror a physical monitor and pull frames from it — the on-glass gate for per-monitor capture
/// (`design/per-monitor-portal-capture.md` P2/P3), without needing a client to connect.
///
/// Opens the display backend exactly as a session would (so a `PUNKTFUNK_CAPTURE_MONITOR` pin
/// routes to the mirror backend), attaches a capturer to whatever PipeWire node comes back, and
/// reports the frames it actually receives. What it proves that a unit test cannot: the compositor
/// accepted the record request for a NAMED head, and that head is producing pixels at its own size.
///
/// `--monitor <CONNECTOR>` pins for this run (else `PUNKTFUNK_CAPTURE_MONITOR`); `--seconds N`.
#[cfg(target_os = "linux")]
pub fn mirror_test(args: &[String]) -> Result<()> {
use std::time::{Duration, Instant};
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(5);
// `--monitor` cannot work by setting PUNKTFUNK_CAPTURE_MONITOR here: pf_host_config parses the
// environment ONCE and startup already read it, so this process would still see the old
// snapshot. An explicit connector therefore goes through `open_mirror` below; only the unset
// case falls back to the pin (and to `open`, which is the production routing).
let explicit = arg("--monitor");
let want = explicit
.clone()
.or_else(crate::vdisplay::capture_monitor)
.context(
"no monitor named — pass --monitor <CONNECTOR> or set PUNKTFUNK_CAPTURE_MONITOR",
)?;
let compositor = crate::vdisplay::detect()?;
let monitors = crate::vdisplay::monitors::list(compositor)?;
let target = crate::vdisplay::monitors::resolve(&monitors, &want)?;
println!(
"mirror-test: {compositor:?} {} ({}) at +{},+{}",
target.connector,
target.mode_label(),
target.x,
target.y
);
// No `--monitor` ⇒ exercise the PRODUCTION routing (`open` consulting the pin), which is the
// more valuable path to prove; an explicit connector takes the direct opener.
let mut vd = match &explicit {
Some(connector) => crate::vdisplay::open_mirror(compositor, connector)?,
None => crate::vdisplay::open(compositor)?,
};
// The mode is ignored by the mirror backend (a panel runs at the mode its owner set); pass the
// head's own so this also behaves if the pin is ever unset mid-test.
let mode = crate::vdisplay::Mode {
width: target.width,
height: target.height,
refresh_hz: 60,
};
let vout = vd.create(mode).context("open the mirror display")?;
println!(
"mirror-test: node_id={} preferred={:?} ownership={:?}",
vout.node_id, vout.preferred_mode, vout.ownership
);
// Default to the GPU (dmabuf zero-copy) path a real session uses; `--cpu` forces the mmap
// path, which is worth having as a switch — the two negotiate different PipeWire buffer types.
let gpu = !args.iter().any(|a| a == "--cpu");
let fmt = pf_frame::OutputFormat::resolve(false, gpu);
println!(
"mirror-test: capture path = {}",
if gpu { "gpu/dmabuf" } else { "cpu/mmap" }
);
let mut cap = crate::capture::capture_virtual_output(
vout,
fmt,
crate::session_plan::CaptureBackend::resolve(),
)
.context("attach a capturer to the mirrored monitor")?;
cap.set_active(true);
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut frames, mut first) = (0u32, None);
let mut idle = 0u32;
let mut dims = (0u32, 0u32);
while Instant::now() < deadline {
match cap.next_frame_within(Duration::from_secs(5)) {
Ok(f) => {
if first.is_none() {
first = Some(Instant::now());
println!(
"mirror-test: FIRST FRAME {}x{} {:?}",
f.width, f.height, f.format
);
}
dims = (f.width, f.height);
frames += 1;
}
// A timeout is NOT fatal here: compositor screencast is damage-driven, so a static
// desktop legitimately produces nothing for seconds at a time (the host's own capture
// diag logs `new_fps=0` for virtual outputs on an idle desktop for the same reason).
// Keep waiting until the deadline instead of ending the measurement on the first gap.
Err(e) => {
idle += 1;
if idle == 1 {
println!("mirror-test: (idle — no damage yet: {e:#})");
}
}
}
}
match first {
Some(_) => println!(
"mirror-test: OK — {frames} frames in {secs}s at {}x{} ({:.1} fps over the whole run, \
{idle} idle gaps). Compositor capture is damage-driven: a static desktop produces \
nothing, so judge this by whether frames track what is happening on screen.",
dims.0,
dims.1,
frames as f64 / secs as f64
),
None => {
anyhow::bail!("no frames arrived in {secs}s — the cast started but produced nothing")
}
}
Ok(())
}
/// Aim absolute input at a named monitor and prove where it landed — the on-glass gate for the
/// input-region ladder (`design/per-monitor-portal-capture.md` §7.2), without needing a client.
///
/// The ladder exists for one case a unit test can only simulate: **two heads of the same size**,
/// where matching a libei region by the streamed mode is a coin flip and the pointer silently ends
/// up on the wrong screen. This drives the real thing — the compositor's own EIS regions, the real
/// anchor, the real resolver — and prints the region absolute coordinates actually mapped into, so
/// "it went to the right monitor" is something you read rather than infer.
///
/// `--monitor <CONNECTOR>` anchors at that head's origin (default: the `PUNKTFUNK_CAPTURE_MONITOR` /
/// policy pin); `--none` deliberately runs UNANCHORED, which is the A/B that makes the anchored run
/// mean something on a same-size pair. It then walks the corners and centre of a `--width`×`--height`
/// client surface so an observer can watch the pointer.
///
/// Read the answer from the log line `libei: absolute input maps into this output`.
#[cfg(target_os = "linux")]
pub fn anchor_test(args: &[String]) -> Result<()> {
use punktfunk_core::input::{InputEvent, InputKind};
use std::time::Duration;
let arg = |name: &str| {
args.iter()
.skip_while(|a| a.as_str() != name)
.nth(1)
.cloned()
};
let unanchored = args.iter().any(|a| a == "--none");
let w: u32 = arg("--width").and_then(|s| s.parse().ok()).unwrap_or(1920);
let h: u32 = arg("--height").and_then(|s| s.parse().ok()).unwrap_or(1080);
let compositor = crate::vdisplay::detect()?;
let monitors = crate::vdisplay::monitors::list(compositor)?;
println!(
"anchor-test: {compositor:?} has {} monitor(s):",
monitors.len()
);
for m in &monitors {
println!(
" {:<12} {:>13} at +{},+{}",
m.connector,
m.mode_label(),
m.x,
m.y
);
}
// Two heads at the same size is the case the ladder exists for; say so when the rig is right,
// and say so when it is NOT — a green run on a single-head box proves nothing about it.
let same_size = monitors.iter().enumerate().any(|(i, a)| {
monitors
.iter()
.skip(i + 1)
.any(|b| a.width == b.width && a.height == b.height)
});
println!(
"anchor-test: two same-size heads present: {} {}",
same_size,
if same_size {
"— this run exercises the case the ladder exists for"
} else {
"— WEAK RIG: size matching would have picked correctly anyway"
}
);
if unanchored {
crate::inject::set_absolute_anchor(None);
println!("anchor-test: UNANCHORED (--none) — the size/first rungs decide");
} else {
let want = arg("--monitor")
.or_else(crate::vdisplay::capture_monitor)
.context("no monitor named — pass --monitor <CONNECTOR>, or --none for the A/B")?;
let m = crate::vdisplay::monitors::resolve(&monitors, &want)?;
crate::inject::set_absolute_anchor(Some(crate::inject::AbsoluteAnchor {
origin: Some((m.x, m.y)),
mapping_id: None,
}));
println!(
"anchor-test: anchored at {} +{},+{} ({})",
m.connector,
m.x,
m.y,
m.mode_label()
);
}
let backend = crate::inject::default_backend();
if backend != crate::inject::Backend::Libei {
// The ladder is libei's; on any other backend this command would report nothing about it.
// Say so rather than emitting a green run that means nothing (sway injects via WlrVirtual,
// which is why the sway box cannot serve as this rig — set PUNKTFUNK_INPUT_BACKEND=libei on
// a compositor that speaks EI).
anyhow::bail!(
"input backend is {backend:?}, not libei — the absolute-region ladder only exists on \
the libei backend; set PUNKTFUNK_INPUT_BACKEND=libei"
);
}
let mut inj = crate::inject::open(backend)?;
// libei establishes its portal/EIS session + device resume asynchronously; events before then
// are dropped (and it is the resume that publishes the regions we are testing).
std::thread::sleep(Duration::from_secs(4));
let flags = (w << 16) | (h & 0xffff);
let pts = [
(w as i32 / 2, h as i32 / 2),
(60, 60),
(w as i32 - 60, 60),
(w as i32 - 60, h as i32 - 60),
(60, h as i32 - 60),
(w as i32 / 2, h as i32 / 2),
];
println!("anchor-test: walking {w}x{h} — centre, four corners, centre (1s apart)");
for (x, y) in pts {
let e = InputEvent {
kind: InputKind::MouseMoveAbs,
_pad: [0; 3],
code: 0,
x,
y,
flags,
};
if let Err(err) = inj.inject(&e) {
tracing::warn!(error = %format!("{err:#}"), "anchor-test: inject failed");
}
std::thread::sleep(Duration::from_secs(1));
}
println!(
"anchor-test: done — read the `libei: absolute input maps into this output` line above \
for the region that was chosen"
);
Ok(())
}