feat(gamepad): add virtual Xbox One/Series + DualShock 4 pad types
Extends virtual-controller support beyond Xbox 360 + DualSense. Goal: a physical Xbox One or PS4 pad on the client gets a near-native matching virtual pad on the host, auto-resolved from the controller type. Protocol/core: - GamepadPref gains XboxOne (wire 3) + DualShock4 (wire 4); to_u8/from_u8/ from_name/as_str + C ABI PUNKTFUNK_GAMEPAD_XBOXONE/_DUALSHOCK4 constants (compile-time guard ties them to the enum). Single-byte wire form is unchanged, so it's forward-compatible (older peers degrade to Auto). Host (Linux): - New UHID DualShock 4 backend (inject/dualshock4.rs) bound by hid-playstation: lightbar, touchpad, motion, rumble — DualSense minus adaptive triggers / player LEDs / mute. Reuses the DualSense pure state + button mapping; only the report byte layout, the real-DS4 HID descriptor, the GET_REPORT handshake (0x12 MAC mandatory; 0x02 calibration; 0xa3 firmware) and the touchpad resolution (1920x942) differ. Touchpad/motion ride the existing 0xCC plane, lightbar the 0xCD Led plane (deduped); rumble the universal 0xCA plane. - Xbox One/Series is the uinput Xbox-360 backend parameterized with the One S USB identity (045e:02ea) for matching glyphs — XInput-identical otherwise. - PadBackend dispatch + resolver handle both; off Linux the UHID pads and One/Series fold into Xbox 360. Windows-host DS4 (ViGEm) deferred. Clients (auto-resolve physical pad -> virtual type, plus manual settings): - Linux/Windows (SDL3): SDL_GAMEPAD_TYPE_PS4 -> DualShock 4, _XBOXONE -> Xbox One; PadInfo carries the resolved pref; DS4 touchpad/motion capture + lightbar already type-agnostic. Linux settings combo + label updated. - Apple (GameController): GCDualShockGamepad/GCXboxGamepad detection, DS4 touchpad capture, settings picker entries. - Android (Kotlin): InputDevice VID/PID auto-detect (matching the other clients) + settings entries. - probe: --gamepad help/aliases. Also hardens the Android JNI boundary: wrap the teardown + poll-thread shims in catch_unwind so a panic degrades to a logged no-op instead of aborting the app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,15 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
||||
`send_rich_input`. **Client-negotiated virtual pad type**: the Hello carries a gamepad
|
||||
preference byte (same trailing-byte back-compat pattern as the compositor), the Welcome
|
||||
echoes the resolved backend — precedence: explicit client choice > `PUNKTFUNK_GAMEPAD`
|
||||
env > uinput Xbox 360; DualSense (UHID) only on Linux hosts.
|
||||
env > uinput Xbox 360. Backends: **Xbox 360** (uinput / ViGEm), **Xbox One/Series** (the same
|
||||
XInput backend with the One/Series USB identity for matching glyphs — no extra game-visible
|
||||
capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID
|
||||
`hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and
|
||||
**DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player
|
||||
LEDs / mute). The UHID pads need a Linux host; off Linux they (and One/Series) fold into Xbox 360.
|
||||
Clients auto-resolve the type from the physical controller (DS5→DualSense, DS4→DualShock 4,
|
||||
Xbox One→Xbox One). Windows-host DualShock 4 (ViGEm) is not yet wired — Windows clients asking for
|
||||
DS4 get Xbox 360 for now.
|
||||
- **Windows host: implemented and shipping (NVIDIA-only, x64-only).** `#[cfg(windows)]` backends
|
||||
behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA**
|
||||
virtual display per session (`vdisplay/sudovda.rs`), NVENC encode (`--features nvenc`), SendInput +
|
||||
|
||||
@@ -63,6 +63,7 @@ import androidx.core.content.ContextCompat
|
||||
import io.unom.punktfunk.components.EmptyHostsState
|
||||
import io.unom.punktfunk.components.HostCard
|
||||
import io.unom.punktfunk.components.SectionLabel
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.discovery.HostDiscovery
|
||||
@@ -143,11 +144,15 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
||||
// Advertise HDR only when this device's display can present it (else the host sends a
|
||||
// proper SDR stream rather than PQ the panel would mis-tone-map).
|
||||
val hdrEnabled = displaySupportsHdr(context)
|
||||
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
|
||||
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
|
||||
// explicit choice is passed through unchanged.
|
||||
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
|
||||
val handle = withContext(Dispatchers.IO) {
|
||||
NativeBridge.nativeConnect(
|
||||
targetHost, targetPort, w, h, hz,
|
||||
id.certPem, id.privateKeyPem, pinHex ?: "",
|
||||
settings.bitrateKbps, settings.compositor, settings.gamepad,
|
||||
settings.bitrateKbps, settings.compositor, gamepadPref,
|
||||
hdrEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -142,9 +142,11 @@ val COMPOSITOR_OPTIONS = listOf(
|
||||
"gamescope",
|
||||
)
|
||||
|
||||
/** index = GamepadPref wire byte. */
|
||||
/** index = GamepadPref wire byte (0=Auto 1=Xbox360 2=DualSense 3=XboxOne 4=DualShock4). */
|
||||
val GAMEPAD_OPTIONS = listOf(
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
"DualSense",
|
||||
"Xbox One",
|
||||
"DualShock 4",
|
||||
)
|
||||
|
||||
@@ -44,6 +44,71 @@ object Gamepad {
|
||||
const val AXIS_LT = 4
|
||||
const val AXIS_RT = 5
|
||||
|
||||
// GamepadPref wire bytes — must equal punktfunk-core `config.rs::GamepadPref::to_u8`.
|
||||
const val PREF_AUTO = 0
|
||||
const val PREF_XBOX360 = 1
|
||||
const val PREF_DUALSENSE = 2
|
||||
const val PREF_XBOXONE = 3
|
||||
const val PREF_DUALSHOCK4 = 4
|
||||
|
||||
// USB vendor ids of the controllers we can identify by VID/PID.
|
||||
private const val VID_SONY = 0x054C
|
||||
private const val VID_MICROSOFT = 0x045E
|
||||
|
||||
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types.
|
||||
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2)
|
||||
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
|
||||
|
||||
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
|
||||
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
|
||||
private val PID_XBOXONE = setOf(
|
||||
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a connected controller's [GamepadPref] wire byte from its USB VID/PID, mirroring the
|
||||
* Linux client's `pref_for_type` (SDL3 `GamepadType`) and the Apple client's GameController type
|
||||
* auto-resolution. Android exposes no controller-type enum, so we match `getVendorId()` /
|
||||
* `getProductId()`. Used only when the user picked "Automatic" — an explicit choice is honored as
|
||||
* is. An unrecognized pad (or none) falls back to [PREF_XBOX360], the safe XInput default the
|
||||
* host always supports. Never returns [PREF_AUTO] (the host would then decide) — once we have a
|
||||
* physical pad we resolve it concretely, matching the other native clients.
|
||||
*/
|
||||
fun prefFor(dev: InputDevice?): Int {
|
||||
if (dev == null) return PREF_XBOX360
|
||||
val vid = dev.vendorId
|
||||
val pid = dev.productId
|
||||
return when {
|
||||
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
|
||||
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
|
||||
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
|
||||
else -> PREF_XBOX360
|
||||
}
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
|
||||
fun firstPad(): InputDevice? {
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
val s = d.sources
|
||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A
|
||||
* non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete
|
||||
* type from the first connected controller via [prefFor] (so the host gets the right pad even
|
||||
* though Android can't tell it the controller type any other way).
|
||||
*/
|
||||
fun resolvePref(setting: Int): Int =
|
||||
if (setting == PREF_AUTO) prefFor(firstPad()) else setting
|
||||
|
||||
/**
|
||||
* Gamepad `KEYCODE_*` → BTN_* bit, or 0 if not a gamepad button we forward. A/B/X/Y are
|
||||
* positional (Xbox layout; Nintendo relabeling needs device-type detection, deferred).
|
||||
|
||||
@@ -81,8 +81,16 @@ class GamepadFeedback(private val handle: Long) {
|
||||
rumbleThread?.interrupt()
|
||||
hidoutThread?.interrupt()
|
||||
runCatching { vm?.cancel() } // drop any held rumble immediately
|
||||
runCatching { rumbleThread?.join(200) }
|
||||
runCatching { hidoutThread?.join(200) }
|
||||
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
|
||||
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
|
||||
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
|
||||
// would let a thread survive into the freed handle → use-after-free SIGSEGV (the
|
||||
// back-while-streaming crash, on the one path the main-thread `closed` guard can't cover).
|
||||
// Safe to block unbounded: the native pulls are internally time-bounded (PULL_TIMEOUT ~100 ms)
|
||||
// and rendering is a quick best-effort binder call, so each thread observes running=false and
|
||||
// exits within ~one timeout — the join returns promptly (well under any ANR threshold).
|
||||
runCatching { rumbleThread?.join() }
|
||||
runCatching { hidoutThread?.join() }
|
||||
rumbleThread = null
|
||||
hidoutThread = null
|
||||
runCatching { lightsSession?.close() }
|
||||
@@ -94,18 +102,7 @@ class GamepadFeedback(private val handle: Long) {
|
||||
}
|
||||
|
||||
/** First connected gamepad/joystick InputDevice, or null (→ logged no-op on the emulator). */
|
||||
private fun resolvePad(): InputDevice? {
|
||||
for (id in InputDevice.getDeviceIds()) {
|
||||
val d = InputDevice.getDevice(id) ?: continue
|
||||
val s = d.sources
|
||||
if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
private fun resolvePad(): InputDevice? = Gamepad.firstPad()
|
||||
|
||||
// ---- Rumble ----
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these
|
||||
//! compile on the host build too (parity with the input shims in [`crate::session`]).
|
||||
|
||||
use crate::session::SessionHandle;
|
||||
use crate::session::{jni_guard, SessionHandle};
|
||||
use jni::objects::{JByteBuffer, JObject};
|
||||
use jni::sys::{jint, jlong};
|
||||
use jni::JNIEnv;
|
||||
@@ -32,17 +32,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jlong {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
||||
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||
// threads (and joins them) before nativeClose frees the handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble(PULL_TIMEOUT) {
|
||||
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||
jni_guard(-1, || {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
||||
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
match h.client.next_rumble(PULL_TIMEOUT) {
|
||||
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
||||
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
||||
@@ -58,57 +61,60 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
||||
handle: jlong,
|
||||
buf: JByteBuffer,
|
||||
) -> jint {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
|
||||
Ok(ev) => ev,
|
||||
Err(_) => return -1, // timeout or closed — Kotlin loops
|
||||
};
|
||||
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
||||
jni_guard(-1, || {
|
||||
if handle == 0 {
|
||||
return -1;
|
||||
}
|
||||
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
|
||||
Ok(ev) => ev,
|
||||
Err(_) => return -1, // timeout or closed — Kotlin loops
|
||||
};
|
||||
|
||||
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
|
||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return -1,
|
||||
};
|
||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||
Ok(p) if !p.is_null() => p,
|
||||
_ => return -1,
|
||||
};
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
|
||||
let cap = match env.get_direct_buffer_capacity(&buf) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return -1,
|
||||
};
|
||||
let ptr = match env.get_direct_buffer_address(&buf) {
|
||||
Ok(p) if !p.is_null() => p,
|
||||
_ => return -1,
|
||||
};
|
||||
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
||||
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
||||
|
||||
let n = match ev {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
if cap < 4 {
|
||||
return -1;
|
||||
let n = match ev {
|
||||
HidOutput::Led { r, g, b, .. } => {
|
||||
if cap < 4 {
|
||||
return -1;
|
||||
}
|
||||
out[0] = TAG_LED;
|
||||
out[1] = r;
|
||||
out[2] = g;
|
||||
out[3] = b;
|
||||
4
|
||||
}
|
||||
out[0] = TAG_LED;
|
||||
out[1] = r;
|
||||
out[2] = g;
|
||||
out[3] = b;
|
||||
4
|
||||
}
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
if cap < 2 {
|
||||
return -1;
|
||||
HidOutput::PlayerLeds { bits, .. } => {
|
||||
if cap < 2 {
|
||||
return -1;
|
||||
}
|
||||
out[0] = TAG_PLAYER_LEDS;
|
||||
out[1] = bits;
|
||||
2
|
||||
}
|
||||
out[0] = TAG_PLAYER_LEDS;
|
||||
out[1] = bits;
|
||||
2
|
||||
}
|
||||
HidOutput::Trigger { which, effect, .. } => {
|
||||
let n = 2 + effect.len();
|
||||
if cap < n {
|
||||
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||
HidOutput::Trigger { which, effect, .. } => {
|
||||
let n = 2 + effect.len();
|
||||
if cap < n {
|
||||
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
||||
}
|
||||
out[0] = TAG_TRIGGER;
|
||||
out[1] = which;
|
||||
out[2..n].copy_from_slice(&effect);
|
||||
n
|
||||
}
|
||||
out[0] = TAG_TRIGGER;
|
||||
out[1] = which;
|
||||
out[2..n].copy_from_slice(&effect);
|
||||
n
|
||||
}
|
||||
};
|
||||
n as jint
|
||||
};
|
||||
n as jint
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,11 +19,28 @@ use jni::JNIEnv;
|
||||
use punktfunk_core::client::NativeClient;
|
||||
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Run a JNI body, catching any panic at the FFI boundary and returning `default` instead.
|
||||
///
|
||||
/// A panic unwinding out of an `extern "system"` function aborts the whole process on Rust ≥ 1.81 —
|
||||
/// a hard crash of the embedding Android app with no logcat trace. This mirrors the discipline the C
|
||||
/// ABI already enforces (`punktfunk_core::abi` wraps every entry point in `catch_unwind`); the
|
||||
/// `panic = "unwind"` profile in the workspace `Cargo.toml` exists precisely so these guards work.
|
||||
/// We apply it to the teardown + background-thread shims (the "leaving a stream" path), where an
|
||||
/// unexpected panic (e.g. a poisoned `Mutex` during concurrent teardown) must degrade to a logged
|
||||
/// no-op rather than kill the app.
|
||||
pub(crate) fn jni_guard<T>(default: T, f: impl FnOnce() -> T) -> T {
|
||||
std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| {
|
||||
log::error!("punktfunk JNI: caught a panic at the FFI boundary (returning default)");
|
||||
default
|
||||
})
|
||||
}
|
||||
|
||||
/// A live session behind the `jlong` handle: the connector + the decode thread it feeds.
|
||||
pub(crate) struct SessionHandle {
|
||||
// Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host
|
||||
@@ -231,10 +248,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
|
||||
unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) };
|
||||
}
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: per the contract, `handle` is a live `Box<SessionHandle>` pointer.
|
||||
unsafe { drop(Box::from_raw(handle as *mut SessionHandle)) };
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host
|
||||
@@ -367,11 +386,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_video();
|
||||
}
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_video();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD.
|
||||
@@ -386,36 +407,38 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) -> jdoubleArray {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let snap = match h.video.lock().unwrap().as_ref() {
|
||||
Some(vt) => vt.stats.drain(),
|
||||
None => return std::ptr::null_mut(), // not streaming → no stats
|
||||
};
|
||||
let mode = h.client.mode();
|
||||
let buf: [f64; 10] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.lat_p50_ms,
|
||||
snap.lat_p95_ms,
|
||||
if snap.lat_valid { 1.0 } else { 0.0 },
|
||||
if snap.skew_corrected { 1.0 } else { 0.0 },
|
||||
mode.width as f64,
|
||||
mode.height as f64,
|
||||
mode.refresh_hz as f64,
|
||||
h.client.frames_dropped() as f64,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
if env.set_double_array_region(&arr, 0, &buf).is_err() {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
arr.into_raw()
|
||||
jni_guard(std::ptr::null_mut(), || {
|
||||
if handle == 0 {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
// SAFETY: live handle per the nativeConnect/nativeClose contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
let snap = match h.video.lock().unwrap().as_ref() {
|
||||
Some(vt) => vt.stats.drain(),
|
||||
None => return std::ptr::null_mut(), // not streaming → no stats
|
||||
};
|
||||
let mode = h.client.mode();
|
||||
let buf: [f64; 10] = [
|
||||
snap.fps,
|
||||
snap.mbps,
|
||||
snap.lat_p50_ms,
|
||||
snap.lat_p95_ms,
|
||||
if snap.lat_valid { 1.0 } else { 0.0 },
|
||||
if snap.skew_corrected { 1.0 } else { 0.0 },
|
||||
mode.width as f64,
|
||||
mode.height as f64,
|
||||
mode.refresh_hz as f64,
|
||||
h.client.frames_dropped() as f64,
|
||||
];
|
||||
let arr = match env.new_double_array(buf.len() as jsize) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return std::ptr::null_mut(),
|
||||
};
|
||||
if env.set_double_array_region(&arr, 0, &buf).is_err() {
|
||||
return std::ptr::null_mut();
|
||||
}
|
||||
arr.into_raw()
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartAudio(handle)` — start the Opus→AAudio playback thread. No-op if already
|
||||
@@ -451,11 +474,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopAudio(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_audio();
|
||||
}
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_audio();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeStartMic(handle)` — start mic capture (AAudio input → Opus → host `send_mic`).
|
||||
@@ -492,11 +517,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
|
||||
_this: JObject,
|
||||
handle: jlong,
|
||||
) {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_mic();
|
||||
}
|
||||
jni_guard((), || {
|
||||
if handle != 0 {
|
||||
// SAFETY: live handle per the contract.
|
||||
let h = unsafe { &*(handle as *const SessionHandle) };
|
||||
h.stop_mic();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Input plane: Kotlin capture → NativeClient::send_input ----------------------------------
|
||||
|
||||
@@ -511,15 +511,18 @@ struct SettingsView: View {
|
||||
private static let padTypes: [(label: String, tag: Int)] = [
|
||||
("Automatic", 0),
|
||||
("Xbox 360", 1),
|
||||
("Xbox One", 3),
|
||||
("DualSense", 2),
|
||||
("DualShock 4", 4),
|
||||
]
|
||||
|
||||
private static let controllersFooter =
|
||||
"One controller is forwarded to the host, as player 1 — Automatic picks the most "
|
||||
+ "recently connected one. The type is the virtual pad the host creates: Automatic "
|
||||
+ "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad "
|
||||
+ "and motion), and changes apply from the next session. Two identical controllers "
|
||||
+ "may swap a manual selection after reconnecting."
|
||||
+ "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply "
|
||||
+ "from the next session. Two identical controllers may swap a manual selection "
|
||||
+ "after reconnecting."
|
||||
|
||||
/// "Use controller" choices: Automatic, every forwardable controller, and — so a stale
|
||||
/// pin stays visible instead of leaving the Picker selection tag-less — any pinned id
|
||||
@@ -537,7 +540,7 @@ struct SettingsView: View {
|
||||
|
||||
private func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: controller.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||
Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(controller.name)
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
// full GCExtendedGamepad state on every valueChanged and diff against the previous
|
||||
// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255.
|
||||
//
|
||||
// DualSense extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
||||
// PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized
|
||||
// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion
|
||||
// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g —
|
||||
// derived from the host's fixed calibration blob; the conversion lives in ONE place,
|
||||
// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both
|
||||
// unless the session's virtual pad is a DualSense.
|
||||
// unless the session's virtual pad is a DualSense or DualShock 4 — both carry a touchpad
|
||||
// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same
|
||||
// `touchpad*` surface as `GCDualSenseGamepad`).
|
||||
//
|
||||
// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture
|
||||
// toggle — a controller can't click local UI, so it always drives the host while the app
|
||||
@@ -154,8 +156,9 @@ public final class GamepadCapture {
|
||||
releaseAll()
|
||||
if let ext = bound?.extendedGamepad {
|
||||
ext.valueChangedHandler = nil
|
||||
(ext as? GCDualSenseGamepad)?.touchpadPrimary.valueChangedHandler = nil
|
||||
(ext as? GCDualSenseGamepad)?.touchpadSecondary.valueChangedHandler = nil
|
||||
let tp = Self.touchpad(ext)
|
||||
tp?.primary.valueChangedHandler = nil
|
||||
tp?.secondary.valueChangedHandler = nil
|
||||
}
|
||||
if let motion = bound?.motion {
|
||||
motion.valueChangedHandler = nil
|
||||
@@ -186,11 +189,11 @@ public final class GamepadCapture {
|
||||
connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0))
|
||||
sync(ext)
|
||||
|
||||
if let ds = ext as? GCDualSenseGamepad {
|
||||
ds.touchpadPrimary.valueChangedHandler = { [weak self] _, x, y in
|
||||
if let tp = Self.touchpad(ext) {
|
||||
tp.primary.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) }
|
||||
}
|
||||
ds.touchpadSecondary.valueChangedHandler = { [weak self] _, x, y in
|
||||
tp.secondary.valueChangedHandler = { [weak self] _, x, y in
|
||||
MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) }
|
||||
}
|
||||
}
|
||||
@@ -257,12 +260,29 @@ public final class GamepadCapture {
|
||||
if g.buttonB.isPressed { b |= GamepadWire.b }
|
||||
if g.buttonX.isPressed { b |= GamepadWire.x }
|
||||
if g.buttonY.isPressed { b |= GamepadWire.y }
|
||||
if (g as? GCDualSenseGamepad)?.touchpadButton.isPressed == true {
|
||||
if Self.touchpad(g)?.button.isPressed == true {
|
||||
b |= GamepadWire.touchpadClick
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
/// The touchpad surface of a PlayStation pad — present on both `GCDualSenseGamepad` and
|
||||
/// `GCDualShockGamepad` (DualShock 4), which don't share a common touchpad type, so we
|
||||
/// downcast either and project the identical `touchpad*` properties. `nil` for any other
|
||||
/// controller (Xbox, MFi).
|
||||
private static func touchpad(
|
||||
_ g: GCExtendedGamepad
|
||||
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||
button: GCControllerButtonInput)? {
|
||||
if let ds = g as? GCDualSenseGamepad {
|
||||
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
|
||||
}
|
||||
if let ds4 = g as? GCDualShockGamepad {
|
||||
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on
|
||||
/// lift — treated as the lift signal (a real finger landing on the precise center
|
||||
/// momentarily reads as a lift; harmless for a 1-in-65k coincidence).
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
|
||||
//
|
||||
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
|
||||
// only on DualSense sessions — the drain always polls both planes with short timeouts and
|
||||
// never spins, so an Xbox session just renders rumble. GameController profile mutation
|
||||
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the
|
||||
// drain always polls both planes with short timeouts and never spins, so an Xbox session
|
||||
// just renders rumble. GameController profile mutation
|
||||
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
|
||||
// touches neither. When GamepadManager switches the active controller mid-session, the
|
||||
// old pad is reset (triggers off, player index unset) and the last known feedback state
|
||||
@@ -248,9 +249,12 @@ public final class GamepadFeedback {
|
||||
public func start() {
|
||||
guard !drainStarted else { return }
|
||||
drainStarted = true
|
||||
// No hidout traffic can exist on a non-DualSense session — poll that plane
|
||||
// nonblocking there and let rumble own the wait.
|
||||
let hidTimeout: UInt32 = connection.resolvedGamepad == .dualSense ? 10 : 0
|
||||
// Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad
|
||||
// session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
|
||||
// let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
|
||||
let hasHidout = connection.resolvedGamepad == .dualSense
|
||||
|| connection.resolvedGamepad == .dualShock4
|
||||
let hidTimeout: UInt32 = hasHidout ? 10 : 0
|
||||
let thread = Thread { [connection, flag, drainDone, weak self] in
|
||||
while !flag.isStopped {
|
||||
do {
|
||||
|
||||
@@ -30,11 +30,22 @@ public final class GamepadManager: ObservableObject {
|
||||
public let productCategory: String
|
||||
/// The full extended profile exists — only these are forwardable.
|
||||
public let isExtended: Bool
|
||||
public let isDualSense: Bool
|
||||
/// The virtual-pad type a physical match resolves to under `.auto`: DualSense →
|
||||
/// `.dualSense`, DualShock 4 → `.dualShock4`, an Xbox pad → `.xboxOne`, anything
|
||||
/// else → `.xbox360`. (`.auto` is never stored here.)
|
||||
public let kind: PunktfunkConnection.GamepadType
|
||||
public let hasLight: Bool
|
||||
public let hasHaptics: Bool
|
||||
public let hasMotion: Bool
|
||||
public let hasAdaptiveTriggers: Bool
|
||||
/// Specifically a DualSense — gates the DualSense-only feedback (adaptive triggers,
|
||||
/// player LEDs) and the PlayStation glyph in Settings.
|
||||
public var isDualSense: Bool { kind == .dualSense }
|
||||
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) — gates
|
||||
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
|
||||
public var hasTouchpadAndMotion: Bool {
|
||||
kind == .dualSense || kind == .dualShock4
|
||||
}
|
||||
/// 0...1, nil when the controller doesn't report a battery (e.g. wired).
|
||||
public let batteryLevel: Float?
|
||||
public let isCharging: Bool
|
||||
@@ -102,7 +113,8 @@ public final class GamepadManager: ObservableObject {
|
||||
|
||||
/// Connect-time resolution of the user's controller-type setting: an explicit choice
|
||||
/// wins; `.auto` matches the virtual pad to the active physical controller (DualSense →
|
||||
/// DualSense, anything else → Xbox 360); no controller at all defers to the host.
|
||||
/// DualSense, DualShock 4 → DualShock 4, an Xbox pad → Xbox One, anything else → Xbox
|
||||
/// 360); no controller at all defers to the host.
|
||||
public func resolveType(
|
||||
setting: PunktfunkConnection.GamepadType
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
@@ -113,7 +125,7 @@ public final class GamepadManager: ObservableObject {
|
||||
// pad. `rebuild()` re-reads `GCController.controllers()` synchronously, closing that race.
|
||||
rebuild()
|
||||
guard let active else { return .auto }
|
||||
return active.isDualSense ? .dualSense : .xbox360
|
||||
return active.kind
|
||||
}
|
||||
|
||||
private func noteConnected(_ c: GCController) {
|
||||
@@ -152,20 +164,38 @@ public final class GamepadManager: ObservableObject {
|
||||
|
||||
private static func describe(_ c: GCController, id: String) -> DiscoveredController {
|
||||
let extended = c.extendedGamepad
|
||||
let ds = extended as? GCDualSenseGamepad
|
||||
let kind = padKind(extended)
|
||||
return DiscoveredController(
|
||||
id: id,
|
||||
name: c.vendorName ?? c.productCategory,
|
||||
productCategory: c.productCategory,
|
||||
isExtended: extended != nil,
|
||||
isDualSense: ds != nil,
|
||||
kind: kind,
|
||||
hasLight: c.light != nil,
|
||||
hasHaptics: c.haptics != nil,
|
||||
hasMotion: c.motion != nil,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration.
|
||||
hasAdaptiveTriggers: ds != nil,
|
||||
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the
|
||||
// DualShock 4 has none.
|
||||
hasAdaptiveTriggers: kind == .dualSense,
|
||||
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
|
||||
isCharging: c.battery?.batteryState == .charging,
|
||||
controller: c)
|
||||
}
|
||||
|
||||
/// Resolve a physical controller's matching virtual-pad type from its GameController
|
||||
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then
|
||||
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent
|
||||
/// profile also falls back to `.xbox360` (it's never forwarded anyway).
|
||||
private static func padKind(
|
||||
_ extended: GCExtendedGamepad?
|
||||
) -> PunktfunkConnection.GamepadType {
|
||||
guard let extended else { return .xbox360 }
|
||||
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
|
||||
// here, so no `@available` guard is needed — matching the unguarded
|
||||
// `GCDualSenseGamepad` use elsewhere in the package.
|
||||
if extended is GCDualSenseGamepad { return .dualSense }
|
||||
if extended is GCDualShockGamepad { return .dualShock4 }
|
||||
if extended is GCXboxGamepad { return .xboxOne }
|
||||
return .xbox360
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,13 +170,18 @@ public final class PunktfunkConnection {
|
||||
|
||||
/// Which virtual gamepad the host creates for this session's pads (the
|
||||
/// `PUNKTFUNK_GAMEPAD_*` ABI values). `.auto` lets the host decide (its env var, else
|
||||
/// X-Box 360); `.dualSense` is honored only on hosts with UHID (Linux) — games then see
|
||||
/// a real DualSense and their lightbar / adaptive-trigger writes come back on the
|
||||
/// HID-output plane (`nextHidOutput`). The host's actual choice is `resolvedGamepad`.
|
||||
/// X-Box 360); `.dualSense` / `.dualShock4` are honored only on hosts with UHID (Linux) —
|
||||
/// games then see a real PlayStation pad and its lightbar (and, on a DualSense,
|
||||
/// adaptive-trigger / player-LED) writes come back on the HID-output plane
|
||||
/// (`nextHidOutput`). `.xboxOne` is an X-Box-Series-glyph variant of `.xbox360` (same
|
||||
/// buttons/sticks/triggers + rumble, no touchpad/motion/lightbar). The host's actual
|
||||
/// choice is `resolvedGamepad`.
|
||||
public enum GamepadType: UInt32, CaseIterable, Sendable {
|
||||
case auto = 0
|
||||
case xbox360 = 1
|
||||
case dualSense = 2
|
||||
case xboxOne = 3
|
||||
case dualShock4 = 4
|
||||
|
||||
/// Loose name parsing for env/dev hooks, mirroring the host's
|
||||
/// `GamepadPref::from_name`.
|
||||
@@ -184,7 +189,9 @@ public final class PunktfunkConnection {
|
||||
switch name.lowercased() {
|
||||
case "auto", "default": self = .auto
|
||||
case "xbox", "xbox360", "x360", "uinput": self = .xbox360
|
||||
case "dualsense", "ds", "ps5": self = .dualSense
|
||||
case "dualsense", "ds", "ds5", "ps5": self = .dualSense
|
||||
case "xboxone", "xbox-one", "xboxseries", "series": self = .xboxOne
|
||||
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
@@ -497,10 +504,11 @@ public final class PunktfunkConnection {
|
||||
case triggerEffect(pad: UInt8, which: UInt8, effect: [UInt8])
|
||||
}
|
||||
|
||||
/// Pull the next DualSense feedback event (lightbar / player LEDs / adaptive triggers);
|
||||
/// nil on timeout, throws `.closed` once the session ended. Drain from the (single)
|
||||
/// feedback thread, alongside `nextRumble`. Nothing ever arrives unless
|
||||
/// `resolvedGamepad == .dualSense` — poll with a short timeout, never spin.
|
||||
/// Pull the next PlayStation-pad feedback event (lightbar / player LEDs / adaptive
|
||||
/// triggers); nil on timeout, throws `.closed` once the session ended. Drain from the
|
||||
/// (single) feedback thread, alongside `nextRumble`. Nothing arrives unless the session's
|
||||
/// virtual pad is a DualSense (all three) or a DualShock 4 (lightbar only) — poll with a
|
||||
/// short timeout, never spin.
|
||||
public func nextHidOutput(timeoutMs: UInt32 = 0) throws -> HidOutputEvent? {
|
||||
feedbackLock.lock()
|
||||
defer { feedbackLock.unlock() }
|
||||
|
||||
@@ -39,7 +39,39 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
|
||||
pub struct PadInfo {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub is_dualsense: bool,
|
||||
/// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a
|
||||
/// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything
|
||||
/// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path.
|
||||
pub pref: GamepadPref,
|
||||
}
|
||||
|
||||
impl PadInfo {
|
||||
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
|
||||
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
|
||||
fn is_dualsense(&self) -> bool {
|
||||
self.pref == GamepadPref::DualSense
|
||||
}
|
||||
|
||||
/// A short controller-kind label for the Settings list (`""` for a plain Xbox/standard pad).
|
||||
pub fn kind_label(&self) -> &'static str {
|
||||
match self.pref {
|
||||
GamepadPref::DualSense => "DualSense",
|
||||
GamepadPref::DualShock4 => "DualShock 4",
|
||||
GamepadPref::XboxOne => "Xbox One",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
use sdl3::gamepad::GamepadType as T;
|
||||
match t {
|
||||
T::PS5 => GamepadPref::DualSense,
|
||||
T::PS4 => GamepadPref::DualShock4,
|
||||
T::XboxOne => GamepadPref::XboxOne,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
|
||||
enum Ctl {
|
||||
@@ -120,8 +152,7 @@ impl GamepadService {
|
||||
/// (Swift parity); no pad connected leaves the host's own default.
|
||||
pub fn auto_pref(&self) -> GamepadPref {
|
||||
match self.active() {
|
||||
Some(p) if p.is_dualsense => GamepadPref::DualSense,
|
||||
Some(_) => GamepadPref::Xbox360,
|
||||
Some(p) => p.pref,
|
||||
None => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -247,10 +278,9 @@ impl Worker {
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
is_dualsense: matches!(
|
||||
pref: pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
sdl3::gamepad::GamepadType::PS5
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -552,7 +582,7 @@ fn run(
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let Some(id) = w.active_id() else { continue };
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense);
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
|
||||
let Some(pad) = w.opened.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ const RESOLUTIONS: &[(u32, u32)] = &[
|
||||
];
|
||||
/// `0` = the monitor's native refresh, resolved at connect.
|
||||
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
|
||||
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense"];
|
||||
const GAMEPADS: &[&str] = &["auto", "xbox360", "dualsense", "xboxone", "dualshock4"];
|
||||
const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"];
|
||||
|
||||
pub fn show(
|
||||
@@ -85,10 +85,11 @@ pub fn show(
|
||||
let pads = gamepads.pads();
|
||||
let mut pad_names = vec!["Automatic (most recent)".to_string()];
|
||||
pad_names.extend(pads.iter().map(|p| {
|
||||
if p.is_dualsense {
|
||||
format!("{} · DualSense", p.name)
|
||||
} else {
|
||||
let kind = p.kind_label();
|
||||
if kind.is_empty() {
|
||||
p.name.clone()
|
||||
} else {
|
||||
format!("{} · {kind}", p.name)
|
||||
}
|
||||
}));
|
||||
let forward_row = adw::ComboRow::builder()
|
||||
@@ -126,6 +127,8 @@ pub fn show(
|
||||
"Automatic",
|
||||
"Xbox 360",
|
||||
"DualSense",
|
||||
"Xbox One",
|
||||
"DualShock 4",
|
||||
]))
|
||||
.build();
|
||||
let inhibit_row = adw::SwitchRow::builder()
|
||||
|
||||
@@ -27,9 +27,10 @@
|
||||
//! `gamescope`); the host honors it if available, else auto-detects and reports the resolved
|
||||
//! choice in its Welcome (logged as `session offer … compositor=…`).
|
||||
//!
|
||||
//! `--gamepad NAME` requests a host virtual-pad backend (`auto`|`xbox360`|`dualsense`); the
|
||||
//! host honors it where available (DualSense needs Linux UHID), else falls back to X-Box 360,
|
||||
//! and reports the resolved choice in its Welcome (logged as `session offer … gamepad=…`).
|
||||
//! `--gamepad NAME` requests a host virtual-pad backend
|
||||
//! (`auto`|`xbox360`|`dualsense`|`xboxone`|`dualshock4`); the host honors it where available (the
|
||||
//! UHID pads — DualSense, DualShock 4 — need Linux), else falls back to X-Box 360, and reports the
|
||||
//! resolved choice in its Welcome (logged as `session offer … gamepad=…`).
|
||||
//!
|
||||
//! `--discover [SECS]` browses the LAN for native (`_punktfunk._udp`) hosts the host advertises
|
||||
//! over mDNS, prints each (name, addr:port, pairing requirement, cert fingerprint to pin), and
|
||||
@@ -178,7 +179,9 @@ fn parse_args() -> Args {
|
||||
Some(s) => match GamepadPref::from_name(s) {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
eprintln!("--gamepad must be one of: auto, xbox360, dualsense");
|
||||
eprintln!(
|
||||
"--gamepad must be one of: auto, xbox360, dualsense, xboxone, dualshock4"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -32,12 +32,33 @@ const G: f32 = 9.80665;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PadInfo {
|
||||
// `id`/`name` feed the settings GUI's pad list (a follow-up); the windowed client only
|
||||
// reads `is_dualsense` (via `auto_pref`), so they're unused in reachable code for now.
|
||||
// reads `pref` (via `auto_pref`), so they're unused in reachable code for now.
|
||||
#[allow(dead_code)]
|
||||
pub id: u32,
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
pub is_dualsense: bool,
|
||||
/// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense,
|
||||
/// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360).
|
||||
pub pref: GamepadPref,
|
||||
}
|
||||
|
||||
impl PadInfo {
|
||||
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
|
||||
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
|
||||
fn is_dualsense(&self) -> bool {
|
||||
self.pref == GamepadPref::DualSense
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||||
use sdl3::gamepad::GamepadType as T;
|
||||
match t {
|
||||
T::PS5 => GamepadPref::DualSense,
|
||||
T::PS4 => GamepadPref::DualShock4,
|
||||
T::XboxOne => GamepadPref::XboxOne,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
|
||||
enum Ctl {
|
||||
@@ -112,8 +133,7 @@ impl GamepadService {
|
||||
/// (Swift parity); no pad connected leaves the host's own default.
|
||||
pub fn auto_pref(&self) -> GamepadPref {
|
||||
match self.active() {
|
||||
Some(p) if p.is_dualsense => GamepadPref::DualSense,
|
||||
Some(_) => GamepadPref::Xbox360,
|
||||
Some(p) => p.pref,
|
||||
None => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -235,10 +255,9 @@ impl Worker {
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
is_dualsense: matches!(
|
||||
pref: pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
sdl3::gamepad::GamepadType::PS5
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -515,7 +534,7 @@ fn run(
|
||||
}
|
||||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||||
let Some(id) = w.active_id() else { continue };
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense);
|
||||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
|
||||
let Some(pad) = w.opened.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -687,6 +687,16 @@ pub const PUNKTFUNK_GAMEPAD_XBOX360: u32 = 1;
|
||||
/// feedback arrives on the HID-output plane ([`punktfunk_connection_next_hidout`]). Honored
|
||||
/// only where available (Linux hosts); otherwise the host falls back to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
|
||||
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
|
||||
/// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
|
||||
/// impulse-trigger rumble is unreachable through a virtual pad). Useful for glyph-matching a
|
||||
/// physical X-Box One/Series controller on the client.
|
||||
pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
|
||||
/// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
|
||||
/// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
|
||||
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||
/// hosts); otherwise the host falls back to X-Box 360.
|
||||
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
|
||||
|
||||
/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
|
||||
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
||||
@@ -706,6 +716,16 @@ const _: () = {
|
||||
assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR);
|
||||
};
|
||||
|
||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||
const _: () = {
|
||||
use crate::config::GamepadPref;
|
||||
assert!(PUNKTFUNK_GAMEPAD_AUTO == GamepadPref::Auto.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_XBOX360 == GamepadPref::Xbox360.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSENSE == GamepadPref::DualSense.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_XBOXONE == GamepadPref::XboxOne.to_u8() as u32);
|
||||
assert!(PUNKTFUNK_GAMEPAD_DUALSHOCK4 == GamepadPref::DualShock4.to_u8() as u32);
|
||||
};
|
||||
|
||||
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
|
||||
/// certificate — a mismatching host is rejected. NULL = trust on first use; persist the
|
||||
/// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and
|
||||
|
||||
@@ -135,10 +135,10 @@ impl CompositorPref {
|
||||
/// Sent in [`Hello`](crate::quic::Hello) as a *preference* and echoed back — resolved to the
|
||||
/// backend actually chosen — in [`Welcome`](crate::quic::Welcome). `Auto` (the default) lets the
|
||||
/// host decide (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). A concrete preference is
|
||||
/// honored only if that backend is available on the host (DualSense needs Linux UHID); otherwise
|
||||
/// the host falls back and reports the real choice in `Welcome`. The wire form is a single byte
|
||||
/// (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`), appended to `Hello`/`Welcome` — older peers
|
||||
/// simply omit/ignore it.
|
||||
/// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID);
|
||||
/// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single
|
||||
/// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`), appended to
|
||||
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GamepadPref {
|
||||
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
|
||||
@@ -148,15 +148,24 @@ pub enum GamepadPref {
|
||||
Xbox360,
|
||||
/// UHID DualSense (kernel `hid-playstation`) — adaptive triggers, lightbar, touchpad, motion.
|
||||
DualSense,
|
||||
/// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity
|
||||
/// (VID/PID/name), so games show One/Series glyphs. XInput-identical otherwise (impulse-trigger
|
||||
/// rumble is unreachable through any virtual pad, so there's no game-visible gain over `Xbox360`).
|
||||
XboxOne,
|
||||
/// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like
|
||||
/// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host.
|
||||
DualShock4,
|
||||
}
|
||||
|
||||
impl GamepadPref {
|
||||
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`.
|
||||
pub fn to_u8(self) -> u8 {
|
||||
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`.
|
||||
pub const fn to_u8(self) -> u8 {
|
||||
match self {
|
||||
GamepadPref::Auto => 0,
|
||||
GamepadPref::Xbox360 => 1,
|
||||
GamepadPref::DualSense => 2,
|
||||
GamepadPref::XboxOne => 3,
|
||||
GamepadPref::DualShock4 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +175,8 @@ impl GamepadPref {
|
||||
match v {
|
||||
1 => GamepadPref::Xbox360,
|
||||
2 => GamepadPref::DualSense,
|
||||
3 => GamepadPref::XboxOne,
|
||||
4 => GamepadPref::DualShock4,
|
||||
_ => GamepadPref::Auto,
|
||||
}
|
||||
}
|
||||
@@ -177,16 +188,23 @@ impl GamepadPref {
|
||||
"auto" | "default" => GamepadPref::Auto,
|
||||
"xbox" | "xbox360" | "x360" | "uinput" => GamepadPref::Xbox360,
|
||||
"dualsense" | "ds" | "ps5" => GamepadPref::DualSense,
|
||||
"xboxone" | "xbox-one" | "xone" | "xbox1" | "series" | "xboxseries" => {
|
||||
GamepadPref::XboxOne
|
||||
}
|
||||
"dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`).
|
||||
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
|
||||
/// `"dualshock4"`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GamepadPref::Auto => "auto",
|
||||
GamepadPref::Xbox360 => "xbox360",
|
||||
GamepadPref::DualSense => "dualsense",
|
||||
GamepadPref::XboxOne => "xboxone",
|
||||
GamepadPref::DualShock4 => "dualshock4",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1883,13 +1883,25 @@ mod tests {
|
||||
GamepadPref::Auto,
|
||||
GamepadPref::Xbox360,
|
||||
GamepadPref::DualSense,
|
||||
GamepadPref::XboxOne,
|
||||
GamepadPref::DualShock4,
|
||||
] {
|
||||
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
|
||||
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
|
||||
}
|
||||
// Distinct wire bytes (forward-compat with peers that only know 0..=2).
|
||||
assert_eq!(GamepadPref::XboxOne.to_u8(), 3);
|
||||
assert_eq!(GamepadPref::DualShock4.to_u8(), 4);
|
||||
// Aliases + unknowns.
|
||||
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
||||
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
||||
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
|
||||
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
|
||||
assert_eq!(
|
||||
GamepadPref::from_name("xbox-one"),
|
||||
Some(GamepadPref::XboxOne)
|
||||
);
|
||||
assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne));
|
||||
assert_eq!(GamepadPref::from_name("nope"), None);
|
||||
// Unknown wire byte degrades to Auto (forward-compatible).
|
||||
assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto);
|
||||
|
||||
@@ -424,6 +424,8 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod dualsense;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod dualshock4;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod gamepad;
|
||||
/// Windows: virtual Xbox 360 pads via ViGEmBus.
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
//! Virtual Sony DualShock 4 (PS4) via UHID — the PS4 sibling of the DualSense backend
|
||||
//! ([`super::dualsense`]). A UHID device presents a *real* DualShock 4 HID interface to the kernel:
|
||||
//! `hid-playstation` binds it (matched by VID `054C`/PID `09CC`, since Linux 6.2) and exposes the
|
||||
//! full controller — gamepad, motion sensors, touchpad, lightbar, rumble — to games. We write HID
|
||||
//! **input** reports (report `0x01`, our controller state) and read HID **output** reports (report
|
||||
//! `0x05`, a game's rumble/lightbar feedback) back, forwarding them to the client.
|
||||
//!
|
||||
//! It carries everything the DualSense does *except* adaptive triggers, player LEDs and the mute
|
||||
//! button (the DS4 hardware has none), so the only feedback it surfaces is motor rumble (universal
|
||||
//! 0xCA plane) and the lightbar (HID-output 0xCD `Led`). The button/stick/dpad/touchpad mapping is
|
||||
//! identical to the DualSense, so we reuse its pure [`DsState`] + [`DsState::from_gamepad`]; only the
|
||||
//! report *byte layout*, the report descriptor, the feature-report handshake and the touchpad
|
||||
//! resolution differ. The report descriptor + struct offsets are the canonical real-DS4-USB layout
|
||||
//! the kernel `struct dualshock4_input_report_usb` / `_output_report_common` parse.
|
||||
|
||||
use super::dualsense::{DsState, Touch};
|
||||
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;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
// Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is
|
||||
// MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the
|
||||
// kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are
|
||||
// non-fatal (the kernel warns + falls back to identity IMU calibration), but we answer them for
|
||||
// correct motion scaling. Each array's first byte is the report id (the kernel hard-checks it).
|
||||
#[rustfmt::skip]
|
||||
const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01)
|
||||
0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words)
|
||||
0x02,
|
||||
0x00, 0x00, // gyro_pitch_bias = 0
|
||||
0x00, 0x00, // gyro_yaw_bias = 0
|
||||
0x00, 0x00, // gyro_roll_bias = 0
|
||||
0x10, 0x00, // gyro_pitch_plus = +16
|
||||
0xF0, 0xFF, // gyro_pitch_minus = -16
|
||||
0x10, 0x00, // gyro_yaw_plus = +16
|
||||
0xF0, 0xFF, // gyro_yaw_minus = -16
|
||||
0x10, 0x00, // gyro_roll_plus = +16
|
||||
0xF0, 0xFF, // gyro_roll_minus = -16
|
||||
0x20, 0x00, // gyro_speed_plus = +32
|
||||
0x20, 0x00, // gyro_speed_minus = +32
|
||||
0x00, 0x20, // acc_x_plus = +8192
|
||||
0x00, 0xE0, // acc_x_minus = -8192
|
||||
0x00, 0x20, // acc_y_plus = +8192
|
||||
0x00, 0xE0, // acc_y_minus = -8192
|
||||
0x00, 0x20, // acc_z_plus = +8192
|
||||
0x00, 0xE0, // acc_z_minus = -8192
|
||||
0x00, 0x00, // trailing pad (descriptor declares 36 data bytes)
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
const DS4_FEATURE_FIRMWARE: &[u8] = &[ // report 0xa3 (build date string + hw/fw versions; cosmetic)
|
||||
0xA3, 0x41, 0x75, 0x67, 0x20, 0x20, 0x33, 0x20, 0x32, 0x30, 0x31, 0x33, // "Aug 3 2013"
|
||||
0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x30, 0x37, 0x3A, 0x30, 0x31, 0x3A, 0x31, 0x32, // "07:01:12"
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0xA0, // hw_version = 0xA000 (buf[35])
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x01, // fw_version = 0x0100 (buf[41])
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // trailing pad (buf[43..49]) → 49 bytes total
|
||||
];
|
||||
|
||||
/// Sony DualShock 4 v2 USB HID report descriptor (507 bytes) — a verbatim real-device capture
|
||||
/// (CUH-ZCT2E, `054C:09CC`). Declares input `0x01` (64 B), output `0x05` (32 B), and the feature
|
||||
/// reports `0x02`/`0x12`/`0xa3` so the kernel's GET_REPORTs route. The kernel binds DS4 by VID/PID,
|
||||
/// but HID core still needs these reports declared.
|
||||
#[rustfmt::skip]
|
||||
const DS4_RDESC: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
|
||||
0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
|
||||
0x04, 0x81, 0x02, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46,
|
||||
0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00,
|
||||
0x05, 0x09, 0x19, 0x01, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
|
||||
0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95,
|
||||
0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09,
|
||||
0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02,
|
||||
0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
|
||||
0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xB1,
|
||||
0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0x85, 0x08, 0x09,
|
||||
0x25, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x10, 0x09, 0x26, 0x95, 0x04, 0xB1,
|
||||
0x02, 0x85, 0x11, 0x09, 0x27, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x12, 0x06,
|
||||
0x02, 0xFF, 0x09, 0x21, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0x13, 0x09, 0x22,
|
||||
0x95, 0x16, 0xB1, 0x02, 0x85, 0x14, 0x06, 0x05, 0xFF, 0x09, 0x20, 0x95,
|
||||
0x10, 0xB1, 0x02, 0x85, 0x15, 0x09, 0x21, 0x95, 0x2C, 0xB1, 0x02, 0x06,
|
||||
0x80, 0xFF, 0x85, 0x80, 0x09, 0x20, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x81,
|
||||
0x09, 0x21, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x22, 0x95, 0x05,
|
||||
0xB1, 0x02, 0x85, 0x83, 0x09, 0x23, 0x95, 0x01, 0xB1, 0x02, 0x85, 0x84,
|
||||
0x09, 0x24, 0x95, 0x04, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x25, 0x95, 0x06,
|
||||
0xB1, 0x02, 0x85, 0x86, 0x09, 0x26, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x87,
|
||||
0x09, 0x27, 0x95, 0x23, 0xB1, 0x02, 0x85, 0x88, 0x09, 0x28, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0x89, 0x09, 0x29, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x90,
|
||||
0x09, 0x30, 0x95, 0x05, 0xB1, 0x02, 0x85, 0x91, 0x09, 0x31, 0x95, 0x03,
|
||||
0xB1, 0x02, 0x85, 0x92, 0x09, 0x32, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x93,
|
||||
0x09, 0x33, 0x95, 0x0C, 0xB1, 0x02, 0x85, 0x94, 0x09, 0x34, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xA0, 0x09, 0x40, 0x95, 0x06, 0xB1, 0x02, 0x85, 0xA1,
|
||||
0x09, 0x41, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA2, 0x09, 0x42, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xA3, 0x09, 0x43, 0x95, 0x30, 0xB1, 0x02, 0x85, 0xA4,
|
||||
0x09, 0x44, 0x95, 0x0D, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x47, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xF1, 0x09, 0x48, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2,
|
||||
0x09, 0x49, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0xA7, 0x09, 0x4A, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xA8, 0x09, 0x4B, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA9,
|
||||
0x09, 0x4C, 0x95, 0x08, 0xB1, 0x02, 0x85, 0xAA, 0x09, 0x4E, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xAB, 0x09, 0x4F, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAC,
|
||||
0x09, 0x50, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAD, 0x09, 0x51, 0x95, 0x0B,
|
||||
0xB1, 0x02, 0x85, 0xAE, 0x09, 0x52, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xAF,
|
||||
0x09, 0x53, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB0, 0x09, 0x54, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xE0, 0x09, 0x57, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB3,
|
||||
0x09, 0x55, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xB4, 0x09, 0x55, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xB5, 0x09, 0x56, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD0,
|
||||
0x09, 0x58, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD4, 0x09, 0x59, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
const DS4_VENDOR: u32 = 0x054C; // Sony Interactive Entertainment
|
||||
const DS4_PRODUCT: u32 = 0x09CC; // DualShock 4 v2 (CUH-ZCT2)
|
||||
/// USB input report `0x01` is 64 bytes total (report id + 63-byte body).
|
||||
const DS4_INPUT_REPORT_LEN: usize = 64;
|
||||
/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower
|
||||
/// than the DualSense's 1920×1080.
|
||||
pub const DS4_TOUCH_W: u16 = 1920;
|
||||
pub const DS4_TOUCH_H: u16 = 942;
|
||||
|
||||
/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's:
|
||||
/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y).
|
||||
fn pack_touch(dst: &mut [u8], t: &Touch) {
|
||||
dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 };
|
||||
// Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1.
|
||||
let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1));
|
||||
dst[1] = (x & 0xFF) as u8;
|
||||
dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4);
|
||||
dst[3] = ((y >> 4) & 0xFF) as u8;
|
||||
}
|
||||
|
||||
/// Serialize a full DS4 input report `0x01` (pure — unit-testable without `/dev/uhid`). Field
|
||||
/// offsets per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch;
|
||||
/// touch[3]; rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp;
|
||||
/// gyro[3] le16; accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common`
|
||||
/// field at struct offset N sits at report byte N+1.
|
||||
fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) {
|
||||
r[0] = 0x01; // report id
|
||||
r[1] = st.lx;
|
||||
r[2] = st.ly;
|
||||
r[3] = st.rx;
|
||||
r[4] = st.ry;
|
||||
r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high)
|
||||
r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3
|
||||
r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter
|
||||
r[8] = st.l2; // L2 analog (z)
|
||||
r[9] = st.r2; // R2 analog (rz)
|
||||
r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9)
|
||||
// r[12] temperature stays 0
|
||||
for (i, v) in st.gyro.iter().enumerate() {
|
||||
r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12
|
||||
}
|
||||
for (i, v) in st.accel.iter().enumerate() {
|
||||
r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18
|
||||
}
|
||||
// r[25..30] reserved2.
|
||||
// status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report
|
||||
// wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad.
|
||||
r[30] = 0x10 | 0x0B;
|
||||
// r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0.
|
||||
r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one)
|
||||
r[34] = ts as u8; // touch_reports[0].timestamp
|
||||
pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0
|
||||
pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1
|
||||
// remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero
|
||||
}
|
||||
|
||||
/// What one [`DualShock4Pad::service`] pass extracted from the device's HID output reports. Rumble
|
||||
/// rides the universal 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player
|
||||
/// LEDs or adaptive triggers, so those never appear).
|
||||
#[derive(Default)]
|
||||
pub struct Ds4Feedback {
|
||||
pub hidout: Vec<HidOutput>,
|
||||
/// `(low, high)` motor levels (0..=0xFF00), if a report carried them.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Lightbar RGB, if the report carried it (deduped by the manager).
|
||||
pub led: Option<(u8, u8, u8)>,
|
||||
}
|
||||
|
||||
/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel
|
||||
/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1],
|
||||
/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5],
|
||||
/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write
|
||||
/// doesn't masquerade as a lightbar change.
|
||||
fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) {
|
||||
if data.first() != Some(&0x05) || data.len() < 11 {
|
||||
return; // not the USB output report (BT 0x11 is shifted) / too short
|
||||
}
|
||||
let flag0 = data[1];
|
||||
if flag0 & 0x01 != 0 {
|
||||
// motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4];
|
||||
// scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends.
|
||||
let low = (data[5] as u16) << 8;
|
||||
let high = (data[4] as u16) << 8;
|
||||
fb.rumble = Some((low, high));
|
||||
}
|
||||
if flag0 & 0x02 != 0 {
|
||||
fb.led = Some((data[6], data[7], data[8]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a NUL-padded C string field into the event buffer.
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated)
|
||||
}
|
||||
|
||||
/// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's).
|
||||
/// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface).
|
||||
pub struct DualShock4Pad {
|
||||
fd: File,
|
||||
counter: u8,
|
||||
ts: u16,
|
||||
}
|
||||
|
||||
impl DualShock4Pad {
|
||||
/// Create the UHID DualShock 4 for pad `index` (used only to make the device name/uniq unique).
|
||||
pub fn open(index: u8) -> Result<DualShock4Pad> {
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(UHID_PATH)
|
||||
.with_context(|| {
|
||||
format!("open {UHID_PATH} (is the 60-punktfunk.rules uhid rule installed + are you in 'input'?)")
|
||||
})?;
|
||||
let mut ds = DualShock4Pad {
|
||||
fd,
|
||||
counter: 0,
|
||||
ts: 0,
|
||||
};
|
||||
ds.send_create2(index).context("UHID_CREATE2 DualShock4")?;
|
||||
Ok(ds)
|
||||
}
|
||||
|
||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
// union (uhid_create2_req) starts at byte 4.
|
||||
put_cstr(&mut ev, 4, 128, &format!("Punktfunk DualShock 4 {index}")); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/dualshock4/{index}")); // phys[64]
|
||||
// A unique uniq[64] keeps the sysfs nodes tidy when several pads coexist (the kernel's
|
||||
// duplicate-device check itself keys off the per-pad MAC in the pairing feature report).
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-ds4-{index}")); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(DS4_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
||||
ev[264..268].copy_from_slice(&DS4_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&DS4_PRODUCT.to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + DS4_RDESC.len()].copy_from_slice(DS4_RDESC); // rd_data
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize `st` into report `0x01` and write it to the kernel (UHID_INPUT2).
|
||||
pub fn write_state(&mut self, st: &DsState) -> Result<()> {
|
||||
self.counter = self.counter.wrapping_add(1);
|
||||
self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, st, self.counter, self.ts);
|
||||
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
ev[4..6].copy_from_slice(&(r.len() as u16).to_ne_bytes()); // input2.size
|
||||
ev[6..6 + r.len()].copy_from_slice(&r); // input2.data
|
||||
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (pairing /
|
||||
/// calibration / firmware — the pairing reply is required during `hid-playstation` init, or no
|
||||
/// input devices appear) and parse any HID OUTPUT reports (rumble / lightbar) into a
|
||||
/// [`Ds4Feedback`]. Call frequently — especially right after [`open`] so the init handshake
|
||||
/// completes.
|
||||
pub fn service(&mut self) -> Ds4Feedback {
|
||||
let mut fb = Ds4Feedback::default();
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
while let Ok(n) = self.fd.read(&mut ev) {
|
||||
if n < UHID_EVENT_SIZE {
|
||||
break;
|
||||
}
|
||||
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
|
||||
UHID_OUTPUT => {
|
||||
// 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_ds4_output(&ev[4..end], &mut fb);
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
// uhid_get_report_req: id u32 [4..8], rnum u8 [8].
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
let data: &[u8] = match ev[8] {
|
||||
0x12 => DS4_FEATURE_PAIRING,
|
||||
0x02 => DS4_FEATURE_CALIBRATION,
|
||||
0xA3 => DS4_FEATURE_FIRMWARE,
|
||||
_ => &[],
|
||||
};
|
||||
let _ = self.reply_get_report(id, data);
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close/SetReport — ignore
|
||||
}
|
||||
}
|
||||
fb
|
||||
}
|
||||
|
||||
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
// uhid_get_report_reply_req: id u32 [4..8], err u16 [8..10], size u16 [10..12], data [12..].
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
let err: u16 = if data.is_empty() { 5 } else { 0 }; // EIO if we don't know the report
|
||||
ev[8..10].copy_from_slice(&err.to_ne_bytes());
|
||||
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
|
||||
ev[12..12 + data.len()].copy_from_slice(data);
|
||||
self.fd
|
||||
.write_all(&ev)
|
||||
.context("write UHID_GET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DualShock4Pad {
|
||||
fn drop(&mut self) {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||
let _ = self.fd.write_all(&ev);
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual DualShock 4 pads of a session — the PS4 analog of
|
||||
/// [`DualSenseManager`](super::dualsense::DualSenseManager), selected with `PUNKTFUNK_GAMEPAD=ps4`.
|
||||
/// Like the DualSense it keeps each pad's full [`DsState`] and re-emits the merged report whenever
|
||||
/// buttons/sticks ([`handle`](Self::handle)) or touchpad/motion ([`apply_rich`](Self::apply_rich))
|
||||
/// change. [`pump`](Self::pump) services the kernel handshake and routes a game's feedback back:
|
||||
/// motor rumble on the universal plane, the lightbar on the HID-output plane.
|
||||
pub struct DualShock4Manager {
|
||||
pads: Vec<Option<DualShock4Pad>>,
|
||||
/// 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 lightbar doesn't re-send it.
|
||||
last_rumble: Vec<(u16, u16)>,
|
||||
/// Last lightbar RGB forwarded per pad — the kernel bundles the lightbar into every output
|
||||
/// report (incl. rumble-only writes), so dedup here to avoid flooding the HID-output plane.
|
||||
last_led: Vec<Option<(u8, u8, u8)>>,
|
||||
/// When each pad last wrote an input report — drives [`heartbeat`](Self::heartbeat).
|
||||
last_write: Vec<Instant>,
|
||||
/// Pad creation failed (e.g. /dev/uhid permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl Default for DualShock4Manager {
|
||||
fn default() -> DualShock4Manager {
|
||||
DualShock4Manager::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DualShock4Manager {
|
||||
pub fn new() -> DualShock4Manager {
|
||||
DualShock4Manager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
state: vec![DsState::neutral(); MAX_PADS],
|
||||
last_rumble: vec![(0, 0); MAX_PADS],
|
||||
last_led: vec![None; MAX_PADS],
|
||||
last_write: vec![Instant::now(); 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 (DualShock 4)");
|
||||
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 (DualShock 4)");
|
||||
*slot = None;
|
||||
self.state[i] = DsState::neutral();
|
||||
self.last_rumble[i] = (0, 0);
|
||||
self.last_led[i] = None;
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
self.ensure(idx);
|
||||
// Merge buttons/sticks/triggers, preserving touch + motion (those arrive 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; 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 DS4 touchpad carries two contacts; clamp to a valid slot and keep the
|
||||
// reported contact id consistent (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 DS4 touchpad range (0..=W-1 / 0..=H-1).
|
||||
t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16;
|
||||
t.y = ((y as u32 * (DS4_TOUCH_H - 1) 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);
|
||||
}
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
|
||||
/// Re-emit each live pad's CURRENT report if it's been silent for `max_gap` — a real DS4 streams
|
||||
/// report `0x01` continuously, and `hid-playstation` / SDL treat a multi-second silence (a
|
||||
/// held-steady stick) as an unplugged controller. Idempotent (a stale-but-correct frame);
|
||||
/// `write_state` bumps the counter + timestamp so each is a fresh, well-formed report.
|
||||
pub fn heartbeat(&mut self, max_gap: Duration) {
|
||||
let now = Instant::now();
|
||||
for i in 0..self.pads.len() {
|
||||
if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap {
|
||||
self.write(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
||||
return;
|
||||
}
|
||||
match DualShock4Pad::open(idx as u8) {
|
||||
Ok(p) => {
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual DualShock 4 created (UHID hid-playstation)"
|
||||
);
|
||||
self.pads[idx] = Some(p);
|
||||
self.state[idx] = DsState::neutral();
|
||||
self.last_rumble[idx] = (0, 0);
|
||||
self.last_led[idx] = None;
|
||||
self.last_write[idx] = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 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* (universal 0xCA plane);
|
||||
/// `hidout` carries the lightbar (0xCD `Led`), deduped. 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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
if let Some(rgb) = fb.led {
|
||||
if self.last_led[i] != Some(rgb) {
|
||||
self.last_led[i] = Some(rgb);
|
||||
hidout(HidOutput::Led {
|
||||
pad: i as u8,
|
||||
r: rgb.0,
|
||||
g: rgb.1,
|
||||
b: rgb.2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets.
|
||||
#[test]
|
||||
fn serialize_offsets() {
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
let mut st = DsState::from_gamepad(
|
||||
gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB,
|
||||
16384, // lx (right)
|
||||
0,
|
||||
0,
|
||||
-32768, // ry (down) — inverted to 0xFF
|
||||
200, // L2
|
||||
0,
|
||||
);
|
||||
st.gyro = [0x0102, 0x0304, 0x0506];
|
||||
st.accel = [0x1112, 0x1314, 0x1516];
|
||||
st.touch[0] = Touch {
|
||||
active: true,
|
||||
id: 0,
|
||||
x: 100,
|
||||
y: 200,
|
||||
};
|
||||
let mut r = [0u8; DS4_INPUT_REPORT_LEN];
|
||||
serialize_state(&mut r, &st, 0, 0);
|
||||
assert_eq!(r[0], 0x01); // report id
|
||||
assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5)
|
||||
assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up)
|
||||
assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit
|
||||
assert_eq!(r[6] & 0x01, 0x01); // L1
|
||||
// gyro le16 at 13..19, accel le16 at 19..25.
|
||||
assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]);
|
||||
assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]);
|
||||
assert_eq!(r[33], 1); // one touch frame
|
||||
assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear)
|
||||
assert_eq!(r[35] & 0x7F, 0); // contact id 0
|
||||
assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set
|
||||
}
|
||||
|
||||
/// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a
|
||||
/// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched.
|
||||
#[test]
|
||||
fn parse_output_rumble_and_lightbar() {
|
||||
let mut report = [0u8; 32];
|
||||
report[0] = 0x05;
|
||||
report[1] = 0x01 | 0x02; // MOTOR | LED
|
||||
report[4] = 0x40; // motor_right (weak/high)
|
||||
report[5] = 0x80; // motor_left (strong/low)
|
||||
report[6] = 0x11; // R
|
||||
report[7] = 0x22; // G
|
||||
report[8] = 0x33; // B
|
||||
let mut fb = Ds4Feedback::default();
|
||||
parse_ds4_output(&report, &mut fb);
|
||||
assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak)
|
||||
assert_eq!(fb.led, Some((0x11, 0x22, 0x33)));
|
||||
|
||||
let mut motor_only = [0u8; 32];
|
||||
motor_only[0] = 0x05;
|
||||
motor_only[1] = 0x01; // MOTOR only
|
||||
motor_only[5] = 0x10;
|
||||
let mut fb2 = Ds4Feedback::default();
|
||||
parse_ds4_output(&motor_only, &mut fb2);
|
||||
assert!(fb2.rumble.is_some());
|
||||
assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change
|
||||
}
|
||||
|
||||
/// Feature-report arrays carry the right report id + length the kernel expects.
|
||||
#[test]
|
||||
fn feature_report_shapes() {
|
||||
assert_eq!(DS4_FEATURE_PAIRING.len(), 16);
|
||||
assert_eq!(DS4_FEATURE_PAIRING[0], 0x12);
|
||||
assert_eq!(DS4_FEATURE_CALIBRATION.len(), 37);
|
||||
assert_eq!(DS4_FEATURE_CALIBRATION[0], 0x02);
|
||||
assert_eq!(DS4_FEATURE_FIRMWARE.len(), 49);
|
||||
assert_eq!(DS4_FEATURE_FIRMWARE[0], 0xA3);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,53 @@ const BUTTON_MAP: [(u32, u16); 11] = [
|
||||
(gamepad::BTN_RS_CLK, BTN_THUMBR),
|
||||
];
|
||||
|
||||
/// The USB identity a virtual uinput pad presents. SDL/Steam/Proton key their built-in mapping off
|
||||
/// `bustype/vendor/product/version` (+ name), and games pick button glyphs from it. The button/axis
|
||||
/// layout this backend emits is the same XInput one regardless — only the identity differs between an
|
||||
/// X-Box 360 pad and an X-Box One/Series pad (which is why "Xbox One" buys glyphs, not new capability;
|
||||
/// impulse-trigger rumble is unreachable through evdev FF either way).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PadIdentity {
|
||||
vendor: u16,
|
||||
product: u16,
|
||||
version: u16,
|
||||
name: &'static [u8],
|
||||
/// Short label for the creation log line.
|
||||
log: &'static str,
|
||||
}
|
||||
|
||||
impl PadIdentity {
|
||||
/// "Microsoft X-Box 360 pad" (`045e:028e`) — the universal default; matches the kernel `xpad`
|
||||
/// table verbatim so SDL/Steam map it with zero config.
|
||||
pub const fn xbox360() -> PadIdentity {
|
||||
PadIdentity {
|
||||
vendor: 0x045e,
|
||||
product: 0x028e,
|
||||
version: 0x0110,
|
||||
name: b"Microsoft X-Box 360 pad",
|
||||
log: "X-Box 360 pad",
|
||||
}
|
||||
}
|
||||
|
||||
/// "Microsoft X-Box One S pad" (`045e:02ea`) — an `xpad`-table entry, so games show One/Series
|
||||
/// glyphs. XInput-identical to the 360 pad otherwise.
|
||||
pub const fn xbox_one() -> PadIdentity {
|
||||
PadIdentity {
|
||||
vendor: 0x045e,
|
||||
product: 0x02ea,
|
||||
version: 0x0408,
|
||||
name: b"Microsoft X-Box One S pad",
|
||||
log: "X-Box One S pad",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PadIdentity {
|
||||
fn default() -> PadIdentity {
|
||||
PadIdentity::xbox360()
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct InputId {
|
||||
bustype: u16,
|
||||
@@ -202,7 +249,7 @@ pub struct VirtualPad {
|
||||
}
|
||||
|
||||
impl VirtualPad {
|
||||
pub fn create(index: usize) -> Result<VirtualPad> {
|
||||
pub fn create(index: usize, identity: PadIdentity) -> Result<VirtualPad> {
|
||||
use std::os::fd::FromRawFd;
|
||||
let raw = unsafe {
|
||||
libc::open(
|
||||
@@ -272,18 +319,22 @@ impl VirtualPad {
|
||||
let mut setup = UinputSetup {
|
||||
id: InputId {
|
||||
bustype: 0x0003, // BUS_USB
|
||||
vendor: 0x045e,
|
||||
product: 0x028e,
|
||||
version: 0x0110,
|
||||
vendor: identity.vendor,
|
||||
product: identity.product,
|
||||
version: identity.version,
|
||||
},
|
||||
name: [0; 80],
|
||||
ff_effects_max: 16, // must be > 0 or FF uploads are never delivered
|
||||
};
|
||||
let name = b"Microsoft X-Box 360 pad";
|
||||
let name = identity.name;
|
||||
setup.name[..name.len()].copy_from_slice(name);
|
||||
ioctl_ptr(raw, UI_DEV_SETUP, &mut setup, "UI_DEV_SETUP")?;
|
||||
ioctl_int(raw, UI_DEV_CREATE, 0, "UI_DEV_CREATE")?;
|
||||
tracing::info!(index, "virtual gamepad created (X-Box 360 pad via uinput)");
|
||||
tracing::info!(
|
||||
index,
|
||||
pad = identity.log,
|
||||
"virtual gamepad created (uinput)"
|
||||
);
|
||||
|
||||
Ok(VirtualPad {
|
||||
fd,
|
||||
@@ -449,14 +500,24 @@ impl Drop for VirtualPad {
|
||||
#[derive(Default)]
|
||||
pub struct GamepadManager {
|
||||
pads: Vec<Option<VirtualPad>>,
|
||||
/// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when
|
||||
/// the client asked for `XboxOne`). All pads in a session share one identity.
|
||||
identity: PadIdentity,
|
||||
/// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl GamepadManager {
|
||||
/// A manager that creates X-Box 360 pads (the universal default).
|
||||
pub fn new() -> GamepadManager {
|
||||
GamepadManager::with_identity(PadIdentity::xbox360())
|
||||
}
|
||||
|
||||
/// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]).
|
||||
pub fn with_identity(identity: PadIdentity) -> GamepadManager {
|
||||
GamepadManager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
identity,
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
@@ -496,7 +557,7 @@ impl GamepadManager {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
||||
return;
|
||||
}
|
||||
match VirtualPad::create(idx) {
|
||||
match VirtualPad::create(idx, self.identity) {
|
||||
Ok(p) => self.pads[idx] = Some(p),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled");
|
||||
|
||||
@@ -1164,26 +1164,50 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
tracing::debug!("mic service stopped (host shutting down)");
|
||||
}
|
||||
|
||||
/// The session's virtual-gamepad backend. Default = uinput X-Box-360 pads
|
||||
/// ([`GamepadManager`](crate::inject::gamepad::GamepadManager)); `PUNKTFUNK_GAMEPAD=dualsense`
|
||||
/// switches to virtual DualSense pads (UHID + the kernel `hid-playstation` driver) so a game sees
|
||||
/// a *real* DualSense — adaptive triggers, lightbar, touchpad, motion — and a game's feedback
|
||||
/// flows back over the rich HID-output plane. Selected once per session (sessions run serially).
|
||||
/// The session's virtual-gamepad backend, resolved once per session (sessions run serially).
|
||||
///
|
||||
/// - `Xbox360` — uinput X-Box-360 pads on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager)),
|
||||
/// ViGEm on Windows. Also the X-Box One/Series identity (`PUNKTFUNK_GAMEPAD=xboxone`): the same
|
||||
/// backend with the One/Series USB VID/PID so games show One/Series glyphs (XInput-identical
|
||||
/// otherwise). The Linux pad carries it as a [`PadIdentity`](crate::inject::gamepad::PadIdentity).
|
||||
/// - `DualSense` (`PUNKTFUNK_GAMEPAD=dualsense`) — virtual DualSense via UHID + `hid-playstation`,
|
||||
/// so a game sees a *real* DualSense (adaptive triggers, lightbar, touchpad, motion); feedback
|
||||
/// flows back over the rich HID-output plane.
|
||||
/// - `DualShock4` (`PUNKTFUNK_GAMEPAD=ps4`) — virtual DualShock 4 via the same UHID path: lightbar,
|
||||
/// touchpad, motion, rumble (DualSense minus adaptive triggers / player LEDs / mute).
|
||||
///
|
||||
/// The two UHID pads are Linux-only; off Linux the resolver already folds them (and One/Series)
|
||||
/// into `Xbox360`, so a non-Linux build never constructs them.
|
||||
enum PadBackend {
|
||||
Xbox360(crate::inject::gamepad::GamepadManager),
|
||||
#[cfg(target_os = "linux")]
|
||||
DualSense(crate::inject::dualsense::DualSenseManager),
|
||||
#[cfg(target_os = "linux")]
|
||||
DualShock4(crate::inject::dualshock4::DualShock4Manager),
|
||||
}
|
||||
|
||||
impl PadBackend {
|
||||
/// `kind` is the session's resolved backend (see [`resolve_gamepad`] — client preference,
|
||||
/// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only
|
||||
/// ever construct the X-Box backend, whatever the resolution said.
|
||||
/// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only ever
|
||||
/// construct the X-Box backend, whatever the resolution said.
|
||||
fn select(kind: GamepadPref) -> PadBackend {
|
||||
#[cfg(target_os = "linux")]
|
||||
if kind == GamepadPref::DualSense {
|
||||
tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)");
|
||||
return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new());
|
||||
match kind {
|
||||
GamepadPref::DualSense => {
|
||||
tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)");
|
||||
return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new());
|
||||
}
|
||||
GamepadPref::DualShock4 => {
|
||||
tracing::info!("gamepad backend: virtual DualShock 4 (UHID hid-playstation)");
|
||||
return PadBackend::DualShock4(crate::inject::dualshock4::DualShock4Manager::new());
|
||||
}
|
||||
GamepadPref::XboxOne => {
|
||||
tracing::info!("gamepad backend: uinput X-Box One/Series pad");
|
||||
return PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::with_identity(
|
||||
crate::inject::gamepad::PadIdentity::xbox_one(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let _ = kind;
|
||||
PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new())
|
||||
@@ -1194,21 +1218,26 @@ impl PadBackend {
|
||||
PadBackend::Xbox360(m) => m.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualSense(m) => m.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(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.
|
||||
/// Apply a rich client→host event (touchpad / motion). A no-op for the X-Box pad, which has no
|
||||
/// equivalent; the DualSense and DualShock 4 pads both carry a touchpad + motion sensors.
|
||||
fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let PadBackend::DualSense(m) = self {
|
||||
m.apply_rich(_rich);
|
||||
match self {
|
||||
PadBackend::DualSense(m) => m.apply_rich(_rich),
|
||||
PadBackend::DualShock4(m) => m.apply_rich(_rich),
|
||||
PadBackend::Xbox360(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// (every backend); `hidout` carries rich feedback on the HID-output plane — lightbar (both
|
||||
/// UHID pads), plus player LEDs / adaptive triggers (DualSense only). The X-Box pad has no
|
||||
/// rich-feedback plane.
|
||||
fn pump(
|
||||
&mut self,
|
||||
rumble: impl FnMut(u16, u16, u16),
|
||||
@@ -1221,10 +1250,12 @@ impl PadBackend {
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualSense(m) => m.pump(rumble, hidout),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.pump(rumble, hidout),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a virtual DualSense alive during input silence: re-emit its current HID report if it's
|
||||
/// Keep a virtual UHID pad alive during input silence: re-emit its current HID report if it's
|
||||
/// gone quiet, so the kernel `hid-playstation` driver / SDL don't treat a held-steady pad as
|
||||
/// unplugged ("controller disconnected every few seconds"). No-op for the X-Box pad (evdev
|
||||
/// holds last-known state with no periodic-report requirement). Called every input-thread tick;
|
||||
@@ -1234,6 +1265,8 @@ impl PadBackend {
|
||||
PadBackend::Xbox360(_) => {}
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1516,10 +1549,13 @@ fn synthetic_stream(
|
||||
}
|
||||
|
||||
/// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins,
|
||||
/// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360. The
|
||||
/// DualSense backend needs Linux UHID — when unavailable any DualSense wish degrades to
|
||||
/// X-Box 360 (never an error: a session without rich pads still streams).
|
||||
fn pick_gamepad(pref: GamepadPref, env: Option<&str>, dualsense_available: bool) -> GamepadPref {
|
||||
/// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360.
|
||||
///
|
||||
/// `linux` is whether this is a Linux host (uinput + UHID). The rich UHID pads (DualSense, DualShock
|
||||
/// 4) need it — off Linux any such wish degrades to X-Box 360 (never an error: a session without rich
|
||||
/// pads still streams). X-Box One/Series is a distinct uinput *identity* on Linux, but XInput-identical
|
||||
/// to the 360 pad on Windows (ViGEm has no One target), so it degrades to `Xbox360` there.
|
||||
fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool) -> GamepadPref {
|
||||
let want = match pref {
|
||||
GamepadPref::Auto => env
|
||||
.and_then(GamepadPref::from_name)
|
||||
@@ -1527,7 +1563,11 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, dualsense_available: bool)
|
||||
explicit => explicit,
|
||||
};
|
||||
match want {
|
||||
GamepadPref::DualSense if dualsense_available => GamepadPref::DualSense,
|
||||
GamepadPref::DualSense if linux => GamepadPref::DualSense,
|
||||
GamepadPref::DualShock4 if linux => GamepadPref::DualShock4,
|
||||
// One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on
|
||||
// Windows (XInput can't tell them apart anyway).
|
||||
GamepadPref::XboxOne if linux => GamepadPref::XboxOne,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
@@ -3012,6 +3052,14 @@ mod tests {
|
||||
// DualSense degrades to X-Box 360 where the backend doesn't exist (non-Linux).
|
||||
assert_eq!(pick_gamepad(DualSense, None, false), Xbox360);
|
||||
assert_eq!(pick_gamepad(Auto, Some("dualsense"), false), Xbox360);
|
||||
// DualShock 4: honored on Linux (UHID), degrades to X-Box 360 off it.
|
||||
assert_eq!(pick_gamepad(DualShock4, None, true), DualShock4);
|
||||
assert_eq!(pick_gamepad(Auto, Some("ps4"), true), DualShock4);
|
||||
assert_eq!(pick_gamepad(DualShock4, None, false), Xbox360);
|
||||
// X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows.
|
||||
assert_eq!(pick_gamepad(XboxOne, None, true), XboxOne);
|
||||
assert_eq!(pick_gamepad(Auto, Some("series"), true), XboxOne);
|
||||
assert_eq!(pick_gamepad(XboxOne, None, false), Xbox360);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -70,6 +70,18 @@
|
||||
// only where available (Linux hosts); otherwise the host falls back to X-Box 360.
|
||||
#define PUNKTFUNK_GAMEPAD_DUALSENSE 2
|
||||
|
||||
// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity, so
|
||||
// games show One/Series glyphs. XInput-identical to `XBOX360` otherwise (no game-visible gain;
|
||||
// impulse-trigger rumble is unreachable through a virtual pad). Useful for glyph-matching a
|
||||
// physical X-Box One/Series controller on the client.
|
||||
#define PUNKTFUNK_GAMEPAD_XBOXONE 3
|
||||
|
||||
// UHID DualShock 4 (kernel `hid-playstation` ≥ 6.2): lightbar, touchpad, motion, rumble — the
|
||||
// touchpad/motion arrive over the rich-input plane and lightbar over the HID-output plane, like
|
||||
// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
|
||||
// hosts); otherwise the host falls back to X-Box 360.
|
||||
#define PUNKTFUNK_GAMEPAD_DUALSHOCK4 4
|
||||
|
||||
// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
|
||||
// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
||||
// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`.
|
||||
|
||||
Reference in New Issue
Block a user