Skip to content

feat(macos): virtual HID gamepad emulating Razer Serval#5171

Open
SayHi044 wants to merge 4 commits into
LizardByte:masterfrom
SayHi044:feat/macos-gamepad
Open

feat(macos): virtual HID gamepad emulating Razer Serval#5171
SayHi044 wants to merge 4 commits into
LizardByte:masterfrom
SayHi044:feat/macos-gamepad

Conversation

@SayHi044
Copy link
Copy Markdown

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.

  • Native macOS Steam — verified with No Man's Sky: the controller is auto-recognized with no setup prompt.
  • Windows game via CrossOver (Wine) — verified with Clair Obscur: Expedition 33.
  • SDL probe — with no injected mapping, SDL reports isGameController=YES and applies the built-in mapping.

Code signing
IOHIDUserDeviceCreate needs the com.apple.hid.manager.user-access-device entitlement (added in sunshine.entitlements and 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

  • feat: New feature (non-breaking change which adds functionality)
  • fix: Bug fix (non-breaking change which fixes an issue)
  • docs: Documentation only changes
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
  • refactor: Code change that neither fixes a bug nor adds a feature
  • perf: Code change that improves performance
  • test: Adding missing tests or correcting existing tests
  • build: Changes that affect the build system or external dependencies
  • ci: Changes to CI configuration files and scripts
  • chore: Other changes that don't modify src or test files
  • revert: Reverts a previous commit
  • BREAKING CHANGE: Introduces a breaking change (can be combined with any type above)

Checklist

  • Code follows the style guidelines of this project
  • Code has been self-reviewed
  • Code has been commented, particularly in hard-to-understand areas
  • Code docstring/documentation-blocks for new or existing methods/components have been added or updated
  • Unit tests have been added or updated for any new or modified functionality

AI Usage

  • None: No AI tools were used in creating this PR
  • Light: AI provided minor assistance (formatting, simple suggestions)
  • Moderate: AI helped with code generation or debugging specific parts
  • Heavy: AI generated most or all of the code changes

SayHi044 added 3 commits May 22, 2026 20:56
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.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 22, 2026

Bundle Report

Bundle size has no change ✅

@ReenigneArcher
Copy link
Copy Markdown
Member

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
Copy link
Copy Markdown

codecov Bot commented May 22, 2026

Codecov Report

❌ Patch coverage is 52.09581% with 80 lines in your changes missing coverage. Please review.
✅ Project coverage is 17.25%. Comparing base (3c54d5f) to head (c01a0ab).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/platform/macos/input.cpp 52.09% 68 Missing and 12 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
Archlinux 11.21% <ø> (ø)
FreeBSD-aarch64 ?
FreeBSD-amd64 13.34% <ø> (+<0.01%) ⬆️
Homebrew-ubuntu-22.04 ?
Linux-AppImage 12.13% <ø> (ø)
Windows-AMD64 14.85% <ø> (ø)
Windows-ARM64 13.19% <ø> (-0.02%) ⬇️
macOS-arm64 19.57% <52.09%> (+0.60%) ⬆️
macOS-x86_64 18.96% <51.21%> (+0.62%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/platform/macos/input.cpp 43.82% <52.09%> (+8.50%) ⬆️

... and 31 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 3c54d5f...c01a0ab. Read the comment docs.

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.
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Member

@ReenigneArcher ReenigneArcher left a comment

Choose a reason for hiding this comment

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

Thank you for this PR! It's a highly requested feature for a long time.

Looks like you can remove these lines:

Comment thread docs/building.md
### macOS code signing & entitlements
The macOS virtual gamepad publishes a virtual HID device via `IOHIDUserDeviceCreate`,
which requires the `com.apple.hid.manager.user-access-device` entitlement. Without it,
AMFI terminates Sunshine the moment a controller is first connected.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So, if we don't sign then Sunshine will not work at all?

This is going to break the homebrew package, and make it difficult to test any PR builds as those are not signed.

Comment on lines +53 to +54
// Gamepad HID report descriptor — emulates a Razer Serval (VID 0x1532 /
// PID 0x0900, an Android/PC Bluetooth gamepad).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What would be required to emulate standard Xbox and PlayStation gamepads?

* @param input The global input context.
* @param touch The touch event.
*/
// Sends a gamepad touch event to the OS (documented in src/platform/common.h). Unimplemented on macOS.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

you can just remove the comments if they're duplicated in the common.h file

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have to make a change in the apple developer portal for this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sunshine: Gamepad support for macOS

2 participants