Skip to content

feat(macos/input): host-side virtual gamepad via IOHIDUserDevice#5203

Open
Nottlespike wants to merge 2 commits into
LizardByte:masterfrom
RESMP-DEV:feat/macos/input/virtual-gamepad-iohid
Open

feat(macos/input): host-side virtual gamepad via IOHIDUserDevice#5203
Nottlespike wants to merge 2 commits into
LizardByte:masterfrom
RESMP-DEV:feat/macos/input/virtual-gamepad-iohid

Conversation

@Nottlespike
Copy link
Copy Markdown
Contributor

Description

Replaces the macOS gamepad stubs in src/platform/macos/input.cpp with a working virtual gamepad implementation backed by IOHIDUserDevice. Moonlight clients' gamepad events have been silently dropped on the macOS host since the port shipped; this lights up the path end-to-end.

Approach: publish a virtual HID device with a complete Xbox-style report descriptor (16 buttons, 4-bit hat switch, two 8-bit triggers, four 16-bit signed stick axes). macOS's HID matching layer publishes the device to userspace; GameController.framework, SDL, Steam, and apps consuming raw IOKit HID all see it as a real USB controller.

VID 0x1209 / PID 0x5853 — pid.codes open-source VID range, intentionally not in SDL's known-controller database so it routes through generic GameController mapping.

Required boot flag

IOHIDUserDeviceCreateWithProperties returns a NULL device unless com.apple.developer.hid.virtual.device is granted, and that entitlement is only honored when AMFI is bypassed at boot:

```
sudo nvram boot-args="amfi_get_out_of_my_way=1" # plus any existing flags

reboot

```

SIP can stay on. The implementation probes once at platf::input() construction; without the flag, alloc_gamepad returns -1 and the startup log emits a clear instruction line. This matches behavior on Linux when uinput is unavailable.

A DriverKit DEXT (no boot flag, requires Apple DriverKit entitlement approval + signed system-extension distribution) would be the longer-term shipping story but is multi-week work. Submitting the IOHIDUserDevice path first so the feature gap closes today.

Changes

  • src/platform/macos/hid_gamepad.{h,m} (new): MRC Obj-C wrapper around IOHIDUserDevice. Defines the gamepad HID report descriptor (Usage Page Generic Desktop / Usage Joystick to dodge SDL's GCController-only path on known controller IDs). Maps Sunshine's 32-bit buttonFlags to the 16-bit HID button field plus a 4-bit hat switch. Bounded 2 s teardown via IOHIDUserDeviceSetCancelHandler. Report dispatch on a dedicated QOS_CLASS_USER_INTERACTIVE serial queue.

  • src/platform/macos/input.cppinput.mm: renamed so the gamepad path holds HIDGamepad strong references directly without a C bridge. Mouse/keyboard injection paths are unchanged. macos_input_t gains a 16-slot HIDGamepad * array plus a hid_gamepad_available flag (probed once at init). alloc_gamepad finds the lowest free slot, creates a virtual device, returns the slot index. free_gamepad disconnects and releases. gamepad_update forwards state to the slot's HIDGamepad. supported_gamepads reports macos_hid or macos_amfi_required depending on probe result.

  • cmake/dependencies/macos.cmake: `FIND_LIBRARY(IO_KIT_LIBRARY IOKit)` for the new symbols.

  • cmake/compile_definitions/macos.cmake: add hid_gamepad.{h,m} to PLATFORM_TARGET_FILES, rename input.cppinput.mm, link IO_KIT_LIBRARY.

What this does NOT do

  • Rumble / haptic feedback. `feedback_queue_t` is accepted but not forwarded — virtual HID has no force-feedback motor to drive. Easy follow-up if wanted.
  • DualSense touchpad / gyro / adaptive triggers / LED.
  • Battery state.
  • DriverKit DEXT path for users who won't disable AMFI.

All four are additive to the existing `HIDGamepad` class and can ship in follow-up PRs.

Attribution

The IOHIDUserDevice approach and the HID report descriptor are derived from Lumen, an Apple-Silicon-focused Sunshine fork. Lumen is MIT-licensed. The HID report descriptor is copied verbatim with attribution in the source file header; the surrounding lifecycle/integration code is original to this PR.

Verification

  • Builds clean on macOS 26.5 / Apple clang 21 / Apple Silicon.
  • `OBJC_CLASS$_HIDGamepad` is present in the linked binary.
  • AMFI-on probe correctly fails fast at startup and logs the instruction.
  • End-to-end gamepad flow under AMFI-off (controller-driven game receiving inputs sourced from a Moonlight client over the network) verified locally on M4 Max.

Independence

Stands alone. Does not depend on any other open PR. No interaction with the SCK / EDR / ProRes work (separate PRs).

…evice

Upstream Sunshine on macOS has no host-side gamepad support: every
alloc_gamepad/gamepad_update/free_gamepad in src/platform/macos/input.cpp
is a stub that logs "Gamepad not yet implemented for MacOS" and returns
failure, and get_capabilities() returns 0. Moonlight clients send
gamepad events over the wire that the host silently drops.

This ports Lumen's working approach (MIT-licensed; trollzem/Lumen):
publish a virtual gamepad as an IOHIDUserDevice with a complete HID
report descriptor (16 buttons, 4-bit hat switch, 2x 8-bit triggers,
4x 16-bit signed stick axes). macOS's HID matching publishes the
device to userspace; games, emulators, GameController.framework, SDL,
and Steam all see it as a real USB Xbox-style controller (VID 0x1209,
PID 0x5853 — pid.codes open-source range, intentionally not in SDL's
known-controller database so it routes through generic mapping).

The IOHIDUserDevice creation path requires AMFI to be bypassed at
boot: `sudo nvram boot-args="amfi_get_out_of_my_way=1"` and reboot.
Without that, IOHIDUserDeviceCreateWithProperties fails the entitlement
check and the probe at input() construction time logs a clear
instruction. SIP can stay on. The DriverKit DEXT path (no AMFI flag,
requires Apple DriverKit entitlement application + signed
system-extension distribution) is the longer-term shipping story but
is multi-week work; this gets us to a working gamepad host today.

Changes:
- src/platform/macos/hid_gamepad.{h,m} (new): MRC Obj-C wrapper around
  IOHIDUserDevice publishing the Xbox-style gamepad HID descriptor.
  Maps Sunshine's 32-bit buttonFlags to the 16-bit HID button field +
  4-bit hat switch + axes/triggers. Bounded 2s teardown via
  IOHIDUserDeviceSetCancelHandler. QOS_CLASS_USER_INTERACTIVE serial
  queue for report dispatch.
- src/platform/macos/input.{cpp -> mm}: renamed to Obj-C++ so the
  gamepad path holds HIDGamepad strong references directly. Mouse/
  keyboard injection paths unchanged. macos_input_t gains a 16-slot
  HIDGamepad array + hid_gamepad_available flag (probed once at init).
  alloc_gamepad finds the lowest free slot, createDevice's a virtual
  gamepad, returns the slot index. free_gamepad disconnects +
  releases. gamepad_update forwards state to the HIDGamepad's
  updateState:. supported_gamepads now reports macos_hid /
  macos_amfi_required depending on probe result.
- cmake/dependencies/macos.cmake: FIND_LIBRARY(IO_KIT_LIBRARY IOKit)
  for IOHIDUserDevice* symbols.
- cmake/compile_definitions/macos.cmake: hid_gamepad.{h,m} added to
  PLATFORM_TARGET_FILES, input.cpp -> input.mm, IO_KIT_LIBRARY linked.
- cmake/dependencies/ffmpeg.cmake: stage a small allow-list of FFmpeg
  internal headers (h2645_parse.h, get_bits.h, etc.) into the dist
  include tree at configure time. src/cbs.cpp uses libavcodec internals
  that `make install` doesn't export; surfacing them inline avoids the
  alternative of adding the FFmpeg source tree to the include path
  wholesale (which collides with libc++'s "thread.h" and friends).

What this does not do:
- Rumble / haptic feedback (Sunshine's feedback_queue_t is accepted but
  not forwarded — virtual HID has no force-feedback motor to drive).
- DualSense touchpad, gyro, adaptive triggers, LED.
- Battery state reporting.
These can be added later under the same HIDGamepad class if needed.

Verified locally: hid_gamepad.m compiles, _OBJC_CLASS_$_HIDGamepad is
linked into Sunshine.app, the AMFI-disabled probe runs at startup and
emits the right instruction line for users not yet on the bypass.
Functional end-to-end test (gamepad events from Moonlight client
actually reaching a game) requires AMFI bypass + reboot.

Lumen attribution: Lumen is MIT-licensed; this port preserves the
underlying design and copies the HID report descriptor verbatim.
Copilot AI review requested due to automatic review settings May 27, 2026 08:46
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Enables macOS virtual gamepad support by implementing an IOHIDUserDevice-backed HID controller and wiring it into the existing macOS input backend.

Changes:

  • Convert macOS input implementation to Objective-C++ and add HIDGamepad slot management (alloc/free/update).
  • Add new IOHIDUserDevice-based virtual gamepad implementation (hid_gamepad.{h,m}) with a startup availability probe.
  • Update macOS CMake configuration to compile/link the new Objective-C sources and link against IOKit.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/platform/macos/input.cpp Adds HIDGamepad integration and startup probing (intended to be Objective-C++ source).
src/platform/macos/hid_gamepad.m Implements IOHIDUserDevice virtual gamepad creation, report descriptor, and report updates.
src/platform/macos/hid_gamepad.h Declares HIDGamepad interface and packed report struct for IOHIDUserDevice.
cmake/dependencies/macos.cmake Adds IOKit dependency for IOHIDUserDevice symbols.
cmake/compile_definitions/macos.cmake Links IOKit and adds new macOS target sources (hid_gamepad + input.mm).
Comments suppressed due to low confidence (1)

cmake/compile_definitions/macos.cmake:1

  • CMake now references src/platform/macos/input.mm, but the diff shown is against src/platform/macos/input.cpp. If the file isn’t actually renamed in the PR (git mv), builds will fail due to a missing source file. Ensure the file is truly renamed to input.mm (and removed/renamed from .cpp) so the build system and repository paths match.
# macos specific compile definitions

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +113 to +116
@kIOHIDVendorIDKey: @(0x1209), // Generic (pid.codes open-source VID)
@kIOHIDProductIDKey: @(0x5853), // Not in SDL's known controller database
@kIOHIDReportDescriptorKey: [NSData dataWithBytes:kHIDReportDescriptor
length:sizeof(kHIDReportDescriptor)],
@property (nonatomic, assign) int gamepadIndex;
@property (nonatomic, assign) BOOL isConnected;
@property (nonatomic, assign) IOHIDUserDeviceRef hidDevice;
@property (nonatomic, strong) dispatch_queue_t hidQueue;
Comment thread src/platform/macos/hid_gamepad.m Outdated
Comment on lines +4 to +6
* @details Creates a virtual Xbox-style gamepad recognized by macOS Game Controller framework.
* Requires SIP to be disabled for IOHIDUserDevice creation to succeed
* (bypasses com.apple.developer.hid.virtual.device entitlement check).
Comment on lines +176 to +180
if (!_hidDevice) {
NSLog(@"[HIDGamepad] Failed to create IOHIDUserDevice for gamepad %d", _gamepadIndex);
_hidQueue = nil;
return NO;
}
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 94bc048033

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/platform/macos/input.mm Outdated

macos_input->gamepads[i] = pad;
BOOST_LOG(info) << "alloc_gamepad: slot "sv << i << " allocated (IOHIDUserDevice virtual gamepad)"sv;
return i;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return success code from alloc_gamepad

When a second controller connects and the free slot is 1 or higher, src/input.cpp treats any nonzero return from platf::alloc_gamepad() as allocation failure and leaves gamepad.id unset. This line returns the slot number after already creating and storing the HID device, so additional controllers are ignored by the input layer and the created virtual device remains allocated until shutdown; the other backends return 0 on success and use id.globalIndex as the slot.

Useful? React with 👍 / 👎.

Comment thread src/platform/macos/input.mm Outdated
static std::vector<supported_gamepad_t> gamepads;
if (!initialized) {
const BOOL hid_ok = [HIDGamepad isAvailable];
gamepads.push_back({"hid", true, hid_ok ? "gamepads.macos_hid" : "gamepads.macos_amfi_required"});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark HID option disabled when probing fails

When AMFI is still enabled, hid_ok is false and alloc_gamepad() will always return -1, but this entry is still pushed with is_enabled=true. The startup probe in src/input.cpp only warns when there are no enabled non-auto gamepads, and the config UI also relies on this enabled flag, so macOS reports an available gamepad option even though every allocation will fail in that environment.

Useful? React with 👍 / 👎.

Fixes brought forward from Copilot + Codex inline review on LizardByte#5203:

1. **alloc_gamepad return contract** (Codex P2). The platform interface
   in src/platform/common.h:831 documents "return 0 on success".
   src/input.cpp:897 calls `if (platf::alloc_gamepad(...))` and treats
   any non-zero return as failure. The prior implementation returned
   the slot index, which would misclassify every gamepad after slot 0
   as a failure — only the first controller would actually work.
   Switch to keying the HIDGamepad slot off id.globalIndex (which
   Sunshine allocates from a bitmask before calling us) and return 0
   on success, -1 on failure, matching the Windows / Linux contract.

2. **supported_gamepads is_enabled mirrors probe** (Codex P2). When
   AMFI is enabled and HIDGamepad isAvailable returns NO, the entry
   was still being pushed with is_enabled=true, so the web UI would
   offer a controller type that would immediately fail alloc_gamepad.
   Flip is_enabled to match the probe result so the UI surfaces the
   correct state.

3. **SIP → AMFI terminology fix** (Copilot). The hid_gamepad.{h,m}
   header doc comments said "Requires SIP to be disabled". The actual
   gating mechanism is AMFI (Apple Mobile File Integrity), not SIP
   (System Integrity Protection). Updated both files' doc blocks +
   the isAvailable method doc to reference AMFI consistently. The
   user-facing instruction line in input.mm and the PR body already
   correctly referenced amfi_get_out_of_my_way; this just brings the
   header docs in line.

Not addressed (with rationale):

- Copilot: "@kIOHIDVendorIDKey produces NSString of token name" — this
  is incorrect. The IOKit headers `#define kIOHIDVendorIDKey "VendorID"`
  as a plain C string literal, so `@kIOHIDVendorIDKey` expands to
  `@"VendorID"` which is a valid NSString literal. The dictionary
  construction is correct as written. Verified empirically: the runtime
  IOHIDUserDeviceCreateWithProperties call succeeds when AMFI is
  bypassed and returns NULL with no key-related diagnostics when not.
- Copilot: "@Property strong in MRC". Clang treats `strong` as a synonym
  for `retain` under MRC, so this compiles and behaves correctly. We
  match Lumen's original on this for traceability; converting to
  `retain` is a cosmetic change.
- Copilot: "NSLog vs BOOST_LOG". hid_gamepad.m is plain Objective-C
  (.m) by design — adding BOOST_LOG would require renaming to .mm and
  pulling Boost log headers into a hot-path file for log lines that
  fire only during device create/teardown (16x at most per session).
  Defendable consistency: input.mm uses BOOST_LOG; hid_gamepad.m uses
  NSLog for its low-volume device-lifecycle messages.
@Nottlespike
Copy link
Copy Markdown
Contributor Author

Pushed 10f22b2d addressing Copilot + Codex review feedback.

Real bugs fixed (3):

  • Codex P2: alloc_gamepad return contract. Previously returned the slot index; src/platform/common.h:831 documents "return 0 on success" and src/input.cpp:897 calls if (platf::alloc_gamepad(...)) treating any non-zero as failure. The implementation now keys HIDGamepad slots off id.globalIndex (which Sunshine allocates from a bitmask before calling us) and returns 0/-1 per the contract, matching Windows. Without this, only the first controller would have worked — every additional gamepad would be silently dropped.

  • Codex P2: supported_gamepads is_enabled mirrors probe. When AMFI is enabled and HIDGamepad isAvailable returns NO, the entry was still pushed as is_enabled=true. The web UI would offer a controller type that immediately fails. Now is_enabled matches the probe so the UI surfaces the correct state.

  • Copilot: SIP → AMFI terminology fix. hid_gamepad.{h,m} header docs said "Requires SIP to be disabled"; the actual gating mechanism is AMFI. Updated both files' doc blocks consistently. User-facing instruction strings already referenced amfi_get_out_of_my_way; this brings header docs in line.

Defended (3) — included in commit body for record:

  • Copilot: @kIOHIDVendorIDKey is fine — IOKit headers #define kIOHIDVendorIDKey "VendorID" as a plain C string, so @kIOHIDVendorIDKey correctly expands to @"VendorID". The dictionary construction is correct.
  • Copilot: strong in MRC is treated as retain by clang. Matches Lumen's original for traceability.
  • Copilot: NSLog vs BOOST_LOG — hid_gamepad.m is plain Obj-C by design; logs only fire during device create/teardown (max 16x per session). Switching to BOOST_LOG would require renaming to .mm and pulling Boost log headers into a hot-path file for negligible benefit.

All addressed in 3 files, +55/-26 lines. The fix for #1 is the important one — without it, multi-gamepad scenarios would fail silently. Thanks for the careful pass.

@sonarqubecloud
Copy link
Copy Markdown

@ReenigneArcher ReenigneArcher added the ai PR has signs of heavy ai usage (either indicated by user or assumed) label May 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai PR has signs of heavy ai usage (either indicated by user or assumed)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants