Bridges a Thrustmaster T-Flight Stick X to a virtual Xbox gamepad on macOS. GeForce NOW and games see a real Xbox controller; under the hood, the flight stick is driving it.
Why this exists: GeForce NOW ignores generic HID joysticks. The T-Flight Stick X shows up that way. This driver tricks macOS into presenting a virtual Xbox controller, fed live by the flight stick.
Works in the browser-based GeForce NOW client. The native app applies stricter validation and rejects the virtual device.
T-Flight Stick X (USB)
↓ IOKit HID
ThrustMacos.app (Swift — reads input, maps axes/buttons, builds 9-byte report)
↓ UDP :12000
WheelerGamepadDaemon (C++ — forwards bytes to the driver)
↓ IOKit user client
WheelerGamepadDriver.dext (DriverKit — virtual Xbox HID device)
↓ HID subsystem
macOS Game Controllers → GeForce NOW / games
All mapping logic lives in the Swift app (InputMapper.swift). The daemon and driver are intentionally dumb pipes — this keeps iteration fast, since rebuilding the dext requires reinstalling and often rebooting.
This project requires disabling macOS System Integrity Protection (SIP) and AMFI. These are serious security changes. Only do this on a machine you control and understand the implications for.
The DriverKit extension must be signed with a certificate that chains to Apple's DriverKit root. Without an Apple Developer Program membership ($99/year), you cannot get that certificate. The workaround is to:
- Disable SIP so macOS allows self-signed DriverKit extensions
- Set the
amfi_get_out_of_my_way=1boot argument so AMFI skips code-signing enforcement
Without both of these, the dext will be rejected at load time regardless of how correctly it's built.
Boot into Recovery Mode:
- Apple Silicon: hold the power button until "Loading startup options" appears, then select Options
In the Recovery terminal:
csrutil disable
Reboot.
After booting back into macOS:
sudo nvram boot-args="amfi_get_out_of_my_way=1"Reboot again.
csrutil status # should say: disabled
nvram boot-args # should show: amfi_get_out_of_my_way=1- macOS on Apple Silicon (arm64e is required for DriverKit — plain arm64 is rejected)
- Xcode with command-line tools
- A self-signed code signing identity named
"DriverKit Dev"in your keychain
# Create a self-signed certificate named "DriverKit Dev" in Keychain Access
# Keychain Access → Certificate Assistant → Create a Certificate
# Name: DriverKit Dev
# Identity Type: Self Signed Root
# Certificate Type: Code SigningVerify it's available:
security find-identity -v -p codesigning | grep "DriverKit Dev"Build both the DriverKit extension and the Swift app:
bash tools/build_all.shThis runs:
xcodebuildonWheelerGamepadDriver.xcodeproj(Debug, no signing — signing happens at deploy time)xcodebuildonThrustMacos.xcodeproj(Release)
The dext must be reinstalled from scratch each time it changes. macOS caches the old one otherwise.
sudo systemextensionsctl reset
# reboot here
sudo bash tools/deploy_signed.shdeploy_signed.sh does:
- Kills the running app and daemon
- Copies the built
.dextintoThrustMacos.app/Contents/Library/SystemExtensions/ - Signs the dext binary and bundle with
"DriverKit Dev" - Signs the app
- Rebuilds, installs, and signs the daemon (
/usr/local/bin/WheelerGamepadDaemon) - Installs the launchd plist and starts the daemon
- Launches the app
When you only changed Swift code (mapping logic, UI, etc.):
bash tools/build_all.sh
sudo bash tools/deploy_app.shThis is much faster — no dext reinstall, no reboot needed.
- Deploy (see above)
- Open ThrustMacos.app
- Click Install Virtual Gamepad — this triggers the system extension activation prompt
- Approve the extension in System Settings → Privacy & Security
- Plug in the T-Flight Stick X (or it may already be detected)
- The app shows live axis and button values
Verify the virtual controller is visible:
# Check that System Settings → Game Controllers shows "GamePad-1"
# Or use a browser gamepad tester: https://hardwaretester.com/gamepadVerify the daemon is running:
ps aux | grep WheelerGamepadDaemon
sudo launchctl list | grep com.wheeler.gamepad.daemonDaemon logs:
tail -f /var/log/wheeler-gamepad-daemon.loglog show --last 2m --predicate 'process == "kernelmanagerd"' 2>&1 | grep -i wheeler
log show --last 2m --predicate 'process == "syspolicyd"' 2>&1 | grep -i wheelerps aux | grep _driverkit | grep -i wheelerThe dext runs as the _driverkit user when active. If it's not there after clicking Install, check Console.app for signing or entitlement errors.
- Check the app is sending: watch the packet counter in the UI
- Check the daemon received it:
tail -f /var/log/wheeler-gamepad-daemon.log - Check IOKit connection: daemon logs print "Connected to Wheeler Gamepad service" on startup
If you see exec_mach_imgact: disallowing arm64 platform driverkit binary, the dext was compiled as arm64 instead of arm64e. Check ARCHS in WheelerGamepadDriver.xcodeproj/project.pbxproj — it must be arm64e.
ThrustMacos/ Swift app
ThrustMacos/
Core/ThrustmasterBridge.swift Orchestration (HID reader → mapper → UDP client)
HID/TFlightHIDReader.swift IOKit HID — reads T-Flight axes, buttons, hat
Mapping/InputMapper.swift T-Flight → 9-byte Xbox HID report mapping
Network/WheelerUDPClient.swift UDP client → daemon on 127.0.0.1:12000
DriverKit/SystemExtensionInstaller.swift Extension activation
ContentView.swift SwiftUI UI
WheelerHost-V1/DriverKit/
GamepadModule/
WheelerGamepadService.cpp IOService — root of the dext, spawns children
WheelerGamepadDriver.cpp IOUserHIDDevice — virtual Xbox HID device
WheelerGamepadUserClient.cpp IOUserClient — IPC from daemon
WheelerGamepadTypes.h Shared struct (9-byte WheelerHIDReport, packed)
WheelerGamepadDaemon/
WheelerGamepadDaemon.cpp UDP server → IOKit → dext bridge
Makefile Build and install daemon
com.wheeler.gamepad.daemon.plist Launchd config
tools/
build_all.sh Build dext + app
deploy_signed.sh Full deploy (dext + app + daemon)
deploy_app.sh App-only deploy
run_daemon_local.sh Run daemon in foreground for debugging
The 9-byte Xbox-compatible report that flows through the whole pipeline:
| Byte | Field | Range | Notes |
|---|---|---|---|
| 0 | Left Stick X | 0–255 | 128 = center |
| 1 | Left Stick Y | 0–255 | 128 = center |
| 2 | Left Trigger | 0–255 | |
| 3 | Right Trigger | 0–255 | |
| 4 | Right Stick X | 0–255 | 128 = center |
| 5 | Right Stick Y | 0–255 | 128 = center |
| 6–7 | Buttons | 16 bits | One bit per button |
| 8 | Hat switch | 0–8 | 0=up, clockwise, 8=center |
The struct is __attribute__((packed)) in WheelerGamepadTypes.h — the daemon and driver must agree on exactly 9 bytes. Off-by-one padding was a real bug.
- The daemon polls at ~1ms intervals, causing unnecessary CPU wakeups. A kqueue-based wait would be cleaner.
- The native GeForce NOW app rejects the virtual device. The browser client works.
- Occasional input jitter from the T-Flight hardware; a rolling-window average would help.