refactor(host): extract src/devtest.rs — the standalone dev/test subcommands
Per plan §W5 (main-cli, 'devtest.rs land first'): move the inline-bodied dev/test subcommand handlers out of main.rs's match into src/devtest.rs — input_test (Linux libei/wlr injection smoke test + its non-Linux stub) and the virtual-gamepad exercisers dualsense_test/switchpro_test (Linux UHID) and deck_windows_spike/ dualsense_windows_test (Windows UMDF + Steam Deck devnode spike). main.rs's arms become one-line forwards; main.rs drops 1004→667 lines. The thin arms that already forward to subsystem modules (zerocopy/capture selftests, probes) stay put — that is their correct layer. Pure code-move (bodies verbatim; crate-local refs qualified with crate::; one doc reword to dodge clippy doc_lazy_continuation now that an arm comment became a /// doc). Verified clippy 0/0 on BOTH Linux (home-worker-5, nvenc,vulkan-encode,pyrowave) and Windows (.173, nvenc,amf-qsv — covers the cfg(windows) handlers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
//! 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;
|
||||
|
||||
/// 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,
|
||||
};
|
||||
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: 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 crate::gamestream::gamepad::{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(())
|
||||
}
|
||||
@@ -22,6 +22,7 @@ mod audio;
|
||||
mod capture;
|
||||
mod config;
|
||||
mod detect;
|
||||
mod devtest;
|
||||
mod discovery;
|
||||
mod wol;
|
||||
// Goal-1 stage 6: top-level platform-only modules live under `src/linux/` and `src/windows/`; `#[path]`
|
||||
@@ -241,7 +242,7 @@ fn real_main() -> Result<()> {
|
||||
}
|
||||
// Standalone input-injection smoke test (no client needed): open the session's input
|
||||
// backend and inject a scripted mouse/keyboard pattern. Watch a focused app / `wev`.
|
||||
Some("input-test") => input_test(),
|
||||
Some("input-test") => devtest::input_test(),
|
||||
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("zerocopy-probe") => zerocopy::probe(),
|
||||
@@ -271,305 +272,19 @@ fn real_main() -> Result<()> {
|
||||
println!("{compositor:?} ready");
|
||||
Ok(())
|
||||
}
|
||||
// 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).
|
||||
// Create a virtual DualSense via UHID and exercise it (validation, no streaming session).
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("dualsense-test") => {
|
||||
use inject::dualsense::{DsUhidIdentity, DualSensePad};
|
||||
use 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".
|
||||
Some("dualsense-test") => devtest::dualsense_test(&args),
|
||||
// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no session).
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("switchpro-test") => {
|
||||
use inject::switch_pro::SwitchProPad;
|
||||
use 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).
|
||||
Some("switchpro-test") => devtest::switchpro_test(&args),
|
||||
// Windows N4 SPIKE: hold a software-devnode HID Steam Deck and watch Steam Input promote it.
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("deck-windows-spike") => {
|
||||
let secs: u64 = args
|
||||
.iter()
|
||||
.skip_while(|a| *a != "--seconds")
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(120);
|
||||
inject::dualsense_windows::deck_spike_hold(0, secs)
|
||||
}
|
||||
// Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode
|
||||
// + 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.
|
||||
Some("deck-windows-spike") => devtest::deck_windows_spike(&args),
|
||||
// Windows: create a virtual DualSense (or --ds4/--edge/--deck/--xbox) via the UMDF driver and
|
||||
// hold it, driving the real *WindowsManager end to end. `--index N`, `--seconds N`.
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("dualsense-windows-test") => {
|
||||
use crate::gamestream::gamepad::{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 = 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!(
|
||||
inject::dualshock4_windows::DualShock4WindowsManager::new(),
|
||||
"DualShock 4"
|
||||
);
|
||||
} else if edge {
|
||||
drive!(
|
||||
inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new(),
|
||||
"DualSense Edge"
|
||||
);
|
||||
} else if deck {
|
||||
drive!(
|
||||
inject::steam_deck_windows::SteamDeckWindowsManager::new(),
|
||||
"Steam Deck"
|
||||
);
|
||||
} else {
|
||||
drive!(
|
||||
inject::dualsense_windows::DualSenseWindowsManager::new(),
|
||||
"DualSense"
|
||||
);
|
||||
}
|
||||
println!("dualsense-windows-test: done (devnode removed)");
|
||||
Ok(())
|
||||
}
|
||||
Some("dualsense-windows-test") => devtest::dualsense_windows_test(&args),
|
||||
// Capture→encode→file pipeline spike (dev tool).
|
||||
Some("spike") => spike::run(parse_spike(&args[1..])?),
|
||||
// Native punktfunk/1 host (QUIC control plane + UDP data plane).
|
||||
@@ -649,58 +364,6 @@ fn real_main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
fn input_test() -> Result<()> {
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::time::Duration;
|
||||
|
||||
let backend = inject::default_backend();
|
||||
tracing::info!(?backend, "input-test: opening injector");
|
||||
let mut inj = 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,
|
||||
};
|
||||
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"))]
|
||||
fn input_test() -> Result<()> {
|
||||
bail!("input-test requires Linux")
|
||||
}
|
||||
|
||||
/// `serve` options. The **native punktfunk/1 plane + management API are the secure default and always
|
||||
/// run**; `--gamestream` additionally enables the GameStream/Moonlight-compat planes (opt-in — they
|
||||
/// carry the inherent on-path #5/#9 weaknesses, so only on a trusted LAN). Returns the mgmt options,
|
||||
|
||||
Reference in New Issue
Block a user