feat(dualsense): Phase C/D/E — virtual DualSense routing + 0xCC/0xCD planes + C ABI
ci / rust (push) Has been cancelled

PUNKTFUNK_GAMEPAD=dualsense now routes a session's gamepad through a real virtual
DualSense (UHID + hid-playstation) end to end:

- host: a `PadBackend` enum (m3.rs) selects `GamepadManager` (uinput xpad, default)
  or the new `DualSenseManager` (dualsense.rs) per session. The manager keeps each
  pad's full DsState so touchpad + motion (rich-input plane) persist across
  button/stick frames, and services the !Send /dev/uhid fd only on the input thread
  (which cycles <=4ms, so the GET_REPORT init handshake completes).
- feedback: `service()` now returns `DsFeedback { hidout, rumble }`. Motor rumble
  stays on the universal 0xCA plane (so non-DualSense clients still feel it; manager
  dedups change); lightbar / player LEDs / adaptive-trigger effects ride the new
  0xCD HID-output plane (host->client) as `HidOutput`.
- rich input: touchpad contacts + motion ride the 0xCC plane (client->host) as
  `RichInput`, applied via `DualSenseManager::apply_rich` (merged with button state;
  touch normalized 0..65535 -> the touchpad resolution).
- connector + C ABI: `NativeClient::next_hidout` / `send_rich_input`, exported as
  `punktfunk_connection_next_hidout` (-> PunktfunkHidOutput) and
  `punktfunk_connection_send_rich_input` (<- PunktfunkRichInput); header regenerated.
- reference client: `--rich-input-test` drives the DualSense touchpad + motion and
  logs the 0xCD feedback that comes back.

Validated live on-box: a synthetic-source m3-host + client-rs created the real
kernel DualSense, drove 0xCC, and decoded 12 live 0xCD events (the kernel's actual
lightbar/trigger init reports) with the data plane unaffected (600/600 frames).
Adversarial review fixes folded in: the input loop no longer skips the rich drain +
feedback pump on a dropped gamepad event, and the touch contact id is clamped to its
slot. Remaining: the Apple client renders triggers/rumble on a real DualSense.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 08:36:12 +00:00
parent e5b15353c7
commit 59edeedf07
8 changed files with 799 additions and 47 deletions
+250 -14
View File
@@ -11,7 +11,9 @@
//! The report descriptor + field layout are the canonical inputtino ones (games-on-whales/
//! inputtino `src/uhid/include/uhid/ps5.hpp`), so `hid-playstation` binds the same as a USB pad.
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;
@@ -254,6 +256,16 @@ fn pack_touch(dst: &mut [u8], t: &Touch) {
dst[3] = ((y >> 4) & 0xFF) as u8;
}
/// What one [`DualSensePad::service`] pass extracted from the device's HID output reports.
/// Rich feedback (lightbar / player LEDs / adaptive triggers) rides the HID-output plane (0xCD);
/// motor rumble rides the universal rumble plane (0xCA) so non-DualSense clients still feel it.
#[derive(Default)]
pub struct DsFeedback {
pub hidout: Vec<HidOutput>,
/// `(low, high)` motor levels (0..=0xFFFF), if a report carried them.
pub rumble: Option<(u16, u16)>,
}
/// 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).
@@ -341,10 +353,10 @@ impl DualSensePad {
/// 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
/// [`HidOutput`] events for pad `pad`. Call frequently — especially right after [`open`] so the
/// 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) -> Vec<punktfunk_core::quic::HidOutput> {
let mut out = Vec::new();
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 {
@@ -355,7 +367,7 @@ impl DualSensePad {
// 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 out);
parse_ds_output(pad, &ev[4..end], &mut fb);
}
UHID_GET_REPORT => {
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
@@ -371,7 +383,7 @@ impl DualSensePad {
_ => {} // Start/Stop/Open/Close/SetReport — ignore
}
}
out
fb
}
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
@@ -398,33 +410,257 @@ impl Drop for DualSensePad {
}
}
/// Parse a DualSense USB output report (`0x02`) into [`HidOutput`] events. The byte layout below
/// is the USB DualSense common report; only the well-understood fields (motor rumble, lightbar
/// RGB, player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
fn parse_ds_output(pad: u8, data: &[u8], out: &mut Vec<punktfunk_core::quic::HidOutput>) {
use punktfunk_core::quic::HidOutput;
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
// data[0] is the report id (0x02). Be defensive about short reports.
if data.first() != Some(&0x02) || data.len() < 48 {
return;
}
// Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at
// data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer,
// and route to the universal rumble plane (0xCA). We don't gate on the report's valid-flags
// (matching the LED/trigger handling) — the manager only forwards a *change*, so a report
// that touches only the LED doesn't spam a rumble-stop.
let high = (data[3] as u16) << 8;
let low = (data[4] as u16) << 8;
fb.rumble = Some((low, high));
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
let (r, g, b) = (data[45], data[46], data[47]);
out.push(HidOutput::Led { pad, r, g, b });
out.push(HidOutput::PlayerLeds {
fb.hidout.push(HidOutput::Led { pad, r, g, b });
fb.hidout.push(HidOutput::PlayerLeds {
pad,
bits: data[44] & 0x1F,
});
// Adaptive-trigger parameter blocks: L2 at bytes 11..22, R2 at 22..33 (11 bytes each).
if data.len() >= 33 {
out.push(HidOutput::Trigger {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 0,
effect: data[11..22].to_vec(),
});
out.push(HidOutput::Trigger {
fb.hidout.push(HidOutput::Trigger {
pad,
which: 1,
effect: data[22..33].to_vec(),
});
}
}
/// 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)>,
/// 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],
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 reported resolution.
t.x = ((x as u32 * DS_TOUCH_W as u32) / u16::MAX as u32) as u16;
t.y = ((y as u32 * DS_TOUCH_H 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);
}
}
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) => {
self.pads[idx] = Some(p);
self.state[idx] = DsState::neutral();
self.last_rumble[idx] = (0, 0);
}
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);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A DualSense USB output report (`0x02`) parses into motor rumble (0xCA), lightbar, player
/// LEDs, and both adaptive-trigger blocks (0xCD).
#[test]
fn parse_output_report() {
let mut data = vec![0u8; 48];
data[0] = 0x02; // report id
data[3] = 0x80; // right (high-freq) motor
data[4] = 0x40; // left (low-freq) motor
data[44] = 0x03; // player LEDs (low 5 bits)
data[45] = 10; // R
data[46] = 20; // G
data[47] = 30; // B
let mut fb = DsFeedback::default();
parse_ds_output(0, &data, &mut fb);
// (low, high) = (left<<8, right<<8).
assert_eq!(fb.rumble, Some((0x4000, 0x8000)));
assert!(fb.hidout.contains(&HidOutput::Led {
pad: 0,
r: 10,
g: 20,
b: 30
}));
assert!(fb
.hidout
.contains(&HidOutput::PlayerLeds { pad: 0, bits: 3 }));
assert_eq!(
fb.hidout
.iter()
.filter(|h| matches!(h, HidOutput::Trigger { .. }))
.count(),
2
);
}
/// A short / wrong-id report yields nothing.
#[test]
fn parse_output_rejects_garbage() {
let mut fb = DsFeedback::default();
parse_ds_output(0, &[0x01, 0, 0], &mut fb); // wrong report id, too short
assert!(fb.rumble.is_none());
assert!(fb.hidout.is_empty());
}
}
+104 -23
View File
@@ -519,25 +519,32 @@ async fn serve_session(
// per-session) and sends force feedback back over `conn`. It exits when the channel closes
// (datagram task ends on disconnect) — fresh gamepad state per session.
let (input_tx, input_rx) = std::sync::mpsc::channel::<InputEvent>();
let (rich_tx, rich_rx) = std::sync::mpsc::channel::<punktfunk_core::quic::RichInput>();
let input_handle = {
let conn = conn.clone();
std::thread::Builder::new()
.name("punktfunk-m3-input".into())
.spawn(move || input_thread(input_rx, conn, inj_tx))
.spawn(move || input_thread(input_rx, rich_rx, conn, inj_tx))
.context("spawn input thread")?
};
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
// would race for datagrams): 0xCB → mic uplink (Opus, forwarded to the host-lifetime mic
// service), 0xC8 → input (forwarded to the per-session input thread). The magics are disjoint,
// so decode order doesn't matter. Unknown tags are ignored.
// service), 0xCC rich input (DualSense touchpad / motion, to the per-session input thread),
// 0xC8 → input (also the input thread). The magics are disjoint, so decode order doesn't
// matter. Unknown tags are ignored.
let input_conn = conn.clone();
tokio::spawn(async move {
let (mut input_count, mut mic_count) = (0u64, 0u64);
let (mut input_count, mut mic_count, mut rich_count) = (0u64, 0u64, 0u64);
while let Ok(d) = input_conn.read_datagram().await {
if let Some((_seq, _pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) {
mic_count += 1;
// Host-lifetime mic service; a send error just means the host is shutting down.
let _ = mic_tx.send(opus.to_vec());
} else if let Some(rich) = punktfunk_core::quic::RichInput::decode(&d) {
rich_count += 1;
if rich_tx.send(rich).is_err() {
break;
}
} else if let Some(ev) = InputEvent::decode(&d) {
input_count += 1;
if input_tx.send(ev).is_err() {
@@ -548,6 +555,7 @@ async fn serve_session(
tracing::info!(
input = input_count,
mic = mic_count,
rich = rich_count,
"client datagram stream ended"
);
});
@@ -873,17 +881,77 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
tracing::debug!("mic service stopped (host shutting down)");
}
/// The session's virtual-gamepad backend. Default = uinput X-Box-360 pads
/// ([`GamepadManager`](crate::inject::gamepad::GamepadManager)); `PUNKTFUNK_GAMEPAD=dualsense`
/// switches to virtual DualSense pads (UHID + the kernel `hid-playstation` driver) so a game sees
/// a *real* DualSense — adaptive triggers, lightbar, touchpad, motion — and a game's feedback
/// flows back over the rich HID-output plane. Selected once per session (sessions run serially).
enum PadBackend {
Xbox360(crate::inject::gamepad::GamepadManager),
#[cfg(target_os = "linux")]
DualSense(crate::inject::dualsense::DualSenseManager),
}
impl PadBackend {
fn select() -> PadBackend {
#[cfg(target_os = "linux")]
if std::env::var("PUNKTFUNK_GAMEPAD").as_deref() == Ok("dualsense") {
tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)");
return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new());
}
PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new())
}
fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
match self {
PadBackend::Xbox360(m) => m.handle(ev),
#[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.handle(ev),
}
}
/// Apply a rich client→host event (DualSense touchpad / motion). A no-op for the X-Box pad,
/// which has no equivalent.
fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) {
#[cfg(target_os = "linux")]
if let PadBackend::DualSense(m) = self {
m.apply_rich(_rich);
}
}
/// Service feedback every cycle. `rumble` carries motor force-feedback on the universal plane
/// (both backends); `hidout` carries DualSense-only rich feedback (lightbar / player LEDs /
/// adaptive triggers — DualSense backend only).
fn pump(
&mut self,
rumble: impl FnMut(u16, u16, u16),
hidout: impl FnMut(punktfunk_core::quic::HidOutput),
) {
match self {
PadBackend::Xbox360(m) => {
let _ = hidout; // the X-Box pad has no rich-feedback plane
m.pump_rumble(rumble)
}
#[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.pump(rumble, hidout),
}
}
}
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
/// service (`inj_tx`) and gamepad events to this session's own [`GamepadManager`]
/// (crate::inject::gamepad), with force feedback pumped between events and sent back as rumble
/// datagrams. The gamepads (uinput) are created and torn down with the session; the
/// pointer/keyboard injector (and its portal grant) lives in the service, across sessions.
/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (uinput X-Box pads or,
/// with `PUNKTFUNK_GAMEPAD=dualsense`, virtual DualSense pads), with rich client→host input
/// (touchpad / motion, `rich_rx`) merged in and feedback pumped between events — rumble on the
/// universal datagram plane, DualSense LED/trigger feedback on the HID-output plane. The gamepads
/// are created and torn down with the session; the pointer/keyboard injector (and its portal
/// grant) lives in the service, across sessions.
fn input_thread(
rx: std::sync::mpsc::Receiver<InputEvent>,
rich_rx: std::sync::mpsc::Receiver<punktfunk_core::quic::RichInput>,
conn: quinn::Connection,
inj_tx: std::sync::mpsc::Sender<InputEvent>,
) {
let mut pads = crate::inject::gamepad::GamepadManager::new();
let mut pads = PadBackend::select();
let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
let mut pad_mask = 0u16;
// Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams),
@@ -896,13 +964,15 @@ fn input_thread(
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
Ok(ev) => match ev.kind {
InputKind::GamepadButton | InputKind::GamepadAxis => {
// A bad index / unknown axis just doesn't update a pad — fall through (no
// `continue`) so the rich-input drain + feedback pump below still run every
// iteration (the DualSense GET_REPORT handshake must be serviced promptly).
let idx = ev.flags as usize;
if idx >= MAX_WIRE_PADS || !pad_state[idx].apply(&ev) {
continue;
if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) {
pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
}
pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
}
_ => {
// Pointer/keyboard → the host-lifetime injector service (one persistent
@@ -915,15 +985,26 @@ fn input_thread(
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
// Service force feedback every iteration (≤4 ms latency; games block on EVIOCSFF).
pads.pump_rumble(|pad, low, high| {
if let Some(s) = rumble_state.get_mut(pad as usize) {
*s = (low, high);
rumble_seen[pad as usize] = true;
}
let d = punktfunk_core::quic::encode_rumble_datagram(pad, low, high);
let _ = conn.send_datagram(d.to_vec().into());
});
// Drain rich client→host input (DualSense touchpad / motion) into the pad backend.
while let Ok(rich) = rich_rx.try_recv() {
pads.apply_rich(rich);
}
// Service feedback every iteration (≤4 ms latency; games block on EVIOCSFF, and the
// DualSense kernel handshake must be answered promptly). Rumble → the universal 0xCA
// plane; DualSense rich feedback (lightbar / player LEDs / adaptive triggers) → 0xCD.
pads.pump(
|pad, low, high| {
if let Some(s) = rumble_state.get_mut(pad as usize) {
*s = (low, high);
rumble_seen[pad as usize] = true;
}
let d = punktfunk_core::quic::encode_rumble_datagram(pad, low, high);
let _ = conn.send_datagram(d.to_vec().into());
},
|h| {
let _ = conn.send_datagram(h.encode().into());
},
);
if last_refresh.elapsed() >= std::time::Duration::from_millis(500) {
last_refresh = std::time::Instant::now();
for (i, &(low, high)) in rumble_state.iter().enumerate() {
+5 -1
View File
@@ -106,7 +106,11 @@ fn real_main() -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut i, mut last_write) = (0i32, Instant::now());
while Instant::now() < deadline {
for o in pad.service(0) {
let fb = pad.service(0);
if let Some((low, high)) = fb.rumble {
println!(" rumble from kernel/game: low={low} high={high}");
}
for o in fb.hidout {
println!(" hid output from kernel/game: {o:?}");
}
if last_write.elapsed() >= Duration::from_millis(300) {