HD rumble on Switch Pro Controllers and Joy-Cons, through SDL's effect call #1213

Merged
hckrman101 merged 6 commits from hd-rumble into main 2026-11-09 20:17:53 +00:00
hckrman101 commented 2026-11-09 17:38:46 +00:00 (Migrated from github.com)

HD rumble on Switch Pro Controllers and Joy-Cons

A Nintendo Switch Pro Controller or Joy-Con now plays the game's HD rumble as the Switch does: each side of the controller, both frequency bands, at the frequencies and amplitudes the game asks for, instead of two motor strengths. Every other controller keeps the rumble it has today.

How

  • The game's vibration values (nn::hid::VibrationValue: low and high band amplitude and frequency, per side) already reach vibration_send in host/nn_hid.c. One added call hands the raw value for its side to host/pad_sdl.c (hos_sdl_rumble_hd), beside the motor values it already gets. The motor mapping is unchanged.
  • host/hd_rumble.h (new, pure, no SDL): encodes a value into the 4 bytes a Switch controller plays per side and builds the 10-byte rumble report (0x10, packet number, left, right) that SDL's Switch driver takes through SDL_SendGamepadEffect (SDL 3.2 on, HIDAPI_DriverSwitch_SendJoystickEffect, which writes it in place of its own rumble).
  • Frequencies are kept to the bands' ranges (low 40.9 to 626 Hz, high 81.75 to 1252 Hz), amplitudes to 1.003 (code 100), where the documented table stops calling them safe for the actuators and where SDL's own table ends. Silence is 00 01 40 40, as SDL sends it.
  • host/hos_sdl.c: the pad's type (SDL_GetGamepadType / SDL_GameControllerGetType) and the effect call (SDL_SendGamepadEffect / SDL_GameControllerSendEffect), both looked up as optional, so an SDL without them loads as before.
  • host/pad_sdl.c: a pad SDL reports as a Switch Pro Controller or Joy-Con (left, right or pair) gets the packet. A Pro Controller (and a pair) plays the game's left side on its left and right on its right; a single Joy-Con plays its own side. A packet goes out when it changes, never sooner than 32 ms after the last one (SDL keeps 30 ms between its own writes: faster can turn a Bluetooth controller off), and a rumble that goes on is sent again every 40 ms (SDL refreshes its own every 50 ms, or the controller stops it). The stop is sent once. Such a pad gets no SDL_RumbleGamepad call, so SDL's internal rumble never runs over it.
  • If SDL refuses the packet once for a pad, that pad goes to the normal motor rumble for as long as it is open (logged once). This is what happens with SDL2's own library (its Switch driver has no effect call: SDL_Unsupported up to 2.32), a Switch 2 controller (SDL's Switch 2 driver has none either), a Joy-Con pair SDL combined into one controller (the combined driver has none), and the Linux kernel's hid-nintendo driver. sdl2-compat (the Windows game's SDL2.dll) forwards to SDL3, so it works there too.
  • HOS_HD_RUMBLE=0 sends the Switch controllers the motor rumble, as before.
  • The Insert menu's Rumble strength (#1169) scales HD rumble's amplitudes too, as it scales the motors: Off is silence, and over 100% both bands of a side get the same factor, the stronger stopping at the top, so they keep their balance.

Tests

tests/test_hd_rumble.py compiles the encoder: rows of the documented frequency and amplitude tables come out as their bytes, every amplitude code 1 to 100 comes back from its own amplitude, the reverse engineering notes' example packet (a8 89 e3 4d), silence, clamping of both frequencies and the amplitude (with a strength over 100%), each side's place in the packet for a Pro Controller, a pair and single Joy-Cons, which SDL3 and SDL2 types count, and the cadence. It also runs pad_sdl.c's hd_open and hd_poll against a fake SDL: nothing is sent while silent, a held rumble about every 40 ms and never faster than 32 ms, one stop, and a pad whose SDL refuses the packet goes to the motor rumble for good. It checks that the poll scales HD rumble by the menu's Rumble strength.

Docs

The readme's Controls section says which controllers get HD rumble, that it needs SDL3, and that on Linux SDL's Switch driver needs read and write access to the controller's hidraw device (Steam's udev rules, steam-devices); without it the kernel's hid-nintendo driver runs the controller, with the motor rumble.

Needs a real controller

I could not test the feel here. Worth checking with a Pro Controller and Joy-Cons, over USB and Bluetooth: that the rumble feels right and steady (no buzz between refreshes, no stop during a long rumble), that it stops when it should, and that Bluetooth stays connected at this cadence. OoT asks for small amplitudes for most effects (0.02 to 0.06), so HD rumble at the game's own strength is gentle, as on the console.

Credits

The encoding follows the community's documentation: Switchbrew's HID "Rumble data" page and dekuNukem's Nintendo_Switch_Reverse_Engineering (rumble_data_table.md), written from their formulas and tables. The report layout and timing follow SDL's Switch driver (zlib license).

Tested on hardware

A Switch 1 Pro Controller over USB on Linux, with the system SDL3 (3.2+) and hidraw access: the log shows hid: HD rumble on "Nintendo Switch Pro Controller" (no fallback), and the HD rumble is felt in play (sword on grass, hits, landing, item catches), steady and stopping cleanly. The first version sent OoT's small amplitudes (mostly 0.02-0.1, from its RumbleCall gains) unboosted and was too faint to feel; ea47d62 puts each band through the same curve the motor path has used since #263, keeping the frequencies.

Still untested on hardware: Bluetooth, single Joy-Cons, Windows with the bundled SDL3.

## HD rumble on Switch Pro Controllers and Joy-Cons A Nintendo Switch Pro Controller or Joy-Con now plays the game's HD rumble as the Switch does: each side of the controller, both frequency bands, at the frequencies and amplitudes the game asks for, instead of two motor strengths. Every other controller keeps the rumble it has today. ### How - The game's vibration values (`nn::hid::VibrationValue`: low and high band amplitude and frequency, per side) already reach `vibration_send` in `host/nn_hid.c`. One added call hands the raw value for its side to `host/pad_sdl.c` (`hos_sdl_rumble_hd`), beside the motor values it already gets. The motor mapping is unchanged. - `host/hd_rumble.h` (new, pure, no SDL): encodes a value into the 4 bytes a Switch controller plays per side and builds the 10-byte rumble report (0x10, packet number, left, right) that SDL's Switch driver takes through `SDL_SendGamepadEffect` (SDL 3.2 on, `HIDAPI_DriverSwitch_SendJoystickEffect`, which writes it in place of its own rumble). - Frequencies are kept to the bands' ranges (low 40.9 to 626 Hz, high 81.75 to 1252 Hz), amplitudes to 1.003 (code 100), where the documented table stops calling them safe for the actuators and where SDL's own table ends. Silence is 00 01 40 40, as SDL sends it. - `host/hos_sdl.c`: the pad's type (`SDL_GetGamepadType` / `SDL_GameControllerGetType`) and the effect call (`SDL_SendGamepadEffect` / `SDL_GameControllerSendEffect`), both looked up as optional, so an SDL without them loads as before. - `host/pad_sdl.c`: a pad SDL reports as a Switch Pro Controller or Joy-Con (left, right or pair) gets the packet. A Pro Controller (and a pair) plays the game's left side on its left and right on its right; a single Joy-Con plays its own side. A packet goes out when it changes, never sooner than 32 ms after the last one (SDL keeps 30 ms between its own writes: faster can turn a Bluetooth controller off), and a rumble that goes on is sent again every 40 ms (SDL refreshes its own every 50 ms, or the controller stops it). The stop is sent once. Such a pad gets no `SDL_RumbleGamepad` call, so SDL's internal rumble never runs over it. - If SDL refuses the packet once for a pad, that pad goes to the normal motor rumble for as long as it is open (logged once). This is what happens with SDL2's own library (its Switch driver has no effect call: SDL_Unsupported up to 2.32), a Switch 2 controller (SDL's Switch 2 driver has none either), a Joy-Con pair SDL combined into one controller (the combined driver has none), and the Linux kernel's hid-nintendo driver. sdl2-compat (the Windows game's SDL2.dll) forwards to SDL3, so it works there too. - `HOS_HD_RUMBLE=0` sends the Switch controllers the motor rumble, as before. - The Insert menu's Rumble strength (#1169) scales HD rumble's amplitudes too, as it scales the motors: Off is silence, and over 100% both bands of a side get the same factor, the stronger stopping at the top, so they keep their balance. ### Tests `tests/test_hd_rumble.py` compiles the encoder: rows of the documented frequency and amplitude tables come out as their bytes, every amplitude code 1 to 100 comes back from its own amplitude, the reverse engineering notes' example packet (a8 89 e3 4d), silence, clamping of both frequencies and the amplitude (with a strength over 100%), each side's place in the packet for a Pro Controller, a pair and single Joy-Cons, which SDL3 and SDL2 types count, and the cadence. It also runs `pad_sdl.c`'s `hd_open` and `hd_poll` against a fake SDL: nothing is sent while silent, a held rumble about every 40 ms and never faster than 32 ms, one stop, and a pad whose SDL refuses the packet goes to the motor rumble for good. It checks that the poll scales HD rumble by the menu's Rumble strength. ### Docs The readme's Controls section says which controllers get HD rumble, that it needs SDL3, and that on Linux SDL's Switch driver needs read and write access to the controller's hidraw device (Steam's udev rules, `steam-devices`); without it the kernel's hid-nintendo driver runs the controller, with the motor rumble. ### Needs a real controller I could not test the feel here. Worth checking with a Pro Controller and Joy-Cons, over USB and Bluetooth: that the rumble feels right and steady (no buzz between refreshes, no stop during a long rumble), that it stops when it should, and that Bluetooth stays connected at this cadence. OoT asks for small amplitudes for most effects (0.02 to 0.06), so HD rumble at the game's own strength is gentle, as on the console. ### Credits The encoding follows the community's documentation: Switchbrew's HID "Rumble data" page and dekuNukem's Nintendo_Switch_Reverse_Engineering (`rumble_data_table.md`), written from their formulas and tables. The report layout and timing follow SDL's Switch driver (zlib license). ## Tested on hardware A Switch 1 Pro Controller over USB on Linux, with the system SDL3 (3.2+) and hidraw access: the log shows `hid: HD rumble on "Nintendo Switch Pro Controller"` (no fallback), and the HD rumble is felt in play (sword on grass, hits, landing, item catches), steady and stopping cleanly. The first version sent OoT's small amplitudes (mostly 0.02-0.1, from its RumbleCall gains) unboosted and was too faint to feel; ea47d62 puts each band through the same curve the motor path has used since #263, keeping the frequencies. Still untested on hardware: Bluetooth, single Joy-Cons, Windows with the bundled SDL3.
hckrman101 (Migrated from github.com) reviewed 2026-11-09 19:26:37 +00:00
hckrman101 (Migrated from github.com) left a comment

Self-review notes:

Checked and fine:

  • Encoding: frequency codes round(log2(f/10)*32), high band (code-0x60)*4 kept to 0x004..0x1FC (81.75-1252.6 Hz), low band code-0x40 kept to 0x01..0x7F (40.9-626.3 Hz). The three amplitude ranges match the rumble_data_table rows (2^(c/4)/119.6 below 16, log2(17a)*16 for 16-31, log2(8.7a)*32 from 32), clamped at code 100 / 1.003. The byte packing (9th bits of the high freq and low amp) is right, and silence 00 01 40 40 is SDL's neutral.
  • Payload: 10 bytes, 0x10 + number + 2x4, which is sizeof(SwitchCommonOutputPacket_t). SDL3's HIDAPI_DriverSwitch_SendJoystickEffect checks that size before its 2..256-byte subcommand branch, copies rumbleData into m_RumblePacket and calls WriteRumble. It skips SDL's own 30 ms gate, so the 32 ms gap here is what keeps Bluetooth safe. The 40 ms refresh is under SDL's 50 ms. SDL's own refresh only runs while m_bRumbleActive, and that never gets set because these pads never get SDL_RumbleGamepad, so nothing competes with the packets.
  • Types: SDL3 7..10 (PRO, JOYCON_L/R/PAIR) and SDL2 5 / 11..13 are the right enum values. The signatures (SDL3 bool vs SDL2 int == 0) are right, and both symbols are optional.
  • Fallback: on SDL2's Switch driver (I'm fairly sure it's SDL_Unsupported there, and either way it falls back), the combined Joy-Con pair, Switch 2 pads and Linux evdev/hid-nintendo (LINUX_JoystickSendEffect is unsupported), the first packet is refused. The pad then switches to the motor path in the same poll, with rumble_lo/ns reset so what's playing goes out right away.
  • Rumble strength: applied after the felt curve, the same order the motor path uses. Off encodes to silence and the stop goes out once, and over 100% keeps the band balance. Locking (SRW between the HLE and the poll) is fine. There's no conflict with main: main hasn't touched these files since the merge base, and the test gets picked up by run_all.py like test_rumble_refresh.py.

To fix / consider:

  1. A single Joy-Con only plays its own side (inline). The game always sees a FullKey Pro Controller, so anything it sends only to the other side is lost on a lone Joy-Con. The motor path takes the max of both sides.
  2. Third-party pads in Switch mode (GameSir, 8BitDo and similar, reported as NINTENDO_SWITCH_PRO) go down the HD path, and SDL accepts the packet, so they never fall back. How well each firmware turns HD packets into its ERM motors is untested. If one turns out weak or silent, HOS_HD_RUMBLE=0 is the only way out. Worth a line in the readme, or a per-pad opt-out later.
  3. Still untested on hardware: Bluetooth cadence, single Joy-Cons and Windows.
Self-review notes: Checked and fine: - Encoding: frequency codes round(log2(f/10)*32), high band (code-0x60)*4 kept to 0x004..0x1FC (81.75-1252.6 Hz), low band code-0x40 kept to 0x01..0x7F (40.9-626.3 Hz). The three amplitude ranges match the rumble_data_table rows (2^(c/4)/119.6 below 16, log2(17a)*16 for 16-31, log2(8.7a)*32 from 32), clamped at code 100 / 1.003. The byte packing (9th bits of the high freq and low amp) is right, and silence 00 01 40 40 is SDL's neutral. - Payload: 10 bytes, 0x10 + number + 2x4, which is sizeof(SwitchCommonOutputPacket_t). SDL3's HIDAPI_DriverSwitch_SendJoystickEffect checks that size before its 2..256-byte subcommand branch, copies rumbleData into m_RumblePacket and calls WriteRumble. It skips SDL's own 30 ms gate, so the 32 ms gap here is what keeps Bluetooth safe. The 40 ms refresh is under SDL's 50 ms. SDL's own refresh only runs while m_bRumbleActive, and that never gets set because these pads never get SDL_RumbleGamepad, so nothing competes with the packets. - Types: SDL3 7..10 (PRO, JOYCON_L/R/PAIR) and SDL2 5 / 11..13 are the right enum values. The signatures (SDL3 bool vs SDL2 int == 0) are right, and both symbols are optional. - Fallback: on SDL2's Switch driver (I'm fairly sure it's SDL_Unsupported there, and either way it falls back), the combined Joy-Con pair, Switch 2 pads and Linux evdev/hid-nintendo (LINUX_JoystickSendEffect is unsupported), the first packet is refused. The pad then switches to the motor path in the same poll, with rumble_lo/ns reset so what's playing goes out right away. - Rumble strength: applied after the felt curve, the same order the motor path uses. Off encodes to silence and the stop goes out once, and over 100% keeps the band balance. Locking (SRW between the HLE and the poll) is fine. There's no conflict with main: main hasn't touched these files since the merge base, and the test gets picked up by run_all.py like test_rumble_refresh.py. To fix / consider: 1. A single Joy-Con only plays its own side (inline). The game always sees a FullKey Pro Controller, so anything it sends only to the other side is lost on a lone Joy-Con. The motor path takes the max of both sides. 2. Third-party pads in Switch mode (GameSir, 8BitDo and similar, reported as NINTENDO_SWITCH_PRO) go down the HD path, and SDL accepts the packet, so they never fall back. How well each firmware turns HD packets into its ERM motors is untested. If one turns out weak or silent, HOS_HD_RUMBLE=0 is the only way out. Worth a line in the readme, or a per-pad opt-out later. 3. Still untested on hardware: Bluetooth cadence, single Joy-Cons and Windows.
@ -0,0 +120,4 @@
static inline void hd_rumble_packet(int kind, const uint8_t left[4], const uint8_t right[4], uint8_t out[HD_RUMBLE_PACKET_SIZE]) {
out[0] = HD_RUMBLE_REPORT;
out[1] = 0; /* (SDL numbers the packets itself) */
const uint8_t *l = kind == HD_RUMBLE_JOYCON_RIGHT ? right : left, *r = kind == HD_RUMBLE_JOYCON_LEFT ? left : right;
hckrman101 (Migrated from github.com) commented 2026-11-09 19:26:38 +00:00

A single Joy-Con plays only its own side here. nn_hid always presents a FullKey Pro Controller, though, so the game keeps addressing both sides, and an effect it sends to just the other side never reaches a lone Joy-Con (the motor path uses the max of both sides). For JOYCON_LEFT/RIGHT, merge the two sides before encoding: take each band's stronger amplitude, with the frequency that goes with it. A Joy-Con pair opened as two separate pads would then both play the merged value, which is still better than losing effects. Needs a test row.

A single Joy-Con plays only its own side here. nn_hid always presents a FullKey Pro Controller, though, so the game keeps addressing both sides, and an effect it sends to just the other side never reaches a lone Joy-Con (the motor path uses the max of both sides). For JOYCON_LEFT/RIGHT, merge the two sides before encoding: take each band's stronger amplitude, with the frequency that goes with it. A Joy-Con pair opened as two separate pads would then both play the merged value, which is still better than losing effects. Needs a test row.
hckrman101 (Migrated from github.com) commented 2026-11-09 19:26:38 +00:00

Minor: any refusal turns HD off for as long as the pad is open, including a one-off write failure on a pad that otherwise takes HD. With SDL3 the write is queued on SDL's rumble thread, so false here effectively means "unsupported", and that's fine. The comment just shouldn't suggest more than that.

Minor: any refusal turns HD off for as long as the pad is open, including a one-off write failure on a pad that otherwise takes HD. With SDL3 the write is queued on SDL's rumble thread, so false here effectively means "unsupported", and that's fine. The comment just shouldn't suggest more than that.
hckrman101 commented 2026-11-09 20:11:40 +00:00 (Migrated from github.com)

Self-review addressed:

  1. Single Joy-Con: fixed in 5e2d7ac. A lone Joy-Con now plays both sides merged (hd_rumble_merge: each band at the stronger side's amplitude, with that side's frequency), like the motor path's max; Pro Controllers and pairs keep each side separate. Test rows cover the per-band max, one-sided input, and a left-only effect on a right Joy-Con (and the reverse).
  2. Third-party pads: added a readme line saying HD rumble goes to anything SDL reports as a Switch Pro Controller or Joy-Con, third-party pads in Switch mode included, that some may turn it into weak or no rumble, and that HOS_HD_RUMBLE=0 switches them to the motor rumble. A vendor-id check wouldn't help: SDL's Switch driver rewrites every pad that identifies as a Pro Controller or Joy-Con to Nintendo's VID/PID (UpdateDeviceIdentity), so they can't be told apart.
  3. Refusal comment: reworded. A refusal turns HD off for that pad until it's closed and opened again, and with SDL3 the write is queued, so false means the driver doesn't support the effect, not a one-off failed write.
  4. Merged current main (c5514b9), no conflicts.
Self-review addressed: 1. **Single Joy-Con:** fixed in 5e2d7ac. A lone Joy-Con now plays both sides merged (`hd_rumble_merge`: each band at the stronger side's amplitude, with that side's frequency), like the motor path's max; Pro Controllers and pairs keep each side separate. Test rows cover the per-band max, one-sided input, and a left-only effect on a right Joy-Con (and the reverse). 2. **Third-party pads:** added a readme line saying HD rumble goes to anything SDL reports as a Switch Pro Controller or Joy-Con, third-party pads in Switch mode included, that some may turn it into weak or no rumble, and that `HOS_HD_RUMBLE=0` switches them to the motor rumble. A vendor-id check wouldn't help: SDL's Switch driver rewrites every pad that identifies as a Pro Controller or Joy-Con to Nintendo's VID/PID (`UpdateDeviceIdentity`), so they can't be told apart. 3. **Refusal comment:** reworded. A refusal turns HD off for that pad until it's closed and opened again, and with SDL3 the write is queued, so false means the driver doesn't support the effect, not a one-off failed write. 4. **Merged current main** (c5514b9), no conflicts.
Commenting is not possible because the repository is archived.
No description provided.