feat(macos): virtual HID gamepad emulating Razer Serval#5171
Conversation
Add gamepad support to the macOS platform backend by publishing a virtual HID device via IOHIDUserDeviceCreate, translating Moonlight controller state into HID reports. The device emulates a Razer Serval (VID 0x1532 / PID 0x0900) because it has a built-in SDL GameControllerDB entry with analog triggers under a vendor ID that SDL's HIDAPI driver never claims. As a result the controller is auto-recognized as a gamepad (isGameController/is_gamepad=1) everywhere — native Steam (no setup prompt), SDL, Wine DirectInput, and winexinput (XInput games under CrossOver) — while still exposing full analog triggers. Report mapping: - HID descriptor: 11 buttons, HAT d-pad, 6x 16-bit axes. Axis HID usages are assigned so SDL's usage-sorted indices match the Serval mapping (X->LX, Y->LY, Z->RX, Rx->RY, Ry->RT, Rz->LT); the report struct field order matches. - Button bits follow the Serval layout (a:b0 b:b1 x:b2 y:b3 ...). - Triggers scale 0..255 -> full signed range so SDL reads them as full-range analog axes. Stick Y axes are negated, with INT16_MIN clamped to INT16_MAX so a fully-deflected axis does not wrap to the wrong extreme. - The state->report mapping is a pure function in input_gamepad.h so it can be unit-tested without a real IOHIDUserDevice (which needs a restricted entitlement and cannot run in CI). Lifecycle & threading: - Device teardown lives in ~macos_gamepad_t (RAII): the object owns the run-loop thread and releases the device only after joining it. The run loop is stopped via a queued CFRunLoopPerformBlock so the stop cannot be lost before CFRunLoopRun() starts, and is retained for the object's lifetime. - The gamepads array is guarded by a mutex, since gamepad_update and free_gamepad can run concurrently on task_pool workers; free detaches under the lock and destroys outside it so a thread join never blocks updates. - alloc_gamepad returns -1 instead of letting a thread-creation exception escape; the HID run-loop thread is named for debuggability. Code signing: - Add src_assets/macos/build/sunshine.entitlements granting com.apple.hid.manager.user-access-device (required by IOHIDUserDeviceCreate) and wire it into the .app codesign step. - Document the (Apple-restricted) entitlement, the input-only limitation (no rumble/LED/motion feedback), and the local ad-hoc + AMFI dev workaround in docs/building.md. Also guards a null CGDisplayCopyDisplayMode (headless/CI hosts) and includes unit tests for the gamepad state-to-HID-report mapping.
Address the SonarCloud quality gate (0 new code smells) and the failing Read the Docs build on the gamepad PR: - Replace #define key macros with inline CFSTR literals (S5028) and move the IOHIDUserDevice extern block below the includes so all #includes are grouped (S954). - Use std::scoped_lock instead of std::lock_guard with explicit template args (S5997, S6012); std::jthread instead of std::thread (S6168); std::format for the thread name (S6185); catch std::system_error rather than std::exception (S1181); drop the MAX_GAMEPADS member that shadowed platf::MAX_GAMEPADS (S1117). - Extract compute_dpad_hat() to cut map_gamepad_state_to_hid_report cognitive complexity below 25 (S3776) and remove the nested ternaries (S3358); reword comments flagged as commented-out code (S125). - Convert the macOS definitions of the shared platf:: gamepad functions to plain comments. They are documented in src/platform/common.h, and Doxygen merges @param blocks across every platform definition, which produced "too many @param" errors under WARN_AS_ERROR. No behavior change; the gamepad unit tests still pass.
Bundle ReportBundle size has no change ✅ |
|
Sorry, jthread doesn't work on older macOS versions. You can use a nosonar comment, like https://github.com/LizardByte/tray/blob/fc343dcea4061b06a81bfa72dfe19626f549edcd/tests/unit/test_tray.cpp#L119 |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #5171 +/- ##
==========================================
- Coverage 17.86% 17.25% -0.61%
==========================================
Files 111 111
Lines 24129 24268 +139
Branches 10675 10720 +45
==========================================
- Hits 4310 4187 -123
+ Misses 15610 14935 -675
- Partials 4209 5146 +937
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 31 files with indirect coverage changes Continue to review full report in Codecov by Sentry.
|
Per maintainer review: std::jthread is unavailable in the libc++ that Sunshine's supported toolchains ship (it depends on the availability-gated C++20 sync library). Revert the two std::jthread uses to std::thread and suppress the SonarCloud S6168 suggestion with NOSONAR, matching the existing convention in tests/unit/test_confighttp.cpp. The destructor already performs an explicit join (required by std::thread), so there is no behavior change.
ReenigneArcher
left a comment
There was a problem hiding this comment.
Thank you for this PR! It's a highly requested feature for a long time.
Looks like you can remove these lines:
Sunshine/docs/getting_started.md
Lines 308 to 309 in 3c54d5f
- ... and might need to adjust the wording if Xbox or PlayStation cannot be emulated on macOS.
| // Gamepad HID report descriptor — emulates a Razer Serval (VID 0x1532 / | ||
| // PID 0x0900, an Android/PC Bluetooth gamepad). |
There was a problem hiding this comment.
What would be required to emulate standard Xbox and PlayStation gamepads?
There was a problem hiding this comment.
I tried emulating an Xbox gamepad, but it doesn't work properly: Xbox controllers use Microsoft's proprietary GIP protocol instead of HID, so even when the device is detected, its inputs aren't recognised.
PlayStation looks doable since DualSense/DualShock are HID, so that could be the next step — but it's a bigger effort, because I'd have to emulate the full controller protocol (rumble, touchpad, motion, etc.) to do it properly.
So I'd start with the generic gamepad: it already works with a PlayStation controller connected on the client side (I tested with a DualSense), just without the PS glyphs and the extra features.
| input device; without it AMFI kills the process when a controller is | ||
| first connected. | ||
|
|
||
| NOTE: this is an Apple-RESTRICTED entitlement. A Developer ID build must |
There was a problem hiding this comment.
I have to make a change in the apple developer portal for this?
There was a problem hiding this comment.
Honestly, I'm not sure — it isn't documented, and I don't have an Apple Developer ID to test it myself. That's my main concern: without an authorized signing identity, it only works in AMFI-relaxed environments, not on normal user machines.
Could you try signing it with your Developer ID and see if it goes through? If it doesn't, it may be worth asking Apple support whether this entitlement can be authorized for the identity at all.
If that doesn't work, I can try to use the CoreHID framework — but it's Swift-only (so it'd need a bridge from the C++ backend) and it raises the minimum macOS to 15+, so I'm not sure it'd suit the project.
P.S. I picked this up mainly because I'd love to see the feature in Sunshine myself. Fair warning though — I'm a Python dev without much hands-on macOS experience, so thanks for your patience.
There was a problem hiding this comment.
You request entitlements by going to https://developer.apple.com/account/resources/identifiers/list and configuring the identifier (FQDN/namespace). Some entitlements are self-service, they are on the first tab Capabilities, I don't think Sunshine needs any of those. The 3rd tab is Capability Requests where you'll find "HID Virtual Device". You have to fill out a simple form where you just explain how the app will use it.
There was a problem hiding this comment.
Thanks! I submitted the request.
|
I saw the comment about not supporting rumble. I think supporting rumble is pretty important, as well as probably touchpad and gyro. Can we match the features that are available on Linux? |
|
It wasn't my initial goal, but yeah, that seems doable. But it means emulating a DualSense specifically, not the generic gamepad I'm using now. As far as I see, the generic one can't do rumble/touchpad/gyro: macOS only sends rumble to a controller it recognises as real, and touchpad/gyro only surface for controllers the system actually parses. It's a fair chunk of work tho — basically a second backend. So, referring to my previous comment, I think it makes sense to start with the generic approach and then move to the DualSense emulation. But I’m flexible about that. If @ReenigneArcher also thinks that we should deliver DS emulation as well - I can work on that since I planned to do that anyway, just as a separate step. |
Since this has been a lacking feature for the whole existence of Sunshine on macOS, I think I agree. I do wonder if it would make sense to make a virtual input library and then just have Sunshine consume it? Similar to https://github.com/games-on-whales/inputtino ... this isn't much code right now, but it might make it nicer to extend with new types of gamepads and new features like rumble. |
|
OK I don't have a sense of how much more work it is. We will need to find a different ID to use though, Razer is not a good choice. I built this and just did a quick initial test. I found that the mapping is a bit wrong. Right trigger and right stick axes are swapped and possibly a few more buttons are wrong. I was testing from a Steam Deck. |
I don't have such issue on my setup. Can you please share more details about yours? I'll try to repro this bug when I'll have some time |
|
I'm not a fan of the Lumen project, but they did a controller implementation, you may want to take a look at what it was doing. For example, here's the device they created. It also uses a different entitlement |



Description
Adds gamepad support to the macOS platform.
Achieved by publishing a virtual gamepad and translating Moonlight controller state into HID input reports via
IOHIDUserDeviceCreate. No kext, no third-party driver — unlike the prior attempt (#756 ), which depended on an external VirtualHID driver. It emulates a Razer Serval, because it is auto-recognized by SDL/Steam/Wine with working analog triggers.Testing
Setup: host — MacBook Pro M4 (macOS 26.5); client — Steam Deck, used as the Moonlight (v6.1) client in both desktop and gaming modes.
Code signing
IOHIDUserDeviceCreateneeds thecom.apple.hid.manager.user-access-deviceentitlement (added insunshine.entitlementsand wired into the codesign step). It's an Apple-restricted entitlement, so the official signing identity must be authorized for it (documented in docs/building.md).Screenshot
Issues Fixed or Closed
Roadmap Issues
Type of Change
Checklist
AI Usage