diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index dbca9df9073..80e30c6aa65 100644 --- a/cmake/compile_definitions/macos.cmake +++ b/cmake/compile_definitions/macos.cmake @@ -35,6 +35,7 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${CORE_MEDIA_LIBRARY} ${CORE_VIDEO_LIBRARY} ${FOUNDATION_LIBRARY} + ${IO_KIT_LIBRARY} ${VIDEO_TOOLBOX_LIBRARY}) set(APPLE_PLIST_TEMPLATE "${SUNSHINE_SOURCE_ASSETS_DIR}/macos/build/Info.plist.in") @@ -48,7 +49,9 @@ set(PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/macos/av_video.h" "${CMAKE_SOURCE_DIR}/src/platform/macos/av_video.m" "${CMAKE_SOURCE_DIR}/src/platform/macos/display.mm" - "${CMAKE_SOURCE_DIR}/src/platform/macos/input.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/macos/hid_gamepad.h" + "${CMAKE_SOURCE_DIR}/src/platform/macos/hid_gamepad.m" + "${CMAKE_SOURCE_DIR}/src/platform/macos/input.mm" "${CMAKE_SOURCE_DIR}/src/platform/macos/microphone.mm" "${CMAKE_SOURCE_DIR}/src/platform/macos/misc.mm" "${CMAKE_SOURCE_DIR}/src/platform/macos/misc.h" diff --git a/cmake/dependencies/macos.cmake b/cmake/dependencies/macos.cmake index 5e225fdac21..57619427f20 100644 --- a/cmake/dependencies/macos.cmake +++ b/cmake/dependencies/macos.cmake @@ -9,6 +9,12 @@ FIND_LIBRARY(CORE_AUDIO_LIBRARY CoreAudio) FIND_LIBRARY(CORE_MEDIA_LIBRARY CoreMedia) FIND_LIBRARY(CORE_VIDEO_LIBRARY CoreVideo) FIND_LIBRARY(FOUNDATION_LIBRARY Foundation) +# IOKit is needed for IOHIDUserDevice* (virtual gamepad device — hid_gamepad.m). +# Actually creating devices at runtime requires the user to disable AMFI via +# `nvram boot-args="amfi_get_out_of_my_way=1"`, but the symbols themselves +# are unconditionally present and the host alloc_gamepad path probes +# availability before relying on them. +FIND_LIBRARY(IO_KIT_LIBRARY IOKit) FIND_LIBRARY(VIDEO_TOOLBOX_LIBRARY VideoToolbox) if(SUNSHINE_ENABLE_TRAY) diff --git a/src/platform/macos/hid_gamepad.h b/src/platform/macos/hid_gamepad.h new file mode 100644 index 00000000000..5910a24716a --- /dev/null +++ b/src/platform/macos/hid_gamepad.h @@ -0,0 +1,78 @@ +/** + * @file src/platform/macos/hid_gamepad.h + * @brief Virtual HID gamepad via IOHIDUserDevice for macOS. + * @details Creates a system-wide virtual gamepad that macOS Game + * Controller framework, SDL, Steam, and raw IOKit HID + * consumers all recognize as a real USB controller. Requires + * AMFI to be bypassed at boot via + * `nvram boot-args="amfi_get_out_of_my_way=1"`. SIP can remain + * on; the relevant subsystem is AMFI, not SIP. + */ +#pragma once + +#import +#import + +/** + * HID report sent to IOHIDUserDevice. Packed to exactly 14 bytes. + * Matches the HID report descriptor defined in hid_gamepad.m. + */ +typedef struct __attribute__((packed)) { + uint8_t reportId; // Always 0x01 + uint16_t buttons; // 16 button bits + uint8_t hatSwitch; // D-pad hat switch (0-7 = directions, 8 = neutral) + uint8_t leftTrigger; // 0-255 + uint8_t rightTrigger; // 0-255 + int16_t leftStickX; // -32768 to 32767 + int16_t leftStickY; // -32768 to 32767 + int16_t rightStickX; // -32768 to 32767 + int16_t rightStickY; // -32768 to 32767 +} HIDGamepadReport; + +@interface HIDGamepad : NSObject + +@property (nonatomic, assign) int gamepadIndex; +@property (nonatomic, assign) BOOL isConnected; +@property (nonatomic, assign) IOHIDUserDeviceRef hidDevice; +@property (nonatomic, strong) dispatch_queue_t hidQueue; + +/** + * Probes whether IOHIDUserDevice virtual gamepads can be created. + * Returns NO when AMFI is enabled — the entitlement check refuses + * IOHIDUserDeviceCreateWithProperties without the AMFI bypass boot + * flag in place. + */ ++ (BOOL)isAvailable; + +- (instancetype)initWithIndex:(int)index; + +/** + * Creates the IOHIDUserDevice and sends an initial neutral-state report. + * @return YES on success, NO on failure. + */ +- (BOOL)createDevice; + +/** + * Maps Sunshine's gamepad state to an HID report and sends it. + * @param buttons Sunshine's 32-bit buttonFlags (only lower 16 bits + HOME used) + * @param lsX Left stick X (-32768..32767) + * @param lsY Left stick Y (-32768..32767) + * @param rsX Right stick X (-32768..32767) + * @param rsY Right stick Y (-32768..32767) + * @param lt Left trigger (0..255) + * @param rt Right trigger (0..255) + */ +- (void)updateState:(uint32_t)buttons + leftStickX:(int16_t)lsX + leftStickY:(int16_t)lsY + rightStickX:(int16_t)rsX + rightStickY:(int16_t)rsY + leftTrigger:(uint8_t)lt + rightTrigger:(uint8_t)rt; + +/** + * Destroys the IOHIDUserDevice and cleans up resources. + */ +- (void)disconnect; + +@end diff --git a/src/platform/macos/hid_gamepad.m b/src/platform/macos/hid_gamepad.m new file mode 100644 index 00000000000..d7d7a6e5526 --- /dev/null +++ b/src/platform/macos/hid_gamepad.m @@ -0,0 +1,318 @@ +/** + * @file src/platform/macos/hid_gamepad.m + * @brief Virtual HID gamepad implementation via IOHIDUserDevice. + * @details Creates a virtual Xbox-style gamepad recognized by macOS Game + * Controller framework, SDL, Steam, and apps consuming raw IOKit + * HID. Requires AMFI to be bypassed at boot via + * `nvram boot-args="amfi_get_out_of_my_way=1"` (and a reboot); + * without that, IOHIDUserDeviceCreateWithProperties refuses to + * publish the device because the + * com.apple.developer.hid.virtual.device entitlement check + * fails. SIP can remain enabled — AMFI is the relevant + * subsystem, not SIP. + */ +#import "hid_gamepad.h" +#import +#import + +// Sunshine button flags (from platform/common.h) +#define SF_DPAD_UP 0x0001 +#define SF_DPAD_DOWN 0x0002 +#define SF_DPAD_LEFT 0x0004 +#define SF_DPAD_RIGHT 0x0008 +#define SF_START 0x0010 +#define SF_BACK 0x0020 +#define SF_LEFT_STICK 0x0040 +#define SF_RIGHT_STICK 0x0080 +#define SF_LEFT_BUTTON 0x0100 +#define SF_RIGHT_BUTTON 0x0200 +#define SF_HOME 0x0400 +#define SF_A 0x1000 +#define SF_B 0x2000 +#define SF_X 0x4000 +#define SF_Y 0x8000 + +// Hat switch directions (matching HID spec 4-bit values) +#define HAT_N 0 +#define HAT_NE 1 +#define HAT_E 2 +#define HAT_SE 3 +#define HAT_S 4 +#define HAT_SW 5 +#define HAT_W 6 +#define HAT_NW 7 +#define HAT_NONE 8 + +/** + * HID Report Descriptor for an Xbox-style gamepad. + * + * Layout (Report ID 0x01, 13 bytes after report ID): + * - 16 buttons (2 bytes) + * - 1 hat switch, 4-bit + 4-bit padding (1 byte) + * - 2 triggers, 8-bit each (2 bytes) + * - 4 stick axes, 16-bit signed each (8 bytes) + */ +static const uint8_t kHIDReportDescriptor[] = { + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x04, // Usage (Joystick) — generic, avoids SDL's GCController-only path + 0xA1, 0x01, // Collection (Application) + 0x85, 0x01, // Report ID (1) + + // --- 16 Buttons --- + 0x05, 0x09, // Usage Page (Button) + 0x19, 0x01, // Usage Minimum (1) + 0x29, 0x10, // Usage Maximum (16) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x95, 0x10, // Report Count (16) + 0x75, 0x01, // Report Size (1) + 0x81, 0x02, // Input (Data, Variable, Absolute) + + // --- Hat Switch (D-pad) --- + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x39, // Usage (Hat Switch) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x07, // Logical Maximum (7) + 0x35, 0x00, // Physical Minimum (0) + 0x46, 0x3B, 0x01, // Physical Maximum (315) + 0x65, 0x14, // Unit (Degrees) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x81, 0x42, // Input (Data, Variable, Absolute, Null State) + + // --- Hat Switch Padding (4 bits) --- + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x81, 0x01, // Input (Constant) + + // --- Triggers (2x 8-bit) --- + 0x05, 0x02, // Usage Page (Simulation Controls) + 0x09, 0xC5, // Usage (Brake) - Left Trigger + 0x09, 0xC4, // Usage (Accelerator) - Right Trigger + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x02, // Report Count (2) + 0x81, 0x02, // Input (Data, Variable, Absolute) + + // --- Stick Axes (4x 16-bit signed) --- + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x30, // Usage (X) - Left Stick X + 0x09, 0x31, // Usage (Y) - Left Stick Y + 0x09, 0x32, // Usage (Z) - Right Stick X + 0x09, 0x35, // Usage (Rz) - Right Stick Y + 0x16, 0x00, 0x80, // Logical Minimum (-32768) + 0x26, 0xFF, 0x7F, // Logical Maximum (32767) + 0x75, 0x10, // Report Size (16) + 0x95, 0x04, // Report Count (4) + 0x81, 0x02, // Input (Data, Variable, Absolute) + + 0xC0 // End Collection +}; + +@implementation HIDGamepad + ++ (BOOL)isAvailable { + // Probe by attempting to create a minimal IOHIDUserDevice. + // This will fail when SIP is enabled (entitlement check). + NSDictionary *props = @{ + @kIOHIDVendorIDKey: @(0x1209), // Generic (pid.codes open-source VID) + @kIOHIDProductIDKey: @(0x5853), // Not in SDL's known controller database + @kIOHIDReportDescriptorKey: [NSData dataWithBytes:kHIDReportDescriptor + length:sizeof(kHIDReportDescriptor)], + }; + + IOHIDUserDeviceRef testDevice = IOHIDUserDeviceCreateWithProperties( + kCFAllocatorDefault, + (__bridge CFDictionaryRef)props, + 0 + ); + + if (testDevice) { + CFRelease(testDevice); + return YES; + } + + return NO; +} + +- (instancetype)initWithIndex:(int)index { + self = [super init]; + if (self) { + _gamepadIndex = index; + _isConnected = NO; + _hidDevice = NULL; + _hidQueue = nil; + } + return self; +} + +- (void)dealloc { + [self disconnect]; + [super dealloc]; +} + +- (BOOL)createDevice { + if (_hidDevice) { + return YES; + } + + NSString *queueLabel = [NSString stringWithFormat:@"com.sunshine.hid.gamepad.%d", _gamepadIndex]; + dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class( + DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0); + _hidQueue = dispatch_queue_create([queueLabel UTF8String], attr); + + NSDictionary *props = @{ + @kIOHIDVendorIDKey: @(0x1209), // Generic (pid.codes open-source VID) + @kIOHIDProductIDKey: @(0x5853), // Not in SDL's known controller database + @kIOHIDManufacturerKey: @"Sunshine Virtual Gamepad", + @kIOHIDProductKey: [NSString stringWithFormat:@"Sunshine Gamepad %d", _gamepadIndex], + @kIOHIDSerialNumberKey: [NSString stringWithFormat:@"SUNSHINE-%d", _gamepadIndex], + @kIOHIDTransportKey: @"USB", + @kIOHIDReportDescriptorKey: [NSData dataWithBytes:kHIDReportDescriptor + length:sizeof(kHIDReportDescriptor)], + }; + + _hidDevice = IOHIDUserDeviceCreateWithProperties( + kCFAllocatorDefault, + (__bridge CFDictionaryRef)props, + 0 + ); + + if (!_hidDevice) { + NSLog(@"[HIDGamepad] Failed to create IOHIDUserDevice for gamepad %d", _gamepadIndex); + _hidQueue = nil; + return NO; + } + + // Set up dispatch queue and activate the device + IOHIDUserDeviceSetDispatchQueue(_hidDevice, _hidQueue); + IOHIDUserDeviceActivate(_hidDevice); + + _isConnected = YES; + + // Send initial neutral state + HIDGamepadReport report = {0}; + report.reportId = 0x01; + report.hatSwitch = HAT_NONE; + + IOReturn result = IOHIDUserDeviceHandleReportWithTimeStamp( + _hidDevice, mach_absolute_time(), + (const uint8_t *)&report, sizeof(report) + ); + if (result != kIOReturnSuccess) { + NSLog(@"[HIDGamepad] Warning: failed to send initial report for gamepad %d (0x%x)", _gamepadIndex, result); + } + + NSLog(@"[HIDGamepad] Gamepad %d created successfully (IOHIDUserDevice)", _gamepadIndex); + return YES; +} + +/** + * Converts Sunshine d-pad button flags to HID hat switch value. + */ +static uint8_t dpadToHatSwitch(uint32_t buttons) { + BOOL up = (buttons & SF_DPAD_UP) != 0; + BOOL down = (buttons & SF_DPAD_DOWN) != 0; + BOOL left = (buttons & SF_DPAD_LEFT) != 0; + BOOL right = (buttons & SF_DPAD_RIGHT) != 0; + + if (up && right) return HAT_NE; + if (up && left) return HAT_NW; + if (down && right) return HAT_SE; + if (down && left) return HAT_SW; + if (up) return HAT_N; + if (right) return HAT_E; + if (down) return HAT_S; + if (left) return HAT_W; + return HAT_NONE; +} + +/** + * Maps Sunshine's 32-bit button flags to the 16-bit HID button field. + * + * HID button layout: + * bit 0: A bit 4: LB bit 8: L3 + * bit 1: B bit 5: RB bit 9: R3 + * bit 2: X bit 6: Back bit 10: Home + * bit 3: Y bit 7: Start bits 11-15: reserved + */ +static uint16_t mapButtons(uint32_t sf) { + uint16_t hid = 0; + if (sf & SF_A) hid |= (1 << 0); + if (sf & SF_B) hid |= (1 << 1); + if (sf & SF_X) hid |= (1 << 2); + if (sf & SF_Y) hid |= (1 << 3); + if (sf & SF_LEFT_BUTTON) hid |= (1 << 4); + if (sf & SF_RIGHT_BUTTON) hid |= (1 << 5); + if (sf & SF_BACK) hid |= (1 << 6); + if (sf & SF_START) hid |= (1 << 7); + if (sf & SF_LEFT_STICK) hid |= (1 << 8); + if (sf & SF_RIGHT_STICK) hid |= (1 << 9); + if (sf & SF_HOME) hid |= (1 << 10); + return hid; +} + +- (void)updateState:(uint32_t)buttons + leftStickX:(int16_t)lsX + leftStickY:(int16_t)lsY + rightStickX:(int16_t)rsX + rightStickY:(int16_t)rsY + leftTrigger:(uint8_t)lt + rightTrigger:(uint8_t)rt { + + if (!_isConnected || !_hidDevice) { + return; + } + + HIDGamepadReport report; + report.reportId = 0x01; + report.buttons = mapButtons(buttons); + report.hatSwitch = dpadToHatSwitch(buttons); + report.leftTrigger = lt; + report.rightTrigger = rt; + report.leftStickX = lsX; + report.leftStickY = lsY; + report.rightStickX = rsX; + report.rightStickY = rsY; + + IOReturn result = IOHIDUserDeviceHandleReportWithTimeStamp( + _hidDevice, mach_absolute_time(), + (const uint8_t *)&report, sizeof(report) + ); + if (result != kIOReturnSuccess) { + NSLog(@"[HIDGamepad] Failed to send report for gamepad %d (0x%x)", _gamepadIndex, result); + } +} + +- (void)disconnect { + if (!_hidDevice) { + return; + } + + _isConnected = NO; + + IOHIDUserDeviceRef device = _hidDevice; + dispatch_queue_t queue = _hidQueue; + int index = _gamepadIndex; + + _hidDevice = NULL; + + if (queue) { + // Cancel the device and release in the cancel handler + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + IOHIDUserDeviceSetCancelHandler(device, ^{ + CFRelease(device); + dispatch_semaphore_signal(sem); + }); + IOHIDUserDeviceCancel(device); + dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC)); + _hidQueue = nil; + } else { + CFRelease(device); + } + + NSLog(@"[HIDGamepad] Gamepad %d disconnected", index); +} + +@end diff --git a/src/platform/macos/input.cpp b/src/platform/macos/input.mm similarity index 82% rename from src/platform/macos/input.cpp rename to src/platform/macos/input.mm index 6eed2c1d365..063abfbc2d9 100644 --- a/src/platform/macos/input.cpp +++ b/src/platform/macos/input.mm @@ -1,6 +1,11 @@ /** - * @file src/platform/macos/input.cpp + * @file src/platform/macos/input.mm * @brief Definitions for macOS input handling. + * + * Compiled as Objective-C++ (was input.cpp) so the gamepad path can + * hold a strong reference to HIDGamepad — the virtual IOHIDUserDevice + * wrapper in hid_gamepad.{h,m} — without going through a C bridge. + * Mouse/keyboard injection paths are unchanged. */ // standard includes #include @@ -22,6 +27,7 @@ #include "src/input.h" #include "src/logging.h" #include "src/platform/common.h" +#include "src/platform/macos/hid_gamepad.h" #include "src/utility.h" /** @@ -37,6 +43,14 @@ namespace platf { constexpr double DEFAULT_SCROLLWHEEL_SCALING = 0.3125; constexpr int DEFAULT_SCROLL_LINES_PER_DETENT = 5; + // MAX_GAMEPADS comes from src/platform/common.h (currently 16, matching + // Windows ViGEm + Linux uinput backends). Each slot owns one + // IOHIDUserDevice-backed virtual gamepad. HID device creation can only + // succeed when the user has booted with AMFI bypassed + // (`sudo nvram boot-args="amfi_get_out_of_my_way=1"`); otherwise + // alloc_gamepad reports failure and the gamepad is unsupported for + // this session. + struct macos_input_t { public: CGDirectDisplayID display {}; @@ -53,6 +67,13 @@ namespace platf { int scroll_lines_per_detent {DEFAULT_SCROLL_LINES_PER_DETENT}; bool mouse_down[3] {}; // mouse button status std::chrono::steady_clock::steady_clock::time_point last_mouse_event[3][2]; // timestamp of last mouse events + + // gamepad related stuff. Each slot is either nil (free) or holds an + // HIDGamepad whose underlying IOHIDUserDevice is currently published. + // Probed once at input() construction so the alloc path doesn't pay + // the IOHIDUserDeviceCreate cost on every connect attempt. + HIDGamepad *gamepads[MAX_GAMEPADS] {}; + bool hid_gamepad_available {}; }; // A struct to hold a Windows keycode to Mac virtual keycode mapping. @@ -343,16 +364,66 @@ const KeyCodeMap kKeyCodesMap[] = { } int alloc_gamepad(input_t &input, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue) { - BOOST_LOG(info) << "alloc_gamepad: Gamepad not yet implemented for MacOS."sv; - return -1; + auto macos_input = static_cast(input.get()); + if (!macos_input->hid_gamepad_available) { + BOOST_LOG(warning) << "alloc_gamepad: IOHIDUserDevice virtual gamepad not available. Boot with `nvram boot-args=\"amfi_get_out_of_my_way=1\"` to enable host-side gamepad support on macOS."sv; + return -1; + } + + // The platform interface expects alloc_gamepad to return 0 on + // success (per the docstring in src/platform/common.h:831). + // src/input.cpp dispatches subsequent free_gamepad / gamepad_update + // by id.globalIndex (Sunshine allocates that index from a bitmask + // before calling us), so we key our HIDGamepad slots on the same + // index. Returning a slot >0 here would be treated as failure by + // `if (platf::alloc_gamepad(...))` in src/input.cpp:897. + const int slot = id.globalIndex; + if (slot < 0 || slot >= MAX_GAMEPADS) { + BOOST_LOG(error) << "alloc_gamepad: invalid globalIndex "sv << slot + << " (must be in [0, "sv << MAX_GAMEPADS << "))"sv; + return -1; + } + if (macos_input->gamepads[slot] != nil) { + BOOST_LOG(warning) << "alloc_gamepad: slot "sv << slot + << " already allocated; refusing to clobber"sv; + return -1; + } + + HIDGamepad *pad = [[HIDGamepad alloc] initWithIndex:slot]; + if (![pad createDevice]) { + [pad release]; + BOOST_LOG(error) << "alloc_gamepad: HIDGamepad createDevice failed for slot "sv << slot; + return -1; + } + + macos_input->gamepads[slot] = pad; + BOOST_LOG(info) << "alloc_gamepad: slot "sv << slot << " allocated (IOHIDUserDevice virtual gamepad)"sv; + return 0; } void free_gamepad(input_t &input, int nr) { - BOOST_LOG(info) << "free_gamepad: Gamepad not yet implemented for MacOS."sv; + auto macos_input = static_cast(input.get()); + if (nr < 0 || nr >= MAX_GAMEPADS || macos_input->gamepads[nr] == nil) { + return; + } + [macos_input->gamepads[nr] disconnect]; + [macos_input->gamepads[nr] release]; + macos_input->gamepads[nr] = nil; + BOOST_LOG(info) << "free_gamepad: slot "sv << nr << " released"sv; } void gamepad_update(input_t &input, int nr, const gamepad_state_t &gamepad_state) { - BOOST_LOG(info) << "gamepad: Gamepad not yet implemented for MacOS."sv; + auto macos_input = static_cast(input.get()); + if (nr < 0 || nr >= MAX_GAMEPADS || macos_input->gamepads[nr] == nil) { + return; + } + [macos_input->gamepads[nr] updateState:gamepad_state.buttonFlags + leftStickX:gamepad_state.lsX + leftStickY:gamepad_state.lsY + rightStickX:gamepad_state.rsX + rightStickY:gamepad_state.rsY + leftTrigger:gamepad_state.lt + rightTrigger:gamepad_state.rt]; } // returns current mouse location: @@ -656,11 +727,33 @@ const KeyCodeMap kKeyCodesMap[] = { BOOST_LOG(debug) << "macOS scroll speed: com.apple.scrollwheel.scaling="sv << macos_input->scrollwheel_scaling << ", lines per detent="sv << macos_input->scroll_lines_per_detent << ", pixels per line="sv << CGEventSourceGetPixelsPerLine(macos_input->source); BOOST_LOG(debug) << "Display "sv << macos_input->display << ", pixel dimension: " << CGDisplayPixelsWide(macos_input->display) << "x"sv << CGDisplayPixelsHigh(macos_input->display); + // Probe HIDGamepad availability once at startup. The probe attempts + // an IOHIDUserDevice creation; it succeeds iff AMFI was disabled at + // boot (`nvram boot-args="amfi_get_out_of_my_way=1"`). Subsequent + // alloc_gamepad calls reuse this flag rather than re-probing. + macos_input->hid_gamepad_available = [HIDGamepad isAvailable] == YES; + if (macos_input->hid_gamepad_available) { + BOOST_LOG(info) << "IOHIDUserDevice virtual gamepad support is available — host-side gamepad will be advertised to clients"sv; + } else { + BOOST_LOG(info) << "IOHIDUserDevice virtual gamepad not available (AMFI enabled). To enable host-side gamepad support: `sudo nvram boot-args=\"amfi_get_out_of_my_way=1\"` and reboot"sv; + } + return result; } void freeInput(void *p) { - const auto *input = static_cast(p); + auto *input = static_cast(p); + + // Release any still-allocated virtual gamepads. Each disconnect + // tears down the IOHIDUserDevice synchronously (bounded 2s + // semaphore wait inside HIDGamepad::disconnect). + for (int i = 0; i < MAX_GAMEPADS; i++) { + if (input->gamepads[i] != nil) { + [input->gamepads[i] disconnect]; + [input->gamepads[i] release]; + input->gamepads[i] = nil; + } + } CFRelease(input->source); CFRelease(input->keyboard_source); @@ -670,10 +763,29 @@ const KeyCodeMap kKeyCodesMap[] = { } std::vector &supported_gamepads(input_t *input) { - static std::vector gamepads { - supported_gamepad_t {"", false, "gamepads.macos_not_implemented"} - }; - + // The two distinct states we report: AMFI-disabled (IOHIDUserDevice + // works → virtual gamepad available) vs AMFI-enabled (will refuse to + // create the device). The string keys plug into the web UI's + // translation table; only the first matters since stock Moonlight + // doesn't pass a gamepad type, it just calls alloc_gamepad and the + // host decides. + static bool initialized = false; + static std::vector gamepads; + if (!initialized) { + // is_enabled mirrors the actual probe result. When AMFI is still + // enabled, alloc_gamepad will always return -1, so report this + // entry as disabled (with a translation key the web UI can + // surface to explain why) rather than offering a controller type + // that will immediately fail. Matches the pattern Linux uses + // when uinput isn't available. + const BOOL hid_ok = [HIDGamepad isAvailable]; + gamepads.push_back({ + "hid", + hid_ok ? true : false, + hid_ok ? "gamepads.macos_hid" : "gamepads.macos_amfi_required", + }); + initialized = true; + } return gamepads; } @@ -682,6 +794,12 @@ const KeyCodeMap kKeyCodesMap[] = { * @return Capability flags. */ platform_caps::caps_t get_capabilities() { + // The IOHIDUserDevice-backed virtual gamepad we expose is a basic + // Xbox-style controller (16 buttons, hat, 2 triggers, 4 stick axes + // — see hid_gamepad.m's report descriptor). We do not expose a + // controller touchpad, gyro, or pen/touch surface, so no capability + // flags are set; the standard buttons+sticks+triggers are implied + // by alloc_gamepad succeeding. return 0; } } // namespace platf