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.
Division of labor¶
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, SDL reads the standard pad state. 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 (a bespoke MidiInputDevice with InputDeviceType.Midi) and unlike a Remote Link peer (RemotePeerDevice on a peer:// path). The earlier direct-HID read path (WiiControllerHidDevice) was removed once SDL could drive the controller.
The buttons, sticks, and D-pad reach the pipeline through the generic SDL gamepad mapping with no Wii-aware code. The Wii-specific reads sit on top of that path in SdlDeviceWrapper: the IR-pointer axes for a Wii Remote (#146), the four load-cell corners for a Balance Board (#146), and the enable of SDL's hidapi_wii driver via one hint. The Wii Remote speaker (#146) is a separate output path in WiiSpeakerService, not part of the read side.
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 with a Wii-specific overlay for IR, the Balance Board, and the speaker.
Files¶
| File | Role |
|---|---|
PadForge.App/Services/WiiPairingService.cs |
The pairing ceremony. public sealed class, P/Invoke over bthprops.cpl. Original C# over the Win32 Bluetooth API, following the sequence Dolphin's Source/Core/Core/HW/WiimoteReal/IOWin.cpp documents (no Dolphin GPL code). |
PadForge.App/Views/PairDeviceDialog.xaml.cs |
The Fluent dialog, family-selectable via a Controller Family combo (index 0 Nintendo Wii, 1 Sony DualShock 3, 2 PlayStation Move / Navigation). The Wii branch runs RunPairingScan on a background thread, which loops RunPairingPass until a controller pairs, a pass reports an error, or the user cancels. Either Sony family hides the temporary-pairing checkbox and the live found-list, and routes Pair_Click to PairDs3 / Ds3PairingService instead (out of scope here). |
PadForge.App/Common/Input/InputManager.cs |
The SDL Wii hint in InitializeSdl and the RescanWiiControllers hint toggle. |
PadForge.App/MainWindow.xaml.cs |
The PairRequested handler that opens the dialog, then runs the rescan when a Wii controller paired, and the 100 ms _sdlPumpTimer. |
PadForge.App/Services/InputService.cs |
RescanWiiControllers passthrough to the input manager. |
PadForge.Engine/Common/SdlDeviceWrapper.cs |
The capability gate that stops a stickless Wii Remote from advertising phantom stick axes, the HasIrCamera / IsBalanceBoard detection, and the IR-pointer read (ReadIrPointer). Twist compensation, the light-gun shot latch and the calibration window (StepIrPointer). |
PadForge.Engine/Common/WiiRemoteIdentity.cs |
The three camera configurations by SDL name, and the raw buttons that carry the remote's B and Home in each. |
PadForge.App/Views/GunCalibrationScreen.cs |
The four-target calibration screen, shared with the GunCon 2 through CalibrationGun. |
PadForge.Engine/Common/Mapping/SourceCoercion.cs |
ReadTunedBalanceBoard: the Lean X / Lean Y / Total Weight coercions from the four corner load cells. The grip rotation (#392): RotateForGrip, GripAxis, GripPov, ApplyMotionGrip, ReadGravity. |
PadForge.App/Services/GyroCalibratorService.cs |
The at-rest Motion Plus zero: a 1500 ms sample at connect, subtracted in both passthrough toggle states. |
PadForge.App/Common/Input/WiiSpeakerService.cs |
The Wii Remote speaker output sink. internal static class, 8-bit PCM at 2 kHz over the raw HID handle. |
PadForge.Engine/Haptics/WiiSpeakerAdpcm.cs |
Yamaha 4-bit ADPCM codec. Compiled and unit-tested, off the live speaker path. |
The pairing service depends only on bthprops.cpl and kernel32.dll. No managed Bluetooth library is involved.
WiiPairingService: one inquiry-and-pair pass¶
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 RunPairingScan, which loops the passes, on a background thread through Task.Run.
The search parameters set every return flag, unknown devices included:
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 | Scan the air rather than 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, psm-verification-failed, or exception, null on success). RunPairingScan loops passes and stops on the first pass that bonds a controller or reports an error, or when the user cancels. A pass that ends at once is paced to one per 100 ms. PairDeviceDialog accumulates the found controllers from each pass's progress report in a HashSet. Dolphin runs a fixed three iterations per click. PadForge instead loops until success, an error, or cancel.
One cross-cutting side effect matters when a DualShock 3 is in play. The whole scan holds a PsmPatchCoordinator.Suspension from Ds3DriverInstaller.SuspendPsmEnablingForWii() (issue #199), and PadForge's own requests to enable PSM patching are deferred while it is held. Before each step of a pass, CanContinuePairing has the suspension verify the BthPS3 PSM filter: the first check disables patching on every radio and reads the state back, and later checks fail if the filter instances changed or patching came back on. A machine with neither BthPS3 driver installed passes. A failed check stops the scan with psm-verification-failed, which the dialog shows as Wii pairing could not keep PS3 Bluetooth support paused. Close other controller tools and try again. When the scan ends, releasing the suspension restores policy through Ds3PairingService.ReconcilePsmPatchForCrashSafety("wii-scan-end"). A Wii Remote's incoming HID connection must not enter BthPS3's identify/deny/destroy path, where the upstream use-after-free lives, so with patching off the Wii's standard HID PSMs pass through to the inbox Bluetooth stack, which is where a Wii Remote belongs anyway. See Driver Management for the BthPS3 crash-safety detail.
Discovery match¶
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.
The two PIN modes¶
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 deprecated authentication sequence¶
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 in-memory diagnostics ring (crash context) with its return code, so a failed bond is diagnosable from the real handshake trace rather than a generic error. The dialog itself sees only the pass result (the found and paired controller names and any error), not the per-step trace.
Cleanup of unusable records¶
How a stale half-paired record is forgotten so the next pass can rediscover it.
This follows the algorithm of Dolphin's RemoveUnusableWiimoteBluetoothDevices, run inside 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.
Struct marshaling¶
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.
Elevation¶
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 |
IsWiiConnected and pairing narration¶
An uncalled public helper, and where the pairing narration actually goes.
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 after the dialog closes when the scan paired a Wii controller (PairDeviceDialog.PairedWii). A canceled dialog and the DualShock 3 and Move ceremonies skip it, because the toggle drops every connected Wii Remote for about 11 seconds.
PadForge writes no pairing log file. The private Log helper is a one-line delegate to the in-memory diagnostics ring: SdlDiagLog.WriteLine("WIIPAIR " + message), the same crash-context buffer the rest of the app narrates into. That ring is the only narration sink. The dialog shows only the pass result (found and paired names from PairPassResult), not the per-step handshake trace. There is no LogPath, no LogLine, and no lock or try/catch around logging, because a ring write does not throw the way a file write can. RescanWiiControllers does not narrate.
The reading side: the SDL Wii hint¶
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") 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 standard buttons, sticks, and D-pad are an ordinary SDL gamepad. The only Wii-aware reads on top of that are the IR pointer and the Balance Board corners, both covered below.
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.
Wii Remote IR pointer (#146)¶
The two sensor-bar dots, where SDL posts them, and how ReadIrPointer turns them into an aim point.
The IR camera path rides the same SdlDeviceWrapper read that surfaces the pad. It is gated by HasIrCamera, set at enumeration: the device is the Wii VID 0x057E, its SDL name is one of the three configurations that carry the camera ("Nintendo Wii Remote", "Nintendo Wii Remote with Nunchuk", "Nintendo Wii Remote with Classic Controller", WiiRemoteIdentity), and its raw joystick axis count is 10 or more. The fork's driver gives the four IR axes to those three configurations only (WiiRemoteHasIRCamera). A decoded extension from hifihedgehog/SDL#33 (guitar, drum kit, turntable, TaTaCon, the two tablets, the Shinkansen controller) spends axes 6 and up on its own controls, and the drum kit reports 13 axes, so the axis count alone read its hit velocities as IR dots before the name check. Reading the raw axis count (SDL_GetNumJoystickAxes), not the gamepad-pinned NumAxes of 6, confirms the DLL posts the IR axes, the same idiom the right Joy-Con NIR and Joy-Con 2 mouse detections use.
The SDL fork's hidapi_wii driver posts the two IR dots on dedicated joystick axes 6-9 (SDL#6 follow-up 2f6b18a7fe), separate from the gamepad sticks so a Nunchuk or Classic extension keeps axes 0-3. Axis 6 is dot 0 X (0..1023), axis 7 is dot 0 Y (0..767), axis 8 and 9 are dot 1. A value of -1 means the dot is not detected.
ReadIrPointer (SdlDeviceWrapper.cs line 1373) runs after the standard state read, joystick-direct, because the gamepad mapping does not surface these axes. It reads the four dot values, the remote's accelerometer X and Z, and the remote's B, and hands them to StepIrPointer (line 1417), which runs without SDL so the tests can drive it. It also records whether Home is down (WiiHomeHeld) for the calibration screen's cancel. ReadIrPointer writes the step's result to CustomInputState.Ir, a WiiIrState with X, Y, Detected and Calibrated.
The fork starts the camera when the remote's sensors are switched on (SDL_hidapi_wii.c SetJoystickSensorsEnabled, which calls EnableIR). Since SDL 77cf0379f5 (hifihedgehog/SDL#40) the start runs on the driver's update loop and waits on nothing: reports 0x13 and 0x1a, then five register writes, each sent once the remote acknowledges the one before it, and a write still unacknowledged 250 ms after it went out goes again, four sends in all (SendCameraStep, UpdateWrites). Since SDL f6459bcb4f (hifihedgehog/SDL#39) the driver starts a camera whose start failed again after 1, 2 and 4 seconds and then every 8, and a status report with the camera bit clear starts it again too. Before f6459bcb4f a write that timed out left the camera off until the next sensor toggle or a reopen, and before 77cf0379f5 each start waited for its acknowledgments under SDL's joystick lock. ReadIrPointer also runs StepIrCameraCheck (SdlDeviceWrapper.cs line 1511) on the four dot values, a backstop that records the start in the log. The fork posts a dot or -1 there with every IR report and SDL starts the axes at 0, so four zeros three seconds after the remote's first report, its first accelerometer sample, mean no IR report has come. The check then bounces the sensors on a worker (ScheduleIrCameraBounce, line 1554, calling BounceMotionSensors, line 203), at most three times per connection, at 3, 9 and 21 seconds. A bounce is one more start and sets the driver's retry delays back to the first. The diagnostics log gets a WIIIR line for each bounce, for a camera that starts after one, and for the give-up, after which the driver's retries carry on alone. Any IR report ends the check, and CloseInternal (line 765) resets it with the accelerometer seed, so a reopened wrapper checks again.
A remote that goes quiet for three seconds gets a status request from the driver. One that takes it keeps its joystick. One that refuses it, as a remote switched off at its power button does, loses its joystick, and the driver asks again every three seconds, so the remote is taken back when it returns without PadForge restarting (SDL f6459bcb4f). Windows keeps a switched-off Wii Remote listed, which is why the driver asks instead of waiting for the device to leave the list (Dolphin IOWin.cpp lines 428-438).
A Mayflash DolphinBar in mode 4 lists its four remote slots as Wii Remotes, and an empty slot fails every write while it can still send input. Since SDL b8afb934a2 (hifihedgehog/SDL#47) a remote connects only after a status request it takes, and a slot where no identify gets a reply gets no joystick, so an empty slot no longer becomes a "Wii Remote with Unknown Extension" whose raw button layout a remote synced into that slot later inherited (#562). The bar reports no serial, so the driver gives a remote with none the serial wiical- and its EEPROM calibration in hex, the camera's 11 bytes at 0x0000 and the accelerometer's 10 at 0x0016, read at each connection. SdlDeviceWrapper.BuildInstanceGuid keys the remote on that serial, so it keeps its PadForge slot in whichever bar slot it lands. A remote saved before that build was keyed by its bar slot's path, and the first time a remote arrives with a serial it takes the offline entry of the bar slot it sits in (FindOrCreateUserDevice).
ComputeIrAim (SdlDeviceWrapper.cs line 1800) turns the dots into the plain pair midpoint:
- All four at exactly 0 means no report has arrived yet (two dots on one pixel is physically impossible), so
Detectedis set false rather than yanking the pointer to a corner on connect. - The aim exists only when BOTH sensor-bar dots are visible.
ComputeIrAimreturnsDetected = falseif any of the four dot slots reads negative (fewer than two dots: out of reach). There is no single-dot fallback. Snapping the midpoint to the surviving dot would sit half a dot-separation away, and a steady sweep would re-walk that span of the screen (the #203 bench "double walk"). Every proven reference (Touchmote, Ryochan7-lightgun, Suegrini-4IR, WiimoteLib) computes the aim as a dot-pair midpoint and treats fewer than two dots as out of reach. - The
1024x768camera frame is normalized to the[-1..+1]stick range with X mirrored and Y direct, grounded against Touchmote and WiimoteLib.
StepIrPointer then corrects the midpoint for twist, latches the calibration shot, and maps the aim through the remote's light-gun window when it has one.
Twist compensation¶
A port of Touchmote's pointer_considerRotation, which base Touchmote at 663fe0b (ScreenPositionCalculator.cs lines 103-147 and 175-184) and the light-gun fork at 3994bb9 (lines 247-301) ship on by default. The 4IR fork at 850b04c has no such setting and always rotates (lines 227-276). Rolling the remote rolls the camera, which turns the dot pair and the midpoint's offset from the camera's center together, so turning the midpoint back by the pair's angle cancels the roll.
- On every poll, with the pair seen or not,
StepIrAccel(SdlDeviceWrapper.csline 1621) smooths the remote's own X and Z acceleration 0.9 to 0.1 per 10 ms report. The weight is spread over the 1 ms polls, and a stretch with no polls counts as one report. Every Touchmote variant smooths only frames with a pair, so a remote turned over while it cannot see the bar starts its next track with the orientation of before the turn. The fork posts the accelerometer with every report that carries it, and both IR reports it asks for, 0x33 for a bare remote without Motion Plus and 0x37 otherwise, carry it, so PadForge keeps the reading current through sight loss. The first sample that carries data seeds it. Touchmote's fields start at 0, which against its 128-count center reads as upside down on the first acquisition and flips the first track's aim, and the zeros SDL reads before the accelerometer's first report are no sample. - When tracking starts,
UpdateIrOrientation(line 1649) picks upright (0), upside down (2) or a side (1 or 3) from the smoothed reading, with Touchmote's hysteresis: the axis the current orientation does not use gives up 0.2 g, which is 5 of the remote's 25 counts per g, so the pick changes only when that axis leads by more than 0.2 g. A reading under 0.2 g changes nothing.PickIrLeftDot(line 1670) then names the left LED by Touchmote's table over raw camera pixels: the smaller X upright, the larger X upside down, the larger Y in orientation 1 and the smaller Y in 3, with ties going to dot 1. The choice holds while tracking lasts and resets when the pair is lost. IrPairAngle(line 1682) isatan2(dy, dx)of the left-to-right vector in raw pixels, andRotateIrAim(line 1703) turns the mirrored aim by it with Touchmote'srotatePoint. The two forks rotate the unmirrored point and negate the angle inside theirrotatePoint, which comes to the same turn. The turn runs in camera pixels: the aim goes to pixel units, half the camera's width across and half its height down, turns, and comes back. The pixels are square, 1024 by 768 over a 4:3 picture (Dolphin'sCamera.hlines 109-116), and the pair's angle is measured in them, but the aim divides X and Y by different half sizes. Every Touchmote variant turns that unevenly scaled point, which moves an off-center aim as the remote twists: an aim halfway to the screen's edge drifts 4 percent of the screen's height at a 30-degree twist and 8 percent of its width on a remote held on its side. The result is clamped to[-1..+1], as the forks clamp their half-scale point to 0.5. Two dots on one pixel give an angle of 0, where Touchmote's normalization divides by zero.
The fork posts SDL's accelerometer X as the remote's own X negated and SDL's Y as the remote's Z (SDL_hidapi_wii.c HandleWiiRemoteAccelData, lines 2159-2179 at b8afb934a2), so the read passes -Accel[0] and Accel[1]. The fork registers that accelerometer for all three camera configurations (lines 1612-1623, hifihedgehog/SDL#36). Before fork commit 3563535126 it registered none with a Classic Controller attached, and since enabling a sensor is what powers the camera, that configuration read no IR at all.
Light-gun calibration¶
The remote shares the GunCon 2's flow from hifihedgehog/SDL#33 Part 9. A shot is a press of the remote's B, read from the raw button its configuration posts it on (WiiRemoteIdentity.RemoteButtons, WiiRemoteIdentity.cs line 47): B on 0 and Home on 5 for a bare remote or one with a Nunchuk, 16 and 21 with a Classic Controller, whose controls take the gamepad positions (SDL_hidapi_wii.c lines 2313-2334 at b8afb934a2, and the k_eWiiButtons block from SDL_GAMEPAD_BUTTON_MISC1 at lines 167-178). RecordWiiPointerShot (SdlDeviceWrapper.cs line 1755) latches the press on the polling thread with the twist-compensated aim of that poll, before any window applies. It keeps the aim in pointer counts, 512 across and 384 down per unit of aim, with a press count and whether the pair was tracked. TryGetWiiPointerShot hands it to the calibration screen, which samples at display rate, the way the GunCon's pull is latched.
GunCalibrationScreen reads either gun through CalibrationGun (GunCalibrationScreen.cs line 21): the latest shot, whether the cancel buttons are down (Home for the remote, A or B for the GunCon) and the screen's text. GunCon2Calibration.TryFit fits the window as it does for the gun, and the Devices page stores it in UserDevice.GunCalibration. CalibrationGun.Wrapper names the wrapper the shot read closes over, and ShotSourceGone ends the screen when the device goes offline or its Device is no longer that wrapper. A reconnect inside the disconnect debounce rebinds the row without taking it offline, and a Wii Remote reopens in place for an extension change, so the old wrapper's shot would never move again. UserDevice.LoadFromSdlDevice and the save in DeviceService hand it to the wrapper's WiiPointerCalibration, as they hand the GunCon its window.
ApplyWiiPointerCalibration (SdlDeviceWrapper.cs line 1735) maps the aim through the window, clamps it to the picture, and stores it divided by the margin stretch, ApplyGunCon2's convention, so the slot read restores it. The step marks that aim Calibrated, and Remote Link carries the mark (BlockExt.IrCalibrated).
The slot read¶
The wrapper stores the screen-aligned aim. All tuning is applied later at the slot-scoped read in SourceCoercion.ReadTunedIrPointer (SourceCoercion.cs line 2227), because one remote can feed several virtual controllers, each with its own settings. That read applies four stages in order:
- Lineage margin stretch (base aim map, #203 pointer modes):
IrMarginStretchX = 1.8,IrMarginStretchY = 2.0, applied first. A tracked pair midpoint physically cannot reach +/-1, because both LEDs must stay in the camera view. Without this stretch the cursor walls off inside the screen in every pointer mode. This is the base map, not an optional extra. - Sensor-bar offset (Y only): the Pointer-tab vertical bar offset, in post-stretch screen space, matching Touchmote's
offsetY. ACalibratedaim skips it. A calibrated remote's window measured where the bar sits, and a GunCon 2's beam window has no bar at all:ApplyGunCon2marks an on-screen aimCalibrated, so a bar offset a Copy From carries onto the gun no longer moves it. - EMA smoothing: a per-(device, slot, axis) exponential moving average, clamped to
[0..0.95], dropped on sight loss so a re-acquire snaps instead of sliding in from stale. - Per-source sensitivity:
MappingSource.IrPointerSensitivity(default1.0), a mapping-row Slider with a reset button on the "IR Pointer X/Y" rows, then a final clamp to[-1..+1].
While no pair is seen, the read serves the last value it returned for that device, slot and axis, after smoothing and before the row's sensitivity, so two rows with different sensitivities each hold their own reading (_irHeld). It used to return 0, which put a light gun's crosshair in the middle of the screen whenever an LED left the camera's view. Touchmote returns its last position marked out of reach (ScreenPositionCalculator.cs lines 153-159), and the light-gun fork skips its IR stick update (ViGEmHandler.cs line 340). A device reads 0 until it is seen after it connects, after a profile switch, and after its slot's rows are replaced, the touch-momentum tiers. ForgetIrPointerForDevice (SourceCoercion.cs line 2311) drops a device's held values and smoothing state from the removal path that resets the trackball (InputManager.NeutralizeMappedOutputsFor) and from UserDevice.LoadFromDevice when a new connection binds, the rule that gives it a fresh ForceFeedbackState. A removal the teardown never sees, or a disconnect that arrives after a stale read took the row offline, left the old aim to the next connection. ForgetIrPointer runs from ClearSourceKindRuntime on a profile switch, and ForgetIrPointerForSlot from ResetSourceKindRuntimeForSlot when a slot's rows are replaced. A forget asked for on a thread other than the poll thread, a paste on the UI thread or a Remote Link registration on the link thread, takes effect at the poll thread's next frame boundary (BeginPollFrame). The poll thread can be between a remote's X and Y reads when it is asked for, and a clear there and then let the read finishing after it write its axis of the old aim back, so the new rows or the new connection served X from center and Y from before. The poll thread's own forgets, such as Step 1 binding a new connection, take effect at once. InputManager.Start registers each poll run before it starts the loop's thread (BeginPollRun), and BeginPollFrame checks the run under the lock the registration takes, so a loop Stop retired after a stalled join changes none of this bookkeeping once its replacement has started. A forget makes its poll-thread check and its clear under the same lock, so one that old loop asks for either finishes before the registration or waits for the new run's next active frame boundary, like one from any other thread. The Triggers tab's live preview reads IR sources on the UI thread and writes neither cache (IrReadOnlyScope). The GunCon 2's Gun Aim sources read through the same path and hold the same way.
On a Keyboard + Mouse slot, FindIrPointerSource routes an IR row to the absolute cursor (KbmRawState.MouseAbs*). It reads the slot's active row, as the touchpad pointer and flick stick do, so a shift layer's IR row drives the cursor while its layer is engaged, and an aiming remote counts as the layer's output for Auto-Cancel. It used to walk Base rows only, and a layer's IR row fell to the velocity lane, where the held aim kept the cursor moving after the bar left view. A layer that forces the mouse off, or a row whose other sources own the target, keeps the legacy per-key IR descriptor from routing around it, the row evaluators' rule. An Invert on Hold modifier is no source here, whatever its descriptor names, and the same holds for the touchpad pointer and the gyro, touchpad and flick stick lanes.
Wii Balance Board (#146)¶
How the board is told apart, how the corner loads reach the mapping grid, and the calibration source.
IsBalanceBoard is set at enumeration: the Wii VID plus an SDL name that contains "Balance Board". The board enumerates as a Wii Remote (PID 0x0306), so the name is the only reliable discriminator, and it is what keeps the board out of the HasIrCamera gate.
The four corner load cells ride the gamepad stick axes: ReadTunedBalanceBoard (SourceCoercion.cs) reads Axis[0], Axis[1], Axis[3], Axis[4] as top-left, bottom-left, top-right, bottom-right, undoing the unsigned +32768 shift and clamping negatives to 0. It coerces three sources by descriptor suffix:
- Lean X:
(right - left) / total, clamped to[-1..+1]. - Lean Y:
(top - bottom) / total. - Total Weight: each corner converted to kilograms and summed, minus the tare, scaled against
BalanceMaxKg.
Calibration is auto-parsed, not user-entered. The SDL fork exposes the board's 24-byte calibration block as the hex property SDL.joystick.wii.balance_board_calibration, which InputService.ParseBalanceCalibrationHex reads once and re-lays into the 12-float [corner × {Kg0, Kg17, Kg34}] shape the coercion interpolates. Without calibration, Total Weight falls back to a coarse raw-sum approximation so the source stays monotonic.
SetBalanceTare (InputService.cs) captures the current total weight as the zero-point so Total Weight reads 0 with whatever is on the board. It is code-reachable only. No UI element calls it today.
Wii Remote speaker (#146)¶
The output side: macro sounds through the Wii Remote's built-in speaker, and why the live path is PCM, not ADPCM.
WiiSpeakerService (PadForge.App/Common/Input/WiiSpeakerService.cs) is the Nintendo analogue of the Sony speaker path in AudioPassthroughService. A Wii Remote assigned to a slot becomes an output sink whose MacroMixer is returned to SoundMacroService alongside the Sony sinks, so a macro Play Sound fans out to it with no macro-layer change. It gates on the Wii Remote PIDs 0x0306 (RVL-CNT-01) and 0x0330 (Wii Remote Plus / -TR).
The live path streams signed 8-bit PCM at 2000 Hz, the WiiBrew-recommended config for slow Bluetooth stacks. The 48 kHz macro mix is resampled to 2000 Hz mono, quantized to 8-bit, and written as one 0x18 speaker report of 20 PCM samples per 10 ms tick (100 reports/s), never in bursts. The 20 is the sample count, not the wire frame size: the on-the-wire report is a report id, a control byte ((len<<3)), and the 20 payload bytes, zero-padded to the device's OutputReportByteLength (ReportLen = 22 floor). The init sequence (0x14 enable, 0x19 mute, the 0xa20001 register config for format 0x40 / rate 0x1770 / volume 0xFF, then 0x19 unmute) is the WiiBrew sequence with register offsets grounded in Dolphin's Speaker.h. Output writes go through overlapped WriteFile when the BT stack accepts it, falling back to HidD_SetOutputReport.
PCM is memoryless, which is the point. A dropped or late report on the SDL-shared BT link is a single click, not the cascading garble that differential ADPCM produces. WiiSpeakerAdpcm.cs (Yamaha 4-bit ADPCM) is compiled and unit-tested as the verified reference codec but stays off the live streaming path. The service also supports a system-audio loopback mirror, the same option the DualSense path offers.
RescanWiiControllers: post-pair recovery¶
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 (InputManager.cs) 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 on the SDL_Init thread, which is where SDL's hidapi posts its device-change messages and where a re-enumerate actually produces joysticks. SDL_PumpEvents runs on every tick. SDL_UpdateJoysticks is throttled to once per 500 ms inside PumpSdlEvents, because it shares SDL's joystick lock with the 1 kHz poll thread and the two were convoying into paired ~100 ms stalls. So the background toggle changes the hint and the UI pump processes each change.
SDL3-fork hidapi_wii fixes¶
The hidapi_wii fixes the bundled SDL3.dll carries beyond the upstream driver. Non-exhaustive: the MotionPlus and D-pad fixes are catalogued elsewhere.
Driving a Bluetooth Wii controller on Windows 8 and later relies on several fixes in PadForge's SDL3 fork, all shipped in the bundled SDL3.dll. These three cover pairing and connect stability:
- Bluetooth
hid_writefallback. A Wii Remote's output reports must go throughHidD_SetOutputReport, because the Microsoft in-box Bluetooth stack rejectsWriteFilefor them. Upstream only routes a device toHidD_SetOutputReportwhenoutput_report_length > 512and the host is pre-Windows 8, so a 22-byte Wiimote report never took that path. The fork keeps theWriteFilepath and adds a per-deviceHidD_SetOutputReportretry whenWriteFilereturns error 87 (ERROR_INVALID_PARAMETER), keeping the switch only if the retry succeeds so a device that needs the interrupt-OUT path is not stranded on the control pipe. This ishifihedgehog/SDL#2, referenced in theInitializeSdlcomment. - Connect-timeout seed.
m_ulLastInputis seeded withSDL_GetTicks()in the device'sInitDevice, and again after the re-identify below. Left at 0,UpdateDevice'snow >= m_ulLastInput + INPUT_WAIT_TIMEOUT_MScheck disconnects the remote the moment app uptime passes three seconds, before the app can open it (the "only works right after a restart" bug). - Extension hot-plug. The device stays physically present across an extension attach or detach, so the HIDAPI core never tears it down and re-runs
InitDevice.UpdateDevicere-identifies in place: drop the old joystick, re-read the extension type, refresh the name and GUID, clear the now-stale IR-active flag, re-seedm_ulLastInput, then re-add with the new capabilities.
A fourth fix feeds the IR pointer above: the driver posts the two IR dots on dedicated joystick axes 6-9 (hifihedgehog/SDL#6 follow-up 2f6b18a7fe), separate from the gamepad sticks so an extension keeps axes 0-3. Further fork fixes handle MotionPlus and D-pad mapping (the MotionPlus identify dead-window, the ~8s MotionPlus presence-churn loop, bare-D-pad POV-hat mapping, IR + MotionPlus coexistence). Those bear on gyro and hat mapping rather than the read path here, so their detail lives on Gyro and in the read-only SDL3 fork.
These live in the SDL3 fork and are read-only from PadForge's side. See SDL3 Integration.
Sensor frame and grip (#392)¶
Where the Wii Remote's motion frame comes from, what the grip rotation does to it, and every place it lands.
The remote's accelerometer reports in its own hardware frame. SDL's hidapi_wii driver permutes it into the standard controller frame for the aiming hold in HandleWiiRemoteAccelData, and PadForge keeps that frame end to end.
| Axis | Hardware accelerometer | SDL frame (values = (-x, z, y)) |
|---|---|---|
| X | Left | Right |
| Y | Toward the player | Up |
| Z | Up | Toward the player |
The Motion Plus report carries its three rates in the order yaw, roll, pitch. HandleMotionPlusData posts them as (-pitch, +yaw, +roll), the same rotation applied to the gyro. The signs are consistent with the accelerometer. Dolphin's emulated calibration block sets the yaw and pitch scales below zero, so a game reading through the block sees negative gain on those two axes, while raw counts under a fixed positive scale, which is what SDL uses, follow the right-hand rule in the hardware frame on all three axes. SDL's permutation is the proper rotation of that into its frame. There is no sign bug in the decode.
That frame is the aiming hold. The Held As row on the Gyro tab (PadSetting.MotionGrip, default "Pointing") supplies a body-to-game rotation for the other three holds, all proper rotations (determinant +1), so one table serves the gyro, the accelerometer, and the gravity estimate:
| Grip value | Label | (x, y, z) becomes | Grounding |
|---|---|---|---|
Pointing |
Pointing | (x, y, z) | Identity. |
Sideways |
Sideways, Face Up | (z, y, -x) | The swap SDL_hidapi_switch.c applies to a single left Joy-Con held sideways. Dolphin's RotateZ sideways orientation, inverted. |
WiiWheel |
Wii Wheel, Face Toward You | (z, x, y) | Bench-derived on a real remote: with the face-up table, the wheel hold steered on yaw. Body yaw becomes roll, body pitch becomes yaw. |
Upright |
Upright | (x, -z, y) | Resting gravity moves from the body's -Z onto +Y, where a flat pad reports it. Dolphin's RotateX, inverted. |
RotateForGrip is the vector form. GripAxis(grip, axis) is the per-axis form (source axis plus sign), used by ReadCalibratedGyroRate so that a single-axis read debiases the source axis in the frame the calibrator sampled and then applies the sign. Calibration and grip are independent. ReadGravity(guid, slot, aux) replaced the seven inline gravity fetches (the four space projections, the Motion Lean reads, ReadGyroLean, SourceKindRuntime.TickMotionLean) and rotates the body sensor's gravity only. The aux sensor (Nunchuk, left Joy-Con) is a separate body and keeps its frame, except inside the fused Joy-Con pair read, where both halves are one body and rotate together.
Where the rotation lands:
ApplyMotionGripon the accelerometer copy of the motion snapshot (InputManager.cs), which feeds the virtual controller's motion report and the DSU server, in both states of the passthrough tuning toggle. The gyro copy gets it insideGetPassthroughGyro's calibrated read.- The Gyro tab's live rate and accelerometer readouts (
InputService.cs), so they show the held frame. GripPov(guid, slot, centidegrees): forSidewaysandWiiWheel,(cd - 9000) mod 36000, so a physical 9000 (Right) reads 0 (Up). Dolphin'sdpad_sideways_bitmaskstable, with the diagonals carried by the angle arithmetic.UprightandPointingare the identity, and a centered hat (negative) passes through. Applied at the evaluator'sPovDirectionreads (SourceEvaluator,SourceKindRuntime,SourceCoercion), the legacyPadSettingmapper inInputManager.Step3, the macro trigger reads inStep4b, andRecorderService, so a press recorded in the hold fires on that same press. Output-side hats (the HIDMaestro raw POVs, the Valve packers, the Step 4 combine) are untouched, and the Devices page stays physical.- The Compass yaw correction is gated on
GripAxis(grip, 1).source == 1: it is measured on the body's yaw axis and only belongs on a hold that keeps yaw on the yaw lane.
A user edit of Held As runs InputService.RecenterMotionForSelectedDevice from the MainWindow property hook, guarded by ShouldRecenterOnGripChange so a PadSetting load (device selection, profile switch) does not drop every device's estimate on the slot. It re-seeds that one device's gravity estimate from the next accelerometer sample and drops its lean, tilt, and shake neutrals.
The tab itself was gated on HasGyro before 4.4.0, so an accelerometer-only remote never saw it and the Gyro Tilt card was unreachable. PadPage.xaml.cs now shows the tab for HasGyro || HasAccel and hides the five rate cards (GyroPassthroughCard, GyroCalibrationCard, GyroSensitivityCard, GyroResponseCard, GyroEngageCard) unless HasGyro.
Motion Plus calibration (known limitation)¶
The fork's Motion Plus decode applies fixed constants and reads no calibration block.
A Motion Plus carries a 32-byte calibration block at register 0xA60020 (0xA40020 when passthrough is active) with a per-axis zero and scale for the slow and fast ranges. Dolphin reads it (MotionPlus.cpp, GetRelevantCalibration). The SDL fork's HandleMotionPlusData does not. It subtracts a fixed zero of 8192 counts on every axis and applies a fixed scale of 8192/440 counts per degree per second in the slow range and 8192/2000 in the fast range, chosen per axis by the report's slow/fast flag bits.
Two consequences follow:
- The zero is PadForge's.
GyroCalibratorServiceauto-runs a 1500 ms at-rest sample the first time a slot sees the remote, andGetPassthroughGyrosubtracts the measured bias in both passthrough toggle states. The calibrator rejects a bias aboveMaxPlausibleBias(0.15 rad/s) and writes nothing, so a remote whose true zero sits further from 8192 than that stays uncorrected. Whether a real Motion Plus zero can exceed that gate has not been measured. - The scale is fixed. Against the calibration block of Dolphin's reference unit (0x4400 >> 2 = 4352 counts per 270 degrees per second), the fixed slow scale reads about 13% low, and the yaw axis is a different sensor chip with its own scale. Computed from the source, not measured on a remote.
A fork patch would read the block once after activation, use the per-axis zero and scale, and fall back to today's constants when the checksum fails. It is not filed.
Related pages¶
- 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, and the Grip card.
- Controller Audio Internals: the shared sink model the Wii Remote speaker follows, and the HD-haptic tone path.
- Driver Management: HIDMaestro and HidHide setup.
Last updated for PadForge 5.2.0.