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:
@@ -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 ----
|
||||
|
||||
|
||||
Reference in New Issue
Block a user