feat(dualsense): Phase C/D/E — virtual DualSense routing + 0xCC/0xCD planes + C ABI
ci / rust (push) Has been cancelled
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:
@@ -465,6 +465,136 @@ pub struct PunktfunkConnection {
|
||||
last_audio: std::sync::Mutex<Option<crate::client::AudioPacket>>,
|
||||
}
|
||||
|
||||
/// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
||||
pub const PUNKTFUNK_HIDOUT_LED: u8 = 1;
|
||||
/// `PunktfunkHidOutput::kind` — player-indicator LEDs (`player_bits` valid, low 5 bits).
|
||||
pub const PUNKTFUNK_HIDOUT_PLAYER_LEDS: u8 = 2;
|
||||
/// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid).
|
||||
pub const PUNKTFUNK_HIDOUT_TRIGGER: u8 = 3;
|
||||
/// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block).
|
||||
pub const PUNKTFUNK_HID_EFFECT_MAX: u8 = 11;
|
||||
|
||||
/// One DualSense HID-output feedback event a game wrote to the host's virtual pad
|
||||
/// ([`punktfunk_connection_next_hidout`]). `kind` selects which fields are meaningful — replay it
|
||||
/// on a real DualSense (lightbar color, player LEDs, or an adaptive-trigger effect via the
|
||||
/// platform's `GCDualSenseAdaptiveTrigger`-style API).
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PunktfunkHidOutput {
|
||||
/// One of `PUNKTFUNK_HIDOUT_*`.
|
||||
pub kind: u8,
|
||||
/// Gamepad index.
|
||||
pub pad: u8,
|
||||
/// LED: lightbar red.
|
||||
pub r: u8,
|
||||
/// LED: lightbar green.
|
||||
pub g: u8,
|
||||
/// LED: lightbar blue.
|
||||
pub b: u8,
|
||||
/// PlayerLeds: lit player indicators (low 5 bits).
|
||||
pub player_bits: u8,
|
||||
/// Trigger: 0 = L2, 1 = R2.
|
||||
pub which: u8,
|
||||
/// Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`).
|
||||
pub effect_len: u8,
|
||||
/// Trigger: the raw DualSense trigger parameter block (mode + params).
|
||||
pub effect: [u8; 11],
|
||||
}
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
impl PunktfunkHidOutput {
|
||||
fn from_hid(h: &crate::quic::HidOutput) -> PunktfunkHidOutput {
|
||||
use crate::quic::HidOutput;
|
||||
let mut out = PunktfunkHidOutput {
|
||||
kind: 0,
|
||||
pad: 0,
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
player_bits: 0,
|
||||
which: 0,
|
||||
effect_len: 0,
|
||||
effect: [0u8; 11],
|
||||
};
|
||||
match h {
|
||||
HidOutput::Led { pad, r, g, b } => {
|
||||
out.kind = PUNKTFUNK_HIDOUT_LED;
|
||||
out.pad = *pad;
|
||||
out.r = *r;
|
||||
out.g = *g;
|
||||
out.b = *b;
|
||||
}
|
||||
HidOutput::PlayerLeds { pad, bits } => {
|
||||
out.kind = PUNKTFUNK_HIDOUT_PLAYER_LEDS;
|
||||
out.pad = *pad;
|
||||
out.player_bits = *bits;
|
||||
}
|
||||
HidOutput::Trigger { pad, which, effect } => {
|
||||
out.kind = PUNKTFUNK_HIDOUT_TRIGGER;
|
||||
out.pad = *pad;
|
||||
out.which = *which;
|
||||
let n = effect.len().min(out.effect.len());
|
||||
out.effect[..n].copy_from_slice(&effect[..n]);
|
||||
out.effect_len = n as u8;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `PunktfunkRichInput::kind` — a touchpad contact (`finger`/`active`/`x`/`y` valid).
|
||||
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
||||
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
||||
pub const PUNKTFUNK_RICH_MOTION: u8 = 2;
|
||||
|
||||
/// One rich client→host input for the host's virtual DualSense
|
||||
/// ([`punktfunk_connection_send_rich_input`]): a touchpad contact or a motion sample. Set `kind`
|
||||
/// and the matching fields; the others are ignored.
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PunktfunkRichInput {
|
||||
/// One of `PUNKTFUNK_RICH_*`.
|
||||
pub kind: u8,
|
||||
/// Gamepad index.
|
||||
pub pad: u8,
|
||||
/// Touchpad: contact id (0 or 1).
|
||||
pub finger: u8,
|
||||
/// Touchpad: 1 = finger down, 0 = lifted.
|
||||
pub active: u8,
|
||||
/// Touchpad: normalized x, 0..=65535 across the touchpad.
|
||||
pub x: u16,
|
||||
/// Touchpad: normalized y, 0..=65535 across the touchpad.
|
||||
pub y: u16,
|
||||
/// Motion: gyro (pitch, yaw, roll), raw signed-16.
|
||||
pub gyro: [i16; 3],
|
||||
/// Motion: accelerometer (x, y, z), raw signed-16.
|
||||
pub accel: [i16; 3],
|
||||
}
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
impl PunktfunkRichInput {
|
||||
fn to_rich(self) -> Option<crate::quic::RichInput> {
|
||||
use crate::quic::RichInput;
|
||||
match self.kind {
|
||||
PUNKTFUNK_RICH_TOUCHPAD => Some(RichInput::Touchpad {
|
||||
pad: self.pad,
|
||||
finger: self.finger,
|
||||
active: self.active != 0,
|
||||
x: self.x,
|
||||
y: self.y,
|
||||
}),
|
||||
PUNKTFUNK_RICH_MOTION => Some(RichInput::Motion {
|
||||
pad: self.pad,
|
||||
gyro: self.gyro,
|
||||
accel: self.accel,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8.
|
||||
#[cfg(feature = "quic")]
|
||||
unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result<Option<&'a str>, ()> {
|
||||
@@ -859,6 +989,42 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger)
|
||||
/// the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on
|
||||
/// timeout, [`PunktfunkStatus::Closed`] once the session ended. Only the DualSense host backend
|
||||
/// emits these. Same threading rules as [`punktfunk_connection_next_rumble`] (one puller, may run
|
||||
/// alongside the other planes).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHidOutput`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_hidout(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkHidOutput,
|
||||
timeout_ms: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if out.is_null() {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
match c
|
||||
.inner
|
||||
.next_hidout(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(h) => {
|
||||
unsafe { *out = PunktfunkHidOutput::from_hid(&h) };
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||
///
|
||||
/// # Safety
|
||||
@@ -921,6 +1087,38 @@ pub unsafe extern "C" fn punktfunk_connection_send_mic(
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one rich input event (DualSense touchpad contact or motion sample) to the host as a QUIC
|
||||
/// datagram (non-blocking enqueue). The host applies it to its virtual DualSense pad — a no-op
|
||||
/// unless the host runs the DualSense gamepad backend. [`PunktfunkStatus::InvalidArg`] on an
|
||||
/// unknown `kind`.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `rich` points to a valid [`PunktfunkRichInput`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_send_rich_input(
|
||||
c: *mut PunktfunkConnection,
|
||||
rich: *const PunktfunkRichInput,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let rich = match unsafe { rich.as_ref() } {
|
||||
Some(r) => r,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
match rich.to_rich() {
|
||||
Some(r) => match c.inner.send_rich_input(r) {
|
||||
Ok(()) => PunktfunkStatus::Ok,
|
||||
Err(e) => e.status(),
|
||||
},
|
||||
None => PunktfunkStatus::InvalidArg,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The currently active session mode — the Welcome's, until an accepted
|
||||
/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
|
||||
///
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
use crate::config::{CompositorPref, Mode, Role};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::quic::{endpoint, io, Hello, Reconfigure, Reconfigured, Start, Welcome};
|
||||
use crate::quic::{
|
||||
endpoint, io, Hello, HidOutput, Reconfigure, Reconfigured, RichInput, Start, Welcome,
|
||||
};
|
||||
use crate::session::{Frame, Session};
|
||||
use crate::transport::UdpTransport;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -36,6 +38,10 @@ const AUDIO_QUEUE: usize = 64;
|
||||
/// periodically, so a dropped transition (including a stop) heals within ~500 ms.
|
||||
const RUMBLE_QUEUE: usize = 16;
|
||||
|
||||
/// HID-output (DualSense lightbar / player LEDs / adaptive triggers) buffered for the embedder.
|
||||
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
|
||||
const HIDOUT_QUEUE: usize = 32;
|
||||
|
||||
/// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioPacket {
|
||||
@@ -49,9 +55,13 @@ pub struct NativeClient {
|
||||
frames: Receiver<Frame>,
|
||||
audio: Receiver<AudioPacket>,
|
||||
rumble: Receiver<(u16, u16, u16)>,
|
||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||
hidout: Receiver<HidOutput>,
|
||||
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
||||
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
||||
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>,
|
||||
/// Outbound rich input (DualSense touchpad / motion) → 0xCC datagrams by the worker.
|
||||
rich_input_tx: tokio::sync::mpsc::UnboundedSender<RichInput>,
|
||||
reconfig_tx: tokio::sync::mpsc::UnboundedSender<Mode>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
worker: Option<std::thread::JoinHandle<()>>,
|
||||
@@ -86,8 +96,10 @@ impl NativeClient {
|
||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Frame>(FRAME_QUEUE);
|
||||
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
||||
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE);
|
||||
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
||||
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();
|
||||
let (mic_tx, mic_rx) = tokio::sync::mpsc::unbounded_channel::<(u32, u64, Vec<u8>)>();
|
||||
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
||||
let (reconfig_tx, reconfig_rx) = tokio::sync::mpsc::unbounded_channel::<Mode>();
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(Mode, [u8; 32])>>();
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
@@ -120,8 +132,10 @@ impl NativeClient {
|
||||
frame_tx,
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
hidout_tx,
|
||||
input_rx,
|
||||
mic_rx,
|
||||
rich_input_rx,
|
||||
reconfig_rx,
|
||||
ready_tx,
|
||||
shutdown: shutdown_w,
|
||||
@@ -143,8 +157,10 @@ impl NativeClient {
|
||||
frames: frame_rx,
|
||||
audio: audio_rx,
|
||||
rumble: rumble_rx,
|
||||
hidout: hidout_rx,
|
||||
input_tx,
|
||||
mic_tx,
|
||||
rich_input_tx,
|
||||
reconfig_tx,
|
||||
shutdown,
|
||||
worker: Some(worker),
|
||||
@@ -297,6 +313,18 @@ impl NativeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive
|
||||
/// trigger) the host's virtual pad received from a game; same timeout/closed semantics as
|
||||
/// [`NativeClient::next_rumble`]. Replay it on a real DualSense (e.g. via the platform's
|
||||
/// `GCDualSenseAdaptiveTrigger` API). Only the DualSense host backend emits these.
|
||||
pub fn next_hidout(&self, timeout: Duration) -> Result<HidOutput> {
|
||||
match self.hidout.recv_timeout(timeout) {
|
||||
Ok(h) => Ok(h),
|
||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue one input event for delivery as a QUIC datagram.
|
||||
pub fn send_input(&self, ev: &InputEvent) -> Result<()> {
|
||||
self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed)
|
||||
@@ -311,6 +339,15 @@ impl NativeClient {
|
||||
.send((seq, pts_ns, opus))
|
||||
.map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
|
||||
/// Queue one rich input event (DualSense touchpad contact or motion sample) for delivery as a
|
||||
/// 0xCC datagram. The host applies it to its virtual DualSense pad. Best-effort, dropped under
|
||||
/// loss like every datagram. No-op unless the host runs the DualSense gamepad backend.
|
||||
pub fn send_rich_input(&self, rich: RichInput) -> Result<()> {
|
||||
self.rich_input_tx
|
||||
.send(rich)
|
||||
.map_err(|_| PunktfunkError::Closed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeClient {
|
||||
@@ -332,8 +369,10 @@ struct WorkerArgs {
|
||||
frame_tx: SyncSender<Frame>,
|
||||
audio_tx: SyncSender<AudioPacket>,
|
||||
rumble_tx: SyncSender<(u16, u16, u16)>,
|
||||
hidout_tx: SyncSender<HidOutput>,
|
||||
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||
mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec<u8>)>,
|
||||
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||
reconfig_rx: tokio::sync::mpsc::UnboundedReceiver<Mode>,
|
||||
ready_tx: std::sync::mpsc::Sender<Result<(Mode, [u8; 32])>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
@@ -353,8 +392,10 @@ async fn worker_main(args: WorkerArgs) {
|
||||
frame_tx,
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
hidout_tx,
|
||||
mut input_rx,
|
||||
mut mic_rx,
|
||||
mut rich_input_rx,
|
||||
mut reconfig_rx,
|
||||
ready_tx,
|
||||
shutdown,
|
||||
@@ -455,6 +496,14 @@ async fn worker_main(args: WorkerArgs) {
|
||||
}
|
||||
});
|
||||
|
||||
// Rich-input task: embedder DualSense touchpad / motion → 0xCC uplink datagrams.
|
||||
let rich_conn = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(rich) = rich_input_rx.recv().await {
|
||||
let _ = rich_conn.send_datagram(rich.encode().into());
|
||||
}
|
||||
});
|
||||
|
||||
// Control task: the handshake stream stays open for mid-stream renegotiation. One
|
||||
// request at a time — write Reconfigure, await Reconfigured, publish the active mode.
|
||||
{
|
||||
@@ -504,6 +553,11 @@ async fn worker_main(args: WorkerArgs) {
|
||||
let _ = rumble_tx.try_send(r);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HIDOUT_MAGIC) => {
|
||||
if let Some(h) = HidOutput::decode(&d) {
|
||||
let _ = hidout_tx.try_send(h);
|
||||
}
|
||||
}
|
||||
_ => {} // unknown tag — a newer host; ignore
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user