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