From e1064dcb950ccbb732f58d85ccca4c5fa2b99b9c Mon Sep 17 00:00:00 2001 From: Wiedy Mi <42713027+wiedymi@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:52:52 +0300 Subject: [PATCH 1/6] Add HID bridge for GameSir G7 Pro in Endfield --- AKPlugin.swift | 182 +++++++++++++ PlayTools/Controls/PlayInput.swift | 408 +++++++++++++++++++++++++++++ PlayTools/PlaySettings.swift | 4 + Plugin.swift | 5 + 4 files changed, 599 insertions(+) diff --git a/AKPlugin.swift b/AKPlugin.swift index b0ebfece..2a3fa527 100644 --- a/AKPlugin.swift +++ b/AKPlugin.swift @@ -8,6 +8,7 @@ import AppKit import CoreGraphics import Foundation +import IOKit.hid // Add a lightweight struct so we can decode only the flag we care about private struct AKAppSettingsData: Codable { @@ -19,6 +20,9 @@ private struct AKAppSettingsData: Codable { } class AKPlugin: NSObject, Plugin { + private static let hidGameSirVendorID = 0x3537 + private static let hidGameSirProductID = 0x1022 + required override init() { super.init() if let window = NSApplication.shared.windows.first { @@ -129,6 +133,37 @@ class AKPlugin: NSObject, Plugin { } private var modifierFlag: UInt = 0 + private var hidManager: IOHIDManager? + private var hidPrimaryDevice: IOHIDDevice? + private var hidOnConnected: (() -> Void)? + private var hidOnDisconnected: (() -> Void)? + private var hidOnButton: ((Int, Bool) -> Void)? + private var hidOnAxis: ((Int, CGFloat) -> Void)? + private var hidOnHat: ((Int) -> Void)? + + private static let hidDeviceMatchingCallback: IOHIDDeviceCallback = { context, _, _, device in + guard let context else { + return + } + let plugin = Unmanaged.fromOpaque(context).takeUnretainedValue() + plugin.handleHIDDeviceConnected(device) + } + + private static let hidDeviceRemovalCallback: IOHIDDeviceCallback = { context, _, _, device in + guard let context else { + return + } + let plugin = Unmanaged.fromOpaque(context).takeUnretainedValue() + plugin.handleHIDDeviceRemoved(device) + } + + private static let hidInputValueCallback: IOHIDValueCallback = { context, _, _, value in + guard let context else { + return + } + let plugin = Unmanaged.fromOpaque(context).takeUnretainedValue() + plugin.handleHIDInputValue(value) + } // swiftlint:disable:next function_body_length func setupKeyboard(keyboard: @escaping (UInt16, Bool, Bool, Bool) -> Bool, @@ -279,6 +314,48 @@ class AKPlugin: NSObject, Plugin { }) } + func setupHIDControllerInput(onConnected: @escaping () -> Void, + onDisconnected: @escaping () -> Void, + onButton: @escaping (Int, Bool) -> Void, + onAxis: @escaping (Int, CGFloat) -> Void, + onHat: @escaping (Int) -> Void) { + hidOnConnected = onConnected + hidOnDisconnected = onDisconnected + hidOnButton = onButton + hidOnAxis = onAxis + hidOnHat = onHat + + if hidManager != nil { + return + } + + let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) + + let matching: [[String: Any]] = [ + [kIOHIDDeviceUsagePageKey as String: 0x01, kIOHIDDeviceUsageKey as String: 0x04], // Joystick + [kIOHIDDeviceUsagePageKey as String: 0x01, kIOHIDDeviceUsageKey as String: 0x05], // Gamepad + // Some Bluetooth gamepads (including G7 Pro) expose top-level usage as consumer control. + [kIOHIDDeviceUsagePageKey as String: 0x0C, kIOHIDDeviceUsageKey as String: 0x01], + // Vendor/product fallback for known G7 Pro in case usage filtering is insufficient. + [kIOHIDVendorIDKey as String: Self.hidGameSirVendorID, + kIOHIDProductIDKey as String: Self.hidGameSirProductID] + ] + IOHIDManagerSetDeviceMatchingMultiple(manager, matching as CFArray) + + let context = Unmanaged.passUnretained(self).toOpaque() + IOHIDManagerRegisterDeviceMatchingCallback(manager, Self.hidDeviceMatchingCallback, context) + IOHIDManagerRegisterDeviceRemovalCallback(manager, Self.hidDeviceRemovalCallback, context) + IOHIDManagerRegisterInputValueCallback(manager, Self.hidInputValueCallback, context) + + IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + let status = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + if status != kIOReturnSuccess { + return + } + + hidManager = manager + } + func urlForApplicationWithBundleIdentifier(_ value: String) -> URL? { NSWorkspace.shared.urlForApplication(withBundleIdentifier: value) } @@ -313,4 +390,109 @@ class AKPlugin: NSObject, Plugin { } return decoded }() + + private func handleHIDDeviceConnected(_ device: IOHIDDevice) { + if !isSupportedControllerDevice(device) { + return + } + + if hidPrimaryDevice == nil { + hidPrimaryDevice = device + hidOnConnected?() + } + } + + private func handleHIDDeviceRemoved(_ device: IOHIDDevice) { + guard let primary = hidPrimaryDevice, sameDevice(primary, device) else { + return + } + hidPrimaryDevice = nil + hidOnDisconnected?() + + if let manager = hidManager, + let devices = IOHIDManagerCopyDevices(manager) as? Set, + let another = devices.first(where: { isSupportedControllerDevice($0) }) { + hidPrimaryDevice = another + hidOnConnected?() + } + } + + private func handleHIDInputValue(_ value: IOHIDValue) { + let element = IOHIDValueGetElement(value) + let device = IOHIDElementGetDevice(element) + if let primary = hidPrimaryDevice, !sameDevice(primary, device) { + return + } + + let usagePage = Int(IOHIDElementGetUsagePage(element)) + let usage = Int(IOHIDElementGetUsage(element)) + let rawValue = IOHIDValueGetIntegerValue(value) + + switch usagePage { + case 0x09: // Button page + hidOnButton?(usage, rawValue != 0) + case 0x01: // Generic Desktop page + if usage == 0x39 { + hidOnHat?(Int(rawValue)) + return + } + if [0x30, 0x31, 0x32, 0x33, 0x34, 0x35].contains(usage) { + let normalized = normalizeAxis(rawValue: rawValue, element: element) + hidOnAxis?(usage, normalized) + } + case 0x0C: + break + default: + break + } + } + + private func normalizeAxis(rawValue: CFIndex, element: IOHIDElement) -> CGFloat { + let logicalMin = IOHIDElementGetLogicalMin(element) + let logicalMax = IOHIDElementGetLogicalMax(element) + if logicalMax <= logicalMin { + return 0 + } + + let clamped = min(max(rawValue, logicalMin), logicalMax) + let range = Double(logicalMax - logicalMin) + let shifted = Double(clamped - logicalMin) + let normalized = (shifted / range) * 2.0 - 1.0 + return CGFloat(normalized) + } + + private func sameDevice(_ lhs: IOHIDDevice, _ rhs: IOHIDDevice) -> Bool { + CFEqual(lhs, rhs) + } + + private func hidDevicePropertyInt(_ device: IOHIDDevice, key: CFString) -> Int? { + if let number = IOHIDDeviceGetProperty(device, key) as? NSNumber { + return number.intValue + } + return nil + } + + private func hidDevicePropertyString(_ device: IOHIDDevice, key: CFString) -> String? { + if let text = IOHIDDeviceGetProperty(device, key) as? String { + return text + } + return nil + } + + private func isSupportedControllerDevice(_ device: IOHIDDevice) -> Bool { + let usagePage = hidDevicePropertyInt(device, key: kIOHIDPrimaryUsagePageKey as CFString) ?? -1 + let usage = hidDevicePropertyInt(device, key: kIOHIDPrimaryUsageKey as CFString) ?? -1 + let vendorID = hidDevicePropertyInt(device, key: kIOHIDVendorIDKey as CFString) ?? -1 + let productID = hidDevicePropertyInt(device, key: kIOHIDProductIDKey as CFString) ?? -1 + + if vendorID == Self.hidGameSirVendorID && productID == Self.hidGameSirProductID { + return true + } + + if usagePage == 0x01 && (usage == 0x04 || usage == 0x05) { + return true + } + + return false + } } diff --git a/PlayTools/Controls/PlayInput.swift b/PlayTools/Controls/PlayInput.swift index 6aa3cc0b..1782ec1c 100644 --- a/PlayTools/Controls/PlayInput.swift +++ b/PlayTools/Controls/PlayInput.swift @@ -24,6 +24,10 @@ class PlayInput { simulateGCMouseDisconnect() } + if PlaySettings.shared.experimentalHIDBridge { + HIDControllerBridge.shared.initializeIfNeeded() + } + if !PlaySettings.shared.keymapping { return } @@ -74,3 +78,407 @@ class PlayInput { } } } + +private final class HIDControllerBridge { + static let shared = HIDControllerBridge() + private static let dpadUpAlias = "Direction Pad Up" + private static let dpadDownAlias = "Direction Pad Down" + private static let dpadLeftAlias = "Direction Pad Left" + private static let dpadRightAlias = "Direction Pad Right" + + private enum HIDAxisProfile: String { + case undecided + case standard + case zAndRzRightStick + } + + private enum HIDAxisRole { + case leftStickX + case leftStickY + case rightStickX + case rightStickY + case leftTrigger + case rightTrigger + } + + private var initialized = false + private var virtualController: GCVirtualController? + private var virtualConnected = false + private var virtualConnecting = false + private var gcObserversInstalled = false + private var leftStick = CGPoint.zero + private var rightStick = CGPoint.zero + private var dpad = CGPoint.zero + private var leftTrigger: CGFloat = 0 + private var rightTrigger: CGFloat = 0 + private var axisProfile: HIDAxisProfile = .undecided + private var observedGenericAxes = Set() + private var dpadButtonState: [String: Bool] = [ + HIDControllerBridge.dpadUpAlias: false, + HIDControllerBridge.dpadDownAlias: false, + HIDControllerBridge.dpadLeftAlias: false, + HIDControllerBridge.dpadRightAlias: false + ] + private var triggerPressedState: [String: Bool] = [ + GCInputLeftTrigger: false, + GCInputRightTrigger: false + ] + + func initializeIfNeeded() { + if initialized { + return + } + initialized = true + installGCControllerObserversIfNeeded() + startVirtualController() + + if let interface = AKInterface.shared { + interface.setupHIDControllerInput(onConnected: { [self] in + onDeviceConnected() + }, onDisconnected: { [self] in + onDeviceDisconnected() + }, onButton: { [self] usage, pressed in + onButton(usage: usage, pressed: pressed) + }, onAxis: { [self] usage, value in + onAxis(usage: usage, value: value) + }, onHat: { [self] hat in + onHat(value: hat) + }) + } + } + + private func startVirtualController() { + guard #available(iOS 17.0, *) else { + return + } + + if virtualConnected { + return + } + if virtualConnecting { + return + } + + let controller: GCVirtualController + if let existing = virtualController { + controller = existing + } else { + let configuration = GCVirtualController.Configuration() + configuration.elements = [ + GCInputButtonA, + GCInputButtonB, + GCInputButtonX, + GCInputButtonY, + GCInputLeftShoulder, + GCInputRightShoulder, + GCInputLeftTrigger, + GCInputRightTrigger, + GCInputDirectionPad, + GCInputLeftThumbstick, + GCInputRightThumbstick, + GCInputLeftThumbstickButton, + GCInputRightThumbstickButton, + GCInputButtonMenu, + GCInputButtonOptions + ] + configuration.isHidden = true + controller = GCVirtualController(configuration: configuration) + virtualController = controller + } + + virtualConnecting = true + controller.connect { [weak self] error in + guard let self else { + return + } + self.virtualConnecting = false + if error == nil { + self.virtualConnected = true + } else { + self.virtualConnected = false + } + } + } + + private func onDeviceConnected() { + if !virtualConnected && !virtualConnecting { + startVirtualController() + } + } + + private func onDeviceDisconnected() { + leftStick = .zero + rightStick = .zero + dpad = .zero + leftTrigger = 0 + rightTrigger = 0 + axisProfile = .undecided + observedGenericAxes.removeAll() + dpadButtonState.keys.forEach { dpadButtonState[$0] = false } + triggerPressedState.keys.forEach { triggerPressedState[$0] = false } + } + + private func installGCControllerObserversIfNeeded() { + if gcObserversInstalled { + return + } + gcObserversInstalled = true + + NotificationCenter.default.addObserver( + forName: .GCControllerDidConnect, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { + return + } + let vendor = (notification.object as? GCController)?.vendorName ?? "unknown" + if vendor == "Apple" { + self.virtualConnected = true + } + } + + NotificationCenter.default.addObserver( + forName: .GCControllerDidDisconnect, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { + return + } + let vendor = (notification.object as? GCController)?.vendorName ?? "unknown" + if vendor == "Apple" { + self.virtualConnected = false + self.virtualController = nil + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in + self?.startVirtualController() + } + } + } + } + + private func shouldProcessHID() -> Bool { + if virtualConnected { + return true + } + return GCController.controllers().isEmpty + } + + private func onButton(usage: Int, pressed: Bool) { + if !shouldProcessHID() { + return + } + + if virtualConnected, #available(iOS 17.0, *), let virtualController, + let elementName = virtualButtonElement(for: usage) { + virtualController.setValue(pressed ? 1.0 : 0.0, forButtonElement: elementName) + return + } + + if !PlaySettings.shared.keymapping { + return + } + + if let alias = fallbackButtonAlias(for: usage) { + _ = ActionDispatcher.dispatch(key: alias, pressed: pressed) + } + } + + private func onAxis(usage: Int, value: CGFloat) { + if !shouldProcessHID() { + return + } + + guard let axisRole = resolveAxisRole(usage: usage, value: value) else { + return + } + + switch axisRole { + case .leftStickX: + leftStick.x = value + case .leftStickY: + leftStick.y = -value + case .rightStickX: + rightStick.x = value + case .rightStickY: + rightStick.y = -value + case .leftTrigger: + leftTrigger = clamp01((value + 1) / 2) + case .rightTrigger: + rightTrigger = clamp01((value + 1) / 2) + } + + if virtualConnected, #available(iOS 17.0, *), let virtualController { + switch axisRole { + case .leftStickX, .leftStickY: + virtualController.setPosition(leftStick, forDirectionPadElement: GCInputLeftThumbstick) + case .rightStickX, .rightStickY: + virtualController.setPosition(rightStick, forDirectionPadElement: GCInputRightThumbstick) + case .leftTrigger: + virtualController.setValue(leftTrigger, forButtonElement: GCInputLeftTrigger) + case .rightTrigger: + virtualController.setValue(rightTrigger, forButtonElement: GCInputRightTrigger) + } + return + } + + if !PlaySettings.shared.keymapping { + return + } + + switch axisRole { + case .leftStickX, .leftStickY: + _ = ActionDispatcher.dispatch(key: GCInputLeftThumbstick, valueX: leftStick.x, valueY: leftStick.y) + case .rightStickX, .rightStickY: + _ = ActionDispatcher.dispatch(key: GCInputRightThumbstick, valueX: rightStick.x, valueY: rightStick.y) + case .leftTrigger: + applyFallbackTrigger(alias: GCInputLeftTrigger, value: leftTrigger) + case .rightTrigger: + applyFallbackTrigger(alias: GCInputRightTrigger, value: rightTrigger) + } + } + + private func resolveAxisRole(usage: Int, value: CGFloat) -> HIDAxisRole? { + guard [0x30, 0x31, 0x32, 0x33, 0x34, 0x35].contains(usage) else { + return nil + } + + observedGenericAxes.insert(usage) + + if axisProfile == .undecided { + if observedGenericAxes.contains(0x33) || observedGenericAxes.contains(0x34) { + axisProfile = .standard + } else if (usage == 0x32 || usage == 0x35), abs(value) < 0.25 { + axisProfile = .zAndRzRightStick + } + } + + switch axisProfile { + case .zAndRzRightStick: + switch usage { + case 0x30: return .leftStickX + case 0x31: return .leftStickY + case 0x32: return .rightStickX + case 0x35: return .rightStickY + case 0x33: return .leftTrigger + case 0x34: return .rightTrigger + default: return nil + } + case .undecided, .standard: + switch usage { + case 0x30: return .leftStickX + case 0x31: return .leftStickY + case 0x33: return .rightStickX + case 0x34: return .rightStickY + case 0x32: return .leftTrigger + case 0x35: return .rightTrigger + default: return nil + } + } + } + + private func onHat(value: Int) { + if !shouldProcessHID() { + return + } + + switch value { + case 0: + dpad = CGPoint(x: 0, y: 1) + case 1: + dpad = CGPoint(x: 1, y: 1) + case 2: + dpad = CGPoint(x: 1, y: 0) + case 3: + dpad = CGPoint(x: 1, y: -1) + case 4: + dpad = CGPoint(x: 0, y: -1) + case 5: + dpad = CGPoint(x: -1, y: -1) + case 6: + dpad = CGPoint(x: -1, y: 0) + case 7: + dpad = CGPoint(x: -1, y: 1) + default: + dpad = .zero + } + + if virtualConnected, #available(iOS 17.0, *), let virtualController { + virtualController.setPosition(dpad, forDirectionPadElement: GCInputDirectionPad) + return + } + + if !PlaySettings.shared.keymapping { + return + } + + applyFallbackDPadState( + up: dpad.y > 0, + down: dpad.y < 0, + left: dpad.x < 0, + right: dpad.x > 0 + ) + } + + private func applyFallbackDPadState(up: Bool, down: Bool, left: Bool, right: Bool) { + let nextState: [String: Bool] = [ + HIDControllerBridge.dpadUpAlias: up, + HIDControllerBridge.dpadDownAlias: down, + HIDControllerBridge.dpadLeftAlias: left, + HIDControllerBridge.dpadRightAlias: right + ] + + for (alias, pressed) in nextState where dpadButtonState[alias] != pressed { + _ = ActionDispatcher.dispatch(key: alias, pressed: pressed) + } + dpadButtonState = nextState + } + + private func applyFallbackTrigger(alias: String, value: CGFloat) { + let pressed = value > 0.45 + if triggerPressedState[alias] != pressed { + _ = ActionDispatcher.dispatch(key: alias, pressed: pressed) + triggerPressedState[alias] = pressed + } + } + + private func virtualButtonElement(for usage: Int) -> String? { + switch usage { + case 1: return GCInputButtonA + case 2: return GCInputButtonB + case 3: return GCInputButtonX + case 4: return GCInputButtonY + case 5: return GCInputLeftShoulder + case 6: return GCInputRightShoulder + case 7: return GCInputLeftTrigger + case 8: return GCInputRightTrigger + case 9: return GCInputLeftThumbstickButton + case 10: return GCInputRightThumbstickButton + case 11: return GCInputButtonMenu + case 12: return GCInputButtonOptions + default: return nil + } + } + + private func fallbackButtonAlias(for usage: Int) -> String? { + switch usage { + case 1: return GCInputButtonA + case 2: return GCInputButtonB + case 3: return GCInputButtonX + case 4: return GCInputButtonY + case 5: return GCInputLeftShoulder + case 6: return GCInputRightShoulder + case 7: return GCInputLeftTrigger + case 8: return GCInputRightTrigger + case 9: return GCInputLeftThumbstickButton + case 10: return GCInputRightThumbstickButton + case 11: return GCInputButtonMenu + case 12: return GCInputButtonOptions + default: return nil + } + } + + private func clamp01(_ value: CGFloat) -> CGFloat { + min(max(value, 0), 1) + } +} diff --git a/PlayTools/PlaySettings.swift b/PlayTools/PlaySettings.swift index c20d6d32..9d207305 100644 --- a/PlayTools/PlaySettings.swift +++ b/PlayTools/PlaySettings.swift @@ -95,6 +95,9 @@ let settings = PlaySettings.shared @objc lazy var disableBuiltinMouse = settingsData.disableBuiltinMouse @objc lazy var blockSleepSpamming = settingsData.blockSleepSpamming + + @objc lazy var experimentalHIDBridge = settingsData.experimentalHIDBridge + || bundleIdentifier == "com.gryphline.endfield.ios" } struct AppSettingsData: Codable { @@ -128,4 +131,5 @@ struct AppSettingsData: Codable { var resizableAspectRatioWidth = 0 var resizableAspectRatioHeight = 0 var blockSleepSpamming = false + var experimentalHIDBridge = false } diff --git a/Plugin.swift b/Plugin.swift index 6520c2eb..998fb9d0 100644 --- a/Plugin.swift +++ b/Plugin.swift @@ -29,6 +29,11 @@ public protocol Plugin: NSObjectProtocol { func setupMouseMoved(_ mouseMoved: @escaping (CGFloat, CGFloat) -> Bool) func setupMouseButton(left: Bool, right: Bool, _ consumed: @escaping (Int, Bool) -> Bool) func setupScrollWheel(_ onMoved: @escaping (CGFloat, CGFloat) -> Bool) + func setupHIDControllerInput(onConnected: @escaping () -> Void, + onDisconnected: @escaping () -> Void, + onButton: @escaping (Int, Bool) -> Void, + onAxis: @escaping (Int, CGFloat) -> Void, + onHat: @escaping (Int) -> Void) func urlForApplicationWithBundleIdentifier(_ value: String) -> URL? func setMenuBarVisible(_ value: Bool) } From f65bd608179a6cda1eac72008cd838cc754af6bb Mon Sep 17 00:00:00 2001 From: Wiedy Mi <42713027+wiedymi@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:57:24 +0300 Subject: [PATCH 2/6] Generalize HID controller detection and axis mapping --- AKPlugin.swift | 73 ++++++++++++++++++++---------- PlayTools/Controls/PlayInput.swift | 49 ++++++++++---------- 2 files changed, 73 insertions(+), 49 deletions(-) diff --git a/AKPlugin.swift b/AKPlugin.swift index 2a3fa527..978583e8 100644 --- a/AKPlugin.swift +++ b/AKPlugin.swift @@ -20,8 +20,14 @@ private struct AKAppSettingsData: Codable { } class AKPlugin: NSObject, Plugin { - private static let hidGameSirVendorID = 0x3537 - private static let hidGameSirProductID = 0x1022 + private static let hidGenericGamepadMatches: [[String: Any]] = [ + [kIOHIDDeviceUsagePageKey as String: 0x01, kIOHIDDeviceUsageKey as String: 0x04], // Joystick + [kIOHIDDeviceUsagePageKey as String: 0x01, kIOHIDDeviceUsageKey as String: 0x05], // Gamepad + // Some Bluetooth controllers expose top-level usage as consumer control. + [kIOHIDDeviceUsagePageKey as String: 0x0C, kIOHIDDeviceUsageKey as String: 0x01] + ] + private static let hidAxisUsages: Set = [0x30, 0x31, 0x32, 0x33, 0x34, 0x35] + private static let hidHatUsage = 0x39 required override init() { super.init() @@ -331,16 +337,7 @@ class AKPlugin: NSObject, Plugin { let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) - let matching: [[String: Any]] = [ - [kIOHIDDeviceUsagePageKey as String: 0x01, kIOHIDDeviceUsageKey as String: 0x04], // Joystick - [kIOHIDDeviceUsagePageKey as String: 0x01, kIOHIDDeviceUsageKey as String: 0x05], // Gamepad - // Some Bluetooth gamepads (including G7 Pro) expose top-level usage as consumer control. - [kIOHIDDeviceUsagePageKey as String: 0x0C, kIOHIDDeviceUsageKey as String: 0x01], - // Vendor/product fallback for known G7 Pro in case usage filtering is insufficient. - [kIOHIDVendorIDKey as String: Self.hidGameSirVendorID, - kIOHIDProductIDKey as String: Self.hidGameSirProductID] - ] - IOHIDManagerSetDeviceMatchingMultiple(manager, matching as CFArray) + IOHIDManagerSetDeviceMatchingMultiple(manager, Self.hidGenericGamepadMatches as CFArray) let context = Unmanaged.passUnretained(self).toOpaque() IOHIDManagerRegisterDeviceMatchingCallback(manager, Self.hidDeviceMatchingCallback, context) @@ -472,27 +469,53 @@ class AKPlugin: NSObject, Plugin { return nil } - private func hidDevicePropertyString(_ device: IOHIDDevice, key: CFString) -> String? { - if let text = IOHIDDeviceGetProperty(device, key) as? String { - return text - } - return nil - } - private func isSupportedControllerDevice(_ device: IOHIDDevice) -> Bool { let usagePage = hidDevicePropertyInt(device, key: kIOHIDPrimaryUsagePageKey as CFString) ?? -1 let usage = hidDevicePropertyInt(device, key: kIOHIDPrimaryUsageKey as CFString) ?? -1 - let vendorID = hidDevicePropertyInt(device, key: kIOHIDVendorIDKey as CFString) ?? -1 - let productID = hidDevicePropertyInt(device, key: kIOHIDProductIDKey as CFString) ?? -1 - if vendorID == Self.hidGameSirVendorID && productID == Self.hidGameSirProductID { + if usagePage == 0x01 && (usage == 0x04 || usage == 0x05) { return true } - if usagePage == 0x01 && (usage == 0x04 || usage == 0x05) { - return true + return hasGamepadLikeElements(device) + } + + private func hasGamepadLikeElements(_ device: IOHIDDevice) -> Bool { + guard let elements = IOHIDDeviceCopyMatchingElements(device, nil, IOOptionBits(kIOHIDOptionsTypeNone)) + as? [IOHIDElement] else { + return false + } + + var buttonUsages = Set() + var axisUsages = Set() + var hasHat = false + + for element in elements { + let type = IOHIDElementGetType(element) + switch type { + case kIOHIDElementTypeInput_Button: + let usagePage = Int(IOHIDElementGetUsagePage(element)) + let usage = Int(IOHIDElementGetUsage(element)) + if usagePage == 0x09 { + buttonUsages.insert(usage) + } + case kIOHIDElementTypeInput_Misc, kIOHIDElementTypeInput_Axis: + let usagePage = Int(IOHIDElementGetUsagePage(element)) + let usage = Int(IOHIDElementGetUsage(element)) + if usagePage != 0x01 { + continue + } + if usage == Self.hidHatUsage { + hasHat = true + } else if Self.hidAxisUsages.contains(usage) { + axisUsages.insert(usage) + } + default: + continue + } } - return false + // Reject non-controller HID devices (headsets, media keys, etc.). + return buttonUsages.count >= 4 && (axisUsages.count >= 2 || hasHat) } } diff --git a/PlayTools/Controls/PlayInput.swift b/PlayTools/Controls/PlayInput.swift index 1782ec1c..69483ae4 100644 --- a/PlayTools/Controls/PlayInput.swift +++ b/PlayTools/Controls/PlayInput.swift @@ -86,7 +86,7 @@ private final class HIDControllerBridge { private static let dpadLeftAlias = "Direction Pad Left" private static let dpadRightAlias = "Direction Pad Right" - private enum HIDAxisProfile: String { + private enum HIDAxisProfile { case undecided case standard case zAndRzRightStick @@ -101,6 +101,27 @@ private final class HIDControllerBridge { case rightTrigger } + private typealias AxisUsageMap = [Int: HIDAxisRole] + private static let axisUsageByProfile: [HIDAxisProfile: AxisUsageMap] = [ + .standard: [ + 0x30: .leftStickX, + 0x31: .leftStickY, + 0x33: .rightStickX, + 0x34: .rightStickY, + 0x32: .leftTrigger, + 0x35: .rightTrigger + ], + .zAndRzRightStick: [ + 0x30: .leftStickX, + 0x31: .leftStickY, + 0x32: .rightStickX, + 0x35: .rightStickY, + 0x33: .leftTrigger, + 0x34: .rightTrigger + ] + ] + private static let supportedAxisUsages = Set(axisUsageByProfile.values.flatMap { $0.keys }) + private var initialized = false private var virtualController: GCVirtualController? private var virtualConnected = false @@ -339,7 +360,7 @@ private final class HIDControllerBridge { } private func resolveAxisRole(usage: Int, value: CGFloat) -> HIDAxisRole? { - guard [0x30, 0x31, 0x32, 0x33, 0x34, 0x35].contains(usage) else { + guard Self.supportedAxisUsages.contains(usage) else { return nil } @@ -353,28 +374,8 @@ private final class HIDControllerBridge { } } - switch axisProfile { - case .zAndRzRightStick: - switch usage { - case 0x30: return .leftStickX - case 0x31: return .leftStickY - case 0x32: return .rightStickX - case 0x35: return .rightStickY - case 0x33: return .leftTrigger - case 0x34: return .rightTrigger - default: return nil - } - case .undecided, .standard: - switch usage { - case 0x30: return .leftStickX - case 0x31: return .leftStickY - case 0x33: return .rightStickX - case 0x34: return .rightStickY - case 0x32: return .leftTrigger - case 0x35: return .rightTrigger - default: return nil - } - } + let profile = axisProfile == .undecided ? HIDAxisProfile.standard : axisProfile + return Self.axisUsageByProfile[profile]?[usage] } private func onHat(value: Int) { From 2da22e99b159bc7acf89972d06ad107c4f71c516 Mon Sep 17 00:00:00 2001 From: Wiedy Mi <42713027+wiedymi@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:58:56 +0300 Subject: [PATCH 3/6] Always enable HID bridge and remove experimental flag --- PlayTools/Controls/PlayInput.swift | 4 +--- PlayTools/PlaySettings.swift | 4 ---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/PlayTools/Controls/PlayInput.swift b/PlayTools/Controls/PlayInput.swift index 69483ae4..e448d7f2 100644 --- a/PlayTools/Controls/PlayInput.swift +++ b/PlayTools/Controls/PlayInput.swift @@ -24,9 +24,7 @@ class PlayInput { simulateGCMouseDisconnect() } - if PlaySettings.shared.experimentalHIDBridge { - HIDControllerBridge.shared.initializeIfNeeded() - } + HIDControllerBridge.shared.initializeIfNeeded() if !PlaySettings.shared.keymapping { return diff --git a/PlayTools/PlaySettings.swift b/PlayTools/PlaySettings.swift index 9d207305..c20d6d32 100644 --- a/PlayTools/PlaySettings.swift +++ b/PlayTools/PlaySettings.swift @@ -95,9 +95,6 @@ let settings = PlaySettings.shared @objc lazy var disableBuiltinMouse = settingsData.disableBuiltinMouse @objc lazy var blockSleepSpamming = settingsData.blockSleepSpamming - - @objc lazy var experimentalHIDBridge = settingsData.experimentalHIDBridge - || bundleIdentifier == "com.gryphline.endfield.ios" } struct AppSettingsData: Codable { @@ -131,5 +128,4 @@ struct AppSettingsData: Codable { var resizableAspectRatioWidth = 0 var resizableAspectRatioHeight = 0 var blockSleepSpamming = false - var experimentalHIDBridge = false } From 9a4b38252b19d2b641228ec568af07e594ad1e16 Mon Sep 17 00:00:00 2001 From: Wiedy Mi <42713027+wiedymi@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:54:15 +0300 Subject: [PATCH 4/6] Add generic HID bridge and launch bootstrap fixes --- AKPlugin.swift | 21 ++- .../Controls/PTFakeTouch/NSObject+Swizzle.m | 2 +- PlayTools/Controls/PlayInput.swift | 137 +++++++++++++----- PlayTools/PlayLoader.m | 13 +- 4 files changed, 130 insertions(+), 43 deletions(-) diff --git a/AKPlugin.swift b/AKPlugin.swift index 978583e8..241a5db9 100644 --- a/AKPlugin.swift +++ b/AKPlugin.swift @@ -20,12 +20,6 @@ private struct AKAppSettingsData: Codable { } class AKPlugin: NSObject, Plugin { - private static let hidGenericGamepadMatches: [[String: Any]] = [ - [kIOHIDDeviceUsagePageKey as String: 0x01, kIOHIDDeviceUsageKey as String: 0x04], // Joystick - [kIOHIDDeviceUsagePageKey as String: 0x01, kIOHIDDeviceUsageKey as String: 0x05], // Gamepad - // Some Bluetooth controllers expose top-level usage as consumer control. - [kIOHIDDeviceUsagePageKey as String: 0x0C, kIOHIDDeviceUsageKey as String: 0x01] - ] private static let hidAxisUsages: Set = [0x30, 0x31, 0x32, 0x33, 0x34, 0x35] private static let hidHatUsage = 0x39 @@ -337,7 +331,7 @@ class AKPlugin: NSObject, Plugin { let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) - IOHIDManagerSetDeviceMatchingMultiple(manager, Self.hidGenericGamepadMatches as CFArray) + IOHIDManagerSetDeviceMatching(manager, nil) let context = Unmanaged.passUnretained(self).toOpaque() IOHIDManagerRegisterDeviceMatchingCallback(manager, Self.hidDeviceMatchingCallback, context) @@ -351,6 +345,14 @@ class AKPlugin: NSObject, Plugin { } hidManager = manager + + // Callbacks are not guaranteed to fire for devices that were already connected before + // manager registration in all launch paths. Seed initial state proactively. + if let devices = IOHIDManagerCopyDevices(manager) as? Set { + if let initialDevice = devices.first(where: { isSupportedControllerDevice($0) }) { + handleHIDDeviceConnected(initialDevice) + } + } } func urlForApplicationWithBundleIdentifier(_ value: String) -> URL? { @@ -417,7 +419,10 @@ class AKPlugin: NSObject, Plugin { private func handleHIDInputValue(_ value: IOHIDValue) { let element = IOHIDValueGetElement(value) let device = IOHIDElementGetDevice(element) - if let primary = hidPrimaryDevice, !sameDevice(primary, device) { + guard let primary = hidPrimaryDevice else { + return + } + if !sameDevice(primary, device) { return } diff --git a/PlayTools/Controls/PTFakeTouch/NSObject+Swizzle.m b/PlayTools/Controls/PTFakeTouch/NSObject+Swizzle.m index 6796b2b7..4ca2ed36 100644 --- a/PlayTools/Controls/PTFakeTouch/NSObject+Swizzle.m +++ b/PlayTools/Controls/PTFakeTouch/NSObject+Swizzle.m @@ -157,7 +157,7 @@ - (BOOL) hook_requiresFullScreen { return NO; } -- (void) hook_setCurrentSubscription:(VSSubscription *)currentSubscription { +- (void) hook_setCurrentSubscription:(id)currentSubscription { // do nothing } diff --git a/PlayTools/Controls/PlayInput.swift b/PlayTools/Controls/PlayInput.swift index e448d7f2..34c16110 100644 --- a/PlayTools/Controls/PlayInput.swift +++ b/PlayTools/Controls/PlayInput.swift @@ -99,7 +99,14 @@ private final class HIDControllerBridge { case rightTrigger } + private enum HIDButtonProfile { + case undecided + case standard + case shifted + } + private typealias AxisUsageMap = [Int: HIDAxisRole] + private typealias ButtonUsageMap = [Int: String] private static let axisUsageByProfile: [HIDAxisProfile: AxisUsageMap] = [ .standard: [ 0x30: .leftStickX, @@ -119,6 +126,38 @@ private final class HIDControllerBridge { ] ] private static let supportedAxisUsages = Set(axisUsageByProfile.values.flatMap { $0.keys }) + private static let buttonUsageByProfile: [HIDButtonProfile: ButtonUsageMap] = [ + .standard: [ + 1: GCInputButtonA, + 2: GCInputButtonB, + 3: GCInputButtonX, + 4: GCInputButtonY, + 5: GCInputLeftShoulder, + 6: GCInputRightShoulder, + 7: GCInputLeftTrigger, + 8: GCInputRightTrigger, + 9: GCInputLeftThumbstickButton, + 10: GCInputRightThumbstickButton, + 11: GCInputButtonMenu, + 12: GCInputButtonOptions + ], + // Some HID gamepads expose face/shoulder/menu usages in a shifted layout. + // Keep this profile generic and infer it from observed usage patterns. + .shifted: [ + 1: GCInputButtonA, + 2: GCInputButtonB, + 4: GCInputButtonX, + 5: GCInputButtonY, + 7: GCInputLeftShoulder, + 8: GCInputRightShoulder, + 9: GCInputLeftThumbstickButton, + 10: GCInputRightThumbstickButton, + 11: GCInputButtonOptions, + 12: GCInputButtonMenu, + 14: GCInputLeftThumbstickButton, + 15: GCInputRightThumbstickButton + ] + ] private var initialized = false private var virtualController: GCVirtualController? @@ -131,7 +170,10 @@ private final class HIDControllerBridge { private var leftTrigger: CGFloat = 0 private var rightTrigger: CGFloat = 0 private var axisProfile: HIDAxisProfile = .undecided + private var buttonProfile: HIDButtonProfile = .undecided private var observedGenericAxes = Set() + private var observedButtonUsages = Set() + private var buttonUsagePressCount: [Int: Int] = [:] private var dpadButtonState: [String: Bool] = [ HIDControllerBridge.dpadUpAlias: false, HIDControllerBridge.dpadDownAlias: false, @@ -232,7 +274,10 @@ private final class HIDControllerBridge { leftTrigger = 0 rightTrigger = 0 axisProfile = .undecided + buttonProfile = .undecided observedGenericAxes.removeAll() + observedButtonUsages.removeAll() + buttonUsagePressCount.removeAll() dpadButtonState.keys.forEach { dpadButtonState[$0] = false } triggerPressedState.keys.forEach { triggerPressedState[$0] = false } } @@ -277,10 +322,9 @@ private final class HIDControllerBridge { } private func shouldProcessHID() -> Bool { - if virtualConnected { - return true - } - return GCController.controllers().isEmpty + // Always process HID input. Launch paths can expose inconsistent GCController + // state; suppressing on that state causes the bridge to work only in some paths. + return true } private func onButton(usage: Int, pressed: Bool) { @@ -288,6 +332,10 @@ private final class HIDControllerBridge { return } + if pressed { + buttonUsagePressCount[usage, default: 0] += 1 + } + if virtualConnected, #available(iOS 17.0, *), let virtualController, let elementName = virtualButtonElement(for: usage) { virtualController.setValue(pressed ? 1.0 : 0.0, forButtonElement: elementName) @@ -442,39 +490,62 @@ private final class HIDControllerBridge { } private func virtualButtonElement(for usage: Int) -> String? { - switch usage { - case 1: return GCInputButtonA - case 2: return GCInputButtonB - case 3: return GCInputButtonX - case 4: return GCInputButtonY - case 5: return GCInputLeftShoulder - case 6: return GCInputRightShoulder - case 7: return GCInputLeftTrigger - case 8: return GCInputRightTrigger - case 9: return GCInputLeftThumbstickButton - case 10: return GCInputRightThumbstickButton - case 11: return GCInputButtonMenu - case 12: return GCInputButtonOptions - default: return nil - } + let profile = resolveButtonProfile(usage: usage) + return Self.buttonUsageByProfile[profile]?[usage] } private func fallbackButtonAlias(for usage: Int) -> String? { - switch usage { - case 1: return GCInputButtonA - case 2: return GCInputButtonB - case 3: return GCInputButtonX - case 4: return GCInputButtonY - case 5: return GCInputLeftShoulder - case 6: return GCInputRightShoulder - case 7: return GCInputLeftTrigger - case 8: return GCInputRightTrigger - case 9: return GCInputLeftThumbstickButton - case 10: return GCInputRightThumbstickButton - case 11: return GCInputButtonMenu - case 12: return GCInputButtonOptions - default: return nil + let profile = resolveButtonProfile(usage: usage) + return Self.buttonUsageByProfile[profile]?[usage] + } + + private func resolveButtonProfile(usage: Int) -> HIDButtonProfile { + observedButtonUsages.insert(usage) + + // Standard profile evidence: + // - X on usage 3 + // - Right shoulder on usage 6 + if observedButtonUsages.contains(3) || observedButtonUsages.contains(6) { + if buttonProfile != .standard { + buttonProfile = .standard + } + return .standard + } + + // Shifted profile evidence: + // - Stick clicks on usage 14/15 + // - Face/shoulder cluster where usage 5 behaves as a face button and + // shoulders appear on 7/8. + let shiftedEvidence = + observedButtonUsages.contains(14) || + observedButtonUsages.contains(15) || + (observedButtonUsages.contains(7) && !observedButtonUsages.contains(6)) || + (observedButtonUsages.contains(8) && !observedButtonUsages.contains(6)) || + (observedButtonUsages.contains(5) && + (observedButtonUsages.contains(7) || observedButtonUsages.contains(8)) && + !observedButtonUsages.contains(3) && + !observedButtonUsages.contains(6)) + + let yLikeEvidence = + (buttonUsagePressCount[5] ?? 0) >= 2 && + (buttonUsagePressCount[3] ?? 0) == 0 && + (buttonUsagePressCount[6] ?? 0) == 0 + + if shiftedEvidence { + if buttonProfile != .shifted { + buttonProfile = .shifted + } + return .shifted } + + if yLikeEvidence { + if buttonProfile != .shifted { + buttonProfile = .shifted + } + return .shifted + } + + return buttonProfile == .undecided ? .standard : buttonProfile } private func clamp01(_ value: CGFloat) -> CGFloat { diff --git a/PlayTools/PlayLoader.m b/PlayTools/PlayLoader.m index b6da6364..d33173c2 100644 --- a/PlayTools/PlayLoader.m +++ b/PlayTools/PlayLoader.m @@ -285,7 +285,9 @@ static int pt_usleep(useconds_t time) { @implementation PlayLoader -static void __attribute__((constructor)) initialize(void) { +static void pt_bootstrap_playcover(void) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ [PlayCover launch]; if (ue_status == 0) { @@ -307,6 +309,15 @@ static void __attribute__((constructor)) initialize(void) { [thread_sleep_lock unlock]; }]; } + }); +} + +static void __attribute__((constructor)) initialize(void) { + pt_bootstrap_playcover(); +} + ++ (void)load { + pt_bootstrap_playcover(); } @end From cc9976e600dc78963401c6fe8e725be10871eb59 Mon Sep 17 00:00:00 2001 From: Wiedy Mi <42713027+wiedymi@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:07:31 +0300 Subject: [PATCH 5/6] Handle simulation-page trigger axes for HID controllers --- AKPlugin.swift | 25 +++++++++++++++++++------ PlayTools/Controls/PlayInput.swift | 17 +++++++++++++++-- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/AKPlugin.swift b/AKPlugin.swift index 241a5db9..fd44a8ed 100644 --- a/AKPlugin.swift +++ b/AKPlugin.swift @@ -21,6 +21,8 @@ private struct AKAppSettingsData: Codable { class AKPlugin: NSObject, Plugin { private static let hidAxisUsages: Set = [0x30, 0x31, 0x32, 0x33, 0x34, 0x35] + // Simulation controls used by some HID gamepads for analog triggers. + private static let hidSimulationAxisUsages: Set = [0xC4, 0xC5] // Accelerator / Brake private static let hidHatUsage = 0x39 required override init() { @@ -442,6 +444,11 @@ class AKPlugin: NSObject, Plugin { let normalized = normalizeAxis(rawValue: rawValue, element: element) hidOnAxis?(usage, normalized) } + case 0x02: // Simulation Controls page (e.g. accelerator / brake triggers) + if Self.hidSimulationAxisUsages.contains(usage) { + let normalized = normalizeAxis(rawValue: rawValue, element: element) + hidOnAxis?(usage, normalized) + } case 0x0C: break default: @@ -507,14 +514,20 @@ class AKPlugin: NSObject, Plugin { case kIOHIDElementTypeInput_Misc, kIOHIDElementTypeInput_Axis: let usagePage = Int(IOHIDElementGetUsagePage(element)) let usage = Int(IOHIDElementGetUsage(element)) - if usagePage != 0x01 { + switch usagePage { + case 0x01: + if usage == Self.hidHatUsage { + hasHat = true + } else if Self.hidAxisUsages.contains(usage) { + axisUsages.insert(usage) + } + case 0x02: + if Self.hidSimulationAxisUsages.contains(usage) { + axisUsages.insert(usage) + } + default: continue } - if usage == Self.hidHatUsage { - hasHat = true - } else if Self.hidAxisUsages.contains(usage) { - axisUsages.insert(usage) - } default: continue } diff --git a/PlayTools/Controls/PlayInput.swift b/PlayTools/Controls/PlayInput.swift index 34c16110..4e051294 100644 --- a/PlayTools/Controls/PlayInput.swift +++ b/PlayTools/Controls/PlayInput.swift @@ -114,7 +114,10 @@ private final class HIDControllerBridge { 0x33: .rightStickX, 0x34: .rightStickY, 0x32: .leftTrigger, - 0x35: .rightTrigger + 0x35: .rightTrigger, + // Some HID gamepads expose analog triggers on Simulation Controls. + 0xC4: .leftTrigger, // Accelerator + 0xC5: .rightTrigger // Brake ], .zAndRzRightStick: [ 0x30: .leftStickX, @@ -122,7 +125,9 @@ private final class HIDControllerBridge { 0x32: .rightStickX, 0x35: .rightStickY, 0x33: .leftTrigger, - 0x34: .rightTrigger + 0x34: .rightTrigger, + 0xC4: .leftTrigger, // Accelerator + 0xC5: .rightTrigger // Brake ] ] private static let supportedAxisUsages = Set(axisUsageByProfile.values.flatMap { $0.keys }) @@ -410,6 +415,14 @@ private final class HIDControllerBridge { return nil } + // Simulation controls are unambiguous trigger axes across profiles. + if usage == 0xC4 { + return .leftTrigger + } + if usage == 0xC5 { + return .rightTrigger + } + observedGenericAxes.insert(usage) if axisProfile == .undecided { From 0f3cc7e2b6958af79494d14dd917b0d6626b3c3c Mon Sep 17 00:00:00 2001 From: Wiedy Mi <42713027+wiedymi@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:16:39 +0300 Subject: [PATCH 6/6] Treat usage 9/10 as trigger buttons when simulation axes are present --- PlayTools/Controls/PlayInput.swift | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/PlayTools/Controls/PlayInput.swift b/PlayTools/Controls/PlayInput.swift index 4e051294..b31ce335 100644 --- a/PlayTools/Controls/PlayInput.swift +++ b/PlayTools/Controls/PlayInput.swift @@ -177,6 +177,7 @@ private final class HIDControllerBridge { private var axisProfile: HIDAxisProfile = .undecided private var buttonProfile: HIDButtonProfile = .undecided private var observedGenericAxes = Set() + private var hasSimulationTriggerAxes = false private var observedButtonUsages = Set() private var buttonUsagePressCount: [Int: Int] = [:] private var dpadButtonState: [String: Bool] = [ @@ -281,6 +282,7 @@ private final class HIDControllerBridge { axisProfile = .undecided buttonProfile = .undecided observedGenericAxes.removeAll() + hasSimulationTriggerAxes = false observedButtonUsages.removeAll() buttonUsagePressCount.removeAll() dpadButtonState.keys.forEach { dpadButtonState[$0] = false } @@ -341,6 +343,28 @@ private final class HIDControllerBridge { buttonUsagePressCount[usage, default: 0] += 1 } + // Some HID controllers emit digital trigger presses on button usages 9/10 + // while exposing analog trigger values on simulation axes C4/C5. + // In that mode, usage 10 corresponds to LT and usage 9 corresponds to RT. + if hasSimulationTriggerAxes { + if usage == 10 { + if virtualConnected, #available(iOS 17.0, *), let virtualController { + virtualController.setValue(pressed ? 1.0 : 0.0, forButtonElement: GCInputLeftTrigger) + } else if PlaySettings.shared.keymapping { + _ = ActionDispatcher.dispatch(key: GCInputLeftTrigger, pressed: pressed) + } + return + } + if usage == 9 { + if virtualConnected, #available(iOS 17.0, *), let virtualController { + virtualController.setValue(pressed ? 1.0 : 0.0, forButtonElement: GCInputRightTrigger) + } else if PlaySettings.shared.keymapping { + _ = ActionDispatcher.dispatch(key: GCInputRightTrigger, pressed: pressed) + } + return + } + } + if virtualConnected, #available(iOS 17.0, *), let virtualController, let elementName = virtualButtonElement(for: usage) { virtualController.setValue(pressed ? 1.0 : 0.0, forButtonElement: elementName) @@ -361,6 +385,10 @@ private final class HIDControllerBridge { return } + if usage == 0xC4 || usage == 0xC5 { + hasSimulationTriggerAxes = true + } + guard let axisRole = resolveAxisRole(usage: usage, value: value) else { return }