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