diff --git a/crates/punktfunk-client-rs/src/main.rs b/crates/punktfunk-client-rs/src/main.rs index 6dad850..58942dc 100644 --- a/crates/punktfunk-client-rs/src/main.rs +++ b/crates/punktfunk-client-rs/src/main.rs @@ -10,7 +10,10 @@ //! stream (watch them land in the host session, e.g. xev inside gamescope). `--mic-test` //! exercises the mic uplink: a synthetic 440 Hz tone streamed as Opus (0xCB) → the host's //! virtual microphone source (record it host-side to hear the tone). `--touch-test` drags a -//! synthetic finger in a circle → host libei `ei_touchscreen` injection. +//! synthetic finger in a circle → host libei `ei_touchscreen` injection. `--rich-input-test` +//! drives a virtual DualSense touchpad + motion over the 0xCC plane (host on +//! `PUNKTFUNK_GAMEPAD=dualsense`) and logs the 0xCD HID-output feedback (lightbar / adaptive +//! triggers) that comes back. //! //! `--pin <64-hex>` pins the host's certificate fingerprint (the host logs it at startup); //! without it the client trusts on first use and prints the observed fingerprint to pin. @@ -44,6 +47,9 @@ struct Args { mic_test: bool, /// `--touch-test` — drag a synthetic finger in a circle (proves the touch path). touch_test: bool, + /// `--rich-input-test` — drive the DualSense touchpad + motion over 0xCC (host needs + /// `PUNKTFUNK_GAMEPAD=dualsense`); also logs the 0xCD HID-output feedback that comes back. + rich_input_test: bool, pin: Option<[u8; 32]>, /// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream. remode: Option<(Mode, u32)>, @@ -146,6 +152,7 @@ fn parse_args() -> Args { input_test: argv.iter().any(|a| a == "--input-test"), mic_test: argv.iter().any(|a| a == "--mic-test"), touch_test: argv.iter().any(|a| a == "--touch-test"), + rich_input_test: argv.iter().any(|a| a == "--rich-input-test"), pin, remode, pair: get("--pair").map(String::from), @@ -450,6 +457,60 @@ async fn session(args: Args) -> Result<()> { }); } + // Rich-input plane: instantiate pad 0 on the host (a gamepad event creates the virtual + // DualSense), then drive its touchpad (drag a finger across) + motion (gyro wobble) over the + // 0xCC plane. Proves the rich client→host path; the 0xCD feedback is logged by the receive + // loop below. Requires the host on the DualSense backend (`PUNKTFUNK_GAMEPAD=dualsense`). + if args.rich_input_test { + let conn2 = conn.clone(); + tokio::spawn(async move { + use punktfunk_core::input::gamepad::AXIS_LS_X; + use punktfunk_core::quic::RichInput; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + // A neutral gamepad axis event makes the host create the virtual DualSense pad 0. + let arrive = InputEvent { + kind: InputKind::GamepadAxis, + _pad: [0; 3], + code: AXIS_LS_X, + x: 0, + y: 0, + flags: 0, + }; + let _ = conn2.send_datagram(arrive.encode().to_vec().into()); + tracing::info!( + "rich-input-test: dragging the DualSense touchpad + wobbling motion for ~6s" + ); + let touch = |active, x, y| RichInput::Touchpad { + pad: 0, + finger: 0, + active, + x, + y, + }; + for _ in 0..3u32 { + let _ = conn2.send_datagram(touch(true, 0, 32768).encode().into()); + for i in 0..60u32 { + let x = ((i * 65535) / 60) as u16; + let _ = conn2.send_datagram(touch(true, x, 32768).encode().into()); + let g = (((i as i32 % 20) - 10) * 500) as i16; // gyro wobble + let _ = conn2.send_datagram( + RichInput::Motion { + pad: 0, + gyro: [g, 0, 0], + accel: [0, 0, 16384], + } + .encode() + .into(), + ); + tokio::time::sleep(std::time::Duration::from_millis(30)).await; + } + let _ = conn2.send_datagram(touch(false, 65535, 32768).encode().into()); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + tracing::info!("rich-input-test: done"); + }); + } + // Closed-flag for the blocking receive loop. let closed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); { @@ -466,8 +527,14 @@ async fn session(args: Args) -> Result<()> { let audio_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); let audio_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); let rumble_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let hidout_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); { - let (a, ab, r) = (audio_pkts.clone(), audio_bytes.clone(), rumble_pkts.clone()); + let (a, ab, r, h) = ( + audio_pkts.clone(), + audio_bytes.clone(), + rumble_pkts.clone(), + hidout_pkts.clone(), + ); let conn2 = conn.clone(); tokio::spawn(async move { use std::sync::atomic::Ordering::Relaxed; @@ -477,6 +544,12 @@ async fn session(args: Args) -> Result<()> { ab.fetch_add(opus.len() as u64, Relaxed); } else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() { r.fetch_add(1, Relaxed); + } else if let Some(hid) = punktfunk_core::quic::HidOutput::decode(&d) { + // The DualSense feedback plane (lightbar / player LEDs / adaptive triggers). + // Log the first few so a playtest can see triggers/LEDs arrive without spam. + if h.fetch_add(1, Relaxed) < 12 { + tracing::info!(?hid, "DualSense HID output (0xCD)"); + } } } }); @@ -587,17 +660,19 @@ async fn session(args: Args) -> Result<()> { // Report the side planes whether or not the video plane succeeded. { use std::sync::atomic::Ordering::Relaxed; - let (a, ab, r) = ( + let (a, ab, r, h) = ( audio_pkts.load(Relaxed), audio_bytes.load(Relaxed), rumble_pkts.load(Relaxed), + hidout_pkts.load(Relaxed), ); - if a > 0 || r > 0 { + if a > 0 || r > 0 || h > 0 { tracing::info!( audio_pkts = a, audio_kb = ab / 1000, rumble_pkts = r, - "host→client datagrams (Opus 48 kHz stereo, 5 ms frames)" + hidout_pkts = h, + "host→client datagrams (Opus 48 kHz stereo, 5 ms frames; rumble; DualSense HID)" ); } } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index bcf7d05..5ef7036 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -465,6 +465,136 @@ pub struct PunktfunkConnection { last_audio: std::sync::Mutex>, } +/// `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 { + 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, ()> { @@ -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. /// diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index 640034e..2cd9742 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -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, audio: Receiver, rumble: Receiver<(u16, u16, u16)>, + /// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams. + hidout: Receiver, input_tx: tokio::sync::mpsc::UnboundedSender, /// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker. mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec)>, + /// Outbound rich input (DualSense touchpad / motion) → 0xCC datagrams by the worker. + rich_input_tx: tokio::sync::mpsc::UnboundedSender, reconfig_tx: tokio::sync::mpsc::UnboundedSender, shutdown: Arc, worker: Option>, @@ -86,8 +96,10 @@ impl NativeClient { let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::(FRAME_QUEUE); let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::(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::(HIDOUT_QUEUE); let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::(); let (mic_tx, mic_rx) = tokio::sync::mpsc::unbounded_channel::<(u32, u64, Vec)>(); + let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::(); let (reconfig_tx, reconfig_rx) = tokio::sync::mpsc::unbounded_channel::(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); 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 { + 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, audio_tx: SyncSender, rumble_tx: SyncSender<(u16, u16, u16)>, + hidout_tx: SyncSender, input_rx: tokio::sync::mpsc::UnboundedReceiver, mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec)>, + rich_input_rx: tokio::sync::mpsc::UnboundedReceiver, reconfig_rx: tokio::sync::mpsc::UnboundedReceiver, ready_tx: std::sync::mpsc::Sender>, shutdown: Arc, @@ -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 } } diff --git a/crates/punktfunk-host/src/inject/dualsense.rs b/crates/punktfunk-host/src/inject/dualsense.rs index 257aff2..68bdc67 100644 --- a/crates/punktfunk-host/src/inject/dualsense.rs +++ b/crates/punktfunk-host/src/inject/dualsense.rs @@ -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, + /// `(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 { - 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) { - 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>, + /// Each pad's current full report — buttons/sticks merged with persisted touch + motion. + state: Vec, + /// 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()); + } +} diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index dcd439d..ac253c0 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -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::(); + let (rich_tx, rich_rx) = std::sync::mpsc::channel::(); 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>) { 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, + rich_rx: std::sync::mpsc::Receiver, conn: quinn::Connection, inj_tx: std::sync::mpsc::Sender, ) { - 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() { diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index ab7b721..b396e6c 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -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) { diff --git a/docs/roadmap.md b/docs/roadmap.md index b86addb..c8e485f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -85,9 +85,18 @@ select = a `pw_stream` with `Direction::Output` + `media.class=Audio/Source`. from gamepad frames; output report `0x02` is parsed for LED RGB, player LEDs, and **adaptive trigger effects (L2/R2)**. Protocol carries new side-planes: rich-input `0xCC` (touchpad/motion) + HID-output `0xCD` (LED/triggers). `/dev/uhid` udev rule shipped. - *Remaining (paused, resume-able):* route gamepad frames → `DualSensePad` behind - `PUNKTFUNK_GAMEPAD=dualsense`, wire the `0xCC`/`0xCD` back-channel end-to-end (+ C ABI - `next_hidout`/`send_rich_input`), and render adaptive triggers + rumble on the Apple client. +- **Rich DualSense — Phase C/D/E end-to-end, validated live.** `PUNKTFUNK_GAMEPAD=dualsense` + selects a per-session `DualSenseManager` (the `PadBackend` enum in `m3.rs`): client gamepad frames + build the DualSense report; the kernel's feedback comes back as `HidOutput` on the **0xCD** plane + (lightbar / player LEDs / adaptive triggers) while **rumble stays on the universal 0xCA plane** + (so non-DualSense clients still feel it); touchpad + motion ride the **0xCC** rich-input plane + (`DualSenseManager::apply_rich`, merged with button state). The connector + C ABI gained + `punktfunk_connection_next_hidout` (→ `PunktfunkHidOutput`) and `punktfunk_connection_send_rich_input` + (← `PunktfunkRichInput`); header regenerated. Validated on-box: a synthetic-source `m3-host` + + `punktfunk-client-rs --rich-input-test` created the real kernel DualSense, drove 0xCC, and decoded + 12 live 0xCD events (the kernel's actual lightbar/trigger init reports) — data plane unaffected + (600/600 frames). *Remaining:* the Apple client renders adaptive triggers + rumble on a real + DualSense (`GCDualSenseAdaptiveTrigger`) — handed off to the client agent for the real playtest. - **Advanced (audio-driven voice-coil) haptics — scoped, NO-GO for now (`docs/dualsense-haptics.md`).** Driven by the DualSense's USB *audio* interface (4-ch, back 2 channels = haptic PCM), not HID — so the UHID backend structurally can't carry it. Three independent walls: host capture needs a kernel diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 3875db3..1eadfd7 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -19,6 +19,24 @@ // added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`. #define ABI_VERSION 2 +// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid). +#define PUNKTFUNK_HIDOUT_LED 1 + +// `PunktfunkHidOutput::kind` — player-indicator LEDs (`player_bits` valid, low 5 bits). +#define PUNKTFUNK_HIDOUT_PLAYER_LEDS 2 + +// `PunktfunkHidOutput::kind` — one adaptive-trigger effect (`which` + `effect`/`effect_len` valid). +#define PUNKTFUNK_HIDOUT_TRIGGER 3 + +// Capacity of `PunktfunkHidOutput::effect` (the DualSense trigger parameter block). +#define PUNKTFUNK_HID_EFFECT_MAX 11 + +// `PunktfunkRichInput::kind` — a touchpad contact (`finger`/`active`/`x`/`y` valid). +#define PUNKTFUNK_RICH_TOUCHPAD 1 + +// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid). +#define PUNKTFUNK_RICH_MOTION 2 + // Compositor preference for [`punktfunk_connect_ex`] (`compositor` arg). `AUTO` lets the host // pick (auto-detect from its running desktop); a concrete value is honored only if that backend // is available on the host right now, else the host falls back to auto-detect. The resolved @@ -319,6 +337,57 @@ typedef struct { } PunktfunkAudioPacket; #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// 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). +typedef struct { + // One of `PUNKTFUNK_HIDOUT_*`. + uint8_t kind; + // Gamepad index. + uint8_t pad; + // LED: lightbar red. + uint8_t r; + // LED: lightbar green. + uint8_t g; + // LED: lightbar blue. + uint8_t b; + // PlayerLeds: lit player indicators (low 5 bits). + uint8_t player_bits; + // Trigger: 0 = L2, 1 = R2. + uint8_t which; + // Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`). + uint8_t effect_len; + // Trigger: the raw DualSense trigger parameter block (mode + params). + uint8_t effect[11]; +} PunktfunkHidOutput; +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// 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. +typedef struct { + // One of `PUNKTFUNK_RICH_*`. + uint8_t kind; + // Gamepad index. + uint8_t pad; + // Touchpad: contact id (0 or 1). + uint8_t finger; + // Touchpad: 1 = finger down, 0 = lifted. + uint8_t active; + // Touchpad: normalized x, 0..=65535 across the touchpad. + uint16_t x; + // Touchpad: normalized y, 0..=65535 across the touchpad. + uint16_t y; + // Motion: gyro (pitch, yaw, roll), raw signed-16. + int16_t gyro[3]; + // Motion: accelerometer (x, y, z), raw signed-16. + int16_t accel[3]; +} PunktfunkRichInput; +#endif + #ifdef __cplusplus extern "C" { #endif // __cplusplus @@ -528,6 +597,20 @@ PunktfunkStatus punktfunk_connection_next_rumble(PunktfunkConnection *c, uint32_t timeout_ms); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// 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`. +PunktfunkStatus punktfunk_connection_next_hidout(PunktfunkConnection *c, + PunktfunkHidOutput *out, + uint32_t timeout_ms); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Send one input event to the host as a QUIC datagram (non-blocking enqueue). // @@ -552,6 +635,18 @@ PunktfunkStatus punktfunk_connection_send_mic(PunktfunkConnection *c, uint64_t pts_ns); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// 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`]. +PunktfunkStatus punktfunk_connection_send_rich_input(PunktfunkConnection *c, + const PunktfunkRichInput *rich); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // The currently active session mode — the Welcome's, until an accepted // [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.