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:
2026-06-21 13:34:44 +00:00
parent b3811ff72e
commit 3e6c9f6060
24 changed files with 1246 additions and 214 deletions
@@ -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
}
}