-
Notifications
You must be signed in to change notification settings - Fork 6
Wii Controllers Internals
How a Bluetooth Wii controller becomes a mappable pad: the Win32 pairing ceremony in WiiPairingService, the SDL hidapi_wii read path, and the post-pair hint-toggle recovery in InputManager.
This is the developer-side companion to Wii Controllers (the user guide) and issue #116.
What PadForge owns versus what SDL owns once a controller is paired.
PadForge owns one thing: the OS-level Bluetooth pairing handshake. Windows cannot drive that handshake from its own pairing UI, because a Wii controller's PIN is six raw bytes that depend on which sync button was pressed, not a typed string. WiiPairingService runs the handshake with the deprecated bthprops.cpl API.
After the controller is bonded, PadForge does not read it. SDL's hidapi_wii driver enumerates the paired device, parses its reports, and surfaces it as an ordinary SDL gamepad. It then flows through SdlDeviceWrapper and the normal six-step pipeline (see Input Pipeline) exactly like an Xbox or DualSense pad. There is no Wii-specific InputDeviceType, no synthetic identity, and no custom sub-state. This is unlike MIDI Input Internals (a bespoke MidiInputDevice with InputDeviceType.Midi) and unlike a Remote Link Internals peer (RemotePeerDevice on a peer:// path). The earlier direct-HID read path (WiiControllerHidDevice) was removed once SDL could drive the controller. The only Wii-specific line in the read path is one SDL hint.
So the data flow is: PadForge pairs over Bluetooth, the Microsoft Bluetooth stack writes the link key, SDL opens the now-stable HID device, and PadForge maps it through the generic SDL path.
| File | Role |
|---|---|
PadForge.App/Services/WiiPairingService.cs |
The pairing ceremony. public sealed class, P/Invoke over bthprops.cpl. A direct port of Dolphin's Source/Core/Core/HW/WiimoteReal/IOWin.cpp. |
PadForge.App/Views/PairDeviceDialog.xaml.cs |
The Fluent dialog. Loops RunPairingPass on a background thread until a controller pairs or the user cancels. |
PadForge.App/Common/Input/InputManager.cs |
The SDL Wii hint at InitializeSdl (line 539) and RescanWiiControllers (line 697). |
PadForge.App/MainWindow.xaml.cs |
The PairRequested handler (~646) that opens the dialog then runs the rescan, and the 100 ms _sdlPumpTimer. |
PadForge.App/Services/InputService.cs |
RescanWiiControllers passthrough to the input manager (line 6527). |
PadForge.Engine/Common/SdlDeviceWrapper.cs |
The capability gate that stops a stickless Wii Remote from advertising phantom stick axes. |
The pairing service depends only on bthprops.cpl and kernel32.dll. No managed Bluetooth library is involved.
The RunPairingPass flow, the inquiry parameters, and why a pass returns every device state.
RunPairingPass(bool temporary, CancellationToken ct) runs a single Bluetooth inquiry and tries to bond every Wii controller it finds in pairing mode. The sequence is BluetoothFindFirstRadio to get the host radio handle, BluetoothGetRadioInfo to read the host address and name, then BluetoothFindFirstDevice and BluetoothFindNextDevice to walk the inquiry results. The inquiry uses cTimeoutMultiplier = 2, about 2.5 seconds per pass. The call blocks for that duration, so the dialog runs it on a background thread through Task.Run.
The search parameters set every return flag, not just unknown devices:
BLUETOOTH_DEVICE_SEARCH_PARAMS field |
Value | Reason |
|---|---|---|
fReturnAuthenticated |
1 | See an already-bonded controller. |
fReturnRemembered |
1 | See a stale half-paired record so it can be reset. |
fReturnUnknown |
1 | See a fresh, never-paired controller. |
fReturnConnected |
1 | See a controller that is already live. |
fIssueInquiry |
1 | Actually scan the air, not just read cached state. |
cTimeoutMultiplier |
2 | About 2.5 s of inquiry. |
Filtering out the remembered state would hide a controller left half-paired by an earlier attempt, so it could never be reset and re-paired. Returning all four states is what makes that record visible to the cleanup step.
RunPairingPass returns a PairPassResult carrying the Wii controllers seen this pass (Found), the ones it bonded (Paired), the total device count from the inquiry (DiscoveredCount), and an Error string (no-radio, radio-info, no-bluetooth-stack, or exception, null on success). PairDeviceDialog loops passes, accumulating found controllers in a HashSet, and stops on the first pass that bonds one or when the user cancels. Dolphin runs a fixed three iterations per click. PadForge instead loops until success or cancel.
How a pass decides a discovered device is a Wii controller and not a stray keyboard.
A device matches if its advertised name starts with Nintendo (case-insensitive). Every Wii peripheral advertises a Nintendo RVL-CNT-01 style name. A fresh inquiry of a never-seen device often returns an empty name, so there is a fallback: when the name is empty, the device matches only on an exact Class-of-Device value.
| Class of Device | Controller |
|---|---|
0x002504 |
Wii Remote |
0x000508 |
Wii Remote Plus / -TR |
IsWiiClassOfDevice matches those two exact values rather than the broad Peripheral major class. A broad match would try to pair a stray Bluetooth keyboard or mouse that happens to be in discovery range. The Class-of-Device fallback only fires when the name is empty, so a named device is never matched by class.
How the SYNC button and the 1+2 hold map to two different PINs, and how the PIN bytes are derived.
A Wii controller has two sync methods, and each one expects a different six-byte PIN.
| Mode | Trigger | temporary |
PIN source | Bonding |
|---|---|---|---|---|
| SYNC | Red SYNC button under the battery cover | false |
The host radio's own address (radioInfo.address) |
Persistent. Reconnects on any button press afterward. |
| 1+2 | Hold the 1 and 2 buttons | true |
The controller's own address (deviceInfo.Address) |
Session only. Not bonded, so it re-pairs next time. |
The PIN derivation is the same shape in both modes. The six Bluetooth-address bytes are taken low byte first, each widened into one WCHAR, and the passkey length is 6:
char[] passkey = new char[6];
for (int i = 0; i < 6; i++)
passkey[i] = (char)((pinSource >> (8 * i)) & 0xFF);This exact shape is what the deprecated authentication API expects and what Dolphin passes.
The three-call bonding sequence Dolphin proved, and why the callback path is not used.
TryPairDevice runs the bonding for one discovered controller. The important detail from Dolphin: do not use the BluetoothRegisterForAuthenticationEx callback path. Use the deprecated BluetoothAuthenticateDevice with the PIN passed directly. The sequence, when the device is not already authenticated:
-
BluetoothAuthenticateDevice(IntPtr.Zero, hRadio, ref device, passkey, 6). The PIN is the wide-char array above, length 6. A non-zero return aborts the pair. -
BluetoothEnumerateInstalledServices(hRadio, ref device, ref pcServices, IntPtr.Zero). A count-only query with a null service array. Dolphin notes this "must be done to make the remote remember the pairing."ERROR_MORE_DATA(234) is tolerated as success because the count query is expected to report that more data exists. -
BluetoothSetServiceState(hRadio, ref device, HumanInterfaceDeviceServiceClass_UUID, BLUETOOTH_SERVICE_ENABLE). Enables the HID service so the controller appears as a HID device. The UUID is{00001124-0000-1000-8000-00805F9B34FB}.
When the device is already authenticated (a re-pair of a remembered controller), steps 1 and 2 are skipped and only the HID-service enable runs.
Every step is written to the pairing log with its return code, so a failed bond is diagnosable from real data rather than a generic error.
How a stale half-paired record is forgotten so the next pass can rediscover it.
This is Dolphin's RemoveUnusableWiimoteBluetoothDevices, inlined into the pass loop. For each discovered Wii controller:
-
Already connected (
fConnected != 0): leave it alone. It is working. The pass counts it as found and paired and moves on. -
Remembered but not authenticated and not connected (
fRemembered != 0 && fAuthenticated == 0): this record cannot reconnect and it blocks re-pairing.BluetoothRemoveDevice(ref deviceInfo.Address)forgets it, and the next pass rediscovers it fresh. -
Otherwise: proceed to
TryPairDevice.
So a controller stuck in a remembered-but-dead state is reset automatically across passes, which is why the search returns the remembered state in the first place.
Why the interop structs use default packing, and the failure mode when they do not.
Default packing is required on every Bluetooth struct. Pack = 1 would undersize them and the API rejects the dwSize field with ERROR_REVISION_MISMATCH (1306). The cause is BLUETOOTH_ADDRESS, an 8-byte (ULONGLONG) union that is 8-byte aligned, so the native layout has pad bytes the packed layout drops.
| Struct | Natural size | Note |
|---|---|---|
BLUETOOTH_RADIO_INFO |
520 bytes | 4 pad bytes after dwSize before the 8-byte-aligned address. |
BLUETOOTH_DEVICE_INFO |
560 bytes | Same alignment requirement. |
BLUETOOTH_DEVICE_SEARCH_PARAMS |
default | The trailing IntPtr hRadio must land on its 8-byte slot. |
Each struct sets its dwSize from Marshal.SizeOf<T>() before the call, so the managed size and the native size must agree, which only holds under natural alignment. String fields are ByValTStr with SizeConst = 248 and CharSet.Unicode.
Why the process must be elevated and how the failure surfaces.
BluetoothAuthenticateDevice and BluetoothSetServiceState return ERROR_ACCESS_DENIED (5) from a non-elevated process. PadForge always runs elevated (HIDMaestro requires it), so this is satisfied in normal use. The service maps the common return codes for the log through DescribeError:
| Code | Meaning |
|---|---|
| 5 |
ACCESS_DENIED (need elevation) |
| 31 | GEN_FAILURE |
| 87 | INVALID_PARAMETER |
| 170 | BUSY |
| 234 | MORE_DATA |
| 259 | NO_MORE_ITEMS |
| 1167 | DEVICE_NOT_CONNECTED |
Two public helpers on the service, and what is and is not wired to them today.
IsWiiConnected() is a fast state read with fIssueInquiry = 0, so it returns the radio's current connection state without paying the 2.5-second inquiry. Its intent, stated in the doc comment, is to time the SDL re-enumeration to the moment the controller actually connects (which happens on a button press, possibly seconds after the pair completes) rather than a fixed delay. It has no caller today. The shipped post-pair recovery uses the fixed-cadence RescanWiiControllers instead, which the MainWindow PairRequested handler runs unconditionally after the dialog closes.
LogPath is %TEMP%\PadForge-WiiPair.log, surfaced in the dialog so a failed pair can be read from the real handshake trace. LogLine(string) is public for the same reason IsWiiConnected exists: so the SDL re-enumeration step could write into the same file as pairing. It is also currently uncalled. RescanWiiControllers does not log. The log file is written only by the pairing pass through the private Log, under a lock, with a try/catch so logging can never break a pair.
The single hint that turns on SDL's Wii driver and what it does at enumeration time.
InitializeSdl sets SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_WII, "1") at line 539, before SDL_Init. That enables SDL's hidapi_wii driver. It surfaces the Bluetooth-paired controller, parses all four forms (Wii Remote / Plus, Remote plus Nunchuk, Classic / Pro, Wii U Pro), and lights the player LED, which stops the idle flashing the controller does while unassigned. From there the controller is an ordinary SDL gamepad. No other PadForge read code is Wii-aware.
One enumeration detail matters for the stickless Wii Remote. SdlDeviceWrapper.GetDeviceObjects gates each standard axis on SDL_GamepadHasAxis (the axis path mirrors the existing button gate on SDL_GamepadHasButton). A Wii Remote with no Nunchuk has no sticks, so it must not advertise phantom Left or Right Stick axes. A phantom axis reads as a dead center, and CreateDefaultPadSetting's auto-map trusts the DeviceObjects capability list, so without the gate it would pin both virtual sticks to a corner. See SDL3 Integration for the wrapper detail.
Why a freshly-paired controller needs a forced re-open, and the hint-toggle that delivers it.
During the pairing ceremony SDL grabs the Wii Remote mid-pairing. Then BluetoothSetServiceState and the rest of the pairing churn invalidate that handle, so the SDL joystick drops and SDL keeps a stale device that only a full app restart clears. RescanWiiControllers (line 697) replicates the restart for just that one driver:
for (int i = 0; i < 8 && !_disposed; i++)
{
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_WII, "0");
Thread.Sleep(200);
SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_WII, "1");
Thread.Sleep(1200);
}Setting the hint to 0 runs SDL_HIDAPIDriverHintChanged, which disables the driver. SDL cleans up the stale device, closes the dead handle, and resets its hidapi change count to force a re-enumerate. Setting the hint back to 1 re-opens and re-inits the now-stable device. The toggle repeats eight times over about 11 seconds ((200 + 1200) * 8) to cover the post-pair settling window, because the controller only connects on a button press that can come several seconds late.
The work runs on a background thread through Task.Run and checks _disposed each iteration. It is safe to call from any thread because SDL_SetHint is thread-safe. The actual driver re-scan happens on the UI thread: MainWindow's _sdlPumpTimer fires PumpSdlEvents every 100 ms, and PumpSdlEvents runs SDL_PumpEvents then SDL_UpdateJoysticks on the SDL_Init thread, which is where SDL's hidapi posts its device-change messages and where a re-enumerate actually produces joysticks. So the background toggle changes the hint and the UI pump processes each change.
The three driver fixes the bundled SDL3.dll carries that the upstream driver does not.
Driving a Bluetooth Wii controller on Windows 8 and later relies on three fixes in PadForge's SDL3 fork, all shipped in the bundled SDL3.dll:
-
Bluetooth
hid_writefallback. A Wii Remote's output reports must go throughHidD_SetOutputReport, because the Microsoft Bluetooth stack rejectsWriteFilefor an output length at or under 512 bytes (a Wiimote report is 22). The fork keeps theWriteFilepath and adds aHidD_SetOutputReportfallback whenWriteFilereturns error 87 (ERROR_INVALID_PARAMETER). This ishifihedgehog/SDL#2, referenced in theInitializeSdlcomment. -
Connect-timeout seed.
m_ulLastInputis seeded withSDL_GetTicks()in the device'sInitDevice. Without it, a freshly-paired remote is disconnected after the app's first three-second input timeout before it has sent anything. -
Extension hot-plug.
UpdateDevicere-identifies the controller when a Nunchuk or other extension is attached or detached, so the form change is picked up and the device re-added without a restart.
These live in the SDL3 fork and are read-only from PadForge's side. See SDL3 Integration.
The pairing ceremony, the SDL hint, and the rescan toggle are implemented and build clean. The runtime is hypothesis-under-test. There was no Wii hardware available to confirm the end-to-end bond, the player-LED behavior, or the post-pair re-open against a live controller.
- Wii Controllers: the user guide for this feature.
- Input Pipeline: the six-step pipeline that maps the controller once SDL surfaces it.
-
SDL3 Integration: the
hidapi_wiidriver, the fork fixes, and the axis capability gate. - Devices: the Pair button and the paired controller's device card.
- Gyro: the Wii Remote's accelerometer and Motion Plus path.
- Driver Management: HIDMaestro and HidHide setup.