47587827ec
apple / swift (push) Successful in 1m22s
release / apple (push) Successful in 6m6s
apple / screenshots (push) Successful in 4m58s
windows-host / package (push) Successful in 15m58s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m59s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m2s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m58s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m35s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m12s
decky / build-publish (push) Successful in 20s
android / android (push) Successful in 12m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m17s
arch / build-publish (push) Successful in 13m21s
docker / deploy-docs (push) Successful in 11s
flatpak / build-publish (push) Successful in 6m37s
deb / build-publish (push) Successful in 12m28s
ci / rust (push) Successful in 21m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m43s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m46s
First de-coupling for the host crate carve (plan §W6.0 / §2.4): the GameStream (Moonlight-plane) decoded controller types were defined in gamestream/gamepad.rs — the "junk drawer" — yet consumed 18× by the platform-neutral input injectors AND by the Moonlight decode path. Once inject becomes pf-inject, reaching them via crate::gamestream would be an illegal upward edge. Move the two types to core::input (below both planes; inject already depends on core) and repoint every consumer. Also consolidate the duplicated MAX_PADS onto the existing core::input::MAX_PADS. The gamestream BTN_* const aliases stay for now (separate follow-up); decode()/rumble/tests remain in the Moonlight plane, now importing the types from core. Verified: Linux (home-worker-5) clippy -p punktfunk-core -p punktfunk-host --all-targets -D warnings + gamepad tests green; Windows (192.168.1.158) clippy -p punktfunk-host --features nvenc,amf-qsv --all-targets green (the inject/windows/* consumers compile). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
659 lines
29 KiB
Rust
659 lines
29 KiB
Rust
//! 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::{
|
|
ds_pairing_reply, edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState,
|
|
DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
|
|
DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
|
|
};
|
|
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
|
use anyhow::{Context, Result};
|
|
use punktfunk_core::quic::RichInput;
|
|
use std::fs::{File, OpenOptions};
|
|
use std::io::{Read, Write};
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
|
|
// /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 UHID_SET_REPORT: u32 = 13;
|
|
const UHID_SET_REPORT_REPLY: u32 = 14;
|
|
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)
|
|
}
|
|
|
|
/// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same
|
|
/// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra
|
|
/// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape.
|
|
pub struct DsUhidIdentity {
|
|
product: u32,
|
|
rdesc: &'static [u8],
|
|
/// Device name prefix ("Punktfunk <name> <index>").
|
|
name: &'static str,
|
|
/// Path token for the phys string ("punktfunk/<phys>/<index>").
|
|
phys: &'static str,
|
|
/// Short slug for the uniq string ("punktfunk-<slug>-<index>").
|
|
slug: &'static str,
|
|
}
|
|
|
|
impl DsUhidIdentity {
|
|
pub const fn dualsense() -> DsUhidIdentity {
|
|
DsUhidIdentity {
|
|
product: DS_PRODUCT,
|
|
rdesc: DUALSENSE_RDESC,
|
|
name: "DualSense",
|
|
phys: "dualsense",
|
|
slug: "ds",
|
|
}
|
|
}
|
|
|
|
pub const fn dualsense_edge() -> DsUhidIdentity {
|
|
DsUhidIdentity {
|
|
product: DS_EDGE_PRODUCT,
|
|
rdesc: DUALSENSE_EDGE_RDESC,
|
|
name: "DualSense Edge",
|
|
phys: "dualsense-edge",
|
|
slug: "dsedge",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A virtual DualSense / DualSense Edge 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 pad for wire index `index` under `id`'s identity (`index` is used only to
|
|
/// make the device name/uniq unique).
|
|
pub fn open(index: u8, id: &DsUhidIdentity) -> 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, id)
|
|
.context("UHID_CREATE2 DualSense")?;
|
|
Ok(ds)
|
|
}
|
|
|
|
/// Send UHID_CREATE2 under `id`'s identity. The uniq written here is cosmetic:
|
|
/// `hid-playstation` replaces it with the MAC from the pairing feature report (see
|
|
/// [`ds_pairing_reply`]) as soon as it binds.
|
|
fn send_create2(&mut self, index: u8, id: &DsUhidIdentity) -> 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 {} {index}", id.name)); // name[128]
|
|
put_cstr(&mut ev, 132, 64, &format!("punktfunk/{}/{index}", id.phys)); // phys[64]
|
|
put_cstr(&mut ev, 196, 64, &format!("punktfunk-{}-{index}", id.slug)); // uniq[64]
|
|
ev[260..262].copy_from_slice(&(id.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(&id.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 + id.rdesc.len()].copy_from_slice(id.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]]);
|
|
// Per-pad MAC: hid-playstation adopts it as the HID uniq, and SDL/Steam
|
|
// dedup controllers by that serial (see `ds_pairing_reply`).
|
|
let pairing = ds_pairing_reply(pad);
|
|
let data: &[u8] = match ev[8] {
|
|
0x05 => DS_FEATURE_CALIBRATION,
|
|
0x09 => &pairing,
|
|
0x20 => DS_FEATURE_FIRMWARE,
|
|
_ => &[],
|
|
};
|
|
let _ = self.reply_get_report(id, data);
|
|
}
|
|
UHID_SET_REPORT => {
|
|
// Ack (err=0) so a SET_REPORT writer doesn't block on the kernel's 5 s
|
|
// timeout. Nothing to parse: every known DualSense writer sends its feedback
|
|
// as OUTPUT reports (handled above), never SET_REPORT.
|
|
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
|
let _ = self.reply_set_report(id);
|
|
}
|
|
_ => {} // Start/Stop/Open/Close — 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(())
|
|
}
|
|
|
|
fn reply_set_report(&mut self, id: u32) -> Result<()> {
|
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
|
ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes());
|
|
// uhid_set_report_reply_req: id u32 [4..8], err u16 [8..10].
|
|
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
|
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack)
|
|
self.fd
|
|
.write_all(&ev)
|
|
.context("write UHID_SET_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);
|
|
}
|
|
}
|
|
|
|
/// The DualSense-specific half of the shared stateful manager (see [`PadProto`]): UHID transport
|
|
/// open, the [`DsState`] mappers, and the kernel-handshake service pass. Everything lifecycle-
|
|
/// shaped (slot table, unplug sweep, heartbeat, feedback dedup) lives in [`UhidManager`].
|
|
pub struct DsLinuxProto {
|
|
/// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button
|
|
/// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop.
|
|
remap: crate::inject::steam_remap::RemapConfig,
|
|
}
|
|
|
|
impl Default for DsLinuxProto {
|
|
fn default() -> DsLinuxProto {
|
|
DsLinuxProto {
|
|
remap: crate::inject::steam_remap::RemapConfig::from_env(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PadProto for DsLinuxProto {
|
|
type Pad = DualSensePad;
|
|
type State = DsState;
|
|
const LABEL: &'static str = "DualSense";
|
|
const DEVICE: &'static str = "DualSense";
|
|
const CREATE_HINT: &'static str = "";
|
|
|
|
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
|
|
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense())?;
|
|
tracing::info!(
|
|
index = idx,
|
|
"virtual DualSense created (UHID hid-playstation)"
|
|
);
|
|
Ok(p)
|
|
}
|
|
|
|
fn neutral(&self) -> DsState {
|
|
DsState::neutral()
|
|
}
|
|
|
|
/// Merge buttons/sticks/triggers from the frame, preserving touch + motion + pad clicks (those
|
|
/// come on the rich-input plane and must survive a button-only frame).
|
|
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
|
// Steam back grips have no DualSense slot — fold them onto standard buttons per the
|
|
// configured policy (default drop) so they aren't silently lost.
|
|
let buttons = crate::inject::steam_remap::fold_paddles(f.buttons, self.remap.paddles);
|
|
let mut s = DsState::from_gamepad(
|
|
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;
|
|
s.touch_click = prev.touch_click;
|
|
s
|
|
}
|
|
|
|
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
|
/// split the one touchpad left/right, pad clicks ride touch_click.
|
|
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
|
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
|
}
|
|
|
|
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
|
|
let _ = pad.write_state(st);
|
|
}
|
|
|
|
/// Answer the kernel's init handshake (it blocks `hid-playstation` init until its GET_REPORTs
|
|
/// are answered — call frequently) and parse a game's feedback: motor rumble on the universal
|
|
/// 0xCA plane, the rich lightbar/player-LED/trigger events on the 0xCD plane.
|
|
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
|
|
let fb = pad.service(idx);
|
|
PadFeedback {
|
|
rumble: fb.rumble,
|
|
hidout: fb.hidout,
|
|
// Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does
|
|
// not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`).
|
|
game_drove: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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`) from the button/stick frames (`handle`); the shared
|
|
/// [`UhidManager`] keeps each pad's full [`DsState`], re-emits the merged report whenever either
|
|
/// source changes, and heartbeats it through input silence (a real DualSense streams report `0x01`
|
|
/// continuously — `hid-playstation`/Proton/SDL treat a multi-second gap as an unplug).
|
|
pub type DualSenseManager = UhidManager<DsLinuxProto>;
|
|
|
|
/// The DualSense **Edge** half of the shared stateful manager: the plain-DualSense transport and
|
|
/// report codec under the Edge USB identity (`054C:0DF2` + the Edge descriptor), with the four
|
|
/// wire back-grip bits mapped onto the Edge's native `buttons[2]` slots instead of the
|
|
/// fold/drop policy — the whole point of this backend (a client's Deck grips / Elite paddles
|
|
/// stop vanishing). No remap config: every paddle has a native home.
|
|
///
|
|
/// Kernel note: `hid-playstation` binds the Edge PID since 6.1 (forced vibration-v2 output), but
|
|
/// only kernels ≥ 7.2 surface the Fn/back bits as evdev keys (`BTN_TRIGGER_HAPPY1..4`); SDL /
|
|
/// Steam Input read the report off hidraw and see them on any kernel.
|
|
#[derive(Default)]
|
|
pub struct DsEdgeLinuxProto;
|
|
|
|
impl PadProto for DsEdgeLinuxProto {
|
|
type Pad = DualSensePad;
|
|
type State = DsState;
|
|
const LABEL: &'static str = "DualSense Edge";
|
|
const DEVICE: &'static str = "DualSense Edge";
|
|
const CREATE_HINT: &'static str = "";
|
|
|
|
fn open(&mut self, idx: u8) -> Result<DualSensePad> {
|
|
let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense_edge())?;
|
|
tracing::info!(
|
|
index = idx,
|
|
"virtual DualSense Edge created (UHID hid-playstation)"
|
|
);
|
|
Ok(p)
|
|
}
|
|
|
|
fn neutral(&self) -> DsState {
|
|
DsState::neutral()
|
|
}
|
|
|
|
/// Merge buttons/sticks/triggers from the frame, preserving the rich-plane fields — like the
|
|
/// plain DualSense, EXCEPT the wire paddles are not folded away: they land on the Edge's own
|
|
/// `buttons[2]` bits (rebuilt from every button frame, so no extra persistence).
|
|
fn merge_frame(&self, prev: &DsState, f: &punktfunk_core::input::GamepadFrame) -> DsState {
|
|
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.buttons[2] |= edge_paddle_bits(f.buttons);
|
|
s.touch = prev.touch;
|
|
s.gyro = prev.gyro;
|
|
s.accel = prev.accel;
|
|
s.touch_click = prev.touch_click;
|
|
s
|
|
}
|
|
|
|
/// The shared DualSense-family mapping (dualsense_proto::DsState::apply_rich): Steam dual pads
|
|
/// split the one touchpad left/right, pad clicks ride touch_click.
|
|
fn apply_rich(&self, st: &mut DsState, rich: RichInput) {
|
|
st.apply_rich(rich, DS_TOUCH_W, DS_TOUCH_H);
|
|
}
|
|
|
|
fn write_state(&self, pad: &mut DualSensePad, st: &DsState) {
|
|
let _ = pad.write_state(st);
|
|
}
|
|
|
|
/// Same kernel handshake + feedback parse as the plain DualSense — the Edge's GET_REPORT set
|
|
/// (calibration 0x05 / pairing 0x09 / firmware 0x20) and output report 0x02 are identical
|
|
/// (the Edge's rumble arrives via the vibration-v2 valid_flag2 bit, which
|
|
/// [`parse_ds_output`] already handles).
|
|
fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback {
|
|
let fb = pad.service(idx);
|
|
PadFeedback {
|
|
rumble: fb.rumble,
|
|
hidout: fb.hidout,
|
|
// Linux hid-playstation reliably surfaces the game's rumble stop, so this backend does
|
|
// not need the abandoned-rumble force-off — stays untracked (see `PadFeedback`).
|
|
game_drove: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// All virtual DualSense Edge pads of a session — `PUNKTFUNK_GAMEPAD=edge`, or the per-pad kind a
|
|
/// client declares for a paddle-bearing physical controller.
|
|
pub type DualSenseEdgeManager = UhidManager<DsEdgeLinuxProto>;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use punktfunk_core::quic::HidOutput;
|
|
use std::os::unix::io::AsRawFd;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// evdev nodes whose input-device name contains `name`: (full name, /dev/input/eventN).
|
|
fn find_nodes(name: &str) -> Vec<(String, String)> {
|
|
let s = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
|
|
let mut out = Vec::new();
|
|
let mut cur = String::new();
|
|
for line in s.lines() {
|
|
if let Some(n) = line.strip_prefix("N: Name=") {
|
|
cur = n.trim_matches('"').to_string();
|
|
} else if let Some(h) = line.strip_prefix("H: Handlers=") {
|
|
if cur.contains(name) {
|
|
if let Some(ev) = h.split_whitespace().find(|t| t.starts_with("event")) {
|
|
out.push((cur.clone(), format!("/dev/input/{ev}")));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Whether the evdev at `node` advertises EV_FF (0x15) — the rumble-capable gamepad node
|
|
/// (the touchpad / motion / headset siblings don't).
|
|
fn has_ff(node: &str) -> bool {
|
|
let Ok(f) = std::fs::OpenOptions::new().read(true).open(node) else {
|
|
return false;
|
|
};
|
|
let mut bits = [0u8; 8];
|
|
// EVIOCGBIT(0, 8): the device's event-type bitmap.
|
|
let req: libc::c_ulong = (2 << 30) | (8 << 16) | (0x45 << 8) | 0x20;
|
|
// SAFETY: EVIOCGBIT(0) copies at most 8 bytes (EV_MAX/8 < 8) into the live `bits` buffer
|
|
// behind the valid evdev fd `f`; the kernel never writes past the ioctl's size argument.
|
|
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, bits.as_mut_ptr()) };
|
|
rc >= 0 && (bits[0x15 / 8] >> (0x15 % 8)) & 1 == 1
|
|
}
|
|
|
|
/// Upload an FF_RUMBLE effect on `node` and play it, exactly like SDL's evdev haptic backend.
|
|
/// Returns the OPEN fd with the id — closing the fd erases the process's effects (stopping
|
|
/// the rumble), so the caller must hold it while asserting.
|
|
fn evdev_rumble(node: &str, strong: u16, weak: u16) -> std::io::Result<(std::fs::File, i16)> {
|
|
use std::io::Write as _;
|
|
let mut f = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.open(node)?;
|
|
// struct ff_effect (48 B): type u16, id s16, direction u16, trigger, replay{len,delay},
|
|
// pad to 16, union (ff_rumble_effect { strong, weak }).
|
|
let mut eff = [0u8; 48];
|
|
eff[0..2].copy_from_slice(&0x50u16.to_ne_bytes()); // FF_RUMBLE
|
|
eff[2..4].copy_from_slice(&(-1i16).to_ne_bytes()); // id: kernel assigns
|
|
eff[10..12].copy_from_slice(&5000u16.to_ne_bytes()); // replay.length ms
|
|
eff[16..18].copy_from_slice(&strong.to_ne_bytes());
|
|
eff[18..20].copy_from_slice(&weak.to_ne_bytes());
|
|
// EVIOCSFF = _IOW('E', 0x80, struct ff_effect)
|
|
let req: libc::c_ulong = (1 << 30) | (48 << 16) | (0x45 << 8) | 0x80;
|
|
// SAFETY: EVIOCSFF reads/writes the 48-byte ff_effect behind the valid fd `f`; `eff` is
|
|
// exactly sizeof(struct ff_effect) and outlives the synchronous call.
|
|
let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, eff.as_mut_ptr()) };
|
|
if rc < 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
let id = i16::from_ne_bytes([eff[2], eff[3]]);
|
|
// struct input_event (24 B on 64-bit): timeval 16, type u16, code u16, value s32.
|
|
let mut ev = [0u8; 24];
|
|
ev[16..18].copy_from_slice(&0x15u16.to_ne_bytes()); // EV_FF
|
|
ev[18..20].copy_from_slice(&(id as u16).to_ne_bytes());
|
|
ev[20..24].copy_from_slice(&1i32.to_ne_bytes()); // play
|
|
f.write_all(&ev)?;
|
|
Ok((f, id))
|
|
}
|
|
|
|
/// `(HID_NAME, HID_UNIQ, /dev/hidrawN)` for every hidraw class device.
|
|
fn hidraw_devices() -> Vec<(String, String, String)> {
|
|
let mut out = Vec::new();
|
|
let Ok(dir) = std::fs::read_dir("/sys/class/hidraw") else {
|
|
return out;
|
|
};
|
|
for e in dir.flatten() {
|
|
let ue = std::fs::read_to_string(e.path().join("device/uevent")).unwrap_or_default();
|
|
let field = |k: &str| {
|
|
ue.lines()
|
|
.find_map(|l| l.strip_prefix(k))
|
|
.unwrap_or_default()
|
|
.to_string()
|
|
};
|
|
out.push((
|
|
field("HID_NAME="),
|
|
field("HID_UNIQ="),
|
|
format!("/dev/{}", e.file_name().to_string_lossy()),
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Service `pad` for `ms`, accumulating every captured feedback pass (all rumble levels in
|
|
/// order + all rich events) while keeping the input heartbeat going.
|
|
fn collect(pad: &mut DualSensePad, st: &DsState, ms: u64) -> (Vec<(u16, u16)>, Vec<HidOutput>) {
|
|
let start = Instant::now();
|
|
let (mut levels, mut hidout) = (Vec::new(), Vec::<HidOutput>::new());
|
|
while start.elapsed() < Duration::from_millis(ms) {
|
|
let fb = pad.service(0);
|
|
levels.extend(fb.rumble);
|
|
hidout.extend(fb.hidout);
|
|
let _ = pad.write_state(st);
|
|
std::thread::sleep(Duration::from_millis(4));
|
|
}
|
|
(levels, hidout)
|
|
}
|
|
|
|
/// On-box proof of the full Linux feedback surface, playing the GAME's role against a real
|
|
/// kernel: chain A drives rumble through evdev force feedback (`hid-playstation`'s ff-memless
|
|
/// → UHID_OUTPUT — what SDL/Steam fall back to without hidraw); chain B writes a raw DS5
|
|
/// output report to the pad's hidraw node (SDL/Steam's real path, and the ONLY way adaptive
|
|
/// triggers can arrive) and expects rumble + lightbar + player LEDs + both trigger blocks
|
|
/// back verbatim. Also pins the per-pad pairing MAC: two pads must present distinct uniqs or
|
|
/// SDL/Steam dedup them into one controller.
|
|
#[test]
|
|
#[ignore = "creates real /dev/uhid devices; needs hid-playstation, the input group, and the 60-punktfunk.rules hidraw rules"]
|
|
fn feedback_flows_via_evdev_ff_and_hidraw() {
|
|
let mut pad0 = DualSensePad::open(0, &DsUhidIdentity::dualsense()).expect("open pad 0");
|
|
let mut pad1 = DualSensePad::open(1, &DsUhidIdentity::dualsense()).expect("open pad 1");
|
|
let st = DsState::neutral();
|
|
// Let hid-playstation complete its GET_REPORT handshakes and register input devices.
|
|
let start = Instant::now();
|
|
while start.elapsed() < Duration::from_millis(1500) {
|
|
let _ = pad0.service(0);
|
|
let _ = pad1.service(1);
|
|
let _ = pad0.write_state(&st);
|
|
let _ = pad1.write_state(&st);
|
|
std::thread::sleep(Duration::from_millis(4));
|
|
}
|
|
let nodes = find_nodes("Punktfunk DualSense 0");
|
|
assert!(
|
|
!nodes.is_empty(),
|
|
"hid-playstation did not bind the uhid device"
|
|
);
|
|
let ff_node = nodes
|
|
.iter()
|
|
.map(|(_, n)| n.as_str())
|
|
.find(|n| has_ff(n))
|
|
.expect("no FF-capable evdev among the pad's input devices");
|
|
|
|
// Per-pad MAC: hid-playstation adopts the pairing-report MAC as HID_UNIQ; the two pads
|
|
// must differ (the SDL/Steam serial-dedup regression, see `ds_pairing_reply`).
|
|
let hidraws = hidraw_devices();
|
|
let uniq = |name: &str| {
|
|
hidraws
|
|
.iter()
|
|
.find(|(n, _, _)| n == name)
|
|
.map(|(_, u, _)| u.clone())
|
|
.unwrap_or_else(|| panic!("no hidraw for {name} in {hidraws:?}"))
|
|
};
|
|
assert_ne!(
|
|
uniq("Punktfunk DualSense 0"),
|
|
uniq("Punktfunk DualSense 1"),
|
|
"pads share one pairing MAC — SDL/Steam will dedup them into one controller"
|
|
);
|
|
|
|
// ---- Chain A: evdev force feedback ----
|
|
let (ff_fd, _) = evdev_rumble(ff_node, 0xC000, 0x4000).expect("EVIOCSFF/play");
|
|
let (levels, _) = collect(&mut pad0, &st, 1000);
|
|
assert!(
|
|
levels.iter().any(|&(l, h)| l > 0 || h > 0),
|
|
"evdev FF rumble never surfaced as UHID_OUTPUT: {levels:?}"
|
|
);
|
|
drop(ff_fd); // closing erases the effect: the stop must surface too
|
|
let (levels, _) = collect(&mut pad0, &st, 800);
|
|
assert!(
|
|
levels.contains(&(0, 0)),
|
|
"erase-on-close never produced a rumble stop: {levels:?}"
|
|
);
|
|
|
|
// ---- Chain B: raw DS5 output report over hidraw ----
|
|
let hr = hidraws
|
|
.iter()
|
|
.find(|(n, _, _)| n == "Punktfunk DualSense 0")
|
|
.map(|(_, _, d)| d.clone())
|
|
.unwrap();
|
|
let mut rep = [0u8; 48];
|
|
rep[0] = 0x02; // USB output report id
|
|
rep[1] = 0x03 | 0x04 | 0x08; // flag0: compat vibration + haptics select + R2 + L2
|
|
rep[2] = 0x04 | 0x10; // flag1: lightbar + player LEDs
|
|
rep[3] = 0x60; // motor right (high)
|
|
rep[4] = 0xA0; // motor left (low)
|
|
rep[11] = 0x21; // R2 trigger block: weapon mode + params
|
|
rep[12] = 0x04;
|
|
rep[13] = 0x07;
|
|
rep[22] = 0x26; // L2 trigger block: vibration mode + params
|
|
rep[23] = 0x02;
|
|
rep[44] = 0x04; // player LED middle
|
|
rep[45] = 0x10;
|
|
rep[46] = 0x20;
|
|
rep[47] = 0x30;
|
|
std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.open(&hr)
|
|
.and_then(|mut f| std::io::Write::write_all(&mut f, &rep))
|
|
.unwrap_or_else(|e| {
|
|
panic!(
|
|
"cannot write {hr} as this user ({e}) — Steam/SDL would be equally blocked; \
|
|
are the 60-punktfunk.rules hidraw rules installed?"
|
|
)
|
|
});
|
|
let (levels, hidout) = collect(&mut pad0, &st, 1000);
|
|
assert!(
|
|
levels.contains(&(0xA000, 0x6000)),
|
|
"hidraw rumble did not surface: {levels:?}"
|
|
);
|
|
let triggers: Vec<_> = hidout
|
|
.iter()
|
|
.filter_map(|h| match h {
|
|
HidOutput::Trigger { which, effect, .. } => Some((*which, effect.clone())),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
triggers.len(),
|
|
2,
|
|
"expected both trigger blocks: {hidout:?}"
|
|
);
|
|
assert!(
|
|
triggers.contains(&(1, rep[11..22].to_vec())),
|
|
"R2 block not verbatim"
|
|
);
|
|
assert!(
|
|
triggers.contains(&(0, rep[22..33].to_vec())),
|
|
"L2 block not verbatim"
|
|
);
|
|
assert!(
|
|
hidout.iter().any(|h| matches!(
|
|
h,
|
|
HidOutput::Led {
|
|
r: 0x10,
|
|
g: 0x20,
|
|
b: 0x30,
|
|
..
|
|
}
|
|
)),
|
|
"lightbar not surfaced: {hidout:?}"
|
|
);
|
|
assert!(
|
|
hidout
|
|
.iter()
|
|
.any(|h| matches!(h, HidOutput::PlayerLeds { bits: 0x04, .. })),
|
|
"player LEDs not surfaced: {hidout:?}"
|
|
);
|
|
}
|
|
}
|