Files
punktfunk/crates/punktfunk-host/src/devtest.rs
T
enricobuehlerandClaude Fable 5 ed3d236ab8 feat(pad-audio): DualSense audio haptics + speaker, host->client end to end
The 0xD1 pad-audio plane streams a DualSense's voice-coil haptics (back
channel pair, 5 ms Opus frames) and speaker (front pair, 10 ms) per pad from
a Windows host to the SDL clients, which render them into a USB DualSense's
own 4-channel audio device.

Wire (punktfunk-core, ABI v15): PAD_AUDIO_MAGIC 0xD1 [pad][kind][seq][pts]
[opus]; CLIENT_CAP_PAD_AUDIO 0x04 / HOST_CAP_PAD_AUDIO 0x20; per-pad render
capability rides GamepadArrival flags bits 8/9, sent only toward a host that
advertised its cap so old hosts see byte-identical arrivals; silence is a
frozen seq (mic-mute discipline), loss is a seq gap concealed via
AudioGapTracker. HidOutput::AudioCtl (0xCD kind 0x06) forwards the 0x02
report's audio-control bytes 5..=10 change-only, value-deduped, with a
once-per-pad "title asserted haptics-select" diagnosis log.

Windows host endpoint provider (audio/windows/pad_endpoint.rs): per-pad
render endpoints are additional devnode instances of Valve's Steam Streaming
Speakers driver (SetupDiRegisterDeviceInfo, NOT the class installer - it
needs an interactive window station), stamped with DualSense identity: desc
"Wireless Controller", device name "DualSense Wireless Controller",
ContainerId = the virtual pad's PFDS GUID, 4ch/48k format triplet.
IPropertyStore route first, ACL-repaired registry fallback (the MMDevices
keys deny writes even to SYSTEM; the owner's implicit WRITE_DAC + an ACE for
S-1-5-18 resolved by SID is the way in). Provisioned at host startup
(PUNKTFUNK_PAD_AUDIO, PUNKTFUNK_PAD_AUDIO_SLOTS, default 1), idempotent via
a persisted PunktfunkPadIndex marker; pad endpoints are structurally
ineligible for the mic/loopback wiring plan and guarded against default-
device theft; capture is WASAPI loopback on the stamped endpoint. Devtest:
punktfunk-host pad-endpoint ensure|remove|status.

Host service (native/pad_audio.rs): per-(session,pad) thread, loopback 4ch
-> pair splitter -> per-kind stereo Opus (48k LowDelay CBR 64k) -> per-kind
silence gate (opens at peak>=1e-3, 250 ms hangover, gated = no send + frozen
seq) -> datagrams. Spawned from the native input pump when a DualSense/Edge
arrival carries audio bits and both caps negotiated; idempotent re-arrivals;
reaped on remove and teardown.

Client tier A (pf-client-core/pad_audio.rs): settings pad_haptics (default
on) and pad_speaker (default "pad"); tier A = wired USB DS5/Edge via SDL
connection state with an audio-sibling fallback; correlation maps the SDL
HID path to the pad's own render endpoint (Windows: ContainerId match +
4ch gate via registry; Linux: Sony sink signature); renderer decodes both
kinds into a quad interleave and plays it on the pad's endpoint (WASAPI
autoconvert / PipeWire target.object, 240-2400 frame ring floor,
dont-reconnect so an unplug never re-routes haptics to the desktop
speakers). SDL's DualSense driver sets "disable audio haptics" whenever it
drives rumble emulation, so tier-A pads suppress wire rumble and send one
cleared-enable-bits effects packet to keep the actuators live; AudioCtl
bytes fold back into the effects packet at report-minus-one offsets.

Verification: punktfunk-core 265 tests (macOS) + clippy -D warnings (mac +
Linux docker); pf-inject 85 tests (Linux docker); punktfunk-host cargo
check + clippy + 19 pad tests + 46 audio-module tests (Windows box);
pf-client-core 30 tests + clippy (Linux docker CI image) + cargo check
(Windows box); punktfunk-client-session clippy (Linux) + check (Windows);
cargo fmt --all --check clean on the final tree. NOT yet verified: any
on-glass run (host deploy + real title + physical pad), the stamp-route
split at runtime, exclusive-mode Initialize isolation, Linux-host emission
(the per-pad PipeWire sink is not in this change - Windows hosts only).
Scope excluded deliberately: tier B (Apple CoreHaptics) and tier C
(haptics->rumble derivation), pad_speaker="mix", Android leg, settings UI
surfaces (keys are serde-defaulted), GameStream-plane arrivals (audio_caps
always 0 there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:07:06 +02:00

786 lines
33 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 CHANNEL-PROOF PROBE: settle, on a real box, which HID IOCTL hidclass forwards to a UMDF
/// HID minidriver — the one assumption in the pad channel's v3 delivery gate that could not be
/// settled by reading. Spins up a throwaway `pf_mouse_probe` devnode at pad index 9 (so it is safe
/// to run beside a live host), asks it for its channel proof over both HID paths, and prints which
/// answered. Needs the CURRENT drivers installed: `punktfunk-host.exe driver install --gamepad`.
#[cfg(target_os = "windows")]
pub fn channel_proof_probe(_args: &[String]) -> Result<()> {
crate::inject::mouse_windows::channel_proof_probe()
}
/// 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,
audio_caps: 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,
audio_caps: 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(())
}
/// Windows: pad-audio endpoint provisioning — `pad-endpoint ensure|remove|status [--index N]`.
/// `ensure` runs the idempotent startup path (reuse-or-create the devnode, bind the Steam
/// Streaming Speakers driver, stamp the DualSense identity + 4ch/48k formats, report whether
/// the stamps are SERVED); `status` prints the devnode/endpoint and per-stamp stored vs served
/// state without changing anything; `remove` deletes the devnode via pnputil — the escape
/// hatch only, endpoints are persistent by design. Stamping needs SYSTEM (the MMDevices ACL);
/// run `ensure` under the service account or PsExec when the property-store route is denied.
#[cfg(target_os = "windows")]
pub fn pad_endpoint(args: &[String]) -> Result<()> {
use crate::audio::pad_endpoint as pe;
let idx: u8 = args
.iter()
.skip_while(|a| *a != "--index")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
match args.get(1).map(String::as_str) {
Some("ensure") => {
let p = pe::ensure(idx)?;
println!(
"pad-endpoint ensure: pad {} devnode {} endpoint {} needs_aeb_kick={}",
p.pad_index, p.device_instance, p.endpoint_id, p.needs_aeb_kick
);
Ok(())
}
Some("remove") => match pe::find(idx)? {
Some(p) => {
pe::remove(&p);
println!(
"pad-endpoint remove: requested removal of {}",
p.device_instance
);
Ok(())
}
None => {
println!("pad-endpoint remove: no pad-audio devnode for index {idx}");
Ok(())
}
},
Some("status") => pe::print_status(idx),
_ => anyhow::bail!("usage: punktfunk-host pad-endpoint <ensure|remove|status> [--index N]"),
}
}
/// 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(())
}