ohmu
·
2026-10-15
1#include "gamepad.h"
2#include "common/apple/cf_helpers.h"
3
4#include <IOKit/IOKitLib.h>
5#include <IOKit/hid/IOHIDLib.h>
6#include <GameController/GameController.h>
7
8#include <math.h>
9
10// macOS offers two overlapping views of the same controller, and neither replaces the other:
11//
12// * `IOHIDManager` sees every HID device whose primary usage is a joystick or a gamepad, third
13// party pads included. It is the only source of the manufacturer, the product and the serial
14// number.
15// * `GCController` is the GameController framework's view of the pads Apple has whitelisted -- the
16// official Sony, Microsoft, Nintendo and MFi ones. It is the only source of a battery level
17// (`GCDeviceBattery`, macOS 11.0+), and it is what the system's own games read.
18//
19// Neither view replaces the other, so each contributes whole entries rather than a name from one and
20// a battery from the other. Nothing on the public surface can pair them: `GCDevice` carries only the
21// display strings `vendorName` and `productCategory`, `+[GCController supportsHIDDevice:]` answers in
22// the other direction only, and SDL -- the reference implementation for all of this -- does not pair
23// them either ("we don't have an easy way to know if those devices correspond to a specific
24// GCController", `SDL_mfijoystick.m`).
25//
26// The serial is the one field both views carry, so it is also the one field that would otherwise be
27// spelled two different ways for the same pad depending on which pass contributed the entry. It is
28// joined through a private property -- see the `GCController()` category below for what was measured
29// -- and the join only *borrows* the serial. The entries themselves stay separate.
30//
31// ⚠️ `[GCController controllers]` is filled in by the private `_GCControllerManager` once it is
32// told that the application became active, which a process that never activates -- a command line
33// tool -- never tells it. SDL records the symptom for macOS 11.0.1 ("this always returns an empty
34// array"). The array may therefore legitimately be empty here, so the IOHID pass only skips a
35// device when the framework actually contributed something: with an empty array there is no
36// duplicate to avoid, and skipping would drop the device from the output altogether.
37//
38// The array also does not fill on its own. Measured on this machine with a Pro Controller attached:
39// it reads 0 at process start, is *still* 0 after 20 ms of unrelated work with no run loop, and
40// only becomes non-empty ~10.7 ms after the first `CFRunLoopRunInMode` call -- the request is sent
41// by the run loop turn, not by the property read. Since nothing else in this file pumps, waiting for
42// the framework has to be explicit, which is what `GAMEPAD_GAMECONTROLLER_TIMEOUT_SECONDS` and the
43// pump loop in `findGameControllerDevices()` are for. Without them this whole pass contributes
44// nothing and macOS never shows a battery.
45//
46// Merely reading `GCController.controllers` is what starts the framework -- it connects to
47// `gamecontrollerd` on first touch -- so that read is *not* hoisted to the top of
48// `ffDetectGamepad()`. It is therefore paid only on the path where the framework has something to
49// answer for. Measured on this machine with no controller attached, module time via `--stat`:
50//
51// nothing hoisted ................. 0.3 ms
52// the read only ................... 8.3 ms
53// the read plus the run loop turn . 14.3 ms
54//
55// Both halves are expensive and neither is optional: the read is the ignition and the turn is what
56// carries the request. The turn is nominally bounded by 0.5 ms, but `returnAfterSourceHandled` does
57// not bound the handler, so it adds ~6 ms of its own. Paying 14 ms on a machine with no pad at all
58// -- to learn what `supportsHIDDevice:` below already answered -- is why the kick is gone rather
59// than merely deferred.
60#define GAMEPAD_GAMECONTROLLER_TIMEOUT_SECONDS 0.05
61
62@interface GCController()
63 // Not in the public header, and the only identity the framework exposes at all: the public
64 // `GCDevice` surface has just the two display strings, so two pads of the same model are
65 // indistinguishable through it -- no vendor id, no product id, no serial.
66 //
67 // On the Bluetooth pad this was measured with, the description is the transport address, which is
68 // the very string IOKit reports as `kIOHIDSerialNumberKey`:
69 //
70 // GCController.identifier LOGICAL_DEVICE(5c-52-1e-88-7e-70)
71 // IOHIDDevice serial 5C:52:1E:88:7E:70
72 //
73 // `persistentIdentifier` answered the same string on that pad and is deliberately not used: its
74 // name promises stability across launches, which is a different contract from "the address this
75 // pad has right now". `physicalDeviceUniqueID` is nil there, and `deviceHash` is a per-process
76 // hash, so neither is something that could be looked up in the IOKit set.
77 @property (readonly, nonatomic) NSString* identifier;
78@end
79
80// ---------------------------------------------------------------------------------------------
81// Joining the two views
82// ---------------------------------------------------------------------------------------------
83//
84// The serial is borrowed rather than guessed. The framework's own spelling of the address is the join
85// key, and both sides are normalised before they are compared.
86//
87// Only that one pad has been measured: the wrapper format is private, so a pad that reports its
88// identity in some other shape -- a wired pad with a real serial number, say -- fails to match. That
89// is the intended failure mode, and it is why this only ever fills in a field that would otherwise be
90// empty: no match means an empty serial, never a wrong one.
91
92// `LOGICAL_DEVICE(5c-52-1e-88-7e-70)` -> `5c-52-1e-88-7e-70`. Anything without a parenthesised part
93// is rejected outright rather than guessed at, because the wrapper is private formatting that can
94// change without notice.
95static bool extractIdentityToken(NSString* identifier, FFstrbuf* result) {
96 // The property is declared opaque in the framework's metadata -- `NSObject<NSCopying,
97 // NSSecureCoding>` -- but the value is a plain string, so it is used as-is: measured on this pad
98 // the object is an `__NSCFString` inheriting `NSMutableString`, and `description` returns the
99 // same 33 characters. Anything that is not a string is rejected rather than stringified, because
100 // the wrapper format is private either way and a non-string value would mean it changed.
101 if (![identifier isKindOfClass: NSString.class]) {
102 return false;
103 }
104 NSRange open = [identifier rangeOfString: @"("];
105 if (open.location == NSNotFound) {
106 return false;
107 }
108 NSRange close = [identifier rangeOfString: @")" options: 0 range: NSMakeRange(open.location + 1, identifier.length - open.location - 1)];
109 if (close.location == NSNotFound) {
110 return false;
111 }
112 NSString* token = [identifier substringWithRange: NSMakeRange(open.location + 1, close.location - open.location - 1)];
113 ffStrbufSetS(result, token.UTF8String);
114 return result->length > 0;
115}
116
117// Drop every separator and fold to upper case, so `5c-52-1e-88-7e-70` and `5C:52:1E:88:7E:70` -- the
118// two spellings of one address above -- compare equal.
119static void stripIdentitySeparators(FFstrbuf* strbuf) {
120 uint32_t out = 0;
121 for (uint32_t i = 0; i < strbuf->length; ++i) {
122 char c = strbuf->chars[i];
123 if (c >= '0' && c <= '9') {
124 strbuf->chars[out++] = c;
125 } else if (c >= 'a' && c <= 'f') {
126 strbuf->chars[out++] = (char) (c - 'a' + 'A');
127 } else if (c >= 'A' && c <= 'F') {
128 strbuf->chars[out++] = c;
129 }
130 }
131 strbuf->chars[out] = '\0';
132 strbuf->length = out;
133}
134
135// The IOKit device the framework is describing, or `nullptr` when the two cannot be matched. The
136// returned ref is owned by `set`, which the caller holds for the rest of the call.
137static IOHIDDeviceRef findHIDDeviceForController(GCController* controller, NSSet* set) {
138 if (set == nil || ![controller respondsToSelector: @selector(identifier)]) {
139 return nullptr;
140 }
141
142 FF_STRBUF_AUTO_DESTROY identity = ffStrbufCreate();
143 if (!extractIdentityToken(controller.identifier, &identity)) {
144 return nullptr;
145 }
146 stripIdentitySeparators(&identity);
147
148 for (id value in set) {
149 IOHIDDeviceRef device = (__bridge IOHIDDeviceRef) value;
150 FF_STRBUF_AUTO_DESTROY serial = ffStrbufCreate();
151 ffCfStrGetString(IOHIDDeviceGetProperty(device, CFSTR(kIOHIDSerialNumberKey)), &serial);
152 stripIdentitySeparators(&serial);
153 if (serial.length > 0 && ffStrbufEqual(&serial, &identity)) {
154 return device;
155 }
156 }
157 return nullptr;
158}
159
160typedef struct FFFindGamepadContext {
161 FFlist* devices;
162 uint32_t gameControllerCount;
163} FFFindGamepadContext;
164
165// The GameController framework's own devices, and how many of them there were. `set` is the IOKit
166// side, used only to borrow the serial -- see `findHIDDeviceForController()`.
167static uint32_t findGameControllerDevices(FFlist* devices, NSSet* set) {
168 if (@available(macOS 11.0, *)) {
169 // See the note at the top: the list stays empty until the run loop has been pumped once, and
170 // the timeout only bounds the case where the framework has nothing to report at all. The
171 // caller checks `supportsHIDDevice:` first, so a pad the framework does not claim never
172 // reaches this wait.
173 //
174 // The turn is handed the time that is left rather than 0, so it blocks until the framework's
175 // source fires or the deadline passes. A zero timeout returns as soon as the run loop has
176 // nothing pending, which turns this into a busy spin: measured with no pad and the loop
177 // forced to run its full timeout, `CFRunLoopRunInMode(mode, 0, true)` burned 50.4 ms of CPU
178 // in 50.0 ms of wall time (30k-40k turns), where passing the remaining time burned 0.08 ms
179 // in a single turn. The deadline is a soft bound either way -- the blocking turn overshot it
180 // by 3-5 ms in the same measurement.
181 NSDate* deadline = [NSDate dateWithTimeIntervalSinceNow: GAMEPAD_GAMECONTROLLER_TIMEOUT_SECONDS];
182 while (GCController.controllers.count == 0 && deadline.timeIntervalSinceNow > 0) {
183 CFRunLoopRunInMode(kCFRunLoopDefaultMode, deadline.timeIntervalSinceNow, true);
184 }
185
186 uint32_t count = 0;
187 @autoreleasepool {
188 for (GCController* controller in GCController.controllers) {
189 FFGamepadDevice* device = FF_LIST_ADD(FFGamepadDevice, *devices);
190 ffStrbufInit(&device->serial);
191 ffStrbufInit(&device->name);
192 device->battery = 0;
193
194 // The framework knows the pad but not its serial -- the public surface has no such
195 // field -- so it is borrowed from the IOKit device its private identity points at.
196 // Both passes then spell the same serial the same way, which is what makes the two
197 // entries comparable in the output.
198 IOHIDDeviceRef hidDevice = findHIDDeviceForController(controller, set);
199 if (hidDevice) {
200 ffCfStrGetString(IOHIDDeviceGetProperty(hidDevice, CFSTR(kIOHIDSerialNumberKey)), &device->serial);
201 }
202
203 if (controller.productCategory) {
204 ffStrbufSetS(&device->name, controller.productCategory.UTF8String);
205 } else if (controller.vendorName) {
206 ffStrbufSetS(&device->name, controller.vendorName.UTF8String);
207 } else {
208 ffStrbufSetS(&device->name, "MFi Gamepad");
209 }
210
211 // A wired pad has no battery object at all, and `batteryLevel` is `0.0` by default,
212 // so an unknown level cannot be told apart from a drained one. Both become the
213 // module's "0 means unknown", which hides the number rather than printing a wrong 0%.
214 GCDeviceBattery* battery = controller.battery;
215 if (battery) {
216 int percent = (int) lroundf(battery.batteryLevel * 100.f);
217 device->battery = (uint8_t) (percent < 0 ? 0 : (percent > 100 ? 100 : percent));
218 }
219
220 ++count;
221 }
222 }
223 return count;
224 }
225 return 0;
226}
227
228// Whether the framework would claim any of these devices. Unlike the list itself this is a local
229// predicate on the HID descriptor, not a daemon round trip, so it is the right thing to ask *before*
230// paying for the wait above. Measured on this machine: 0.3 ms warm, 0.9 ms as the very first
231// GameController call in a process -- and reading `GCController.controllers` immediately afterwards
232// still cost 6 ms. That is the point: the gate answers without starting the framework, so the
233// ~14 ms above is only ever paid by a pad Apple actually claims. A Logitech pad, or any pad outside
234// Apple's whitelist, fails this and skips the wait entirely instead of sitting through a timeout for
235// a list that will never contain it.
236static bool anyDeviceIsClaimedByGameController(NSSet* set) {
237 if (@available(macOS 11.0, *)) {
238 for (id value in set) {
239 if ([GCController supportsHIDDevice: (__bridge IOHIDDeviceRef) value]) {
240 return true;
241 }
242 }
243 }
244 return false;
245}
246
247static void enumSet(IOHIDDeviceRef value, FFFindGamepadContext* ctx) {
248 // A device the framework owns is already in the list under its own name; see the note at the
249 // top for why this is conditional on the framework having contributed anything at all.
250 if (ctx->gameControllerCount > 0) {
251 if (@available(macOS 11.0, *)) {
252 if ([GCController supportsHIDDevice:value]) {
253 return;
254 }
255 }
256 }
257
258 FFGamepadDevice* device = FF_LIST_ADD(FFGamepadDevice, *ctx->devices);
259 ffStrbufInit(&device->serial);
260 ffStrbufInit(&device->name);
261 device->battery = 0;
262
263 CFStringRef manufacturer = IOHIDDeviceGetProperty(value, CFSTR(kIOHIDManufacturerKey));
264 ffCfStrGetString(manufacturer, &device->name);
265
266 CFStringRef product = IOHIDDeviceGetProperty(value, CFSTR(kIOHIDProductKey));
267 if (device->name.length) {
268 ffCfStrGetString(product, &device->serial);
269 ffStrbufAppendC(&device->name, ' ');
270 ffStrbufAppend(&device->name, &device->serial);
271 } else {
272 ffCfStrGetString(product, &device->name);
273 }
274
275 CFStringRef serialNumber = IOHIDDeviceGetProperty(value, CFSTR(kIOHIDSerialNumberKey));
276 ffCfStrGetString(serialNumber, &device->serial);
277}
278
279const char* ffDetectGamepad(FFlist* devices /* List of FFGamepadDevice */) {
280 FF_CFTYPE_AUTO_RELEASE IOHIDManagerRef manager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone);
281 if (IOHIDManagerOpen(manager, kIOHIDOptionsTypeNone) != kIOReturnSuccess) {
282 return "IOHIDManagerOpen() failed";
283 }
284
285 // Built as Foundation collections and bridged for the call. The C spelling of this is four lines
286 // of `CFDictionaryCreate` with inline array casts around two numbers each; a literal says the same
287 // thing in two. `kIOHIDDevice*Key` are C string macros -- the old spelling had to wrap them in
288 // `CFSTR()` -- and `@()` boxes them straight to `NSString`.
289 //
290 // The old spelling was *not* leaking, tempting as that is to assume: the four `ffCfCreateInt()`
291 // numbers do hand a +1 reference to a dictionary that retains them and never give it back, but
292 // `CFNumberCreate()` answers with a tagged pointer for every int32 -- measured, all seven test
293 // values came back pointer-identical and 200k stranded references grew RSS by 0 KB. So this is a
294 // readability change, not a fix.
295 NSArray* matchings = @[
296 @{ @(kIOHIDDeviceUsagePageKey): @(kHIDPage_GenericDesktop), @(kIOHIDDeviceUsageKey): @(kHIDUsage_GD_Joystick) },
297 @{ @(kIOHIDDeviceUsagePageKey): @(kHIDPage_GenericDesktop), @(kIOHIDDeviceUsageKey): @(kHIDUsage_GD_GamePad) },
298 ];
299 IOHIDManagerSetDeviceMatchingMultiple(manager, (__bridge CFArrayRef) matchings);
300
301 // `IOHIDManagerCopyDevices()` follows the Create rule, so the set is handed to ARC rather than
302 // tracked with a cleanup attribute. It may also return NULL, which bridging turns into nil.
303 NSSet* set = CFBridgingRelease(IOHIDManagerCopyDevices(manager));
304
305 // The framework's entries come first, and the wait for them happens only when the framework has
306 // a reason to answer -- see `anyDeviceIsClaimedByGameController()`.
307 FFFindGamepadContext ctx = {
308 .devices = devices,
309 .gameControllerCount = anyDeviceIsClaimedByGameController(set) ? findGameControllerDevices(devices, set) : 0,
310 };
311
312 for (id value in set) {
313 enumSet((__bridge IOHIDDeviceRef) value, &ctx);
314 }
315 IOHIDManagerClose(manager, kIOHIDOptionsTypeNone);
316
317 return nullptr;
318}