diff --git a/AKPlugin.swift b/AKPlugin.swift index b0ebfece..fd44a8ed 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,11 @@ 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() { super.init() if let window = NSApplication.shared.windows.first { @@ -129,6 +135,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 +316,47 @@ 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)) + + IOHIDManagerSetDeviceMatching(manager, nil) + + 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 + + // 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? { NSWorkspace.shared.urlForApplication(withBundleIdentifier: value) } @@ -313,4 +391,149 @@ 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) + guard let primary = hidPrimaryDevice else { + return + } + if !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 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: + 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 isSupportedControllerDevice(_ device: IOHIDDevice) -> Bool { + let usagePage = hidDevicePropertyInt(device, key: kIOHIDPrimaryUsagePageKey as CFString) ?? -1 + let usage = hidDevicePropertyInt(device, key: kIOHIDPrimaryUsageKey as CFString) ?? -1 + + 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)) + 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 + } + default: + continue + } + } + + // Reject non-controller HID devices (headsets, media keys, etc.). + return buttonUsages.count >= 4 && (axisUsages.count >= 2 || hasHat) + } } 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 6aa3cc0b..b31ce335 100644 --- a/PlayTools/Controls/PlayInput.swift +++ b/PlayTools/Controls/PlayInput.swift @@ -24,6 +24,8 @@ class PlayInput { simulateGCMouseDisconnect() } + HIDControllerBridge.shared.initializeIfNeeded() + if !PlaySettings.shared.keymapping { return } @@ -74,3 +76,520 @@ 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 { + case undecided + case standard + case zAndRzRightStick + } + + private enum HIDAxisRole { + case leftStickX + case leftStickY + case rightStickX + case rightStickY + case leftTrigger + 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, + 0x31: .leftStickY, + 0x33: .rightStickX, + 0x34: .rightStickY, + 0x32: .leftTrigger, + 0x35: .rightTrigger, + // Some HID gamepads expose analog triggers on Simulation Controls. + 0xC4: .leftTrigger, // Accelerator + 0xC5: .rightTrigger // Brake + ], + .zAndRzRightStick: [ + 0x30: .leftStickX, + 0x31: .leftStickY, + 0x32: .rightStickX, + 0x35: .rightStickY, + 0x33: .leftTrigger, + 0x34: .rightTrigger, + 0xC4: .leftTrigger, // Accelerator + 0xC5: .rightTrigger // Brake + ] + ] + 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? + 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 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] = [ + 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 + buttonProfile = .undecided + observedGenericAxes.removeAll() + hasSimulationTriggerAxes = false + observedButtonUsages.removeAll() + buttonUsagePressCount.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 { + // 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) { + if !shouldProcessHID() { + return + } + + if pressed { + 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) + 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 + } + + if usage == 0xC4 || usage == 0xC5 { + hasSimulationTriggerAxes = true + } + + 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 Self.supportedAxisUsages.contains(usage) else { + 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 { + if observedGenericAxes.contains(0x33) || observedGenericAxes.contains(0x34) { + axisProfile = .standard + } else if (usage == 0x32 || usage == 0x35), abs(value) < 0.25 { + axisProfile = .zAndRzRightStick + } + } + + let profile = axisProfile == .undecided ? HIDAxisProfile.standard : axisProfile + return Self.axisUsageByProfile[profile]?[usage] + } + + 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? { + let profile = resolveButtonProfile(usage: usage) + return Self.buttonUsageByProfile[profile]?[usage] + } + + private func fallbackButtonAlias(for usage: Int) -> String? { + 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 { + min(max(value, 0), 1) + } +} 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 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) }