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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 08:36:12 +00:00
parent e5b15353c7
commit 59edeedf07
8 changed files with 799 additions and 47 deletions
+80 -5
View File
@@ -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)"
);
}
}