refactor(windows-host): confine platform code under windows/ + linux/ folders (Goal-1 stage 6)
Move 36 platform-specific files into per-module `windows/` and `linux/` subfolders (and the
shared HID codecs into `inject/proto/`):
capture/{windows,linux}/ encode/{windows,linux}/ inject/{windows,linux,proto}/
audio/{windows,linux}/ vdisplay/{windows,linux}/
src/windows/ (service, wgc_helper, win_adapter, win_display)
src/linux/ (dmabuf_fence, drm_sync, zerocopy/)
Done with `#[path]`, NOT a module rename: every file moves into its folder while the
`crate::*::*` module names stay FLAT, so all caller paths and every internal `super::`/`crate::`
reference are unchanged — only the parent `mod` decls gained `#[path = "..."]`. This is the
codebase's existing pattern (inject's gamepad_windows) and makes the move byte-identical in
behaviour with ZERO reference churn, far lower risk than collapsing to a single
`crate::capture::windows::` namespace (that deeper rename is an optional follow-on; this delivers
the cfg-sprawl folder confinement the stage is about). Done LAST, after the semantic stages, so
the path churn didn't fight them.
Verified: Linux cargo check + clippy (-D warnings) clean; my mod-decl changes fmt-clean (the 3
remaining fmt diffs are pre-existing local-rustfmt-version skew that moved with their files); all
36 `#[path]` targets exist; no internal `#[path]`/`include!`/file-child-mod in any moved file
(the inline `mod X {` blocks are self-contained). Box build to follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
//! Virtual Sony DualSense via UHID — the rich-controller path (roadmap §5).
|
||||
//!
|
||||
//! Unlike the uinput X-Box-360 pad ([`super::gamepad`]), which only carries buttons + axes + a
|
||||
//! rumble back-channel, a UHID device presents a *real* DualSense HID interface to the kernel:
|
||||
//! `hid-playstation` binds it (matched by VID `054C`/PID `0CE6`) and exposes the full controller
|
||||
//! — gamepad, motion sensors, touchpad, lightbar + player LEDs, and adaptive triggers — to games.
|
||||
//! The host writes HID **input** reports (report `0x01`, our controller state) and reads HID
|
||||
//! **output** reports (report `0x02`, a game's rumble/LED/trigger feedback) back, which it
|
||||
//! forwards to the client as [`punktfunk_core::quic::HidOutput`].
|
||||
//!
|
||||
//! The transport-independent contract (report descriptor, feature blobs, [`DsState`], the `0x01`
|
||||
//! serializer and `0x02` parser) lives in [`super::dualsense_proto`], shared with the Windows
|
||||
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
|
||||
|
||||
use super::dualsense_proto::{
|
||||
parse_ds_output, serialize_state, DsFeedback, DsState, DS_FEATURE_CALIBRATION,
|
||||
DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H,
|
||||
DS_TOUCH_W, DS_VENDOR, DUALSENSE_RDESC,
|
||||
};
|
||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a
|
||||
// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372).
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual DualSense backed by `/dev/uhid` (hand-rolled codec — no bindgen, mirroring the
|
||||
/// uinput pad's style). Dropping it destroys the device (the kernel tears down the bound
|
||||
/// `hid-playstation` interface).
|
||||
pub struct DualSensePad {
|
||||
fd: File,
|
||||
seq: u8,
|
||||
ts: u32,
|
||||
}
|
||||
|
||||
impl DualSensePad {
|
||||
/// Create the UHID DualSense for pad `index` (used only to make the device name/uniq unique).
|
||||
pub fn open(index: u8) -> Result<DualSensePad> {
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(UHID_PATH)
|
||||
.with_context(|| {
|
||||
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
||||
})?;
|
||||
let mut ds = DualSensePad { fd, seq: 0, ts: 0 };
|
||||
ds.send_create2(index).context("UHID_CREATE2 DualSense")?;
|
||||
Ok(ds)
|
||||
}
|
||||
|
||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
// union (uhid_create2_req) starts at byte 4.
|
||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualSense {index}")); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualsense/{index}")); // phys[64]
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds-{index}")); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(DUALSENSE_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
||||
ev[264..268].copy_from_slice(&DS_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&DS_PRODUCT.to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + DUALSENSE_RDESC.len()].copy_from_slice(DUALSENSE_RDESC); // rd_data
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2).
|
||||
pub fn write_state(&mut self, st: &DsState) -> Result<()> {
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
self.ts = self.ts.wrapping_add(1); // monotonic sensor timestamp is all the kernel needs
|
||||
let mut r = [0u8; DS_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.seq, self.ts);
|
||||
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size
|
||||
ev[6..6 + r.len()].copy_from_slice(&r); // input2.data
|
||||
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (calibration
|
||||
/// / pairing / firmware — required during `hid-playstation` init, or no input devices appear)
|
||||
/// and parse any HID OUTPUT reports (rumble / lightbar / player LEDs / adaptive triggers) into
|
||||
/// a [`DsFeedback`] for pad `pad`. Call frequently — especially right after [`open`] so the
|
||||
/// init handshake completes. The fd is `O_NONBLOCK`, so once drained `read` returns `WouldBlock`.
|
||||
pub fn service(&mut self, pad: u8) -> DsFeedback {
|
||||
let mut fb = DsFeedback::default();
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
while let Ok(n) = self.fd.read(&mut ev) {
|
||||
if n < UHID_EVENT_SIZE {
|
||||
break;
|
||||
}
|
||||
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
|
||||
UHID_OUTPUT => {
|
||||
// uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102].
|
||||
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
||||
parse_ds_output(pad, &ev[4..end], &mut fb);
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let data: &[u8] = match ev[8] {
|
||||
0x05 => DS_FEATURE_CALIBRATION,
|
||||
0x09 => DS_FEATURE_PAIRING,
|
||||
0x20 => DS_FEATURE_FIRMWARE,
|
||||
_ => &[],
|
||||
};
|
||||
let _ = self.reply_get_report(id, data);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
||||
}
|
||||
}
|
||||
fb
|
||||
}
|
||||
|
||||
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
// uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12], data [12..].
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
let err: u16 = if data.is_empty() { 5 } else { 0 }; // EIO if we don't know the report
|
||||
ev[8..10].copy_from_slice(&err.to_ne_bytes());
|
||||
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
|
||||
ev[12..12 + data.len()].copy_from_slice(data);
|
||||
self.fd
|
||||
.write_all(&ev)
|
||||
.context("write UHID_GET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DualSensePad {
|
||||
fn drop(&mut self) {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||
let _ = self.fd.write_all(&ev);
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualSense pads of a session — the rich-controller analog of
|
||||
/// [`GamepadManager`](super::gamepad::GamepadManager), selected with `PUNKTFUNK_GAMEPAD=dualsense`.
|
||||
///
|
||||
/// Unlike the uinput pad, a DualSense carries touchpad + motion, which arrive on a *separate*
|
||||
/// rich-input plane ([`apply_rich`](Self::apply_rich)) from the button/stick frames
|
||||
/// ([`handle`](Self::handle)). So the manager keeps each pad's full [`DsState`] and re-emits the
|
||||
/// merged report whenever either source changes. [`pump`](Self::pump) services the kernel
|
||||
/// handshake and routes a game's feedback back out: motor rumble on the universal plane, the rich
|
||||
/// LED/player-LED/trigger feedback on the HID-output plane.
|
||||
pub struct DualSenseManager {
|
||||
pads: Vec<Option<DualSensePad>>,
|
||||
/// Each pad's current full report — buttons/sticks merged with persisted touch + motion.
|
||||
state: Vec<DsState>,
|
||||
/// Last rumble forwarded per pad, so a report that only changes the LED doesn't re-send it.
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
/// When each pad last wrote an input report — drives [`DualSenseManager::heartbeat`], which
|
||||
/// re-emits the current state during input silence so the kernel never sees the device go quiet.
|
||||
last_write: Vec<Instant>,
|
||||
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl Default for DualSenseManager {
|
||||
fn default() -> DualSenseManager {
|
||||
DualSenseManager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DualSenseManager {
|
||||
pub fn new() -> DualSenseManager {
|
||||
DualSenseManager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
state: vec![DsState::neutral(); MAX_PADS],
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival (DualSense)");
|
||||
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, resetting its state.
|
||||
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 (DualSense)");
|
||||
*slot = None;
|
||||
self.state[i] = DsState::neutral();
|
||||
self.last_rumble[i] = (0, 0);
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
self.ensure(idx);
|
||||
// Merge buttons/sticks/triggers from the frame, preserving touch + motion (those
|
||||
// come on the rich-input plane and must survive a button-only frame).
|
||||
let prev = self.state[idx];
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad,
|
||||
/// preserving its button/stick state. Rich events never create a pad (a controller must have
|
||||
/// arrived first); they're dropped if the pad isn't present.
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// The DualSense touchpad carries two contacts; clamp to a valid slot and keep the
|
||||
// reported contact id consistent with it (the wire `finger` is untrusted).
|
||||
let slot = (finger as usize).min(1);
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = active;
|
||||
t.id = slot as u8;
|
||||
// Normalized 0..=65535 → the touchpad's coordinate range (0..=W-1 / 0..=H-1,
|
||||
// what the kernel advertises as the ABS_MT extents).
|
||||
t.x = ((x as u32 * (DS_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * (DS_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
}
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
fn write(&mut self, idx: usize) {
|
||||
let st = self.state[idx];
|
||||
if let Some(pad) = self.pads[idx].as_mut() {
|
||||
let _ = pad.write_state(&st);
|
||||
}
|
||||
// Reset the heartbeat timer on every write (real input or heartbeat), so an actively-used
|
||||
// pad emits no extra reports — the heartbeat only fills genuine input-silence gaps.
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
|
||||
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap`. A real DualSense
|
||||
/// streams report `0x01` continuously (~250 Hz); the kernel `hid-playstation` driver / Proton /
|
||||
/// SDL treat a multi-second silence (a held-steady stick produces no wire events) as an
|
||||
/// unplugged controller — the "controller disconnected every few seconds" symptom. Re-sending
|
||||
/// the current state is idempotent (a stale-but-correct frame, never a phantom input);
|
||||
/// `write_state` bumps the report's seq + timestamp, so each is a fresh, well-formed report.
|
||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||
let now = Instant::now();
|
||||
for i in 0..self.pads.len() {
|
||||
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
|
||||
self.write(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
||||
return;
|
||||
}
|
||||
match DualSensePad::open(idx as u8) {
|
||||
Ok(p) => {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualSense created (UHID hid-playstation)"
|
||||
);
|
||||
self.pads[idx] = Some(p);
|
||||
self.state[idx] = DsState::neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual DualSense creation failed — controller input disabled");
|
||||
self.broken = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service every pad: answer the kernel's init handshake and parse a game's feedback. `rumble`
|
||||
/// is invoked `(index, low, high)` only when the motor level *changes* (the universal 0xCA
|
||||
/// plane — both backends use it); `hidout` is invoked for each DualSense-only rich feedback
|
||||
/// event (lightbar / player LEDs / adaptive triggers — the 0xCD plane). Call frequently:
|
||||
/// the kernel blocks `hid-playstation` init until its GET_REPORTs are answered.
|
||||
pub fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
for i in 0..self.pads.len() {
|
||||
let Some(pad) = self.pads[i].as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let fb = pad.service(i as u8);
|
||||
if let Some(r) = fb.rumble {
|
||||
if self.last_rumble[i] != r {
|
||||
self.last_rumble[i] = r;
|
||||
rumble(i as u16, r.0, r.1);
|
||||
}
|
||||
}
|
||||
for h in fb.hidout {
|
||||
hidout(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
//! Virtual Sony DualShock 4 (PS4) via UHID — the PS4 sibling of the DualSense backend
|
||||
//! ([`super::dualsense`]). A UHID device presents a *real* DualShock 4 HID interface to the kernel:
|
||||
//! `hid-playstation` binds it (matched by VID `054C`/PID `09CC`, since Linux 6.2) and exposes the
|
||||
//! full controller — gamepad, motion sensors, touchpad, lightbar, rumble — to games. We write HID
|
||||
//! **input** reports (report `0x01`, our controller state) and read HID **output** reports (report
|
||||
//! `0x05`, a game's rumble/lightbar feedback) back, forwarding them to the client.
|
||||
//!
|
||||
//! It carries everything the DualSense does *except* adaptive triggers, player LEDs and the mute
|
||||
//! button (the DS4 hardware has none), so the only feedback it surfaces is motor rumble (universal
|
||||
//! 0xCA plane) and the lightbar (HID-output 0xCD `Led`). The button/stick/dpad/touchpad mapping is
|
||||
//! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; only the
|
||||
//! report *byte layout*, the report descriptor, the feature-report handshake and the touchpad
|
||||
//! resolution differ. The report descriptor + struct offsets are the canonical real-DS4-USB layout
|
||||
//! the kernel `struct dualshock4_input_report_usb` / `_output_report_common` parse.
|
||||
|
||||
use super::dualsense_proto::{DsState, Touch};
|
||||
use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is
|
||||
// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the
|
||||
// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are
|
||||
// non-fatal (the kernel warns + falls back to identity IMU calibration), but we answer them for
|
||||
// correct motion scaling. Each array's first byte is the report id (the kernel hard-checks it).
|
||||
#[rustfmt::skip]
|
||||
const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01)
|
||||
0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words)
|
||||
0x02,
|
||||
0x00, 0x00, // gyro_pitch_bias = 0
|
||||
0x00, 0x00, // gyro_yaw_bias = 0
|
||||
0x00, 0x00, // gyro_roll_bias = 0
|
||||
0x10, 0x00, // gyro_pitch_plus = +16
|
||||
0xF0, 0xFF, // gyro_pitch_minus = -16
|
||||
0x10, 0x00, // gyro_yaw_plus = +16
|
||||
0xF0, 0xFF, // gyro_yaw_minus = -16
|
||||
0x10, 0x00, // gyro_roll_plus = +16
|
||||
0xF0, 0xFF, // gyro_roll_minus = -16
|
||||
0x20, 0x00, // gyro_speed_plus = +32
|
||||
0x20, 0x00, // gyro_speed_minus = +32
|
||||
0x00, 0x20, // acc_x_plus = +8192
|
||||
0x00, 0xE0, // acc_x_minus = -8192
|
||||
0x00, 0x20, // acc_y_plus = +8192
|
||||
0x00, 0xE0, // acc_y_minus = -8192
|
||||
0x00, 0x20, // acc_z_plus = +8192
|
||||
0x00, 0xE0, // acc_z_minus = -8192
|
||||
0x00, 0x00, // trailing pad (descriptor declares 36 data bytes)
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
const DS4_FEATURE_FIRMWARE: &[u8] = &[ // report 0xa3 (build date string + hw/fw versions; cosmetic)
|
||||
0xA3, 0x41, 0x75, 0x67, 0x20, 0x20, 0x33, 0x20, 0x32, 0x30, 0x31, 0x33, // "Aug 3 2013"
|
||||
0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x30, 0x37, 0x3A, 0x30, 0x31, 0x3A, 0x31, 0x32, // "07:01:12"
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0xA0, // hw_version = 0xA000 (buf[35])
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x01, // fw_version = 0x0100 (buf[41])
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // trailing pad (buf[43..49]) → 49 bytes total
|
||||
];
|
||||
|
||||
/// Sony DualShock 4 v2 USB HID report descriptor (507 bytes) — a verbatim real-device capture
|
||||
/// (CUH-ZCT2E, `054C:09CC`). Declares input `0x01` (64 B), output `0x05` (32 B), and the feature
|
||||
/// reports `0x02`/`0x12`/`0xa3` so the kernel's GET_REPORTs route. The kernel binds DS4 by VID/PID,
|
||||
/// but HID core still needs these reports declared.
|
||||
#[rustfmt::skip]
|
||||
const DS4_RDESC: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
|
||||
0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
|
||||
0x04, 0x81, 0x02, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46,
|
||||
0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00,
|
||||
0x05, 0x09, 0x19, 0x01, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
|
||||
0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95,
|
||||
0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09,
|
||||
0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02,
|
||||
0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
|
||||
0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xB1,
|
||||
0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0x85, 0x08, 0x09,
|
||||
0x25, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x10, 0x09, 0x26, 0x95, 0x04, 0xB1,
|
||||
0x02, 0x85, 0x11, 0x09, 0x27, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x12, 0x06,
|
||||
0x02, 0xFF, 0x09, 0x21, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0x13, 0x09, 0x22,
|
||||
0x95, 0x16, 0xB1, 0x02, 0x85, 0x14, 0x06, 0x05, 0xFF, 0x09, 0x20, 0x95,
|
||||
0x10, 0xB1, 0x02, 0x85, 0x15, 0x09, 0x21, 0x95, 0x2C, 0xB1, 0x02, 0x06,
|
||||
0x80, 0xFF, 0x85, 0x80, 0x09, 0x20, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x81,
|
||||
0x09, 0x21, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x22, 0x95, 0x05,
|
||||
0xB1, 0x02, 0x85, 0x83, 0x09, 0x23, 0x95, 0x01, 0xB1, 0x02, 0x85, 0x84,
|
||||
0x09, 0x24, 0x95, 0x04, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x25, 0x95, 0x06,
|
||||
0xB1, 0x02, 0x85, 0x86, 0x09, 0x26, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x87,
|
||||
0x09, 0x27, 0x95, 0x23, 0xB1, 0x02, 0x85, 0x88, 0x09, 0x28, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0x89, 0x09, 0x29, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x90,
|
||||
0x09, 0x30, 0x95, 0x05, 0xB1, 0x02, 0x85, 0x91, 0x09, 0x31, 0x95, 0x03,
|
||||
0xB1, 0x02, 0x85, 0x92, 0x09, 0x32, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x93,
|
||||
0x09, 0x33, 0x95, 0x0C, 0xB1, 0x02, 0x85, 0x94, 0x09, 0x34, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xA0, 0x09, 0x40, 0x95, 0x06, 0xB1, 0x02, 0x85, 0xA1,
|
||||
0x09, 0x41, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA2, 0x09, 0x42, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xA3, 0x09, 0x43, 0x95, 0x30, 0xB1, 0x02, 0x85, 0xA4,
|
||||
0x09, 0x44, 0x95, 0x0D, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x47, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xF1, 0x09, 0x48, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2,
|
||||
0x09, 0x49, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0xA7, 0x09, 0x4A, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xA8, 0x09, 0x4B, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA9,
|
||||
0x09, 0x4C, 0x95, 0x08, 0xB1, 0x02, 0x85, 0xAA, 0x09, 0x4E, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xAB, 0x09, 0x4F, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAC,
|
||||
0x09, 0x50, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAD, 0x09, 0x51, 0x95, 0x0B,
|
||||
0xB1, 0x02, 0x85, 0xAE, 0x09, 0x52, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xAF,
|
||||
0x09, 0x53, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB0, 0x09, 0x54, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xE0, 0x09, 0x57, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB3,
|
||||
0x09, 0x55, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xB4, 0x09, 0x55, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xB5, 0x09, 0x56, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD0,
|
||||
0x09, 0x58, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD4, 0x09, 0x59, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
const DS4_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
|
||||
const DS4_PRODUCT: u32 = 0x09CC; // DualShock 4 v2 (CUH-ZCT2)
|
||||
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
|
||||
const DS4_INPUT_REPORT_LEN: usize = 64;
|
||||
/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower
|
||||
/// than the DualSense's 1920×1080.
|
||||
pub const DS4_TOUCH_W: u16 = 1920;
|
||||
pub const DS4_TOUCH_H: u16 = 942;
|
||||
|
||||
/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's:
|
||||
/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y).
|
||||
fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
|
||||
// Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1.
|
||||
let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1));
|
||||
dst[1] = (x & 0xFF) as u8;
|
||||
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
|
||||
dst[3] = ((y >> 4) & 0xFF) as u8;
|
||||
}
|
||||
|
||||
/// Serialize a full DS4 input report `0x01` (pure — unit-testable without `/dev/uhid`). Field
|
||||
/// offsets per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch;
|
||||
/// touch[3]; rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp;
|
||||
/// gyro[3] le16; accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common`
|
||||
/// field at struct offset N sits at report byte N+1.
|
||||
fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) {
|
||||
r[0] = 0x01; // report id
|
||||
r[1] = st.lx;
|
||||
r[2] = st.ly;
|
||||
r[3] = st.rx;
|
||||
r[4] = st.ry;
|
||||
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
|
||||
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
|
||||
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
|
||||
r[8] = st.l2; // L2 analog (z)
|
||||
r[9] = st.r2; // R2 analog (rz)
|
||||
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
|
||||
// r[12] temperature stays 0
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12
|
||||
}
|
||||
for (i, v) in st.accel.iter().enumerate() {
|
||||
r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18
|
||||
}
|
||||
// r[25..30] reserved2.
|
||||
// status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report
|
||||
// wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad.
|
||||
r[30] = 0x10 | 0x0B;
|
||||
// r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0.
|
||||
r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one)
|
||||
r[34] = ts as u8; // touch_reports[0].timestamp
|
||||
pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0
|
||||
pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1
|
||||
// remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero
|
||||
}
|
||||
|
||||
/// What one [`DualShock4Pad::service`] pass extracted from the device's HID output reports. Rumble
|
||||
/// rides the universal 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player
|
||||
/// LEDs or adaptive triggers, so those never appear).
|
||||
#[derive(Default)]
|
||||
pub struct Ds4Feedback {
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Lightbar RGB, if the report carried it (deduped by the manager).
|
||||
pub led: Option<(u8, u8, u8)>,
|
||||
}
|
||||
|
||||
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
|
||||
/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1],
|
||||
/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5],
|
||||
/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write
|
||||
/// doesn't masquerade as a lightbar change.
|
||||
fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) {
|
||||
if data.first() != Some(&0x05) || data.len() < 11 {
|
||||
return; // not the USB output report (BT 0x11 is shifted) / too short
|
||||
}
|
||||
let flag0 = data[1];
|
||||
if flag0 & 0x01 != 0 {
|
||||
// motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4];
|
||||
// scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends.
|
||||
let low = (data[5] as u16) << 8;
|
||||
let high = (data[4] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
}
|
||||
if flag0 & 0x02 != 0 {
|
||||
fb.led = Some((data[6], data[7], data[8]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's).
|
||||
/// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface).
|
||||
pub struct DualShock4Pad {
|
||||
fd: File,
|
||||
counter: u8,
|
||||
ts: u16,
|
||||
}
|
||||
|
||||
impl DualShock4Pad {
|
||||
/// Create the UHID DualShock 4 for pad `index` (used only to make the device name/uniq unique).
|
||||
pub fn open(index: u8) -> Result<DualShock4Pad> {
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(UHID_PATH)
|
||||
.with_context(|| {
|
||||
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
||||
})?;
|
||||
let mut ds = DualShock4Pad {
|
||||
fd,
|
||||
counter: 0,
|
||||
ts: 0,
|
||||
};
|
||||
ds.send_create2(index).context("UHID_CREATE2 DualShock4")?;
|
||||
Ok(ds)
|
||||
}
|
||||
|
||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
// union (uhid_create2_req) starts at byte 4.
|
||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualShock 4 {index}")); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualshock4/{index}")); // phys[64]
|
||||
|
||||
// A unique uniq[64] keeps the sysfs nodes tidy when several pads coexist (the kernel's
|
||||
// duplicate-device check itself keys off the per-pad MAC in the pairing feature report).
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(DS4_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
||||
ev[264..268].copy_from_slice(&DS4_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&DS4_PRODUCT.to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + DS4_RDESC.len()].copy_from_slice(DS4_RDESC); // rd_data
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2).
|
||||
pub fn write_state(&mut self, st: &DsState) -> Result<()> {
|
||||
self.counter = self.counter.wrapping_add(1);
|
||||
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.counter, self.ts);
|
||||
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size
|
||||
ev[6..6 + r.len()].copy_from_slice(&r); // input2.data
|
||||
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (pairing /
|
||||
/// calibration / firmware — the pairing reply is required during `hid-playstation` init, or no
|
||||
/// input devices appear) and parse any HID OUTPUT reports (rumble / lightbar) into a
|
||||
/// [`Ds4Feedback`]. Call frequently — especially right after [`open`] so the init handshake
|
||||
/// completes.
|
||||
pub fn service(&mut self) -> Ds4Feedback {
|
||||
let mut fb = Ds4Feedback::default();
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
while let Ok(n) = self.fd.read(&mut ev) {
|
||||
if n < UHID_EVENT_SIZE {
|
||||
break;
|
||||
}
|
||||
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
|
||||
UHID_OUTPUT => {
|
||||
// uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102].
|
||||
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
||||
parse_ds4_output(&ev[4..end], &mut fb);
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let data: &[u8] = match ev[8] {
|
||||
0x12 => DS4_FEATURE_PAIRING,
|
||||
0x02 => DS4_FEATURE_CALIBRATION,
|
||||
0xA3 => DS4_FEATURE_FIRMWARE,
|
||||
_ => &[],
|
||||
};
|
||||
let _ = self.reply_get_report(id, data);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
||||
}
|
||||
}
|
||||
fb
|
||||
}
|
||||
|
||||
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
// uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12], data [12..].
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
let err: u16 = if data.is_empty() { 5 } else { 0 }; // EIO if we don't know the report
|
||||
ev[8..10].copy_from_slice(&err.to_ne_bytes());
|
||||
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
|
||||
ev[12..12 + data.len()].copy_from_slice(data);
|
||||
self.fd
|
||||
.write_all(&ev)
|
||||
.context("write UHID_GET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DualShock4Pad {
|
||||
fn drop(&mut self) {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||
let _ = self.fd.write_all(&ev);
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualShock 4 pads of a session — the PS4 analog of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`.
|
||||
/// Like the DualSense it keeps each pad's full [`DsState`] and re-emits the merged report whenever
|
||||
/// buttons/sticks ([`handle`](Self::handle)) or touchpad/motion ([`apply_rich`](Self::apply_rich))
|
||||
/// change. [`pump`](Self::pump) services the kernel handshake and routes a game's feedback back:
|
||||
/// motor rumble on the universal plane, the lightbar on the HID-output plane.
|
||||
pub struct DualShock4Manager {
|
||||
pads: Vec<Option<DualShock4Pad>>,
|
||||
/// Each pad's current full report — buttons/sticks merged with persisted touch + motion.
|
||||
state: Vec<DsState>,
|
||||
/// Last rumble forwarded per pad, so a report that only changes the lightbar doesn't re-send it.
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
/// Last lightbar RGB forwarded per pad — the kernel bundles the lightbar into every output
|
||||
/// report (incl. rumble-only writes), so dedup here to avoid flooding the HID-output plane.
|
||||
last_led: Vec<Option<(u8, u8, u8)>>,
|
||||
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
|
||||
last_write: Vec<Instant>,
|
||||
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl Default for DualShock4Manager {
|
||||
fn default() -> DualShock4Manager {
|
||||
DualShock4Manager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DualShock4Manager {
|
||||
pub fn new() -> DualShock4Manager {
|
||||
DualShock4Manager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
state: vec![DsState::neutral(); MAX_PADS],
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_led: vec![None; MAX_PADS],
|
||||
last_write: vec![Instant::now(); MAX_PADS],
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded controller event (create/destroy by mask, then merge button/stick state).
|
||||
pub fn handle(&mut self, ev: &GamepadEvent) {
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival (DualShock 4)");
|
||||
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, resetting its state.
|
||||
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 (DualShock 4)");
|
||||
*slot = None;
|
||||
self.state[i] = DsState::neutral();
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_led[i] = None;
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
self.ensure(idx);
|
||||
// Merge buttons/sticks/triggers, preserving touch + motion (those arrive on the
|
||||
// rich-input plane and must survive a button-only frame).
|
||||
let prev = self.state[idx];
|
||||
let mut s = DsState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
s.touch = prev.touch;
|
||||
s.gyro = prev.gyro;
|
||||
s.accel = prev.accel;
|
||||
self.state[idx] = s;
|
||||
self.write(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad,
|
||||
/// preserving its button/stick state. Rich events never create a pad; they're dropped if the
|
||||
/// pad isn't present.
|
||||
pub fn apply_rich(&mut self, rich: RichInput) {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.pads[idx].is_none() {
|
||||
return;
|
||||
}
|
||||
match rich {
|
||||
RichInput::Touchpad {
|
||||
finger,
|
||||
active,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} => {
|
||||
// The DS4 touchpad carries two contacts; clamp to a valid slot and keep the
|
||||
// reported contact id consistent (the wire `finger` is untrusted).
|
||||
let slot = (finger as usize).min(1);
|
||||
let t = &mut self.state[idx].touch[slot];
|
||||
t.active = active;
|
||||
t.id = slot as u8;
|
||||
// Normalized 0..=65535 → the DS4 touchpad range (0..=W-1 / 0..=H-1).
|
||||
t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16;
|
||||
}
|
||||
RichInput::Motion { gyro, accel, .. } => {
|
||||
self.state[idx].gyro = gyro;
|
||||
self.state[idx].accel = accel;
|
||||
}
|
||||
}
|
||||
self.write(idx);
|
||||
}
|
||||
|
||||
fn write(&mut self, idx: usize) {
|
||||
let st = self.state[idx];
|
||||
if let Some(pad) = self.pads[idx].as_mut() {
|
||||
let _ = pad.write_state(&st);
|
||||
}
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
|
||||
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap` — a real DS4 streams
|
||||
/// report `0x01` continuously, and `hid-playstation` / SDL treat a multi-second silence (a
|
||||
/// held-steady stick) as an unplugged controller. Idempotent (a stale-but-correct frame);
|
||||
/// `write_state` bumps the counter + timestamp so each is a fresh, well-formed report.
|
||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||
let now = Instant::now();
|
||||
for i in 0..self.pads.len() {
|
||||
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
|
||||
self.write(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
||||
return;
|
||||
}
|
||||
match DualShock4Pad::open(idx as u8) {
|
||||
Ok(p) => {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualShock 4 created (UHID hid-playstation)"
|
||||
);
|
||||
self.pads[idx] = Some(p);
|
||||
self.state[idx] = DsState::neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_led[idx] = None;
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled");
|
||||
self.broken = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service every pad: answer the kernel's init handshake and parse a game's feedback. `rumble`
|
||||
/// is invoked `(index, low, high)` only when the motor level *changes* (universal 0xCA plane);
|
||||
/// `hidout` carries the lightbar (0xCD `Led`), deduped. Call frequently — the kernel blocks
|
||||
/// `hid-playstation` init until its GET_REPORTs are answered.
|
||||
pub fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(HidOutput),
|
||||
) {
|
||||
for i in 0..self.pads.len() {
|
||||
let Some(pad) = self.pads[i].as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let fb = pad.service();
|
||||
if let Some(r) = fb.rumble {
|
||||
if self.last_rumble[i] != r {
|
||||
self.last_rumble[i] = r;
|
||||
rumble(i as u16, r.0, r.1);
|
||||
}
|
||||
}
|
||||
if let Some(rgb) = fb.led {
|
||||
if self.last_led[i] != Some(rgb) {
|
||||
self.last_led[i] = Some(rgb);
|
||||
hidout(HidOutput::Led {
|
||||
pad: i as u8,
|
||||
r: rgb.0,
|
||||
g: rgb.1,
|
||||
b: rgb.2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets.
|
||||
#[test]
|
||||
fn serialize_offsets() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let mut st = DsState::from_gamepad(
|
||||
gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB,
|
||||
16384, // lx (right)
|
||||
0,
|
||||
0,
|
||||
-32768, // ry (down) — inverted to 0xFF
|
||||
200, // L2
|
||||
0,
|
||||
);
|
||||
st.gyro = [0x0102, 0x0304, 0x0506];
|
||||
st.accel = [0x1112, 0x1314, 0x1516];
|
||||
st.touch[0] = Touch {
|
||||
active: true,
|
||||
id: 0,
|
||||
x: 100,
|
||||
y: 200,
|
||||
};
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &st, 0, 0);
|
||||
assert_eq!(r[0], 0x01); // report id
|
||||
assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5)
|
||||
assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up)
|
||||
assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit
|
||||
assert_eq!(r[6] & 0x01, 0x01); // L1
|
||||
// gyro le16 at 13..19, accel le16 at 19..25.
|
||||
assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]);
|
||||
assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]);
|
||||
assert_eq!(r[33], 1); // one touch frame
|
||||
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
|
||||
assert_eq!(r[35] & 0x7F, 0); // contact id 0
|
||||
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
|
||||
}
|
||||
|
||||
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
|
||||
/// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched.
|
||||
#[test]
|
||||
fn parse_output_rumble_and_lightbar() {
|
||||
let mut report = [0u8; 32];
|
||||
report[0] = 0x05;
|
||||
report[1] = 0x01 | 0x02; // MOTOR | LED
|
||||
report[4] = 0x40; // motor_right (weak/high)
|
||||
report[5] = 0x80; // motor_left (strong/low)
|
||||
report[6] = 0x11; // R
|
||||
report[7] = 0x22; // G
|
||||
report[8] = 0x33; // B
|
||||
let mut fb = Ds4Feedback::default();
|
||||
parse_ds4_output(&report, &mut fb);
|
||||
assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak)
|
||||
assert_eq!(fb.led, Some((0x11, 0x22, 0x33)));
|
||||
|
||||
let mut motor_only = [0u8; 32];
|
||||
motor_only[0] = 0x05;
|
||||
motor_only[1] = 0x01; // MOTOR only
|
||||
motor_only[5] = 0x10;
|
||||
let mut fb2 = Ds4Feedback::default();
|
||||
parse_ds4_output(&motor_only, &mut fb2);
|
||||
assert!(fb2.rumble.is_some());
|
||||
assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change
|
||||
}
|
||||
|
||||
/// Feature-report arrays carry the right report id + length the kernel expects.
|
||||
#[test]
|
||||
fn feature_report_shapes() {
|
||||
assert_eq!(DS4_FEATURE_PAIRING.len(), 16);
|
||||
assert_eq!(DS4_FEATURE_PAIRING[0], 0x12);
|
||||
assert_eq!(DS4_FEATURE_CALIBRATION.len(), 37);
|
||||
assert_eq!(DS4_FEATURE_CALIBRATION[0], 0x02);
|
||||
assert_eq!(DS4_FEATURE_FIRMWARE.len(), 49);
|
||||
assert_eq!(DS4_FEATURE_FIRMWARE[0], 0xA3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
//! 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.
|
||||
|
||||
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<()> {
|
||||
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<()> {
|
||||
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;
|
||||
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()
|
||||
);
|
||||
}
|
||||
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,
|
||||
};
|
||||
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.
|
||||
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 {
|
||||
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) => {
|
||||
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) => {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
//! libei input injection — the portable EI-sender path.
|
||||
//!
|
||||
//! Two ways to reach an EIS server ([`EiSource`]):
|
||||
//! * **Portal** — `org.freedesktop.portal.RemoteDesktop` via `ashpd` (KWin, GNOME/Mutter),
|
||||
//! which hands us the EIS socket fd after the session grant.
|
||||
//! * **Socket** — connect directly to a compositor's own EIS socket. gamescope runs an EIS
|
||||
//! server and exports its path to its children as `LIBEI_SOCKET`; our gamescope backend
|
||||
//! relays that path through a file so the injector can connect (no portal involved).
|
||||
//!
|
||||
//! Either way, `reis` drives the connection as an EI *sender*: bind the seat's
|
||||
//! pointer/keyboard/scroll/button capabilities and, per device, `start_emulating` → emit →
|
||||
//! `frame`. The session and the EIS connection must stay alive and the event stream must be
|
||||
//! polled continuously (resume/pause/ping/modifier traffic), so the whole thing runs on a
|
||||
//! dedicated thread with its own tokio runtime; the synchronous control thread reaches it
|
||||
//! through an unbounded channel and [`LibeiInjector::inject`] merely enqueues.
|
||||
//!
|
||||
//! Keyboard codes are Linux evdev (the same space our VK→evdev table produces) and the
|
||||
//! compositor supplies the keymap, so — unlike the wlr path — there is no keymap to upload and
|
||||
//! no modifier mask to serialize: pressing the modifier *keys* (which Moonlight sends as normal
|
||||
//! key events) is enough.
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputInjector};
|
||||
use anyhow::{anyhow, Result};
|
||||
use ashpd::desktop::{
|
||||
remote_desktop::{
|
||||
ConnectToEISOptions, DeviceType, RemoteDesktop, SelectDevicesOptions, StartOptions,
|
||||
},
|
||||
CreateSessionOptions, PersistMode,
|
||||
};
|
||||
use ashpd::zbus;
|
||||
use futures_util::StreamExt;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use reis::ei;
|
||||
use reis::event::{DeviceCapability, EiEvent};
|
||||
use std::collections::HashMap;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
|
||||
|
||||
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
|
||||
const SCROLL_HORIZONTAL: u32 = 1;
|
||||
|
||||
/// Where to find the EIS server.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum EiSource {
|
||||
/// `org.freedesktop.portal.RemoteDesktop` via `ashpd` (KWin — a pre-seeded grant avoids the
|
||||
/// approval dialog).
|
||||
Portal,
|
||||
/// Mutter's *direct* `org.gnome.Mutter.RemoteDesktop` EIS (GNOME). Unlike the xdg portal, this
|
||||
/// needs no interactive "Allow remote control?" approval — which a headless host can't answer,
|
||||
/// so the portal's `Start()` would just time out. Mirrors how the Mutter *video* backend uses
|
||||
/// the same direct API.
|
||||
MutterEis,
|
||||
/// A file containing the EIS socket path/name (gamescope's relayed `LIBEI_SOCKET`); polled
|
||||
/// until it appears, since the compositor may still be starting.
|
||||
SocketPathFile(std::path::PathBuf),
|
||||
}
|
||||
|
||||
/// Handle held by the control thread; forwards events to the libei worker thread.
|
||||
pub struct LibeiInjector {
|
||||
tx: UnboundedSender<InputEvent>,
|
||||
}
|
||||
|
||||
impl LibeiInjector {
|
||||
pub fn open() -> Result<Self> {
|
||||
Self::open_with(EiSource::Portal)
|
||||
}
|
||||
|
||||
pub fn open_with(source: EiSource) -> Result<Self> {
|
||||
let (tx, rx) = unbounded_channel::<InputEvent>();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-libei".into())
|
||||
.spawn(move || worker(rx, source))
|
||||
.map_err(|e| anyhow!("spawn libei worker thread: {e}"))?;
|
||||
// Return immediately — the portal/socket handshake must NOT run on the caller's
|
||||
// (control) thread, or a slow/denied setup would freeze the ENet control stream and
|
||||
// drop the client. The worker establishes the session asynchronously and logs its
|
||||
// status; events enqueue until devices resume (a few startup events may be dropped).
|
||||
Ok(Self { tx })
|
||||
}
|
||||
}
|
||||
|
||||
impl InputInjector for LibeiInjector {
|
||||
fn inject(&mut self, event: &InputEvent) -> Result<()> {
|
||||
self.tx
|
||||
.send(*event)
|
||||
.map_err(|_| anyhow!("libei worker thread has exited"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker thread entry: build a tokio runtime and run the session to completion.
|
||||
fn worker(rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "libei: build tokio runtime failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(session_main(rx, source));
|
||||
}
|
||||
|
||||
/// Open the portal/socket + EIS (bounded), then pump events until disconnect or shutdown.
|
||||
async fn session_main(mut rx: UnboundedReceiver<InputEvent>, source: EiSource) {
|
||||
// Keep `_rd`/`_session` bound for the whole loop — dropping the portal session closes the
|
||||
// EIS connection. Bound the setup so a headless approval dialog (un-bypassed grant) can't
|
||||
// hang the worker forever.
|
||||
let (_keepalive, context, mut events) = match tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
connect(source),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "libei: portal/EIS setup failed");
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::error!(
|
||||
"libei: EIS setup timed out (headless approval needed / kde-authorized grant not seeded / gamescope socket never appeared)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!("libei: EIS connected — awaiting devices");
|
||||
|
||||
let mut state = EiState::new();
|
||||
// Watchdog: a healthy EIS server adds + resumes an input device within a beat of the handshake.
|
||||
// If none has resumed by this deadline, the connection is dead-on-arrival (stale/half-ready
|
||||
// gamescope socket the handshake passed but no real server is behind) — exit so the next
|
||||
// inject() fails and InjectorService reopens against a fresh socket, instead of silently
|
||||
// swallowing every event for the whole session.
|
||||
let resume_deadline = tokio::time::sleep(Duration::from_secs(5));
|
||||
tokio::pin!(resume_deadline);
|
||||
let mut resumed_once = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
ei = events.next() => match ei {
|
||||
Some(Ok(ev)) => {
|
||||
state.handle_ei(ev, &context);
|
||||
if !resumed_once && state.devices.iter().any(|d| d.resumed) {
|
||||
resumed_once = true;
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => { tracing::warn!(error = %e, "libei: event stream error"); break; }
|
||||
None => { tracing::info!("libei: EIS disconnected"); break; }
|
||||
},
|
||||
msg = rx.recv() => match msg {
|
||||
Some(input) => state.inject(&input, &context),
|
||||
None => { tracing::info!("libei: injector closed — ending session"); break; }
|
||||
},
|
||||
_ = &mut resume_deadline, if !resumed_once => {
|
||||
tracing::warn!(
|
||||
"libei: no input device resumed within 5s of connecting — treating the EIS \
|
||||
connection as dead and reopening (stale or half-ready compositor socket)"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A client that vanished mid-press must not leave keys/buttons latched in the
|
||||
// compositor — Mutter keeps the implicit grab of a destroyed device's button and the
|
||||
// focused app stops taking clicks until it is restarted. Release everything still
|
||||
// held before the EIS connection (and its devices) go away.
|
||||
state.release_all(&context);
|
||||
}
|
||||
|
||||
/// Tie down the verbose tuple the connect step returns. The keep-alive must stay alive for the
|
||||
/// whole session — dropping the portal/Mutter session closes the EIS connection; for the
|
||||
/// direct-socket path it's `Box::new(())`.
|
||||
type Connected = (
|
||||
Box<dyn Send>,
|
||||
ei::Context,
|
||||
reis::tokio::EiConvertEventStream,
|
||||
);
|
||||
|
||||
/// Reach an EIS server per `source` and run the EI sender handshake.
|
||||
async fn connect(source: EiSource) -> Result<Connected> {
|
||||
let (keepalive, stream): (Box<dyn Send>, UnixStream) = match source {
|
||||
EiSource::Portal => {
|
||||
let (rd, session, fd) = connect_portal().await?;
|
||||
(Box::new((rd, session)), UnixStream::from(fd))
|
||||
}
|
||||
EiSource::MutterEis => {
|
||||
let (keepalive, fd) = connect_mutter().await?;
|
||||
(keepalive, UnixStream::from(fd))
|
||||
}
|
||||
EiSource::SocketPathFile(file) => (Box::new(()), connect_socket_file(&file).await?),
|
||||
};
|
||||
let context = ei::Context::new(stream).map_err(|e| anyhow!("reis EI context: {e}"))?;
|
||||
// Bound the handshake. `UnixStream::connect` to a socket *file* succeeds the moment the path
|
||||
// exists, but a stale/half-ready gamescope (its socket created early in startup, or left behind
|
||||
// by a SIGKILLed prior session) may never drive the EI handshake — which would otherwise hang
|
||||
// this worker forever. A bounded handshake lets the worker error out so InjectorService reopens.
|
||||
let (_conn, events) = tokio::time::timeout(
|
||||
Duration::from_secs(8),
|
||||
context.handshake_tokio("punktfunk-host", ei::handshake::ContextType::Sender),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!("EI handshake timed out (EIS server not responding — stale/half-ready socket?)")
|
||||
})?
|
||||
.map_err(|e| anyhow!("EI handshake: {e}"))?;
|
||||
Ok((keepalive, context, events))
|
||||
}
|
||||
|
||||
/// Open a RemoteDesktop portal session (pointer + keyboard) and obtain the EIS socket fd.
|
||||
async fn connect_portal() -> Result<(
|
||||
RemoteDesktop,
|
||||
ashpd::desktop::Session<RemoteDesktop>,
|
||||
std::os::fd::OwnedFd,
|
||||
)> {
|
||||
let rd = RemoteDesktop::new()
|
||||
.await
|
||||
.map_err(|e| anyhow!("open RemoteDesktop portal (is xdg-desktop-portal-kde/gnome running and XDG_CURRENT_DESKTOP set?): {e}"))?;
|
||||
let session = rd
|
||||
.create_session(CreateSessionOptions::default())
|
||||
.await
|
||||
.map_err(|e| anyhow!("create RemoteDesktop session: {e}"))?;
|
||||
rd.select_devices(
|
||||
&session,
|
||||
SelectDevicesOptions::default()
|
||||
.set_devices(DeviceType::Keyboard | DeviceType::Pointer | DeviceType::Touchscreen)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("select_devices: {e}"))?
|
||||
.response()
|
||||
.map_err(|e| anyhow!("select_devices response: {e}"))?;
|
||||
let started = rd
|
||||
.start(&session, None, StartOptions::default())
|
||||
.await
|
||||
.map_err(|e| anyhow!("start RemoteDesktop session: {e}"))?;
|
||||
let granted = started
|
||||
.response()
|
||||
.map_err(|e| anyhow!("RemoteDesktop start denied: {e}"))?;
|
||||
tracing::info!(devices = ?granted.devices(), "libei: portal granted devices");
|
||||
|
||||
let fd = rd
|
||||
.connect_to_eis(&session, ConnectToEISOptions::default())
|
||||
.await
|
||||
.map_err(|e| anyhow!("connect_to_eis (RemoteDesktop portal version < 2?): {e}"))?;
|
||||
Ok((rd, session, fd))
|
||||
}
|
||||
|
||||
/// GNOME path: get the EIS socket fd from Mutter's *direct* `org.gnome.Mutter.RemoteDesktop` API
|
||||
/// (`CreateSession` → `Start` → `ConnectToEIS`). No xdg portal is involved, so there is no
|
||||
/// interactive "Allow remote control?" approval to satisfy — exactly why [`connect_portal`] times
|
||||
/// out on a headless GNOME host. (Same direct API the Mutter *video* backend uses.) The returned
|
||||
/// keep-alive owns the D-Bus connection + session; dropping it tears the Mutter session down and
|
||||
/// closes the EIS connection (Mutter sessions die with their D-Bus connection).
|
||||
async fn connect_mutter() -> Result<(Box<dyn Send>, std::os::fd::OwnedFd)> {
|
||||
use zbus::zvariant::{OwnedObjectPath, Value};
|
||||
let conn = zbus::Connection::session()
|
||||
.await
|
||||
.map_err(|e| anyhow!("connect session D-Bus (Mutter RemoteDesktop): {e}"))?;
|
||||
let rd = zbus::Proxy::new(
|
||||
&conn,
|
||||
"org.gnome.Mutter.RemoteDesktop",
|
||||
"/org/gnome/Mutter/RemoteDesktop",
|
||||
"org.gnome.Mutter.RemoteDesktop",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Mutter RemoteDesktop proxy (is gnome-shell running?): {e}"))?;
|
||||
let session_path: OwnedObjectPath = rd
|
||||
.call("CreateSession", &())
|
||||
.await
|
||||
.map_err(|e| anyhow!("Mutter RemoteDesktop.CreateSession: {e}"))?;
|
||||
let session = zbus::Proxy::new(
|
||||
&conn,
|
||||
"org.gnome.Mutter.RemoteDesktop",
|
||||
session_path,
|
||||
"org.gnome.Mutter.RemoteDesktop.Session",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Mutter RemoteDesktop.Session proxy: {e}"))?;
|
||||
session
|
||||
.call_method("Start", &())
|
||||
.await
|
||||
.map_err(|e| anyhow!("Mutter RemoteDesktop.Session.Start: {e}"))?;
|
||||
let options: HashMap<&str, Value> = HashMap::new();
|
||||
let fd: zbus::zvariant::OwnedFd = session
|
||||
.call("ConnectToEIS", &(options,))
|
||||
.await
|
||||
.map_err(|e| anyhow!("Mutter RemoteDesktop.Session.ConnectToEIS: {e}"))?;
|
||||
tracing::info!("libei: connected to Mutter's direct RemoteDesktop EIS (no portal approval)");
|
||||
Ok((Box::new((conn, session)), std::os::fd::OwnedFd::from(fd)))
|
||||
}
|
||||
|
||||
/// Poll `file` for the EIS socket path (the gamescope backend relays `LIBEI_SOCKET` there once
|
||||
/// the nested app launches), then connect. A bare name is resolved against `XDG_RUNTIME_DIR`,
|
||||
/// mirroring libei's own `LIBEI_SOCKET` semantics.
|
||||
async fn connect_socket_file(file: &std::path::Path) -> Result<UnixStream> {
|
||||
// The relay file is rewritten each session with the CURRENT gamescope's `LIBEI_SOCKET`, and the
|
||||
// socket may not be `listen()`ing the instant its name appears — or the file may briefly still
|
||||
// hold a prior, now-dead session's name (the host-lifetime injector reconnecting between
|
||||
// sessions). So poll: RE-READ the file and RETRY the connect, treating "refused"/"missing" as
|
||||
// not-ready-yet (the exact "Connection refused" we saw when a stale socket lingered). Bounded so
|
||||
// a genuinely wedged setup still surfaces an error.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(15);
|
||||
let mut logged = String::new();
|
||||
loop {
|
||||
if let Ok(s) = std::fs::read_to_string(file) {
|
||||
let name = s.trim();
|
||||
if !name.is_empty() {
|
||||
let full = if name.starts_with('/') {
|
||||
std::path::PathBuf::from(name)
|
||||
} else {
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR").map_err(|_| {
|
||||
anyhow!("XDG_RUNTIME_DIR unset (needed to resolve EIS socket '{name}')")
|
||||
})?;
|
||||
std::path::Path::new(&runtime).join(name)
|
||||
};
|
||||
if logged != name {
|
||||
tracing::info!(socket = %full.display(), "libei: connecting to EIS socket");
|
||||
logged = name.to_string();
|
||||
}
|
||||
match UnixStream::connect(&full) {
|
||||
Ok(stream) => return Ok(stream),
|
||||
// Refused = socket file exists but no listener yet (or a dead session);
|
||||
// NotFound = path not created yet. Both heal once the live gamescope's EIS is
|
||||
// up — retry. Anything else (e.g. permission) is a real failure.
|
||||
Err(e)
|
||||
if matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
|
||||
) => {}
|
||||
Err(e) => return Err(anyhow!("connect EIS socket {}: {e}", full.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(anyhow!(
|
||||
"EIS socket from {} never became connectable (gamescope not up, or its EIS crashed)",
|
||||
file.display()
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One EI device and its emulation state.
|
||||
struct DeviceSlot {
|
||||
device: reis::event::Device,
|
||||
/// The device is resumed (allowed to emit). Devices arrive paused and may pause again.
|
||||
resumed: bool,
|
||||
/// We have issued `start_emulating` since the last resume.
|
||||
emulating: bool,
|
||||
}
|
||||
|
||||
/// Tracks bound devices + the serial/sequence/timebase the EI protocol requires.
|
||||
struct EiState {
|
||||
devices: Vec<DeviceSlot>,
|
||||
last_serial: u32,
|
||||
sequence: u32,
|
||||
start: Instant,
|
||||
/// Total inject() calls — used only to throttle diagnostic logging.
|
||||
injected: u64,
|
||||
/// Bitmask of [`InputKind`]s already logged once (diagnostics: surface the FIRST of each
|
||||
/// kind a client sends + whether it emitted, so an unexpected client — e.g. a touch-only
|
||||
/// tablet hitting a compositor without ei_touchscreen — is immediately diagnosable).
|
||||
seen_kinds: u32,
|
||||
/// Wire codes currently held down (keys = VK, buttons = GameStream ids, touches = ids)
|
||||
/// — synthesized back up at session end ([`EiState::release_all`]). A client that
|
||||
/// vanishes mid-press must not leave the compositor with a latched key or an implicit
|
||||
/// pointer grab: observed on Mutter, a button held by a destroyed EIS device wedges
|
||||
/// click delivery to the focused app until that app is restarted.
|
||||
held_keys: Vec<u32>,
|
||||
held_buttons: Vec<u32>,
|
||||
held_touches: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Stable small index per [`InputKind`] for the `seen_kinds` bitmask.
|
||||
fn kind_bit(kind: InputKind) -> u32 {
|
||||
let i = match kind {
|
||||
InputKind::MouseMove => 0,
|
||||
InputKind::MouseMoveAbs => 1,
|
||||
InputKind::MouseButtonDown => 2,
|
||||
InputKind::MouseButtonUp => 3,
|
||||
InputKind::MouseScroll => 4,
|
||||
InputKind::KeyDown => 5,
|
||||
InputKind::KeyUp => 6,
|
||||
InputKind::TouchDown => 7,
|
||||
InputKind::TouchMove => 8,
|
||||
InputKind::TouchUp => 9,
|
||||
InputKind::GamepadButton => 10,
|
||||
InputKind::GamepadAxis => 11,
|
||||
};
|
||||
1 << i
|
||||
}
|
||||
|
||||
impl EiState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
devices: Vec::new(),
|
||||
last_serial: 0,
|
||||
sequence: 0,
|
||||
start: Instant::now(),
|
||||
injected: 0,
|
||||
seen_kinds: 0,
|
||||
held_keys: Vec::new(),
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Release everything the remote client still holds — called when the session ends
|
||||
/// (client gone, EIS closing). Synthesizes wire-level release events through the
|
||||
/// normal [`EiState::inject`] path so the compositor sees proper key-up / button-up /
|
||||
/// touch-up frames before the devices disappear.
|
||||
fn release_all(&mut self, ctx: &ei::Context) {
|
||||
let (keys, buttons, touches) = (
|
||||
std::mem::take(&mut self.held_keys),
|
||||
std::mem::take(&mut self.held_buttons),
|
||||
std::mem::take(&mut self.held_touches),
|
||||
);
|
||||
if keys.is_empty() && buttons.is_empty() && touches.is_empty() {
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
keys = keys.len(),
|
||||
buttons = buttons.len(),
|
||||
touches = touches.len(),
|
||||
"libei: releasing input still held at session end"
|
||||
);
|
||||
let release = |kind: InputKind, code: u32| InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
for code in buttons {
|
||||
self.inject(&release(InputKind::MouseButtonUp, code), ctx);
|
||||
}
|
||||
for code in keys {
|
||||
self.inject(&release(InputKind::KeyUp, code), ctx);
|
||||
}
|
||||
for id in touches {
|
||||
self.inject(&release(InputKind::TouchUp, id), ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn now_us(&self) -> u64 {
|
||||
self.start.elapsed().as_micros() as u64
|
||||
}
|
||||
|
||||
/// Apply a server event: bind capabilities, track devices, and follow resume/pause.
|
||||
fn handle_ei(&mut self, ev: EiEvent, ctx: &ei::Context) {
|
||||
match ev {
|
||||
EiEvent::SeatAdded(e) => {
|
||||
e.seat.bind_capabilities(
|
||||
DeviceCapability::Pointer
|
||||
| DeviceCapability::PointerAbsolute
|
||||
| DeviceCapability::Keyboard
|
||||
| DeviceCapability::Scroll
|
||||
| DeviceCapability::Button
|
||||
| DeviceCapability::Touch,
|
||||
);
|
||||
let _ = ctx.flush();
|
||||
}
|
||||
EiEvent::DeviceAdded(e) => {
|
||||
tracing::info!(device = ?e.device.name(), ty = ?e.device.device_type(), "libei: device added");
|
||||
self.devices.push(DeviceSlot {
|
||||
device: e.device,
|
||||
resumed: false,
|
||||
emulating: false,
|
||||
});
|
||||
}
|
||||
EiEvent::DeviceRemoved(e) => {
|
||||
self.devices.retain(|d| d.device != e.device);
|
||||
}
|
||||
EiEvent::DeviceResumed(e) => {
|
||||
self.last_serial = e.serial;
|
||||
if let Some(d) = self.devices.iter_mut().find(|d| d.device == e.device) {
|
||||
d.resumed = true;
|
||||
d.emulating = false; // must re-issue start_emulating after a resume
|
||||
}
|
||||
let dev = &e.device;
|
||||
tracing::info!(
|
||||
name = ?dev.name(),
|
||||
pointer = dev.has_capability(DeviceCapability::Pointer),
|
||||
pointer_abs = dev.has_capability(DeviceCapability::PointerAbsolute),
|
||||
keyboard = dev.has_capability(DeviceCapability::Keyboard),
|
||||
button = dev.has_capability(DeviceCapability::Button),
|
||||
scroll = dev.has_capability(DeviceCapability::Scroll),
|
||||
"libei: device RESUMED (now emittable)"
|
||||
);
|
||||
}
|
||||
EiEvent::DevicePaused(e) => {
|
||||
if let Some(d) = self.devices.iter_mut().find(|d| d.device == e.device) {
|
||||
d.resumed = false;
|
||||
d.emulating = false;
|
||||
}
|
||||
}
|
||||
// Informational: the server reports resulting modifier/group state; we don't set it.
|
||||
EiEvent::KeyboardModifiers(e) => self.last_serial = e.serial,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of a resumed device exposing `cap`.
|
||||
fn device_for(&self, cap: DeviceCapability) -> Option<usize> {
|
||||
self.devices
|
||||
.iter()
|
||||
.position(|d| d.resumed && d.device.has_capability(cap))
|
||||
}
|
||||
|
||||
/// Ensure the device at `idx` is in `start_emulating` state before we emit on it.
|
||||
fn ensure_emulating(&mut self, idx: usize, dev: &ei::Device) {
|
||||
if !self.devices[idx].emulating {
|
||||
dev.start_emulating(self.last_serial, self.sequence);
|
||||
self.sequence = self.sequence.wrapping_add(1);
|
||||
self.devices[idx].emulating = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate and emit one client input event, committing it as a single `frame`.
|
||||
fn inject(&mut self, ev: &InputEvent, ctx: &ei::Context) {
|
||||
let cap = match ev.kind {
|
||||
InputKind::MouseMove => DeviceCapability::Pointer,
|
||||
InputKind::MouseMoveAbs => DeviceCapability::PointerAbsolute,
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => DeviceCapability::Button,
|
||||
InputKind::MouseScroll => DeviceCapability::Scroll,
|
||||
InputKind::KeyDown | InputKind::KeyUp => DeviceCapability::Keyboard,
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
|
||||
DeviceCapability::Touch
|
||||
}
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
|
||||
};
|
||||
self.injected += 1;
|
||||
let n = self.injected;
|
||||
// Log the first of each kind always (diagnostics), then occasionally.
|
||||
let bit = kind_bit(ev.kind);
|
||||
let first = self.seen_kinds & bit == 0;
|
||||
self.seen_kinds |= bit;
|
||||
let loud = first || n <= 5 || n % 600 == 0;
|
||||
let Some(idx) = self.device_for(cap) else {
|
||||
if loud {
|
||||
tracing::warn!(
|
||||
n,
|
||||
kind = ?ev.kind,
|
||||
?cap,
|
||||
devices = self.devices.len(),
|
||||
resumed = self.devices.iter().filter(|d| d.resumed).count(),
|
||||
"libei: DROP — no resumed device exposes this capability"
|
||||
);
|
||||
}
|
||||
// No resumed device with this capability yet. For touch this is usually permanent on
|
||||
// this compositor — the RemoteDesktop portal may grant the Touchscreen *device type*
|
||||
// while the EIS server never creates a touchscreen *device* (observed on headless
|
||||
// KWin). Surface it once so touch silently going nowhere is diagnosable.
|
||||
if matches!(
|
||||
ev.kind,
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp
|
||||
) {
|
||||
static WARNED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
"touch received but the compositor's EIS exposed no touchscreen device — \
|
||||
touch is dropped (KWin's libei may not implement ei_touchscreen yet; \
|
||||
gamescope / a newer compositor may)"
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
let dev = self.devices[idx].device.device().clone();
|
||||
self.ensure_emulating(idx, &dev);
|
||||
|
||||
let mut emitted = true;
|
||||
let slot = &self.devices[idx].device;
|
||||
match ev.kind {
|
||||
InputKind::MouseMove => match slot.interface::<ei::Pointer>() {
|
||||
Some(p) => p.motion_relative(ev.x as f32, ev.y as f32),
|
||||
None => emitted = false,
|
||||
},
|
||||
InputKind::MouseMoveAbs => {
|
||||
let w = ((ev.flags >> 16) & 0xffff) as f32;
|
||||
let h = (ev.flags & 0xffff) as f32;
|
||||
match (
|
||||
slot.interface::<ei::PointerAbsolute>(),
|
||||
slot.regions().first(),
|
||||
) {
|
||||
(Some(p), Some(region)) if w > 0.0 && h > 0.0 => {
|
||||
// Map the normalized client position into the device's first region.
|
||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||
let x = region.x as f32 + nx * region.width as f32;
|
||||
let y = region.y as f32 + ny * region.height as f32;
|
||||
p.motion_absolute(x, y);
|
||||
}
|
||||
_ => emitted = false,
|
||||
}
|
||||
}
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
match (slot.interface::<ei::Button>(), gs_button_to_evdev(ev.code)) {
|
||||
(Some(b), Some(btn)) => {
|
||||
let st = if ev.kind == InputKind::MouseButtonDown {
|
||||
ei::button::ButtonState::Press
|
||||
} else {
|
||||
ei::button::ButtonState::Released
|
||||
};
|
||||
b.button(btn, st);
|
||||
}
|
||||
_ => emitted = false,
|
||||
}
|
||||
}
|
||||
InputKind::MouseScroll => match slot.interface::<ei::Scroll>() {
|
||||
Some(s) => {
|
||||
// Wire deltas are WHEEL_DELTA(120)-scaled in `x`. Emit BOTH ei scroll axes
|
||||
// from it: `scroll_discrete` (120-per-detent — drives line/page scrolling)
|
||||
// AND the continuous `scroll` axis in logical px (≈15 px/detent). Without
|
||||
// the continuous axis Mutter floors a sub-detent delta (trackpad / precise
|
||||
// wheel / fractional smooth scroll) to zero whole clicks, so small scrolls
|
||||
// never register and you have to spin the wheel a lot — emitting the pixel
|
||||
// axis too makes every delta move proportionally (matches the wlr backend's
|
||||
// 15 px/notch). Positive wire = up (vertical, negated on the ei axis) /
|
||||
// RIGHT (horizontal, already positive — moonlight-qt/Sunshine pass it
|
||||
// through unnegated); only the vertical axis flips.
|
||||
const PX_PER_DETENT: f32 = 15.0;
|
||||
let px = ev.x as f32 / 120.0 * PX_PER_DETENT;
|
||||
if ev.code == SCROLL_HORIZONTAL {
|
||||
s.scroll_discrete(ev.x, 0);
|
||||
s.scroll(px, 0.0);
|
||||
} else {
|
||||
s.scroll_discrete(0, -ev.x);
|
||||
s.scroll(0.0, -px);
|
||||
}
|
||||
}
|
||||
None => emitted = false,
|
||||
},
|
||||
InputKind::KeyDown | InputKind::KeyUp => {
|
||||
match (slot.interface::<ei::Keyboard>(), vk_to_evdev(ev.code as u8)) {
|
||||
(Some(k), Some(evdev)) => {
|
||||
let st = if ev.kind == InputKind::KeyDown {
|
||||
ei::keyboard::KeyState::Press
|
||||
} else {
|
||||
ei::keyboard::KeyState::Released
|
||||
};
|
||||
k.key(evdev as u32, st);
|
||||
}
|
||||
_ => {
|
||||
emitted = false;
|
||||
tracing::debug!(vk = ev.code, "libei: unmapped VK keycode — dropped");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Touch: `code` is the touch id, `x`/`y` are client pixels and `flags` packs the
|
||||
// client surface w/h — mapped into the device's region exactly like MouseMoveAbs.
|
||||
// One InputEvent = one frame, which satisfies the ei_touchscreen rule that a down /
|
||||
// motion / up must not share a frame.
|
||||
InputKind::TouchDown | InputKind::TouchMove => {
|
||||
let w = ((ev.flags >> 16) & 0xffff) as f32;
|
||||
let h = (ev.flags & 0xffff) as f32;
|
||||
match (slot.interface::<ei::Touchscreen>(), slot.regions().first()) {
|
||||
(Some(t), Some(region)) if w > 0.0 && h > 0.0 => {
|
||||
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
|
||||
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
|
||||
let x = region.x as f32 + nx * region.width as f32;
|
||||
let y = region.y as f32 + ny * region.height as f32;
|
||||
if ev.kind == InputKind::TouchDown {
|
||||
t.down(ev.code, x, y);
|
||||
} else {
|
||||
t.motion(ev.code, x, y);
|
||||
}
|
||||
}
|
||||
_ => emitted = false,
|
||||
}
|
||||
}
|
||||
InputKind::TouchUp => match slot.interface::<ei::Touchscreen>() {
|
||||
Some(t) => t.up(ev.code),
|
||||
None => emitted = false,
|
||||
},
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => emitted = false,
|
||||
}
|
||||
|
||||
if emitted {
|
||||
// Track held state on the wire codes so `release_all` can undo it at
|
||||
// session end (vanished clients must not leave anything latched).
|
||||
match ev.kind {
|
||||
InputKind::KeyDown if !self.held_keys.contains(&ev.code) => {
|
||||
self.held_keys.push(ev.code);
|
||||
}
|
||||
InputKind::KeyUp => self.held_keys.retain(|&c| c != ev.code),
|
||||
InputKind::MouseButtonDown if !self.held_buttons.contains(&ev.code) => {
|
||||
self.held_buttons.push(ev.code);
|
||||
}
|
||||
InputKind::MouseButtonUp => self.held_buttons.retain(|&c| c != ev.code),
|
||||
InputKind::TouchDown if !self.held_touches.contains(&ev.code) => {
|
||||
self.held_touches.push(ev.code);
|
||||
}
|
||||
InputKind::TouchUp => self.held_touches.retain(|&c| c != ev.code),
|
||||
_ => {}
|
||||
}
|
||||
dev.frame(self.last_serial, self.now_us());
|
||||
}
|
||||
if let Err(e) = ctx.flush() {
|
||||
tracing::warn!(error = %e, "libei: ctx.flush failed");
|
||||
}
|
||||
if loud {
|
||||
tracing::info!(n, kind = ?ev.kind, idx, emitted, "libei: emitted");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Input injection through the wlroots virtual-input Wayland protocols
|
||||
//! (`zwlr_virtual_pointer_manager_v1` + `zwp_virtual_keyboard_manager_v1`) — the headless-Sway
|
||||
//! path. We connect as an ordinary Wayland client (the host inherits Sway's
|
||||
//! `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`), bind the two managers, upload a standard evdev/US xkb
|
||||
//! keymap, and translate events into virtual pointer/keyboard requests, tracking modifier state
|
||||
//! so the compositor resolves shifted keysyms correctly.
|
||||
|
||||
use super::{gs_button_to_evdev, vk_to_evdev, InputEvent, InputInjector};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use punktfunk_core::input::InputKind;
|
||||
use std::io::Write;
|
||||
use std::os::fd::{AsFd, FromRawFd};
|
||||
use std::time::Instant;
|
||||
use wayland_client::protocol::{wl_output::WlOutput, wl_pointer, wl_registry, wl_seat::WlSeat};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
|
||||
use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
|
||||
zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
|
||||
zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1,
|
||||
};
|
||||
use wayland_protocols_wlr::virtual_pointer::v1::client::{
|
||||
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
|
||||
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
|
||||
};
|
||||
use xkbcommon::xkb;
|
||||
|
||||
/// `code` value marking a horizontal scroll event (mirrors `gamestream::input`).
|
||||
const SCROLL_HORIZONTAL: u32 = 1;
|
||||
|
||||
/// Globals bound from the registry (the Wayland dispatch state).
|
||||
#[derive(Default)]
|
||||
struct Globals {
|
||||
pointer_mgr: Option<ZwlrVirtualPointerManagerV1>,
|
||||
keyboard_mgr: Option<ZwpVirtualKeyboardManagerV1>,
|
||||
seat: Option<WlSeat>,
|
||||
output: Option<WlOutput>,
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
match interface.as_str() {
|
||||
"zwlr_virtual_pointer_manager_v1" => {
|
||||
state.pointer_mgr = Some(registry.bind(name, version.min(2), qh, ()));
|
||||
}
|
||||
"zwp_virtual_keyboard_manager_v1" => {
|
||||
state.keyboard_mgr = Some(registry.bind(name, version.min(1), qh, ()));
|
||||
}
|
||||
"wl_seat" => {
|
||||
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
|
||||
}
|
||||
"wl_output" if state.output.is_none() => {
|
||||
state.output = Some(registry.bind(name, version.min(3), qh, ()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The managers, the two virtual devices, the seat and the output emit no events we use.
|
||||
macro_rules! ignore_events {
|
||||
($($t:ty),* $(,)?) => {$(
|
||||
impl Dispatch<$t, ()> for Globals {
|
||||
fn event(_: &mut Self, _: &$t, _: <$t as Proxy>::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
ignore_events!(
|
||||
WlSeat,
|
||||
WlOutput,
|
||||
ZwlrVirtualPointerManagerV1,
|
||||
ZwlrVirtualPointerV1,
|
||||
ZwpVirtualKeyboardManagerV1,
|
||||
ZwpVirtualKeyboardV1,
|
||||
);
|
||||
|
||||
pub struct WlrootsInjector {
|
||||
conn: Connection,
|
||||
queue: EventQueue<Globals>,
|
||||
globals: Globals,
|
||||
pointer: ZwlrVirtualPointerV1,
|
||||
keyboard: ZwpVirtualKeyboardV1,
|
||||
xkb_state: xkb::State,
|
||||
_keymap_file: std::fs::File, // keep the memfd alive for the compositor's mmap
|
||||
start: Instant,
|
||||
}
|
||||
|
||||
impl WlrootsInjector {
|
||||
pub fn open() -> Result<Self> {
|
||||
let conn = Connection::connect_to_env()
|
||||
.context("connect to Wayland (is Sway up + WAYLAND_DISPLAY/XDG_RUNTIME_DIR set?)")?;
|
||||
let mut queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
let mut globals = Globals::default();
|
||||
queue
|
||||
.roundtrip(&mut globals)
|
||||
.context("Wayland registry roundtrip")?;
|
||||
|
||||
let pointer_mgr = globals
|
||||
.pointer_mgr
|
||||
.clone()
|
||||
.context("compositor lacks zwlr_virtual_pointer_manager_v1")?;
|
||||
let keyboard_mgr = globals
|
||||
.keyboard_mgr
|
||||
.clone()
|
||||
.context("compositor lacks zwp_virtual_keyboard_manager_v1")?;
|
||||
let seat = globals
|
||||
.seat
|
||||
.clone()
|
||||
.context("compositor advertised no wl_seat")?;
|
||||
|
||||
let pointer = pointer_mgr.create_virtual_pointer_with_output(
|
||||
Some(&seat),
|
||||
globals.output.as_ref(),
|
||||
&qh,
|
||||
(),
|
||||
);
|
||||
let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ());
|
||||
|
||||
// A standard evdev/US keymap so raw evdev keycodes resolve to the right keysyms.
|
||||
let ctx = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
|
||||
let keymap = xkb::Keymap::new_from_names(
|
||||
&ctx,
|
||||
"evdev",
|
||||
"pc105",
|
||||
"us",
|
||||
"",
|
||||
None,
|
||||
xkb::KEYMAP_COMPILE_NO_FLAGS,
|
||||
)
|
||||
.context("compile xkb keymap")?;
|
||||
let keymap_str = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1);
|
||||
let xkb_state = xkb::State::new(&keymap);
|
||||
|
||||
let file = memfd_with(&keymap_str)?;
|
||||
let size = keymap_str.len() as u32 + 1; // include the trailing NUL
|
||||
keyboard.keymap(1 /* XKB_V1 */, file.as_fd(), size);
|
||||
queue
|
||||
.roundtrip(&mut globals)
|
||||
.context("keymap upload roundtrip")?;
|
||||
conn.flush().ok();
|
||||
|
||||
tracing::info!(
|
||||
output = globals.output.is_some(),
|
||||
"wlroots virtual input ready (pointer + keyboard)"
|
||||
);
|
||||
Ok(Self {
|
||||
conn,
|
||||
queue,
|
||||
globals,
|
||||
pointer,
|
||||
keyboard,
|
||||
xkb_state,
|
||||
_keymap_file: file,
|
||||
start: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
fn now_ms(&self) -> u32 {
|
||||
self.start.elapsed().as_millis() as u32
|
||||
}
|
||||
|
||||
/// Update xkb state for a key and tell the compositor the resulting modifier mask.
|
||||
fn send_modifiers(&mut self, evdev: u16, down: bool) {
|
||||
let kc = xkb::Keycode::new(evdev as u32 + 8); // evdev -> xkb keycode
|
||||
let dir = if down {
|
||||
xkb::KeyDirection::Down
|
||||
} else {
|
||||
xkb::KeyDirection::Up
|
||||
};
|
||||
self.xkb_state.update_key(kc, dir);
|
||||
let depressed = self.xkb_state.serialize_mods(xkb::STATE_MODS_DEPRESSED);
|
||||
let latched = self.xkb_state.serialize_mods(xkb::STATE_MODS_LATCHED);
|
||||
let locked = self.xkb_state.serialize_mods(xkb::STATE_MODS_LOCKED);
|
||||
let group = self.xkb_state.serialize_layout(xkb::STATE_LAYOUT_EFFECTIVE);
|
||||
self.keyboard.modifiers(depressed, latched, locked, group);
|
||||
}
|
||||
}
|
||||
|
||||
impl InputInjector for WlrootsInjector {
|
||||
fn inject(&mut self, event: &InputEvent) -> Result<()> {
|
||||
let t = self.now_ms();
|
||||
match event.kind {
|
||||
InputKind::MouseMove => {
|
||||
self.pointer.motion(t, event.x as f64, event.y as f64);
|
||||
self.pointer.frame();
|
||||
}
|
||||
InputKind::MouseMoveAbs => {
|
||||
let w = (event.flags >> 16) & 0xffff;
|
||||
let h = event.flags & 0xffff;
|
||||
if w > 0 && h > 0 {
|
||||
let x = event.x.clamp(0, w as i32) as u32;
|
||||
let y = event.y.clamp(0, h as i32) as u32;
|
||||
self.pointer.motion_absolute(t, x, y, w, h);
|
||||
self.pointer.frame();
|
||||
}
|
||||
}
|
||||
InputKind::MouseButtonDown | InputKind::MouseButtonUp => {
|
||||
if let Some(btn) = gs_button_to_evdev(event.code) {
|
||||
let st = if event.kind == InputKind::MouseButtonDown {
|
||||
wl_pointer::ButtonState::Pressed
|
||||
} else {
|
||||
wl_pointer::ButtonState::Released
|
||||
};
|
||||
self.pointer.button(t, btn, st);
|
||||
self.pointer.frame();
|
||||
}
|
||||
}
|
||||
InputKind::MouseScroll => {
|
||||
let axis = if event.code == SCROLL_HORIZONTAL {
|
||||
wl_pointer::Axis::HorizontalScroll
|
||||
} else {
|
||||
wl_pointer::Axis::VerticalScroll
|
||||
};
|
||||
// GameStream sends WHEEL_DELTA(120)-scaled units; a notch ≈ 15px. Positive
|
||||
// GameStream = up (vertical), negative on the Wayland axis; but = RIGHT
|
||||
// (horizontal), already positive there (moonlight-qt/Sunshine pass
|
||||
// horizontal through unnegated) — only the vertical axis flips.
|
||||
let notches = event.x as f64 / 120.0;
|
||||
let sign = if event.code == SCROLL_HORIZONTAL {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
};
|
||||
self.pointer.axis_source(wl_pointer::AxisSource::Wheel);
|
||||
self.pointer.axis(t, axis, sign * notches * 15.0);
|
||||
self.pointer.frame();
|
||||
}
|
||||
InputKind::KeyDown | InputKind::KeyUp => {
|
||||
let down = event.kind == InputKind::KeyDown;
|
||||
if let Some(evdev) = vk_to_evdev(event.code as u8) {
|
||||
self.keyboard.key(t, evdev as u32, if down { 1 } else { 0 });
|
||||
self.send_modifiers(evdev, down);
|
||||
} else {
|
||||
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped");
|
||||
}
|
||||
}
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
|
||||
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
|
||||
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
|
||||
}
|
||||
// Surface protocol errors / disconnects, then push the batch to the compositor.
|
||||
self.queue
|
||||
.dispatch_pending(&mut self.globals)
|
||||
.context("wayland dispatch")?;
|
||||
self.conn.flush().context("wayland flush")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an anonymous in-memory file holding `s` + a trailing NUL (for the keymap fd).
|
||||
fn memfd_with(s: &str) -> Result<std::fs::File> {
|
||||
let name = b"punktfunk-keymap\0";
|
||||
let fd = unsafe { libc::memfd_create(name.as_ptr() as *const libc::c_char, libc::MFD_CLOEXEC) };
|
||||
if fd < 0 {
|
||||
bail!("memfd_create failed: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
let mut f = unsafe { std::fs::File::from_raw_fd(fd) };
|
||||
f.write_all(s.as_bytes()).context("write keymap")?;
|
||||
f.write_all(&[0]).context("write keymap NUL")?;
|
||||
Ok(f)
|
||||
}
|
||||
Reference in New Issue
Block a user