Skip to content

Commit eedcf8f

Browse files
committed
feat(macos/input): implement host-side virtual gamepad via IOHIDUserDevice
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.
1 parent 42ad27b commit eedcf8f

6 files changed

Lines changed: 559 additions & 10 deletions

File tree

cmake/compile_definitions/macos.cmake

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES
3535
${CORE_MEDIA_LIBRARY}
3636
${CORE_VIDEO_LIBRARY}
3737
${FOUNDATION_LIBRARY}
38+
${IO_KIT_LIBRARY}
3839
${SCREEN_CAPTURE_KIT_LIBRARY}
3940
${VIDEO_TOOLBOX_LIBRARY})
4041

@@ -47,7 +48,9 @@ set(PLATFORM_TARGET_FILES
4748
"${CMAKE_SOURCE_DIR}/src/platform/macos/av_audio.mm"
4849
"${CMAKE_SOURCE_DIR}/src/platform/macos/av_img_t.h"
4950
"${CMAKE_SOURCE_DIR}/src/platform/macos/display.mm"
50-
"${CMAKE_SOURCE_DIR}/src/platform/macos/input.cpp"
51+
"${CMAKE_SOURCE_DIR}/src/platform/macos/hid_gamepad.h"
52+
"${CMAKE_SOURCE_DIR}/src/platform/macos/hid_gamepad.m"
53+
"${CMAKE_SOURCE_DIR}/src/platform/macos/input.mm"
5154
"${CMAKE_SOURCE_DIR}/src/platform/macos/microphone.mm"
5255
"${CMAKE_SOURCE_DIR}/src/platform/macos/misc.mm"
5356
"${CMAKE_SOURCE_DIR}/src/platform/macos/misc.h"

cmake/dependencies/ffmpeg.cmake

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,58 @@ else()
173173
endif()
174174

175175
set(FFMPEG_INCLUDE_DIRS "${FFMPEG_PREPARED_BINARIES}/include")
176+
177+
# Sunshine's src/cbs.cpp uses libavcodec's INTERNAL headers (cbs_h264.h,
178+
# cbs_h2645.h, h2645_parse.h, etc.) which FFmpeg's `make install` does
179+
# not export. Stage the needed internal headers into the dist include
180+
# tree alongside the public ones. Done at configure time so subsequent
181+
# rebuilds don't pay the cost. Limited to a known-good list so we don't
182+
# shadow system headers (FFmpeg has its own "thread.h", "internal.h",
183+
# etc. that collide with libc++ when the entire source tree is on the
184+
# include path).
185+
get_filename_component(_FFMPEG_BINARY_PARENT "${FFMPEG_PREPARED_BINARIES}" DIRECTORY)
186+
set(_FFMPEG_SOURCE_CANDIDATES
187+
"${_FFMPEG_BINARY_PARENT}/FFmpeg/FFmpeg"
188+
"${CMAKE_SOURCE_DIR}/third-party/build-deps/build-prores-vt/FFmpeg/FFmpeg"
189+
)
190+
foreach(_candidate ${_FFMPEG_SOURCE_CANDIDATES})
191+
if(EXISTS "${_candidate}/libavcodec/h2645_parse.h")
192+
set(_FFMPEG_INTERNAL_HEADERS
193+
libavcodec/h2645_parse.h
194+
libavcodec/h2645_sei.h
195+
libavcodec/h264_sei.h
196+
libavcodec/hevc/sei.h
197+
libavcodec/sei.h
198+
libavcodec/cbs.h
199+
libavcodec/cbs_internal.h
200+
libavcodec/cbs_sei.h
201+
libavcodec/get_bits.h
202+
libavcodec/golomb.h
203+
libavcodec/mathops.h
204+
libavcodec/mpegutils.h
205+
libavcodec/vlc.h
206+
libavutil/attributes_internal.h
207+
libavutil/internal.h
208+
libavutil/thread.h
209+
libavutil/timer.h
210+
libavutil/reverse.h
211+
libavutil/libm.h
212+
libavutil/cpu_internal.h
213+
)
214+
foreach(_hdr ${_FFMPEG_INTERNAL_HEADERS})
215+
if(EXISTS "${_candidate}/${_hdr}" AND NOT EXISTS "${FFMPEG_PREPARED_BINARIES}/include/${_hdr}")
216+
get_filename_component(_hdr_dir "${FFMPEG_PREPARED_BINARIES}/include/${_hdr}" DIRECTORY)
217+
file(MAKE_DIRECTORY "${_hdr_dir}")
218+
configure_file("${_candidate}/${_hdr}" "${FFMPEG_PREPARED_BINARIES}/include/${_hdr}" COPYONLY)
219+
endif()
220+
endforeach()
221+
# ffbuild/config.h is needed by libavutil/internal.h. Stage it
222+
# at the include-path root so the relative #include "config.h"
223+
# from mathops.h finds it.
224+
if(EXISTS "${_candidate}/ffbuild/config.h" AND NOT EXISTS "${FFMPEG_PREPARED_BINARIES}/include/config.h")
225+
configure_file("${_candidate}/ffbuild/config.h" "${FFMPEG_PREPARED_BINARIES}/include/config.h" COPYONLY)
226+
endif()
227+
message(STATUS "Sunshine cbs.cpp: staged FFmpeg internal headers from ${_candidate}")
228+
break()
229+
endif()
230+
endforeach()

cmake/dependencies/macos.cmake

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ FIND_LIBRARY(CORE_AUDIO_LIBRARY CoreAudio)
99
FIND_LIBRARY(CORE_MEDIA_LIBRARY CoreMedia)
1010
FIND_LIBRARY(CORE_VIDEO_LIBRARY CoreVideo)
1111
FIND_LIBRARY(FOUNDATION_LIBRARY Foundation)
12+
# IOKit is needed for IOHIDUserDevice* (virtual gamepad device — hid_gamepad.m).
13+
# Actually creating devices at runtime requires the user to disable AMFI via
14+
# `nvram boot-args="amfi_get_out_of_my_way=1"`, but the symbols themselves
15+
# are unconditionally present and the host alloc_gamepad path probes
16+
# availability before relying on them.
17+
FIND_LIBRARY(IO_KIT_LIBRARY IOKit)
1218
FIND_LIBRARY(VIDEO_TOOLBOX_LIBRARY VideoToolbox)
1319
# ScreenCaptureKit is the modern (macOS 12.3+) replacement for the
1420
# deprecated AVCaptureScreenInput-based capture path. Sunshine's

src/platform/macos/hid_gamepad.h

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* @file src/platform/macos/hid_gamepad.h
3+
* @brief Virtual HID gamepad via IOHIDUserDevice for macOS.
4+
* @details Creates a system-wide virtual gamepad that macOS Game Controller
5+
* framework recognizes. Requires SIP to be disabled.
6+
*/
7+
#pragma once
8+
9+
#import <Foundation/Foundation.h>
10+
#import <IOKit/hidsystem/IOHIDUserDevice.h>
11+
12+
/**
13+
* HID report sent to IOHIDUserDevice. Packed to exactly 14 bytes.
14+
* Matches the HID report descriptor defined in hid_gamepad.m.
15+
*/
16+
typedef struct __attribute__((packed)) {
17+
uint8_t reportId; // Always 0x01
18+
uint16_t buttons; // 16 button bits
19+
uint8_t hatSwitch; // D-pad hat switch (0-7 = directions, 8 = neutral)
20+
uint8_t leftTrigger; // 0-255
21+
uint8_t rightTrigger; // 0-255
22+
int16_t leftStickX; // -32768 to 32767
23+
int16_t leftStickY; // -32768 to 32767
24+
int16_t rightStickX; // -32768 to 32767
25+
int16_t rightStickY; // -32768 to 32767
26+
} HIDGamepadReport;
27+
28+
@interface HIDGamepad : NSObject
29+
30+
@property (nonatomic, assign) int gamepadIndex;
31+
@property (nonatomic, assign) BOOL isConnected;
32+
@property (nonatomic, assign) IOHIDUserDeviceRef hidDevice;
33+
@property (nonatomic, strong) dispatch_queue_t hidQueue;
34+
35+
/**
36+
* Probes whether IOHIDUserDevice virtual gamepads can be created.
37+
* Returns NO when SIP is enabled (device creation fails).
38+
*/
39+
+ (BOOL)isAvailable;
40+
41+
- (instancetype)initWithIndex:(int)index;
42+
43+
/**
44+
* Creates the IOHIDUserDevice and sends an initial neutral-state report.
45+
* @return YES on success, NO on failure.
46+
*/
47+
- (BOOL)createDevice;
48+
49+
/**
50+
* Maps Sunshine's gamepad state to an HID report and sends it.
51+
* @param buttons Sunshine's 32-bit buttonFlags (only lower 16 bits + HOME used)
52+
* @param lsX Left stick X (-32768..32767)
53+
* @param lsY Left stick Y (-32768..32767)
54+
* @param rsX Right stick X (-32768..32767)
55+
* @param rsY Right stick Y (-32768..32767)
56+
* @param lt Left trigger (0..255)
57+
* @param rt Right trigger (0..255)
58+
*/
59+
- (void)updateState:(uint32_t)buttons
60+
leftStickX:(int16_t)lsX
61+
leftStickY:(int16_t)lsY
62+
rightStickX:(int16_t)rsX
63+
rightStickY:(int16_t)rsY
64+
leftTrigger:(uint8_t)lt
65+
rightTrigger:(uint8_t)rt;
66+
67+
/**
68+
* Destroys the IOHIDUserDevice and cleans up resources.
69+
*/
70+
- (void)disconnect;
71+
72+
@end

0 commit comments

Comments
 (0)