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:
@@ -10,7 +10,10 @@
|
|||||||
//! stream (watch them land in the host session, e.g. xev inside gamescope). `--mic-test`
|
//! 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
|
//! 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
|
//! 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);
|
//! `--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.
|
//! without it the client trusts on first use and prints the observed fingerprint to pin.
|
||||||
@@ -44,6 +47,9 @@ struct Args {
|
|||||||
mic_test: bool,
|
mic_test: bool,
|
||||||
/// `--touch-test` — drag a synthetic finger in a circle (proves the touch path).
|
/// `--touch-test` — drag a synthetic finger in a circle (proves the touch path).
|
||||||
touch_test: bool,
|
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]>,
|
pin: Option<[u8; 32]>,
|
||||||
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
|
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
|
||||||
remode: Option<(Mode, u32)>,
|
remode: Option<(Mode, u32)>,
|
||||||
@@ -146,6 +152,7 @@ fn parse_args() -> Args {
|
|||||||
input_test: argv.iter().any(|a| a == "--input-test"),
|
input_test: argv.iter().any(|a| a == "--input-test"),
|
||||||
mic_test: argv.iter().any(|a| a == "--mic-test"),
|
mic_test: argv.iter().any(|a| a == "--mic-test"),
|
||||||
touch_test: argv.iter().any(|a| a == "--touch-test"),
|
touch_test: argv.iter().any(|a| a == "--touch-test"),
|
||||||
|
rich_input_test: argv.iter().any(|a| a == "--rich-input-test"),
|
||||||
pin,
|
pin,
|
||||||
remode,
|
remode,
|
||||||
pair: get("--pair").map(String::from),
|
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.
|
// Closed-flag for the blocking receive loop.
|
||||||
let closed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
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_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 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 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();
|
let conn2 = conn.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
use std::sync::atomic::Ordering::Relaxed;
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
@@ -477,6 +544,12 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
ab.fetch_add(opus.len() as u64, Relaxed);
|
ab.fetch_add(opus.len() as u64, Relaxed);
|
||||||
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() {
|
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() {
|
||||||
r.fetch_add(1, Relaxed);
|
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.
|
// Report the side planes whether or not the video plane succeeded.
|
||||||
{
|
{
|
||||||
use std::sync::atomic::Ordering::Relaxed;
|
use std::sync::atomic::Ordering::Relaxed;
|
||||||
let (a, ab, r) = (
|
let (a, ab, r, h) = (
|
||||||
audio_pkts.load(Relaxed),
|
audio_pkts.load(Relaxed),
|
||||||
audio_bytes.load(Relaxed),
|
audio_bytes.load(Relaxed),
|
||||||
rumble_pkts.load(Relaxed),
|
rumble_pkts.load(Relaxed),
|
||||||
|
hidout_pkts.load(Relaxed),
|
||||||
);
|
);
|
||||||
if a > 0 || r > 0 {
|
if a > 0 || r > 0 || h > 0 {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
audio_pkts = a,
|
audio_pkts = a,
|
||||||
audio_kb = ab / 1000,
|
audio_kb = ab / 1000,
|
||||||
rumble_pkts = r,
|
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)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -465,6 +465,136 @@ pub struct PunktfunkConnection {
|
|||||||
last_audio: std::sync::Mutex<Option<crate::client::AudioPacket>>,
|
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.
|
/// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8.
|
||||||
#[cfg(feature = "quic")]
|
#[cfg(feature = "quic")]
|
||||||
unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result<Option<&'a str>, ()> {
|
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).
|
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # 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
|
/// The currently active session mode — the Welcome's, until an accepted
|
||||||
/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
|
/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
use crate::config::{CompositorPref, Mode, Role};
|
use crate::config::{CompositorPref, Mode, Role};
|
||||||
use crate::error::{PunktfunkError, Result};
|
use crate::error::{PunktfunkError, Result};
|
||||||
use crate::input::InputEvent;
|
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::session::{Frame, Session};
|
||||||
use crate::transport::UdpTransport;
|
use crate::transport::UdpTransport;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
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.
|
/// periodically, so a dropped transition (including a stop) heals within ~500 ms.
|
||||||
const RUMBLE_QUEUE: usize = 16;
|
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).
|
/// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AudioPacket {
|
pub struct AudioPacket {
|
||||||
@@ -49,9 +55,13 @@ pub struct NativeClient {
|
|||||||
frames: Receiver<Frame>,
|
frames: Receiver<Frame>,
|
||||||
audio: Receiver<AudioPacket>,
|
audio: Receiver<AudioPacket>,
|
||||||
rumble: Receiver<(u16, u16, u16)>,
|
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>,
|
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
||||||
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
||||||
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>,
|
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>,
|
reconfig_tx: tokio::sync::mpsc::UnboundedSender<Mode>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
worker: Option<std::thread::JoinHandle<()>>,
|
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 (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 (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 (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 (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 (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 (reconfig_tx, reconfig_rx) = tokio::sync::mpsc::unbounded_channel::<Mode>();
|
||||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(Mode, [u8; 32])>>();
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(Mode, [u8; 32])>>();
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
@@ -120,8 +132,10 @@ impl NativeClient {
|
|||||||
frame_tx,
|
frame_tx,
|
||||||
audio_tx,
|
audio_tx,
|
||||||
rumble_tx,
|
rumble_tx,
|
||||||
|
hidout_tx,
|
||||||
input_rx,
|
input_rx,
|
||||||
mic_rx,
|
mic_rx,
|
||||||
|
rich_input_rx,
|
||||||
reconfig_rx,
|
reconfig_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
shutdown: shutdown_w,
|
shutdown: shutdown_w,
|
||||||
@@ -143,8 +157,10 @@ impl NativeClient {
|
|||||||
frames: frame_rx,
|
frames: frame_rx,
|
||||||
audio: audio_rx,
|
audio: audio_rx,
|
||||||
rumble: rumble_rx,
|
rumble: rumble_rx,
|
||||||
|
hidout: hidout_rx,
|
||||||
input_tx,
|
input_tx,
|
||||||
mic_tx,
|
mic_tx,
|
||||||
|
rich_input_tx,
|
||||||
reconfig_tx,
|
reconfig_tx,
|
||||||
shutdown,
|
shutdown,
|
||||||
worker: Some(worker),
|
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.
|
/// Queue one input event for delivery as a QUIC datagram.
|
||||||
pub fn send_input(&self, ev: &InputEvent) -> Result<()> {
|
pub fn send_input(&self, ev: &InputEvent) -> Result<()> {
|
||||||
self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed)
|
self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed)
|
||||||
@@ -311,6 +339,15 @@ impl NativeClient {
|
|||||||
.send((seq, pts_ns, opus))
|
.send((seq, pts_ns, opus))
|
||||||
.map_err(|_| PunktfunkError::Closed)
|
.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 {
|
impl Drop for NativeClient {
|
||||||
@@ -332,8 +369,10 @@ struct WorkerArgs {
|
|||||||
frame_tx: SyncSender<Frame>,
|
frame_tx: SyncSender<Frame>,
|
||||||
audio_tx: SyncSender<AudioPacket>,
|
audio_tx: SyncSender<AudioPacket>,
|
||||||
rumble_tx: SyncSender<(u16, u16, u16)>,
|
rumble_tx: SyncSender<(u16, u16, u16)>,
|
||||||
|
hidout_tx: SyncSender<HidOutput>,
|
||||||
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||||
mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec<u8>)>,
|
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>,
|
reconfig_rx: tokio::sync::mpsc::UnboundedReceiver<Mode>,
|
||||||
ready_tx: std::sync::mpsc::Sender<Result<(Mode, [u8; 32])>>,
|
ready_tx: std::sync::mpsc::Sender<Result<(Mode, [u8; 32])>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
@@ -353,8 +392,10 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
frame_tx,
|
frame_tx,
|
||||||
audio_tx,
|
audio_tx,
|
||||||
rumble_tx,
|
rumble_tx,
|
||||||
|
hidout_tx,
|
||||||
mut input_rx,
|
mut input_rx,
|
||||||
mut mic_rx,
|
mut mic_rx,
|
||||||
|
mut rich_input_rx,
|
||||||
mut reconfig_rx,
|
mut reconfig_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
shutdown,
|
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
|
// Control task: the handshake stream stays open for mid-stream renegotiation. One
|
||||||
// request at a time — write Reconfigure, await Reconfigured, publish the active mode.
|
// 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);
|
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
|
_ => {} // unknown tag — a newer host; ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
//! The report descriptor + field layout are the canonical inputtino ones (games-on-whales/
|
//! 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.
|
//! 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 anyhow::{Context, Result};
|
||||||
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
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;
|
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
|
/// 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
|
/// uinput pad's style). Dropping it destroys the device (the kernel tears down the bound
|
||||||
/// `hid-playstation` interface).
|
/// `hid-playstation` interface).
|
||||||
@@ -341,10 +353,10 @@ impl DualSensePad {
|
|||||||
/// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (calibration
|
/// 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)
|
/// / 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
|
/// 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`.
|
/// 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> {
|
pub fn service(&mut self, pad: u8) -> DsFeedback {
|
||||||
let mut out = Vec::new();
|
let mut fb = DsFeedback::default();
|
||||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||||
while let Ok(n) = self.fd.read(&mut ev) {
|
while let Ok(n) = self.fd.read(&mut ev) {
|
||||||
if n < UHID_EVENT_SIZE {
|
if n < UHID_EVENT_SIZE {
|
||||||
@@ -355,7 +367,7 @@ impl DualSensePad {
|
|||||||
// uhid_output_req: data[4096] at [4..4100], size u16 at [4100..4102].
|
// 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 size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||||
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
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 => {
|
||||||
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
|
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
|
||||||
@@ -371,7 +383,7 @@ impl DualSensePad {
|
|||||||
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
fb
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
|
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
|
/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is
|
||||||
/// is the USB DualSense common report; only the well-understood fields (motor rumble, lightbar
|
/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB,
|
||||||
/// RGB, player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client.
|
/// 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>) {
|
fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) {
|
||||||
use punktfunk_core::quic::HidOutput;
|
|
||||||
// data[0] is the report id (0x02). Be defensive about short reports.
|
// data[0] is the report id (0x02). Be defensive about short reports.
|
||||||
if data.first() != Some(&0x02) || data.len() < 48 {
|
if data.first() != Some(&0x02) || data.len() < 48 {
|
||||||
return;
|
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.
|
// Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44.
|
||||||
let (r, g, b) = (data[45], data[46], data[47]);
|
let (r, g, b) = (data[45], data[46], data[47]);
|
||||||
out.push(HidOutput::Led { pad, r, g, b });
|
fb.hidout.push(HidOutput::Led { pad, r, g, b });
|
||||||
out.push(HidOutput::PlayerLeds {
|
fb.hidout.push(HidOutput::PlayerLeds {
|
||||||
pad,
|
pad,
|
||||||
bits: data[44] & 0x1F,
|
bits: data[44] & 0x1F,
|
||||||
});
|
});
|
||||||
// Adaptive-trigger parameter blocks: L2 at bytes 11..22, R2 at 22..33 (11 bytes each).
|
// Adaptive-trigger parameter blocks: L2 at bytes 11..22, R2 at 22..33 (11 bytes each).
|
||||||
if data.len() >= 33 {
|
if data.len() >= 33 {
|
||||||
out.push(HidOutput::Trigger {
|
fb.hidout.push(HidOutput::Trigger {
|
||||||
pad,
|
pad,
|
||||||
which: 0,
|
which: 0,
|
||||||
effect: data[11..22].to_vec(),
|
effect: data[11..22].to_vec(),
|
||||||
});
|
});
|
||||||
out.push(HidOutput::Trigger {
|
fb.hidout.push(HidOutput::Trigger {
|
||||||
pad,
|
pad,
|
||||||
which: 1,
|
which: 1,
|
||||||
effect: data[22..33].to_vec(),
|
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
@@ -519,25 +519,32 @@ async fn serve_session(
|
|||||||
// per-session) and sends force feedback back over `conn`. It exits when the channel closes
|
// 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.
|
// (datagram task ends on disconnect) — fresh gamepad state per session.
|
||||||
let (input_tx, input_rx) = std::sync::mpsc::channel::<InputEvent>();
|
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 input_handle = {
|
||||||
let conn = conn.clone();
|
let conn = conn.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("punktfunk-m3-input".into())
|
.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")?
|
.context("spawn input thread")?
|
||||||
};
|
};
|
||||||
// One reader for ALL client→host datagrams, demuxed by magic byte (two read_datagram loops
|
// 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
|
// 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,
|
// service), 0xCC → rich input (DualSense touchpad / motion, to the per-session input thread),
|
||||||
// so decode order doesn't matter. Unknown tags are ignored.
|
// 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();
|
let input_conn = conn.clone();
|
||||||
tokio::spawn(async move {
|
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 {
|
while let Ok(d) = input_conn.read_datagram().await {
|
||||||
if let Some((_seq, _pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) {
|
if let Some((_seq, _pts, opus)) = punktfunk_core::quic::decode_mic_datagram(&d) {
|
||||||
mic_count += 1;
|
mic_count += 1;
|
||||||
// Host-lifetime mic service; a send error just means the host is shutting down.
|
// Host-lifetime mic service; a send error just means the host is shutting down.
|
||||||
let _ = mic_tx.send(opus.to_vec());
|
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) {
|
} else if let Some(ev) = InputEvent::decode(&d) {
|
||||||
input_count += 1;
|
input_count += 1;
|
||||||
if input_tx.send(ev).is_err() {
|
if input_tx.send(ev).is_err() {
|
||||||
@@ -548,6 +555,7 @@ async fn serve_session(
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
input = input_count,
|
input = input_count,
|
||||||
mic = mic_count,
|
mic = mic_count,
|
||||||
|
rich = rich_count,
|
||||||
"client datagram stream ended"
|
"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)");
|
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
|
/// 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`]
|
/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (uinput X-Box pads or,
|
||||||
/// (crate::inject::gamepad), with force feedback pumped between events and sent back as rumble
|
/// with `PUNKTFUNK_GAMEPAD=dualsense`, virtual DualSense pads), with rich client→host input
|
||||||
/// datagrams. The gamepads (uinput) are created and torn down with the session; the
|
/// (touchpad / motion, `rich_rx`) merged in and feedback pumped between events — rumble on the
|
||||||
/// pointer/keyboard injector (and its portal grant) lives in the service, across sessions.
|
/// 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(
|
fn input_thread(
|
||||||
rx: std::sync::mpsc::Receiver<InputEvent>,
|
rx: std::sync::mpsc::Receiver<InputEvent>,
|
||||||
|
rich_rx: std::sync::mpsc::Receiver<punktfunk_core::quic::RichInput>,
|
||||||
conn: quinn::Connection,
|
conn: quinn::Connection,
|
||||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
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_state = [PadState::default(); MAX_WIRE_PADS];
|
||||||
let mut pad_mask = 0u16;
|
let mut pad_mask = 0u16;
|
||||||
// Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams),
|
// 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)) {
|
match rx.recv_timeout(std::time::Duration::from_millis(4)) {
|
||||||
Ok(ev) => match ev.kind {
|
Ok(ev) => match ev.kind {
|
||||||
InputKind::GamepadButton | InputKind::GamepadAxis => {
|
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;
|
let idx = ev.flags as usize;
|
||||||
if idx >= MAX_WIRE_PADS || !pad_state[idx].apply(&ev) {
|
if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) {
|
||||||
continue;
|
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
|
// 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::Timeout) => {}
|
||||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
}
|
}
|
||||||
// Service force feedback every iteration (≤4 ms latency; games block on EVIOCSFF).
|
// Drain rich client→host input (DualSense touchpad / motion) into the pad backend.
|
||||||
pads.pump_rumble(|pad, low, high| {
|
while let Ok(rich) = rich_rx.try_recv() {
|
||||||
if let Some(s) = rumble_state.get_mut(pad as usize) {
|
pads.apply_rich(rich);
|
||||||
*s = (low, high);
|
}
|
||||||
rumble_seen[pad as usize] = true;
|
// Service feedback every iteration (≤4 ms latency; games block on EVIOCSFF, and the
|
||||||
}
|
// DualSense kernel handshake must be answered promptly). Rumble → the universal 0xCA
|
||||||
let d = punktfunk_core::quic::encode_rumble_datagram(pad, low, high);
|
// plane; DualSense rich feedback (lightbar / player LEDs / adaptive triggers) → 0xCD.
|
||||||
let _ = conn.send_datagram(d.to_vec().into());
|
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) {
|
if last_refresh.elapsed() >= std::time::Duration::from_millis(500) {
|
||||||
last_refresh = std::time::Instant::now();
|
last_refresh = std::time::Instant::now();
|
||||||
for (i, &(low, high)) in rumble_state.iter().enumerate() {
|
for (i, &(low, high)) in rumble_state.iter().enumerate() {
|
||||||
|
|||||||
@@ -106,7 +106,11 @@ fn real_main() -> Result<()> {
|
|||||||
let deadline = Instant::now() + Duration::from_secs(secs);
|
let deadline = Instant::now() + Duration::from_secs(secs);
|
||||||
let (mut i, mut last_write) = (0i32, Instant::now());
|
let (mut i, mut last_write) = (0i32, Instant::now());
|
||||||
while Instant::now() < deadline {
|
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:?}");
|
println!(" hid output from kernel/game: {o:?}");
|
||||||
}
|
}
|
||||||
if last_write.elapsed() >= Duration::from_millis(300) {
|
if last_write.elapsed() >= Duration::from_millis(300) {
|
||||||
|
|||||||
+12
-3
@@ -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
|
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`
|
trigger effects (L2/R2)**. Protocol carries new side-planes: rich-input `0xCC`
|
||||||
(touchpad/motion) + HID-output `0xCD` (LED/triggers). `/dev/uhid` udev rule shipped.
|
(touchpad/motion) + HID-output `0xCD` (LED/triggers). `/dev/uhid` udev rule shipped.
|
||||||
*Remaining (paused, resume-able):* route gamepad frames → `DualSensePad` behind
|
- **Rich DualSense — Phase C/D/E end-to-end, validated live.** `PUNKTFUNK_GAMEPAD=dualsense`
|
||||||
`PUNKTFUNK_GAMEPAD=dualsense`, wire the `0xCC`/`0xCD` back-channel end-to-end (+ C ABI
|
selects a per-session `DualSenseManager` (the `PadBackend` enum in `m3.rs`): client gamepad frames
|
||||||
`next_hidout`/`send_rich_input`), and render adaptive triggers + rumble on the Apple client.
|
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`).**
|
- **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
|
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
|
the UHID backend structurally can't carry it. Three independent walls: host capture needs a kernel
|
||||||
|
|||||||
@@ -19,6 +19,24 @@
|
|||||||
// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
|
// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
|
||||||
#define ABI_VERSION 2
|
#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
|
// 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
|
// 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
|
// is available on the host right now, else the host falls back to auto-detect. The resolved
|
||||||
@@ -319,6 +337,57 @@ typedef struct {
|
|||||||
} PunktfunkAudioPacket;
|
} PunktfunkAudioPacket;
|
||||||
#endif
|
#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
|
#ifdef __cplusplus
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#endif // __cplusplus
|
#endif // __cplusplus
|
||||||
@@ -528,6 +597,20 @@ PunktfunkStatus punktfunk_connection_next_rumble(PunktfunkConnection *c,
|
|||||||
uint32_t timeout_ms);
|
uint32_t timeout_ms);
|
||||||
#endif
|
#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)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
// 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);
|
uint64_t pts_ns);
|
||||||
#endif
|
#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)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// The currently active session mode — the Welcome's, until an accepted
|
// The currently active session mode — the Welcome's, until an accepted
|
||||||
// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
|
// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
|
||||||
|
|||||||
Reference in New Issue
Block a user