Files
portersky 176a563400 docs: phase 4 uses userspace IOHIDUserDevice, not DriverKit
macOS has a user-space virtual HID device API (IOHIDUserDevice,
available since 10.15). No DriverKit extension is needed: the app
creates one virtual device per controller with our report descriptor.

Development works with SIP disabled and no Apple account (verified on
macOS 15.7 that creation fails with SIP enabled; dji-fpv3 and
ControlLab document the SIP-disabled dev workflow). Distribution with
SIP enabled requires the com.apple.developer.hid.virtual.device
entitlement from a paid Developer Program membership.

Co-Authored-By: qwen3.8-27b@q3_k_xl: researched virtual HID options
2026-08-29 15:49:30 +02:00

16 KiB

Xbox Wireless Dongle: macOS Port

Goal

User-space macOS app that speaks to the Xbox Wireless Dongle (MT76xx chip) and exposes connected controllers as HID gamepads.

Architecture

┌──────────────────────────────────────────────────────┐
│  macOS App (Swift/C++)                               │
│  ┌──────────┐  ┌──────────┐  ┌──────────────────┐   │
│  │ App/UI   │  │ HID Proxy│  │ Core Audio (opt) │   │
│  └────┬─────┘  └────▲─────┘  └──────────────────┘   │
│       │              │                                │
│  ┌────▼──────────────▼──────────────────────┐        │
│  │         GIP Protocol Layer               │        │
│  │  (bus/protocol.c: ported, pure C)       │        │
│  └──────────────────┬───────────────────────┘        │
│                     │                                │
│  ┌──────────────────▼───────────────────────┐        │
│  │      MT76 Chip Protocol Layer            │        │
│  │  (transport/mt76.c: ported, USB calls)  │        │
│  └──────────────────┬───────────────────────┘        │
│                     │                                │
│  ┌──────────────────▼───────────────────────┐        │
│  │      USB Transport Layer (new)           │        │
│  │  (IOKit / IOUSBInterfaceInterface)       │        │
│  └──────────────────────────────────────────┘        │
│                     │                                │
│  ┌──────────────────▼───────────────────────┐        │
│  │        Xbox Wireless Dongle              │        │
│  │  VID:045E PID:02FE (02E6, 02F9, 091E)   │        │
│  └──────────────────────────────────────────┘        │
└──────────────────────────────────────────────────────┘

Current Status

  • Phase 1 (GIP + auth): ported and unit-tested.
  • Phase 2 (USB transport): probe/open, async read pump, vendor requests, bulk write. Verified on hardware.
  • Phase 3 (MT76 chip): register/EFUSE access, firmware load, radio init (registers, crystal, MAC/BSSID, channel eval, beacon), pairing mode, controller association, GIP handshake + auth. Verified end-to-end on hardware: live controller input is shown in the app (RX data path) and rumble TX works from the app's Rumble button.
  • C API + Swift app: async session (fast probe; firmware + radio on a worker thread), state display (idle/starting/ready/error), controller list with live input monitor, battery level, and a rumble test button.
  • Phase 4 (HID) started: standard Xbox One report layout, HID report descriptor, and GIP-to-report mapping implemented in src/hid/ and unit tested. Remaining: present the reports via the user-space IOHIDUserDevice API (no DriverKit needed; development requires SIP disabled, distribution requires the com.apple.developer.hid.virtual.device entitlement).

The dongle LED is driven at chip level for pairing and client lifecycle. Phase 3 is complete. Next: Phase 4 (virtual HID gamepad) and Phase 5 (app polish); exposing the controller as a macOS HID gamepad is the main user-visible gap.

Directory Layout

macos/
├── PLAN.md                  ← this file
├── src/
│   ├── usb/                 ← IOKit USB transport (new)
│   ├── mt76/                ← MT76 chip protocol (port from transport/mt76.c)
│   ├── gip/                 ← GIP protocol (port from bus/protocol.c)
│   ├── auth/                ← Auth + crypto (port from auth/)
│   ├── hid/                 ← Virtual HID gamepad (new)
│   ├── cli/                 ← C++ CLI for manual protocol sequences
│   └── app/                 ← macOS app entry point + UI (new)
├── include/
│   ├── common/              ← Shared types, platform abstraction
│   ├── usb/
│   ├── mt76/
│   ├── gip/
│   ├── auth/
│   └── hid/
├── scripts/
│   └── download-firmware.sh ← Port of install/firmware.sh
├── firmware/                ← Downloaded firmware binaries (gitignored)
└── build/                   ← Build output (gitignored)

Implementation Phases

Phase 1: Foundation (Linux → C library)

Extract the protocol logic from Linux kernel code into standalone C.

src/gip/

  • Port bus/protocol.c → pure C, no kernel deps
  • Redefine __le16, __packed, guid_t for user-space
  • Replace kzalloc/kfreemalloc/free
  • Replace spin_lock_irqsave → pthread mutex or lock-free ring buffer
  • Replace dev_dbg/gip_err → NSLog or custom logger
  • Replace EXPORT_SYMBOL_GPL → nothing (static library)

src/auth/

  • Port auth/auth.c + auth/crypto.c → AES-CCMP, authentication handshake
  • Should be mostly copy-paste, these are pure crypto

include/common/

  • Platform types (stdint.h based)
  • Endianness helpers
  • Memory allocator abstraction (so we can swap malloc if needed)

Phase 2: USB Transport (new)

src/usb/

  • IOKit device matching on USB\VID_045E&PID_02FE (and other PIDs)
  • Open device, claim interface (bInterfaceNumber = 1)
  • IOUSBDevInterface for control transfers (vendor requests)
  • IOUSBInterfaceInterface for bulk endpoints:
    • EP 0x04 IN: WLAN data (802.11 frames from controllers)
    • EP 0x04 OUT: Bulk out (commands to chip)
    • EP 0x05 IN: MCU commands (firmware load responses)
  • Async completion callbacks → callback/dispatch queue
  • Device disconnect handling (chip reconnects during firmware load)

Key IOKit APIs:

  • IOServiceMatching("IOUSBDevice"): find dongle
  • IOUSBDeviceOpen / IOUSBInterfaceOpen: claim
  • DeviceRequest: control transfers (register R/W)
  • WritePipe / ReadPipe: bulk transfers
  • CreateInterruptEndpoint: for EP 0x05 (MCU)

Phase 3: MT76 Chip Protocol (port)

src/mt76/

  • Port transport/mt76.c → replace USB calls with Phase 2 transport
  • Firmware loading: send binary in 0x3800-byte chunks, poll for completion
  • EFUSE read: MAC address, chip ID, crystal trim, TX power calibration
  • Radio init: ~80 hardcoded register writes (AGC, EDCA, TX power, protection)
  • Channel evaluation: cycle through 12 channels, pick highest power
  • Beacon transmission: 802.11 beacons with Microsoft OUI IE
  • Pairing mode: rotate channels every 2s when pairing enabled
  • Controller association: 802.11 assoc, WCID assignment, AES-CCMP encryption
  • Data path: QoS data frames to/from controllers

Key replacement map:

  • usb_control_msg()IOUSBDevInterface->DeviceRequest()
  • usb_bulk_msg()IOUSBInterfaceInterface->WritePipe() / ReadPipe()
  • kzalloc/skb_putmalloc/buffer management
  • msleep/mdelayusleep/nanosleep
  • ieee80211_* → custom 802.11 frame builders
  • cfg80211_* → nothing (no regulatory domain reporting needed)

Phase 4: Virtual HID Gamepad (in progress)

src/hid/

  • Expose controller as macOS HID device so games work natively
  • Presentation mechanism: IOHIDUserDevice (IOKit, user-space virtual HID device API, available since macOS 10.15). No DriverKit needed: the app creates one virtual device per controller with our report descriptor, pushes input reports from GIP state, and receives output reports for rumble relay.
  • Signing/entitlements:
    • Development (SIP disabled): IOHIDUserDeviceCreateWithProperties works without any Apple account. Verified on macOS 15.7: with SIP on, creation fails; open-source projects (dji-fpv3, ControlLab) document the SIP-disabled dev workflow.
    • Distribution (SIP enabled): requires the com.apple.developer.hid.virtual.device entitlement in a provisioning profile (paid Apple Developer Program membership).
  • Map Xbox controller buttons/sticks/triggers to standard Xbox One HID report descriptor: done. hid/hid_report.hpp defines the 64-byte input/output report layout and the HID report descriptor; GIP state maps to input reports via make_input_report() (unit tested).
  • Handle force feedback (rumble): send back to dongle via GIP. TX is done (Phase 3); output-report parsing for the relay is implemented in parse_output_report().
  • Battery status reporting
  • Remaining: implement the IOHIDUserDevice wrapper in src/hid/, wire it into the app, verify on hardware with SIP disabled.

Phase 5: macOS App (new)

src/app/

  • Swift or C++ main app
  • Device discovery (IOKit notification when dongle plugged in)
  • Pairing UI (LED blink, controller button press)
  • Status display (connected controllers, battery)
  • Preferences (channel selection, LED mode)
  • Menubar icon for status
  • Firmware download helper (script or in-app)

Controller Association Design

Ported from transport/dongle.c and transport/mt76.c. Goal: pair a controller and show it in the GUI. Exposing it as a macOS HID device is deferred.

RX Path

Both EP IN endpoints (0x04 WLAN, 0x05 CMD) feed one handler:

  1. process_buffer: take the raw IN buffer.
  2. process_message: parse the u32 info header; read D_PORT. Ignore command responses (CMD_SEQ == 0x01). Strip header + 4-byte trailer.
    • D_PORT == WLAN: go to step 3.
    • D_PORT == CPU_RX: dispatch by EVT_TYPE:
      • BUTTON (0x04): enter pairing mode.
      • PACKET_RX (0x0c): go to step 3 (payload is a WLAN frame).
      • CLIENT_LOST (0x0e): payload[0] = wcid; remove that client.
  3. process_wlan: parse rxwi (16 bytes); if RXINFO_L2PAD set, skip the 2-byte pad after the 802.11 header; trim to MPDU_LEN from rxwi.ctl.
  4. process_frame: dispatch by frame_control:
    • DATA|QOS_DATA: feed the client's GIP adapter (gip_process_buffer).
    • MGMT|ASSOC_REQ: add a client (addr2 = controller MAC).
    • MGMT|DISASSOC: remove client (wcid from rxwi.ctl).
    • MGMT|0x70 (WLAN_RESERVED): client command; payload[1] is PAIR_REQ (0x01) or ENABLE_ENCRYPTION (0x10).

Client Lifecycle

  • create_client: find a free WCID (1..16); create a GIP adapter for it.
  • associate_client(wcid, mac): write_burst(WCID_ADDR, mac); ms_command( ADD_CLIENT, {wcid-1,0,0,0,0x40,0x1f,0,0}); send an ASSOC_RESP mgmt frame via send_wlan (fc = MGMT|ASSOC_RESP, da/sa/bssid, status_code = 0x0110, aid = 0x0f00).
  • pair_client(mac): send a PAIR_RESP mgmt frame via send_wlan (fc = MGMT|0x70, reserved + PAIR_RESP byte + 9-byte payload).
  • remove_client(wcid): ms_command(REMOVE_CLIENT, {wcid-1,0,0,0}); zero the WCID ADDR/IV/ATTR regions.
  • LED: on when a client is added outside pairing mode; off when the last client leaves.

Architecture

One GIP adapter per controller, keyed by the chip's WCID (matches upstream). The rxwi.wcid identifies which controller a frame belongs to; within each adapter the GIP client ID is 0. Our ported get_client auto-creates a client on first packet, so a fresh adapter yields its single client on demand. (Verify the GIP header client ID on hardware.)

Constants To Add (mt76_defs.hpp)

  • WCID regions: ADDR base 0x1800 (+n8), KEY base 0x8000 (+n32, len 16), IV base 0xa000 (+n8), ATTR base 0xa800 (+n4); ATTR pairwise bit 0, pkey mode genmask(3,1) = AES_CCMP (4).
  • TXD info: DPORT genmask(29,27), QSEL genmask(26,25) EDCA=2, WIV bit 24, 80211 bit 19.
  • RX FCE info: CMD_SEQ genmask(19,16), EVT_TYPE genmask(23,20), D_PORT genmask(29,27).
  • rxwi: RXINFO_L2PAD bit 14; CTL_WCID genmask(7,0); CTL_MPDU_LEN genmask(29,16).
  • Events: BUTTON 0x04, PACKET_RX 0x0c, CLIENT_LOST 0x0e.
  • Client commands: WLAN_RESERVED fc 0x70; PAIR_REQ 0x01, PAIR_RESP 0x02, ENABLE_ENCRYPTION 0x10.
  • 802.11 FCTL: MGMT 0x00, DATA 0x08; ASSOC_REQ 0x00, ASSOC_RESP 0x10, DISASSOC 0xa0, QOS_DATA 0x70.

chip Functions To Port (mt76.c)

send_wlan, associate_client, pair_client, send_client_command, set_client_key, remove_client.

GUI

  • C API: expose connected controllers (count + per-client info: MAC, product name from GIP identify, battery).
  • Swift app: list controllers in the Controllers section of the Debug view.

Dongle Initialization Sequence

1. Detect dongle via IOKit
2. Download/verify firmware (firmware.sh)
3. Load firmware into chip (bulk transfer, 0x3800 byte chunks)
4. Chip disconnects + reconnects (handle this!)
5. Read EFUSE (MAC, chip ID, TX power cal)
6. Initialize radio (~80 register writes)
7. Evaluate channels (find best channel)
8. Start beacon transmission
9. Enter pairing mode (channel rotation)
10. When controller associates:
    a. Parse 802.11 assoc request
    b. Assign WCID (client ID)
    c. Set up AES-CCMP encryption
    d. Send assoc response
11. GIP handshake:
    a. Controller ANNOUNCE (vendor/product/fw version)
    b. Host sends IDENTIFY request
    c. Controller sends IDENTIFY (capabilities, interfaces, HID descriptor)
    d. Auth handshake (AES-CCMP key exchange)
    e. Status reports begin (battery, connected)
12. Game loop:
    a. Controller → HID reports (input)
    b. Host → rumble, LED, audio control

Firmware

Firmware binaries are downloaded from Microsoft Windows Update driver catalog:

PID Dongle Type Firmware File
all Supported dongles xow_dongle.bin

Downloaded via scripts/download-firmware.sh (port of install/firmware.sh).

Known Challenges

  1. USB timing: MT76 is timing-sensitive. User-space USB on macOS may have different latency than Linux kernel URBs. May need careful tuning of usleep values.

  2. Chip reconnect: During firmware load, the dongle disconnects and reconnects. IOKit needs to handle this gracefully (close, wait, reopen, re-claim).

  3. Virtual HID: Games expect a real HID device. HID Proxy Driver (DriverKit) is the cleanest path but requires a minimal kernel extension.

  4. 5GHz regulatory: The dongle uses 5GHz channels. macOS may have regulatory restrictions. May need to limit to 2.4GHz only.

  5. Audio: Headset audio is complex (real-time PCM streaming). Lower priority, tackle after gamepad works.

Source Files to Port

Linux source Target Notes
transport/mt76.c src/mt76/mt76.c Replace USB calls, remove cfg80211
transport/mt76.h include/mt76/mt76.hpp Clean up kernel types
transport/mt76_defs.h include/mt76/mt76_defs.hpp Mostly copy (register defs)
bus/protocol.c src/gip/protocol.c Replace kernel alloc/lock/debug
bus/protocol.h include/gip/protocol.hpp Clean up kernel types
bus/bus.c src/gip/bus.c Client lifecycle management
auth/auth.c src/auth/auth.c Pure crypto, mostly copy
auth/auth.h include/auth/auth.hpp Copy
auth/crypto.c src/auth/crypto.cpp AES-CCMP, mostly copy
auth/crypto.h include/auth/crypto.hpp Copy
driver/gamepad.c N/A Replaced by HID layer

Build System

  • CMake or Xcode project: either works
  • Static library for protocol/auth layers
  • macOS app bundle for the final product
  • Minimum macOS: 12.0 (Monterey): for modern IOKit/DriverKit support

Dependencies

  • IOKit (system)
  • CoreFoundation (system)
  • HID Proxy Driver framework (optional, for virtual gamepad)
  • Core Audio (optional, for headset support)
  • No external dependencies for protocol/auth layers