feat(gamepad): Apple + Android pickers declare DualSense Edge / Switch Pro

Plan 0.4 for the N1/N2 backends (SDL landed with them):

- Apple: GamepadType grows dualSenseEdge=7 / switchPro=8 (wire-byte
  parity + name parsing). padKind splits the Edge out of the shared
  GCDualSenseGamepad subclass by product category, and resolves Switch
  Pro / a paired Joy-Con set by category (GameController has no Nintendo
  subclass; single Joy-Cons stay on the Xbox 360 fallback — half a pad).
  The DualSense-only gates (adaptive-trigger feedback, player LEDs, the
  touchpad+motion rich capture) now include the Edge — same surfaces.
  Paddle CAPTURE stays gated on G22 (needs a real pad to pin the
  paddleButton1..4 correspondence); the declared identity is right
  meanwhile. swift build + 124 tests green.

- Android: PREF_DUALSENSEEDGE/PREF_SWITCHPRO wire bytes; the Sony PID
  table splits 0x0DF2 (Edge) out of DualSense; Nintendo 057E:2009
  declares Switch Pro; ControllersScreen labels the new kinds.
  :kit/:app Kotlin compile green (-PskipRustBuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:21:42 +02:00
parent 8c854e0a19
commit 61c752e91e
4 changed files with 53 additions and 16 deletions
@@ -387,6 +387,8 @@ private fun prefLabel(pref: Int): String = when (pref) {
Gamepad.PREF_DUALSHOCK4 -> "DualShock 4" Gamepad.PREF_DUALSHOCK4 -> "DualShock 4"
Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller" Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller"
Gamepad.PREF_STEAMDECK -> "Steam Deck" Gamepad.PREF_STEAMDECK -> "Steam Deck"
Gamepad.PREF_DUALSENSEEDGE -> "DualSense Edge"
Gamepad.PREF_SWITCHPRO -> "Switch Pro"
else -> "Automatic" else -> "Automatic"
} }
@@ -52,6 +52,8 @@ object Gamepad {
const val PREF_DUALSHOCK4 = 4 const val PREF_DUALSHOCK4 = 4
const val PREF_STEAMCONTROLLER = 5 const val PREF_STEAMCONTROLLER = 5
const val PREF_STEAMDECK = 6 const val PREF_STEAMDECK = 6
const val PREF_DUALSENSEEDGE = 7
const val PREF_SWITCHPRO = 8
// USB vendor ids of the controllers we can identify by VID/PID. // USB vendor ids of the controllers we can identify by VID/PID.
private const val VID_SONY = 0x054C private const val VID_SONY = 0x054C
@@ -59,10 +61,17 @@ object Gamepad {
private const val VID_VALVE = 0x28DE private const val VID_VALVE = 0x28DE
private const val VID_NINTENDO = 0x057E private const val VID_NINTENDO = 0x057E
// Sony product ids. DualSense (PS5) and DualShock 4 (PS4) map to distinct host pad types. // Sony product ids. DualSense (PS5), DualSense Edge, and DualShock 4 (PS4) map to distinct
private val PID_DUALSENSE = setOf(0x0CE6, 0x0DF2) // host pad types — the Edge's back paddles get native slots on the virtual Edge (Android
// forwards no paddle input yet, but the identity + rich planes match the physical pad).
private val PID_DUALSENSE = setOf(0x0CE6)
private val PID_DUALSENSEEDGE = setOf(0x0DF2)
private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC) private val PID_DUALSHOCK4 = setOf(0x05C4, 0x09CC)
// Nintendo: Switch Pro Controller — the host builds the virtual hid-nintendo pad (correct
// glyphs + positional layout).
private val PID_SWITCHPRO = setOf(0x2009)
// Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) / // Valve: Steam Deck built-in controller (0x1205); classic Steam Controller wired (0x1102) /
// dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles / // dongle (0x1142). The host builds the virtual hid-steam pad; rich-input capture (paddles /
// trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard // trackpads / gyro) is out of scope on Android (no rich-input plane yet), so only the standard
@@ -91,10 +100,12 @@ object Gamepad {
val pid = dev.productId val pid = dev.productId
return when { return when {
vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE vid == VID_SONY && pid in PID_DUALSENSE -> PREF_DUALSENSE
vid == VID_SONY && pid in PID_DUALSENSEEDGE -> PREF_DUALSENSEEDGE
vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4 vid == VID_SONY && pid in PID_DUALSHOCK4 -> PREF_DUALSHOCK4
vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE vid == VID_MICROSOFT && pid in PID_XBOXONE -> PREF_XBOXONE
vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK vid == VID_VALVE && pid in PID_STEAMDECK -> PREF_STEAMDECK
vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER vid == VID_VALVE && pid in PID_STEAMCONTROLLER -> PREF_STEAMCONTROLLER
vid == VID_NINTENDO && pid in PID_SWITCHPRO -> PREF_SWITCHPRO
else -> PREF_XBOX360 else -> PREF_XBOX360
} }
} }
@@ -188,6 +188,14 @@ public final class PunktfunkConnection {
// exist so the resolved type round-trips and name parsing matches the host. // exist so the resolved type round-trips and name parsing matches the host.
case steamController = 5 case steamController = 5
case steamDeck = 6 case steamDeck = 6
/// DualSense Edge (Linux UHID / Windows UMDF hosts): the DualSense plus native back/Fn
/// buttons. GameController exposes the Edge as a `GCDualSenseGamepad` with its own
/// product category; paddle CAPTURE is still gated on G22, but the declared identity +
/// rich planes match the physical pad.
case dualSenseEdge = 7
/// Nintendo Switch Pro Controller (Linux UHID hid-nintendo hosts): correct Nintendo
/// glyphs + positional layout on the host side.
case switchPro = 8
/// Loose name parsing for env/dev hooks, mirroring the host's /// Loose name parsing for env/dev hooks, mirroring the host's
/// `GamepadPref::from_name`. /// `GamepadPref::from_name`.
@@ -200,6 +208,9 @@ public final class PunktfunkConnection {
case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4 case "dualshock4", "dualshock", "ds4", "ps4": self = .dualShock4
case "steamdeck", "steam-deck", "deck": self = .steamDeck case "steamdeck", "steam-deck", "deck": self = .steamDeck
case "steamcontroller", "steam-controller", "steamcon": self = .steamController case "steamcontroller", "steam-controller", "steamcon": self = .steamController
case "dualsenseedge", "dualsense-edge", "edge", "dsedge": self = .dualSenseEdge
case "switchpro", "switch-pro", "switch", "procontroller", "pro-controller":
self = .switchPro
default: return nil default: return nil
} }
} }
@@ -42,13 +42,14 @@ public final class GamepadManager: ObservableObject {
public let hasHaptics: Bool public let hasHaptics: Bool
public let hasMotion: Bool public let hasMotion: Bool
public let hasAdaptiveTriggers: Bool public let hasAdaptiveTriggers: Bool
/// Specifically a DualSense gates the DualSense-only feedback (adaptive triggers, /// Specifically a DualSense (incl. the Edge same feedback surface) gates the
/// player LEDs) and the PlayStation glyph in Settings. /// DualSense-only feedback (adaptive triggers, player LEDs) and the PlayStation glyph
public var isDualSense: Bool { kind == .dualSense } /// in Settings.
/// A PlayStation pad with a touchpad + motion (DualSense OR DualShock 4) gates public var isDualSense: Bool { kind == .dualSense || kind == .dualSenseEdge }
/// A PlayStation pad with a touchpad + motion (DualSense family OR DualShock 4) gates
/// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC). /// rich-input CAPTURE (touchpad contacts + gyro/accel on plane 0xCC).
public var hasTouchpadAndMotion: Bool { public var hasTouchpadAndMotion: Bool {
kind == .dualSense || kind == .dualShock4 kind == .dualSense || kind == .dualSenseEdge || kind == .dualShock4
} }
/// 0...1, nil when the controller doesn't report a battery (e.g. wired). /// 0...1, nil when the controller doesn't report a battery (e.g. wired).
public let batteryLevel: Float? public let batteryLevel: Float?
@@ -227,7 +228,7 @@ public final class GamepadManager: ObservableObject {
private static func describe(_ c: GCController, id: String) -> DiscoveredController { private static func describe(_ c: GCController, id: String) -> DiscoveredController {
let extended = c.extendedGamepad let extended = c.extendedGamepad
let kind = padKind(extended) let kind = padKind(extended, productCategory: c.productCategory)
return DiscoveredController( return DiscoveredController(
id: id, id: id,
name: c.vendorName ?? c.productCategory, name: c.vendorName ?? c.productCategory,
@@ -237,28 +238,40 @@ public final class GamepadManager: ObservableObject {
hasLight: c.light != nil, hasLight: c.light != nil,
hasHaptics: c.haptics != nil, hasHaptics: c.haptics != nil,
hasMotion: c.motion != nil, hasMotion: c.motion != nil,
// GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration; the // GCDualSenseGamepad's triggers are GCDualSenseAdaptiveTrigger by declaration (the
// DualShock 4 has none. // Edge included); the DualShock 4 has none.
hasAdaptiveTriggers: kind == .dualSense, hasAdaptiveTriggers: kind == .dualSense || kind == .dualSenseEdge,
batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil }, batteryLevel: c.battery.flatMap { $0.batteryLevel >= 0 ? $0.batteryLevel : nil },
isCharging: c.battery?.batteryState == .charging, isCharging: c.battery?.batteryState == .charging,
controller: c) controller: c)
} }
/// Resolve a physical controller's matching virtual-pad type from its GameController /// Resolve a physical controller's matching virtual-pad type from its GameController
/// subclass. Detection order (all are `: GCExtendedGamepad`): DualSense first, then /// subclass (+ the product-category string where the subclass is shared). Detection order
/// DualShock 4, then any Xbox pad, else fall back to Xbox 360. A non-extended / absent /// (all are `: GCExtendedGamepad`): DualSense family first (the Edge is a
/// profile also falls back to `.xbox360` (it's never forwarded anyway). /// `GCDualSenseGamepad` too its distinct product category splits it out), then
/// DualShock 4, any Xbox pad, then Nintendo Switch pads by category (GameController has no
/// dedicated subclass for them). A non-extended / absent profile falls back to `.xbox360`
/// (it's never forwarded anyway).
private static func padKind( private static func padKind(
_ extended: GCExtendedGamepad? _ extended: GCExtendedGamepad?,
productCategory: String
) -> PunktfunkConnection.GamepadType { ) -> PunktfunkConnection.GamepadType {
guard let extended else { return .xbox360 } guard let extended else { return .xbox360 }
let category = productCategory.lowercased()
// Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version // Deployment floor (macOS 14 / iOS 17 / tvOS 17) clears every introduction version
// here, so no `@available` guard is needed matching the unguarded // here, so no `@available` guard is needed matching the unguarded
// `GCDualSenseGamepad` use elsewhere in the package. // `GCDualSenseGamepad` use elsewhere in the package.
if extended is GCDualSenseGamepad { return .dualSense } if extended is GCDualSenseGamepad {
return category.contains("edge") ? .dualSenseEdge : .dualSense
}
if extended is GCDualShockGamepad { return .dualShock4 } if extended is GCDualShockGamepad { return .dualShock4 }
if extended is GCXboxGamepad { return .xboxOne } if extended is GCXboxGamepad { return .xboxOne }
// Nintendo Switch Pro Controller / a paired Joy-Con set (a full pad surface). Single
// Joy-Cons ("Joy-Con (L)" / "(R)") stay on the Xbox 360 fallback half a pad.
if category.contains("switch pro") || category.contains("joy-con (l/r)") {
return .switchPro
}
return .xbox360 return .xbox360
} }
} }