B: PadFeedback.game_drove -> rumble_drove, keyed on vibration-asserting reports — an LED/adaptive-trigger stream can no longer feed the abandoned-rumble force-off while a coalesced stop never re-asserts (the confirmed unbounded stuck-ON path). C: Linux parity — every UHID backend now arms the shared watchdog (Steam Input drives these pads over hidraw with Windows abandonment semantics) and the uinput mixer force-stops abandoned infinite-replay FF effects (FfState, unit-tested). Shared PUNKTFUNK_RUMBLE_IDLE_MS hatch (0 = off; non-zero floored above SDL's ~2 s rumble resend). A: PadShm v2.1 — a 1024 B tail extension carrying an 8-slot lossless output-report ring, feature-negotiated via zeroed reserved fields (out_ring_ver; deliberately NO GAMEPAD_PROTO_VERSION bump — mixed generations degrade to the legacy latest-report slot instead of failing closed). The pf-dualsense driver dual-writes both planes (publish_output); the host's shared OutputDrain drains oldest->newest with a torn-read recheck and an overflow->resync path (PadFeedback.resync force-stops + re-arms dedups). pf-umdf-util grows a min_data_size map fallback. Ds*Feedback.fresh removed (dead). design/rumble-root-fix.md par. A-C. Verified: pf-inject tests+clippy Linux+Windows (53/53 on winbox incl. the stop-coalesce repro); drivers ws check+clippy on the CI runner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
865 lines
35 KiB
Rust
865 lines
35 KiB
Rust
//! Virtual gamepads via `/dev/uinput`, cloning the kernel `xpad` identity ("Microsoft X-Box
|
|
//! 360 pad", `045e:028e`) so SDL/Steam/Proton match their built-in mapping with zero
|
|
//! configuration — exactly what Sunshine emulates. One [`VirtualPad`] per attached client
|
|
//! controller, managed by [`GamepadManager`] from decoded
|
|
//! [`GamepadFrame`](punktfunk_core::input::GamepadFrame)s.
|
|
//!
|
|
//! Rumble flows the *other* way on the same fd: games upload force-feedback effects
|
|
//! (`EV_UINPUT`/`UI_FF_UPLOAD` → `UI_BEGIN/END_FF_UPLOAD` ioctls) and trigger them with
|
|
//! `EV_FF` writes; [`GamepadManager::pump_rumble`] services that protocol non-blockingly
|
|
//! (the control thread calls it every tick) and reports mixed `(low, high)` motor levels for
|
|
//! the host to send to the client. Note: a game's `EVIOCSFF` ioctl BLOCKS until we answer
|
|
//! `UI_END_FF_UPLOAD`, so the pump must run regularly.
|
|
//!
|
|
//! All ioctl numbers/struct layouts below were verified against this generation's
|
|
//! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership
|
|
//! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise.
|
|
|
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
|
|
|
use crate::pad_slots::PadSlots;
|
|
use anyhow::{bail, Result};
|
|
use punktfunk_core::input::{gamepad, GamepadFrame, MAX_PADS};
|
|
use std::collections::HashMap;
|
|
use std::os::fd::{AsRawFd, OwnedFd};
|
|
use std::time::{Duration, Instant};
|
|
|
|
// ioctls (x86_64).
|
|
const UI_DEV_CREATE: libc::c_ulong = 0x5501;
|
|
const UI_DEV_DESTROY: libc::c_ulong = 0x5502;
|
|
const UI_DEV_SETUP: libc::c_ulong = 0x405c_5503;
|
|
const UI_ABS_SETUP: libc::c_ulong = 0x401c_5504;
|
|
const UI_SET_EVBIT: libc::c_ulong = 0x4004_5564;
|
|
const UI_SET_KEYBIT: libc::c_ulong = 0x4004_5565;
|
|
const UI_SET_FFBIT: libc::c_ulong = 0x4004_556b;
|
|
const UI_BEGIN_FF_UPLOAD: libc::c_ulong = 0xc068_55c8;
|
|
const UI_END_FF_UPLOAD: libc::c_ulong = 0x4068_55c9;
|
|
const UI_BEGIN_FF_ERASE: libc::c_ulong = 0xc00c_55ca;
|
|
const UI_END_FF_ERASE: libc::c_ulong = 0x400c_55cb;
|
|
|
|
// Event types/codes.
|
|
const EV_SYN: u16 = 0x00;
|
|
const EV_KEY: u16 = 0x01;
|
|
const EV_ABS: u16 = 0x03;
|
|
const EV_FF: u16 = 0x15;
|
|
const EV_UINPUT: u16 = 0x0101;
|
|
const SYN_REPORT: u16 = 0;
|
|
const UI_FF_UPLOAD: u16 = 1;
|
|
const UI_FF_ERASE: u16 = 2;
|
|
const FF_RUMBLE: u16 = 0x50;
|
|
const FF_GAIN: u16 = 0x60;
|
|
|
|
const ABS_X: u16 = 0x00;
|
|
const ABS_Y: u16 = 0x01;
|
|
const ABS_Z: u16 = 0x02;
|
|
const ABS_RX: u16 = 0x03;
|
|
const ABS_RY: u16 = 0x04;
|
|
const ABS_RZ: u16 = 0x05;
|
|
const ABS_HAT0X: u16 = 0x10;
|
|
const ABS_HAT0Y: u16 = 0x11;
|
|
|
|
const BTN_SOUTH: u16 = 0x130; // A
|
|
const BTN_EAST: u16 = 0x131; // B
|
|
const BTN_NORTH: u16 = 0x133; // X (kernel calls it BTN_NORTH/BTN_X)
|
|
const BTN_WEST: u16 = 0x134; // Y
|
|
const BTN_TL: u16 = 0x136;
|
|
const BTN_TR: u16 = 0x137;
|
|
const BTN_SELECT: u16 = 0x13a;
|
|
const BTN_START: u16 = 0x13b;
|
|
const BTN_MODE: u16 = 0x13c;
|
|
const BTN_THUMBL: u16 = 0x13d;
|
|
const BTN_THUMBR: u16 = 0x13e;
|
|
// Xbox-Elite paddle codes (the xpad convention SDL / Steam Input recognize). A client's back grips —
|
|
// and the GameStream `buttonFlags2` paddle bits, which were silently dropped before — land here, so
|
|
// the virtual X-Box pad exposes paddles like an Elite controller. PADDLE1/2/3/4 = R4/L4/R5/L5.
|
|
const BTN_TRIGGER_HAPPY5: u16 = 0x2c4;
|
|
const BTN_TRIGGER_HAPPY6: u16 = 0x2c5;
|
|
const BTN_TRIGGER_HAPPY7: u16 = 0x2c6;
|
|
const BTN_TRIGGER_HAPPY8: u16 = 0x2c7;
|
|
|
|
/// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead.
|
|
const BUTTON_MAP: [(u32, u16); 15] = [
|
|
(gamepad::BTN_A, BTN_SOUTH),
|
|
(gamepad::BTN_B, BTN_EAST),
|
|
(gamepad::BTN_X, BTN_NORTH),
|
|
(gamepad::BTN_Y, BTN_WEST),
|
|
(gamepad::BTN_LB, BTN_TL),
|
|
(gamepad::BTN_RB, BTN_TR),
|
|
(gamepad::BTN_BACK, BTN_SELECT),
|
|
(gamepad::BTN_START, BTN_START),
|
|
(gamepad::BTN_GUIDE, BTN_MODE),
|
|
(gamepad::BTN_LS_CLICK, BTN_THUMBL),
|
|
(gamepad::BTN_RS_CLICK, BTN_THUMBR),
|
|
(gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5),
|
|
(gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6),
|
|
(gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7),
|
|
(gamepad::BTN_PADDLE4, BTN_TRIGGER_HAPPY8),
|
|
];
|
|
|
|
/// The USB identity a virtual uinput pad presents. SDL/Steam/Proton key their built-in mapping off
|
|
/// `bustype/vendor/product/version` (+ name), and games pick button glyphs from it. The button/axis
|
|
/// layout this backend emits is the same XInput one regardless — only the identity differs between an
|
|
/// X-Box 360 pad and an X-Box One/Series pad (which is why "Xbox One" buys glyphs, not new capability;
|
|
/// impulse-trigger rumble is unreachable through evdev FF either way).
|
|
#[derive(Clone, Copy)]
|
|
pub struct PadIdentity {
|
|
vendor: u16,
|
|
product: u16,
|
|
version: u16,
|
|
name: &'static [u8],
|
|
/// Short label for the creation log line.
|
|
log: &'static str,
|
|
}
|
|
|
|
impl PadIdentity {
|
|
/// "Microsoft X-Box 360 pad" (`045e:028e`) — the universal default; matches the kernel `xpad`
|
|
/// table verbatim so SDL/Steam map it with zero config.
|
|
pub const fn xbox360() -> PadIdentity {
|
|
PadIdentity {
|
|
vendor: 0x045e,
|
|
product: 0x028e,
|
|
version: 0x0110,
|
|
name: b"Microsoft X-Box 360 pad",
|
|
log: "X-Box 360 pad",
|
|
}
|
|
}
|
|
|
|
/// "Microsoft X-Box One S pad" (`045e:02ea`) — an `xpad`-table entry, so games show One/Series
|
|
/// glyphs. XInput-identical to the 360 pad otherwise.
|
|
pub const fn xbox_one() -> PadIdentity {
|
|
PadIdentity {
|
|
vendor: 0x045e,
|
|
product: 0x02ea,
|
|
version: 0x0408,
|
|
name: b"Microsoft X-Box One S pad",
|
|
log: "X-Box One S pad",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for PadIdentity {
|
|
fn default() -> PadIdentity {
|
|
PadIdentity::xbox360()
|
|
}
|
|
}
|
|
|
|
#[repr(C)]
|
|
struct InputId {
|
|
bustype: u16,
|
|
vendor: u16,
|
|
product: u16,
|
|
version: u16,
|
|
}
|
|
|
|
#[repr(C)]
|
|
struct UinputSetup {
|
|
id: InputId,
|
|
name: [u8; 80],
|
|
ff_effects_max: u32,
|
|
}
|
|
|
|
#[repr(C)]
|
|
#[derive(Default, Clone, Copy)]
|
|
struct AbsInfo {
|
|
value: i32,
|
|
minimum: i32,
|
|
maximum: i32,
|
|
fuzz: i32,
|
|
flat: i32,
|
|
resolution: i32,
|
|
}
|
|
|
|
#[repr(C)]
|
|
struct UinputAbsSetup {
|
|
code: u16,
|
|
_pad: u16,
|
|
absinfo: AbsInfo,
|
|
}
|
|
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy)]
|
|
struct InputEventRaw {
|
|
time: libc::timeval,
|
|
type_: u16,
|
|
code: u16,
|
|
value: i32,
|
|
}
|
|
|
|
/// `struct ff_effect` (48 bytes; the union starts 8-aligned at offset 16).
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy)]
|
|
struct FfEffect {
|
|
type_: u16,
|
|
id: i16,
|
|
direction: u16,
|
|
trigger_button: u16,
|
|
trigger_interval: u16,
|
|
replay_length: u16,
|
|
replay_delay: u16,
|
|
_pad: u16,
|
|
/// Union; for `FF_RUMBLE`: `u16 strong_magnitude` at [0..2], `u16 weak_magnitude` at [2..4].
|
|
u: [u8; 32],
|
|
}
|
|
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy)]
|
|
struct UinputFfUpload {
|
|
request_id: u32,
|
|
retval: i32,
|
|
effect: FfEffect,
|
|
old: FfEffect,
|
|
}
|
|
|
|
#[repr(C)]
|
|
#[derive(Clone, Copy)]
|
|
struct UinputFfErase {
|
|
request_id: u32,
|
|
retval: i32,
|
|
effect_id: u32,
|
|
}
|
|
|
|
// Layouts verified by compiling a probe against this generation's <linux/uinput.h> (x86_64).
|
|
const _: () = {
|
|
assert!(std::mem::size_of::<UinputSetup>() == 92);
|
|
assert!(std::mem::size_of::<UinputAbsSetup>() == 28);
|
|
assert!(std::mem::size_of::<InputEventRaw>() == 24);
|
|
assert!(std::mem::size_of::<FfEffect>() == 48);
|
|
assert!(std::mem::size_of::<UinputFfUpload>() == 104);
|
|
assert!(std::mem::size_of::<UinputFfErase>() == 12);
|
|
};
|
|
|
|
fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> {
|
|
// SAFETY: every caller passes one of UI_SET_EVBIT/KEYBIT/FFBIT/UI_DEV_CREATE/UI_DEV_DESTROY as
|
|
// `req` — all integer-argument ioctls whose third arg the kernel takes BY VALUE, so nothing is
|
|
// dereferenced through `arg` and no memory must outlive the call. The only precondition is `fd`
|
|
// being a valid open descriptor; callers pass the live `/dev/uinput` fd, and even a stale fd
|
|
// would merely return -1/EBADF (reported below), never UB.
|
|
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
|
bail!("{what}: {}", std::io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> {
|
|
// SAFETY: `fd` is the caller's live `/dev/uinput` fd. Every call site passes `&mut x` for a live,
|
|
// uniquely-borrowed `#[repr(C)]` `x: T` whose size matches the struct the request number encodes
|
|
// (UI_DEV_SETUP=0x405c_5503 → 0x5c=92=size_of::<UinputSetup>(); UI_ABS_SETUP → 0x1c=28; the FF
|
|
// upload/erase ioctls → 0x68/0x0c — all pinned by the `size_of` asserts above). The kernel copies
|
|
// exactly that many bytes in/out through `arg`; the `&mut` keeps the pointee alive and unaliased
|
|
// for the whole synchronous call.
|
|
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
|
bail!("{what}: {}", std::io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// One FF effect a game uploaded: rumble magnitudes + playback state.
|
|
struct Effect {
|
|
strong: u16,
|
|
weak: u16,
|
|
/// `Some(deadline)` while playing (replay length 0 = until stopped).
|
|
playing: Option<Option<Instant>>,
|
|
replay_ms: u16,
|
|
}
|
|
|
|
/// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy
|
|
/// (finite-replay expiry + the abandoned-INFINITE-effect force-off), split from [`VirtualPad`] so
|
|
/// the policy is pure and unit-testable without a live uinput fd.
|
|
struct FfState {
|
|
effects: HashMap<i16, Effect>,
|
|
next_effect_id: i16,
|
|
gain: u32,
|
|
/// Last `(low, high)` reported, to dedup.
|
|
last_mix: (u16, u16),
|
|
/// When a game last touched the FF plane (upload / erase / play / stop / gain). An
|
|
/// infinite-replay effect still playing past the shared idle window against this is a residual
|
|
/// the game abandoned (kernel auto-erase only covers a game whose fd CLOSED) — finite effects
|
|
/// are untouched: their declared replay deadline is the contract, exactly as a real pad honors
|
|
/// it. SDL-class writers re-play held rumble every ~2 s, refreshing this clock.
|
|
last_activity: Instant,
|
|
}
|
|
|
|
impl FfState {
|
|
fn new() -> FfState {
|
|
FfState {
|
|
effects: HashMap::new(),
|
|
next_effect_id: 0,
|
|
gain: 0xFFFF,
|
|
last_mix: (0, 0),
|
|
last_activity: Instant::now(),
|
|
}
|
|
}
|
|
|
|
/// The game touched the FF plane — refresh the abandoned-effect clock.
|
|
fn note_activity(&mut self) {
|
|
self.last_activity = Instant::now();
|
|
}
|
|
|
|
/// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones),
|
|
/// scale by gain. Returns the new `(low, high)` only when it changed since the last call.
|
|
fn mix(&mut self, now: Instant, idle: Option<Duration>) -> Option<(u16, u16)> {
|
|
let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t);
|
|
let (mut strong, mut weak) = (0u32, 0u32);
|
|
for e in self.effects.values_mut() {
|
|
let Some(deadline) = e.playing else { continue };
|
|
match deadline {
|
|
Some(d) if now >= d => e.playing = None,
|
|
// An infinite-replay effect the game stopped driving (no FF traffic for the whole
|
|
// idle window) — the alive-but-abandoned case the kernel's close-time auto-erase
|
|
// cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the
|
|
// clock). Mirrors the XUSB/UHID abandoned-rumble force-off.
|
|
None if stale => {
|
|
tracing::info!(
|
|
strong = e.strong,
|
|
weak = e.weak,
|
|
"rumble: stale infinite FF effect (game stopped driving the pad) — forcing off"
|
|
);
|
|
e.playing = None;
|
|
}
|
|
_ => {
|
|
strong = strong.saturating_add(e.strong as u32);
|
|
weak = weak.saturating_add(e.weak as u32);
|
|
}
|
|
}
|
|
}
|
|
// Linux FF: strong = low-frequency (big) motor, weak = high-frequency motor.
|
|
let low = ((strong.min(0xFFFF) * self.gain) >> 16) as u16;
|
|
let high = ((weak.min(0xFFFF) * self.gain) >> 16) as u16;
|
|
(self.last_mix != (low, high)).then(|| {
|
|
self.last_mix = (low, high);
|
|
(low, high)
|
|
})
|
|
}
|
|
}
|
|
|
|
/// One virtual X-Box-360 pad backed by a uinput device.
|
|
pub struct VirtualPad {
|
|
fd: OwnedFd,
|
|
ff: FfState,
|
|
}
|
|
|
|
impl VirtualPad {
|
|
pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> {
|
|
use std::os::fd::FromRawFd;
|
|
// SAFETY: `c"/dev/uinput"` is a 'static NUL-terminated C string literal; `as_ptr()` yields a
|
|
// valid pointer the kernel only reads as a filesystem path. `open` returns a fresh fd (or -1)
|
|
// and retains nothing; no Rust memory is aliased or handed to the kernel beyond that 'static path.
|
|
let raw = unsafe {
|
|
libc::open(
|
|
c"/dev/uinput".as_ptr(),
|
|
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
|
)
|
|
};
|
|
if raw < 0 {
|
|
bail!(
|
|
"open /dev/uinput: {} (install the udev rule granting the 'input' group access \
|
|
— see scripts/60-punktfunk.rules — and add the user to the 'input' group)",
|
|
std::io::Error::last_os_error()
|
|
);
|
|
}
|
|
// SAFETY: `raw >= 0` here (the `< 0` branch above already bailed), so it is a freshly-opened fd
|
|
// from `libc::open` that is not stored or owned anywhere else. Transferring it to `OwnedFd` makes
|
|
// this the unique owner, which will `close` it exactly once on drop (no double-close, no leak).
|
|
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
|
|
|
ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?;
|
|
ioctl_int(raw, UI_SET_EVBIT, EV_ABS as i32, "UI_SET_EVBIT(EV_ABS)")?;
|
|
ioctl_int(raw, UI_SET_EVBIT, EV_FF as i32, "UI_SET_EVBIT(EV_FF)")?;
|
|
for (_, key) in BUTTON_MAP {
|
|
ioctl_int(raw, UI_SET_KEYBIT, key as i32, "UI_SET_KEYBIT")?;
|
|
}
|
|
ioctl_int(
|
|
raw,
|
|
UI_SET_FFBIT,
|
|
FF_RUMBLE as i32,
|
|
"UI_SET_FFBIT(FF_RUMBLE)",
|
|
)?;
|
|
ioctl_int(raw, UI_SET_FFBIT, FF_GAIN as i32, "UI_SET_FFBIT(FF_GAIN)")?;
|
|
|
|
let stick = AbsInfo {
|
|
minimum: -32768,
|
|
maximum: 32767,
|
|
fuzz: 16,
|
|
flat: 128,
|
|
..Default::default()
|
|
};
|
|
let trigger = AbsInfo {
|
|
minimum: 0,
|
|
maximum: 255,
|
|
..Default::default()
|
|
};
|
|
let hat = AbsInfo {
|
|
minimum: -1,
|
|
maximum: 1,
|
|
..Default::default()
|
|
};
|
|
for (code, info) in [
|
|
(ABS_X, stick),
|
|
(ABS_Y, stick),
|
|
(ABS_RX, stick),
|
|
(ABS_RY, stick),
|
|
(ABS_Z, trigger),
|
|
(ABS_RZ, trigger),
|
|
(ABS_HAT0X, hat),
|
|
(ABS_HAT0Y, hat),
|
|
] {
|
|
let mut a = UinputAbsSetup {
|
|
code,
|
|
_pad: 0,
|
|
absinfo: info,
|
|
};
|
|
ioctl_ptr(raw, UI_ABS_SETUP, &mut a, "UI_ABS_SETUP")?;
|
|
}
|
|
|
|
// The xpad identity: SDL keys its built-in mapping off bustype/vendor/product/version.
|
|
let mut setup = UinputSetup {
|
|
id: InputId {
|
|
bustype: 0x0003, // BUS_USB
|
|
vendor: identity.vendor,
|
|
product: identity.product,
|
|
version: identity.version,
|
|
},
|
|
name: [0; 80],
|
|
ff_effects_max: 16, // must be > 0 or FF uploads are never delivered
|
|
};
|
|
let name = identity.name;
|
|
setup.name[..name.len()].copy_from_slice(name);
|
|
ioctl_ptr(raw, UI_DEV_SETUP, &mut setup, "UI_DEV_SETUP")?;
|
|
ioctl_int(raw, UI_DEV_CREATE, 0, "UI_DEV_CREATE")?;
|
|
tracing::info!(
|
|
index,
|
|
pad = identity.log,
|
|
"virtual gamepad created (uinput)"
|
|
);
|
|
|
|
Ok(VirtualPad {
|
|
fd,
|
|
ff: FfState::new(),
|
|
})
|
|
}
|
|
|
|
fn emit(&self, type_: u16, code: u16, value: i32) {
|
|
let ev = InputEventRaw {
|
|
time: libc::timeval {
|
|
tv_sec: 0,
|
|
tv_usec: 0,
|
|
},
|
|
type_,
|
|
code,
|
|
value,
|
|
};
|
|
// SAFETY: `ev` is a live local `#[repr(C)]` struct of all-integer fields with no padding bytes
|
|
// (timeval=16 + u16 + u16 + i32 = 24, the size asserted above), so every byte is initialized and
|
|
// valid to read as `u8`. The pointer is non-null and `u8`-aligned (align 1), the length is exactly
|
|
// `size_of::<InputEventRaw>()` so the slice spans precisely `ev`'s bytes (in bounds), and `ev`
|
|
// outlives `bytes` (used immediately below) with no concurrent mutation (single-threaded local).
|
|
let bytes = unsafe {
|
|
std::slice::from_raw_parts(
|
|
&ev as *const _ as *const u8,
|
|
std::mem::size_of::<InputEventRaw>(),
|
|
)
|
|
};
|
|
// Best-effort: a full kernel queue drops the event; the next frame re-syncs state.
|
|
// SAFETY: `self.fd` is the live uinput `OwnedFd` (borrowed via `as_raw_fd`, so it stays open for
|
|
// the call); `bytes` is the slice above backed by the still-live local `ev`. `write` only READS
|
|
// exactly `bytes.len()` bytes from `bytes.as_ptr()` (in bounds) and retains nothing past return,
|
|
// so the buffer outlives the synchronous call and the read-only access cannot race or alias.
|
|
let _ = unsafe {
|
|
libc::write(
|
|
self.fd.as_raw_fd(),
|
|
bytes.as_ptr() as *const libc::c_void,
|
|
bytes.len(),
|
|
)
|
|
};
|
|
}
|
|
|
|
/// Apply one decoded frame: button state, axes, D-pad hat, one SYN_REPORT.
|
|
pub fn apply(&mut self, f: &GamepadFrame) {
|
|
// Re-assert every mapped button's absolute state each frame — exactly like the axes below —
|
|
// instead of only writing XOR-changed edges. `emit` is best-effort (a full kernel queue drops
|
|
// the write), so an edge-only scheme would strand a dropped press/release until that button
|
|
// next toggles; re-asserting re-syncs it on the following frame. Restating an unchanged key is
|
|
// free downstream: the kernel input core discards an EV_KEY whose value already matches the
|
|
// device's current state (no duplicate event reaches consumers, and BTN_* keys don't autorepeat).
|
|
for (bit, key) in BUTTON_MAP {
|
|
self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32);
|
|
}
|
|
|
|
// Moonlight: +Y = up; evdev: +Y = down → negate (i32 math avoids -(-32768) overflow).
|
|
self.emit(EV_ABS, ABS_X, f.ls_x as i32);
|
|
self.emit(EV_ABS, ABS_Y, -(f.ls_y as i32));
|
|
self.emit(EV_ABS, ABS_RX, f.rs_x as i32);
|
|
self.emit(EV_ABS, ABS_RY, -(f.rs_y as i32));
|
|
self.emit(EV_ABS, ABS_Z, f.left_trigger as i32);
|
|
self.emit(EV_ABS, ABS_RZ, f.right_trigger as i32);
|
|
let hat_x = ((f.buttons & gamepad::BTN_DPAD_RIGHT != 0) as i32)
|
|
- ((f.buttons & gamepad::BTN_DPAD_LEFT != 0) as i32);
|
|
let hat_y = ((f.buttons & gamepad::BTN_DPAD_DOWN != 0) as i32)
|
|
- ((f.buttons & gamepad::BTN_DPAD_UP != 0) as i32);
|
|
self.emit(EV_ABS, ABS_HAT0X, hat_x);
|
|
self.emit(EV_ABS, ABS_HAT0Y, hat_y);
|
|
self.emit(EV_SYN, SYN_REPORT, 0);
|
|
}
|
|
|
|
/// Service the FF protocol on this pad's fd (non-blocking). Returns the new mixed
|
|
/// `(low, high)` motor levels if they changed since last call.
|
|
fn pump_ff(&mut self) -> Option<(u16, u16)> {
|
|
let raw = self.fd.as_raw_fd();
|
|
let mut buf = [0u8; std::mem::size_of::<InputEventRaw>()];
|
|
loop {
|
|
// SAFETY: `raw` is the live raw fd of `self.fd` (the non-blocking uinput device). `buf` is a
|
|
// live local `[u8; size_of::<InputEventRaw>()]`; `buf.as_mut_ptr()` is a valid writable pointer
|
|
// to its `buf.len()` bytes. `read` writes AT MOST `buf.len()` bytes (in bounds), the buffer
|
|
// outlives this synchronous call, and `buf` is borrowed uniquely here (no alias/race).
|
|
let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
|
|
if n != buf.len() as isize {
|
|
break; // EAGAIN / short read — queue drained
|
|
}
|
|
// SAFETY: `buf` is exactly `size_of::<InputEventRaw>()` bytes and fully written by the
|
|
// `read` above. `read_unaligned` (not `read`) because the `[u8]` buffer is 1-aligned but
|
|
// `InputEventRaw` needs 8 (it holds a `timeval`) — a plain `ptr::read` would be UB.
|
|
let ev: InputEventRaw =
|
|
unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) };
|
|
match (ev.type_, ev.code) {
|
|
(EV_UINPUT, UI_FF_UPLOAD) => {
|
|
self.ff.note_activity();
|
|
// SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two
|
|
// `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field
|
|
// (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid
|
|
// value — `request_id` is then set below and the rest filled by UI_BEGIN_FF_UPLOAD.
|
|
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
|
|
up.request_id = ev.value as u32;
|
|
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
|
let mut e = up.effect;
|
|
if e.id == -1 {
|
|
e.id = self.ff.next_effect_id;
|
|
self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1);
|
|
}
|
|
if e.type_ == FF_RUMBLE {
|
|
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
|
|
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
|
|
let slot = self.ff.effects.entry(e.id).or_insert(Effect {
|
|
strong: 0,
|
|
weak: 0,
|
|
playing: None,
|
|
replay_ms: 0,
|
|
});
|
|
slot.strong = strong;
|
|
slot.weak = weak;
|
|
slot.replay_ms = e.replay_length;
|
|
}
|
|
up.effect.id = e.id; // hand the assigned slot back to the kernel
|
|
up.retval = 0;
|
|
let _ = ioctl_ptr(raw, UI_END_FF_UPLOAD, &mut up, "UI_END_FF_UPLOAD");
|
|
}
|
|
}
|
|
(EV_UINPUT, UI_FF_ERASE) => {
|
|
self.ff.note_activity();
|
|
// SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`,
|
|
// `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid
|
|
// initialized value — `request_id` is set below and `effect_id` filled by the ioctl.
|
|
let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
|
|
er.request_id = ev.value as u32;
|
|
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
|
|
self.ff.effects.remove(&(er.effect_id as i16));
|
|
er.retval = 0;
|
|
let _ = ioctl_ptr(raw, UI_END_FF_ERASE, &mut er, "UI_END_FF_ERASE");
|
|
}
|
|
}
|
|
(EV_FF, FF_GAIN) => {
|
|
self.ff.note_activity();
|
|
self.ff.gain = (ev.value as u32).min(0xFFFF);
|
|
}
|
|
(EV_FF, code) => {
|
|
self.ff.note_activity();
|
|
if let Some(e) = self.ff.effects.get_mut(&(code as i16)) {
|
|
e.playing = if ev.value != 0 {
|
|
Some((e.replay_ms > 0).then(|| {
|
|
Instant::now()
|
|
+ std::time::Duration::from_millis(e.replay_ms as u64)
|
|
}))
|
|
} else {
|
|
None
|
|
};
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
self.ff
|
|
.mix(Instant::now(), crate::uhid_manager::rumble_idle_timeout())
|
|
}
|
|
}
|
|
|
|
impl Drop for VirtualPad {
|
|
fn drop(&mut self) {
|
|
// SAFETY: `self.fd` is still the live owned uinput fd here (the `OwnedFd` field is closed only
|
|
// AFTER this `drop` body returns), borrowed by `as_raw_fd`. UI_DEV_DESTROY takes its argument
|
|
// (0) BY VALUE, so nothing is dereferenced or aliased; the ioctl just tears down the device.
|
|
let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) };
|
|
}
|
|
}
|
|
|
|
/// All virtual pads of a session, driven from decoded controller events. Stateless per frame
|
|
/// (uinput/evdev holds last-known state kernel-side), so it rides [`PadSlots`] directly — no state
|
|
/// vec, heartbeat, or rich plane like the UHID managers.
|
|
pub struct GamepadManager {
|
|
slots: PadSlots<VirtualPad>,
|
|
/// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when
|
|
/// the client asked for `XboxOne`). All pads in a session share one identity.
|
|
identity: PadIdentity,
|
|
}
|
|
|
|
impl Default for GamepadManager {
|
|
fn default() -> GamepadManager {
|
|
GamepadManager::new()
|
|
}
|
|
}
|
|
|
|
impl GamepadManager {
|
|
/// A manager that creates X-Box 360 pads (the universal default).
|
|
pub fn new() -> GamepadManager {
|
|
GamepadManager::with_identity(PadIdentity::xbox360())
|
|
}
|
|
|
|
/// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]).
|
|
pub fn with_identity(identity: PadIdentity) -> GamepadManager {
|
|
GamepadManager {
|
|
slots: PadSlots::new(identity.log, "gamepad", ""),
|
|
identity,
|
|
}
|
|
}
|
|
|
|
/// Handle one decoded controller event (create/destroy by mask, then apply state).
|
|
pub fn handle(&mut self, ev: &punktfunk_core::input::GamepadEvent) {
|
|
use punktfunk_core::input::GamepadEvent;
|
|
match ev {
|
|
GamepadEvent::Arrival { index, kind, .. } => {
|
|
tracing::info!(index, kind, "controller arrival ({})", self.slots.label());
|
|
self.ensure(*index as usize);
|
|
}
|
|
GamepadEvent::State(f) => {
|
|
let idx = f.index as usize;
|
|
if idx >= MAX_PADS {
|
|
return;
|
|
}
|
|
// Unplugs: drop any allocated pad whose mask bit cleared (no per-index sibling
|
|
// state to reset — the pads mix rumble internally).
|
|
self.slots.sweep(f.active_mask);
|
|
if f.active_mask & (1 << idx) == 0 {
|
|
return; // this event WAS the unplug
|
|
}
|
|
self.ensure(idx);
|
|
if let Some(pad) = self.slots.get_mut(idx) {
|
|
pad.apply(f);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ensure(&mut self, idx: usize) {
|
|
let identity = self.identity;
|
|
// `VirtualPad::create` logs its own success line (it knows the identity + transport).
|
|
self.slots
|
|
.ensure(idx, |i| VirtualPad::create(i as usize, identity));
|
|
}
|
|
|
|
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
|
|
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
|
|
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
|
for (i, pad) in self.slots.iter_mut() {
|
|
if let Some((low, high)) = pad.pump_ff() {
|
|
send(i as u16, low, high);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
/// The FF-capable evdev node whose input-device name contains `name`.
|
|
fn find_ff_node(name: &str) -> Option<String> {
|
|
let s = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
|
let mut cur = String::new();
|
|
let mut node = None;
|
|
for line in s.lines() {
|
|
if let Some(n) = line.strip_prefix("N: Name=") {
|
|
cur = n.trim_matches('"').to_string();
|
|
} else if let Some(h) = line.strip_prefix("H: Handlers=") {
|
|
if cur.contains(name) {
|
|
node = h
|
|
.split_whitespace()
|
|
.find(|t| t.starts_with("event"))
|
|
.map(|ev| format!("/dev/input/{ev}"));
|
|
}
|
|
} else if line.starts_with("B: FF=")
|
|
&& cur.contains(name)
|
|
&& node.is_some()
|
|
&& !line.trim_end().ends_with("FF=0")
|
|
{
|
|
return node;
|
|
}
|
|
}
|
|
node
|
|
}
|
|
|
|
/// Upload + play an FF_RUMBLE like SDL's evdev haptic backend. Returns the OPEN fd (closing
|
|
/// it erases the process's effects, stopping the rumble) with the kernel-assigned id.
|
|
/// NOTE: EVIOCSFF BLOCKS until the uinput owner answers UI_FF_UPLOAD — the caller must be a
|
|
/// separate thread from the one running [`VirtualPad::pump_ff`], exactly like a real game vs
|
|
/// the host input loop.
|
|
fn evdev_rumble(node: &str, strong: u16, weak: u16) -> std::io::Result<(std::fs::File, i16)> {
|
|
use std::io::Write as _;
|
|
let mut f = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.open(node)?;
|
|
let mut eff = [0u8; 48]; // struct ff_effect; union (rumble magnitudes) at offset 16
|
|
eff[0..2].copy_from_slice(&FF_RUMBLE.to_ne_bytes());
|
|
eff[2..4].copy_from_slice(&(-1i16).to_ne_bytes()); // id: kernel assigns
|
|
eff[10..12].copy_from_slice(&5000u16.to_ne_bytes()); // replay.length ms
|
|
eff[16..18].copy_from_slice(&strong.to_ne_bytes());
|
|
eff[18..20].copy_from_slice(&weak.to_ne_bytes());
|
|
// EVIOCSFF = _IOW('E', 0x80, struct ff_effect)
|
|
let req: libc::c_ulong = (1 << 30) | (48 << 16) | (0x45 << 8) | 0x80;
|
|
// SAFETY: EVIOCSFF reads/writes the 48-byte ff_effect behind the valid fd `f`; `eff` is
|
|
// exactly sizeof(struct ff_effect) and outlives the synchronous call.
|
|
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, eff.as_mut_ptr()) };
|
|
if rc < 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
let id = i16::from_ne_bytes([eff[2], eff[3]]);
|
|
let mut ev = [0u8; 24]; // struct input_event: timeval 16, type u16, code u16, value s32
|
|
ev[16..18].copy_from_slice(&EV_FF.to_ne_bytes());
|
|
ev[18..20].copy_from_slice(&(id as u16).to_ne_bytes());
|
|
ev[20..24].copy_from_slice(&1i32.to_ne_bytes()); // play
|
|
f.write_all(&ev)?;
|
|
Ok((f, id))
|
|
}
|
|
|
|
/// On-box proof of the uinput FF back-channel, playing the GAME's role: an evdev FF_RUMBLE
|
|
/// upload+play against the virtual X-Box 360 pad must surface through `pump_ff` (the
|
|
/// EV_UINPUT UI_FF_UPLOAD protocol) — the path every `auto`-kind session's rumble rides on
|
|
/// Linux — and erasing the effect (fd close) must surface the stop.
|
|
#[test]
|
|
#[ignore = "creates a real /dev/uinput device; needs the input group"]
|
|
fn ff_upload_reaches_pump_and_stops_on_erase() {
|
|
let mut pad = VirtualPad::create(0, PadIdentity::xbox360()).expect("create uinput pad");
|
|
std::thread::sleep(Duration::from_millis(700)); // let udev settle the node
|
|
let node = find_ff_node("Microsoft X-Box 360 pad").expect("no X-Box 360 evdev node");
|
|
let game = std::thread::spawn(move || {
|
|
let r = evdev_rumble(&node, 0xC000, 0x4000);
|
|
std::thread::sleep(Duration::from_millis(1200)); // hold the effect, then erase
|
|
r.expect("EVIOCSFF/play (fd held meanwhile)");
|
|
});
|
|
let start = Instant::now();
|
|
let mut seen = Vec::new();
|
|
while start.elapsed() < Duration::from_millis(2500) {
|
|
if let Some(mix) = pad.pump_ff() {
|
|
seen.push(mix);
|
|
}
|
|
std::thread::sleep(Duration::from_millis(4));
|
|
}
|
|
game.join().unwrap();
|
|
// Requested magnitudes scaled by the 0xFFFF default gain (>> 16).
|
|
assert!(
|
|
seen.contains(&(0xBFFF, 0x3FFF)),
|
|
"evdev FF rumble never surfaced through pump_ff: {seen:?}"
|
|
);
|
|
assert_eq!(
|
|
seen.last(),
|
|
Some(&(0, 0)),
|
|
"erase-on-close never produced a stop mix: {seen:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod ff_state_tests {
|
|
use super::*;
|
|
|
|
/// The default idle window the shared hatch resolves to when the env is unset.
|
|
const IDLE: Option<Duration> = Some(Duration::from_millis(2500));
|
|
|
|
/// `gain` is 0xFFFF (not a true 1.0 multiplier), so a magnitude loses 1 LSB in the mixdown.
|
|
fn scaled(v: u16) -> u16 {
|
|
((v as u32 * 0xFFFF) >> 16) as u16
|
|
}
|
|
|
|
fn ff_with(effect: Effect) -> FfState {
|
|
let mut ff = FfState::new();
|
|
ff.effects.insert(0, effect);
|
|
ff
|
|
}
|
|
|
|
#[test]
|
|
fn abandoned_infinite_effect_is_forced_off_after_idle_window() {
|
|
let mut ff = ff_with(Effect {
|
|
strong: 0x8000,
|
|
weak: 0,
|
|
playing: Some(None),
|
|
replay_ms: 0,
|
|
});
|
|
let now = Instant::now();
|
|
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
|
assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing
|
|
// The game goes silent on the FF plane past the idle window: cut, exactly once.
|
|
ff.last_activity = now - Duration::from_millis(2600);
|
|
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
|
|
assert_eq!(ff.mix(now, IDLE), None); // already off — no repeat
|
|
}
|
|
|
|
#[test]
|
|
fn finite_effect_honors_its_replay_deadline_not_the_idle_window() {
|
|
let now = Instant::now();
|
|
let mut ff = ff_with(Effect {
|
|
strong: 0x4000,
|
|
weak: 0,
|
|
playing: Some(Some(now + Duration::from_secs(10))),
|
|
replay_ms: 10_000,
|
|
});
|
|
// FF plane long stale, but the effect declared a finite replay — the declared duration is
|
|
// the contract (a real pad honors it too), so it keeps playing…
|
|
ff.last_activity = now - Duration::from_secs(60);
|
|
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x4000), 0)));
|
|
// …and expires at its own deadline.
|
|
assert_eq!(ff.mix(now + Duration::from_secs(11), IDLE), Some((0, 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn replay_after_cut_rearms_the_effect() {
|
|
let now = Instant::now();
|
|
let mut ff = ff_with(Effect {
|
|
strong: 0x8000,
|
|
weak: 0,
|
|
playing: Some(None),
|
|
replay_ms: 0,
|
|
});
|
|
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
|
ff.last_activity = now - Duration::from_millis(3000);
|
|
assert_eq!(ff.mix(now, IDLE), Some((0, 0)));
|
|
// The game plays the effect again — an FF event refreshes the clock and re-arms playback.
|
|
ff.last_activity = now;
|
|
ff.effects.get_mut(&0).unwrap().playing = Some(None);
|
|
assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_watchdog_never_cuts() {
|
|
let now = Instant::now();
|
|
let mut ff = ff_with(Effect {
|
|
strong: 0x8000,
|
|
weak: 0,
|
|
playing: Some(None),
|
|
replay_ms: 0,
|
|
});
|
|
ff.last_activity = now - Duration::from_secs(600);
|
|
assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0)));
|
|
}
|
|
}
|