ba68a98873
Continues the structural unsafe-proof program (every unsafe carries a documented
proof of soundness; the file gains #![deny(clippy::undocumented_unsafe_blocks)]
so it stays proven). This batch covers all 10 remaining pure-Linux files
(104 blocks), each proof stating the REAL invariant — not boilerplate:
zerocopy/cuda.rs (26) leaked process-lifetime libcuda fn-ptr table; opaque
CUcontext never dereferenced; free-exactly-once via the
Arc<Mutex<PoolInner>> ownership graph; dmabuf fd take/close split
zerocopy/egl.rs (18) eglGetProcAddress'd procs with the GL context current;
EGLImage liveness; the two-call modifier-query bounds
zerocopy/vulkan.rs (4) copy-bounds arithmetic (src_size>=span); Send = thread
confinement to the punktfunk-pipewire thread
dmabuf_fence.rs (4) poll/ioctl/close fd liveness + ownership
capture/linux/mod.rs (16) spa_data repr(transparent) cast; null-checked spa
derefs; single-loop-thread buffer ownership until requeue
inject/linux/gamepad.rs (10) uinput ioctl request-number ↔ struct-size match
(static-asserted); InputEventRaw no-padding for the byte cast
encode/linux/vaapi.rs (15) + encode/linux/mod.rs (9) ffmpeg object ownership/
free ladders; VAAPI/DRM graph; Send = single-thread transfer
inject/linux/wlr.rs (2), vdisplay/linux/kwin.rs (1)
No memory-unsafety SUSPECT blocks were found — the unsafe is sound. The vaapi
agent did flag two real AVBufferRef *leaks* (not UB) in DmabufInner::open; marked
inline with NOTE(leak) and addressed in a follow-up.
Verified: cargo clippy -p punktfunk-host --all-targets -- -D warnings is clean
(each file's deny gate hard-errors on any undocumented block).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
624 lines
24 KiB
Rust
624 lines
24 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`](crate::gamestream::gamepad::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::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
|
|
use anyhow::{bail, Result};
|
|
use std::collections::HashMap;
|
|
use std::os::fd::{AsRawFd, OwnedFd};
|
|
use std::time::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;
|
|
|
|
/// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead.
|
|
const BUTTON_MAP: [(u32, u16); 11] = [
|
|
(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_CLK, BTN_THUMBL),
|
|
(gamepad::BTN_RS_CLK, BTN_THUMBR),
|
|
];
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// One virtual X-Box-360 pad backed by a uinput device.
|
|
pub struct VirtualPad {
|
|
fd: OwnedFd,
|
|
prev_buttons: u32,
|
|
effects: HashMap<i16, Effect>,
|
|
next_effect_id: i16,
|
|
gain: u32,
|
|
/// Last `(low, high)` reported, to dedup.
|
|
last_mix: (u16, u16),
|
|
}
|
|
|
|
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,
|
|
prev_buttons: 0,
|
|
effects: HashMap::new(),
|
|
next_effect_id: 0,
|
|
gain: 0xFFFF,
|
|
last_mix: (0, 0),
|
|
})
|
|
}
|
|
|
|
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 transitions, axes, D-pad hat, one SYN_REPORT.
|
|
pub fn apply(&mut self, f: &GamepadFrame) {
|
|
let changed = self.prev_buttons ^ f.buttons;
|
|
for (bit, key) in BUTTON_MAP {
|
|
if changed & bit != 0 {
|
|
self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32);
|
|
}
|
|
}
|
|
self.prev_buttons = f.buttons;
|
|
|
|
// 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) => {
|
|
// 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.next_effect_id;
|
|
self.next_effect_id = self.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.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) => {
|
|
// 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.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.gain = (ev.value as u32).min(0xFFFF),
|
|
(EV_FF, code) => {
|
|
if let Some(e) = self.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
|
|
};
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Mix: sum playing effects (expiring finished ones), scale by gain.
|
|
let now = Instant::now();
|
|
let (mut strong, mut weak) = (0u32, 0u32);
|
|
for e in self.effects.values_mut() {
|
|
if let Some(deadline) = e.playing {
|
|
if deadline.is_some_and(|d| now >= d) {
|
|
e.playing = None;
|
|
} else {
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
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.
|
|
#[derive(Default)]
|
|
pub struct GamepadManager {
|
|
pads: Vec<Option<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,
|
|
/// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events.
|
|
broken: bool,
|
|
}
|
|
|
|
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 {
|
|
pads: (0..MAX_PADS).map(|_| None).collect(),
|
|
identity,
|
|
broken: false,
|
|
}
|
|
}
|
|
|
|
/// Handle one decoded controller event (create/destroy by mask, then apply state).
|
|
pub fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
|
|
use crate::gamestream::gamepad::GamepadEvent;
|
|
match ev {
|
|
GamepadEvent::Arrival { index, kind, .. } => {
|
|
tracing::info!(index, kind, "controller arrival");
|
|
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.
|
|
for (i, slot) in self.pads.iter_mut().enumerate() {
|
|
if slot.is_some() && f.active_mask & (1 << i) == 0 {
|
|
tracing::info!(index = i, "controller unplugged");
|
|
*slot = None;
|
|
}
|
|
}
|
|
if f.active_mask & (1 << idx) == 0 {
|
|
return; // this event WAS the unplug
|
|
}
|
|
self.ensure(idx);
|
|
if let Some(pad) = self.pads[idx].as_mut() {
|
|
pad.apply(f);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ensure(&mut self, idx: usize) {
|
|
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
|
return;
|
|
}
|
|
match VirtualPad::create(idx, self.identity) {
|
|
Ok(p) => self.pads[idx] = Some(p),
|
|
Err(e) => {
|
|
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled");
|
|
self.broken = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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, slot) in self.pads.iter_mut().enumerate() {
|
|
if let Some(pad) = slot {
|
|
if let Some((low, high)) = pad.pump_ff() {
|
|
send(i as u16, low, high);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|