Skip to content

leopiney/thrustmacos

Repository files navigation

ThrustMacos

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.


Architecture

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.


⚠️ System Security Requirements

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.

What you're doing and why

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:

  1. Disable SIP so macOS allows self-signed DriverKit extensions
  2. Set the amfi_get_out_of_my_way=1 boot 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.

Disable SIP

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.

Disable AMFI

After booting back into macOS:

sudo nvram boot-args="amfi_get_out_of_my_way=1"

Reboot again.

Verify

csrutil status          # should say: disabled
nvram boot-args         # should show: amfi_get_out_of_my_way=1

Prerequisites

  • 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 the signing identity

# 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 Signing

Verify it's available:

security find-identity -v -p codesigning | grep "DriverKit Dev"

Build

Build both the DriverKit extension and the Swift app:

bash tools/build_all.sh

This runs:

  • xcodebuild on WheelerGamepadDriver.xcodeproj (Debug, no signing — signing happens at deploy time)
  • xcodebuild on ThrustMacos.xcodeproj (Release)

Deploy

Full deploy (first time, or after changing the .dext)

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.sh

deploy_signed.sh does:

  1. Kills the running app and daemon
  2. Copies the built .dext into ThrustMacos.app/Contents/Library/SystemExtensions/
  3. Signs the dext binary and bundle with "DriverKit Dev"
  4. Signs the app
  5. Rebuilds, installs, and signs the daemon (/usr/local/bin/WheelerGamepadDaemon)
  6. Installs the launchd plist and starts the daemon
  7. Launches the app

App-only deploy (no dext changes)

When you only changed Swift code (mapping logic, UI, etc.):

bash tools/build_all.sh
sudo bash tools/deploy_app.sh

This is much faster — no dext reinstall, no reboot needed.


Running

  1. Deploy (see above)
  2. Open ThrustMacos.app
  3. Click Install Virtual Gamepad — this triggers the system extension activation prompt
  4. Approve the extension in System Settings → Privacy & Security
  5. Plug in the T-Flight Stick X (or it may already be detected)
  6. 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/gamepad

Verify the daemon is running:

ps aux | grep WheelerGamepadDaemon
sudo launchctl list | grep com.wheeler.gamepad.daemon

Daemon logs:

tail -f /var/log/wheeler-gamepad-daemon.log

Debugging

Extension not loading

log show --last 2m --predicate 'process == "kernelmanagerd"' 2>&1 | grep -i wheeler
log show --last 2m --predicate 'process == "syspolicyd"' 2>&1 | grep -i wheeler

Driver process not appearing

ps aux | grep _driverkit | grep -i wheeler

The dext runs as the _driverkit user when active. If it's not there after clicking Install, check Console.app for signing or entitlement errors.

Input not flowing

  1. Check the app is sending: watch the packet counter in the UI
  2. Check the daemon received it: tail -f /var/log/wheeler-gamepad-daemon.log
  3. Check IOKit connection: daemon logs print "Connected to Wheeler Gamepad service" on startup

arm64e errors

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.


Project Structure

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

HID Report Format

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.


Known Issues

  • 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.

About

not recommended DIY HOTAS macOS GeForce Now DriverKit tool

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors