feat: add Xbox impulse-trigger rumble mapping

This commit is contained in:
Joey Yakimowich-Payne 2026-09-06 16:14:11 -06:00
commit 4e48b7a9e7
9 changed files with 136 additions and 11 deletions

View file

@ -317,7 +317,7 @@ When a controller becomes ready, RGB-capable devices such as DualSense and DualS
| PS Move ZCM1/ZCM2 | Buttons/trigger | Yes | Yes, after calibration |
| Wii Remote | Mode-dependent | Yes | Accelerometer |
| 8BitDo in Switch-compatible Bluetooth mode | Yes | Model-dependent | Yes when the mode exposes IMU |
| Xbox Bluetooth controller | Yes | Yes | No hardware IMU |
| Xbox Bluetooth controller | Yes | Grip + impulse-trigger motors (Microsoft Xbox parser) | No hardware IMU |
Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and 8192 units per g in SDL-oriented axes before conversion to Nintendo samples. PS Move motion remains neutral until all model-specific calibration blocks have been received and validated; buttons and rumble remain available while calibration is pending or unavailable. The latest normalized sample is duplicated across the report's three nominal 5 ms slots and remains pending until a regular `0x30` USB report successfully consumes it.
@ -325,6 +325,10 @@ Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and
Commands remain bound to a USB slot and Bluetooth connection generation. Compatibility output uses a latest-value mailbox; native output keeps a bounded timestamped command history instead of collapsing substeps.
Microsoft controllers (`045E`) using Bluepad32's Xbox parser now add **impulse-trigger rumble** while retaining the existing strong/weak grip output. Switch high-band amplitude drives the corresponding left/right trigger, taking the peak across each command's substeps and capping the added output at half scale. Conventional/XInput high-frequency magnitude drives both triggers at half strength. Profile rumble scaling applies before this mapping; local confirmation/identify pulses remain grip-only. All four motors share the existing duration/stop handling. This is amplitude-only translation, not HD/PCM playback or adaptive-trigger resistance; the compatibility mailbox and transport cadence are unchanged.
The connected Classic Xbox (`045E:02E0`) was exercised with 307 USB reports: two rounds of left high-band, right high-band, and both, with intervening stops. It remained connected, and configuration generation 13 / CRC `3af5ee18` was preserved. Firmware counters confirmed host rumble dispatch; the user tested the effect and accepted it as good. The 261-test suite passed, including Xbox side isolation, amplified trigger-only output, conventional mapping, stop, and disconnect coverage.
The standard AIO and XInput builds use **300 MHz at 1.3 V**, packet-level CYW43 reads, bounded HCI credit returns, and native DualSense haptics by default. The first eligible DualSense/DualSense Edge that becomes ready can occupy the one native PCM stream, in any slot; later controllers do not steal it. Nintendo native output is a separate, explicit per-controller opt-in described below. Unapproved and unsupported controllers retain their existing parser-specific output. To change the selected DualSense manually, stop the current run and use `haptics-experiment gameplay --slot N` (API slots are zero-based).
In Switch mode, that stream preserves decoded left/right, low/high-band HD commands. In XInput mode, strong/low magnitude drives the left 160 Hz carrier and weak/high drives the right 320 Hz carrier; these commands stay active until changed or stopped. XInput does not supply Nintendo frequency/substep detail. USB reset, unmount, and suspend stop held host rumble. Auto-mode XInput additionally reboots to Switch probe after unmount, by the existing one-attachment policy; manual XInput is exempt.
@ -780,15 +784,15 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file:
| Resource | Used or reserved | Device capacity |
|---|---:|---:|
| Executable flash image | 786,864 bytes | 4 MiB |
| Executable flash image | 787,824 bytes | 4 MiB |
| Indexed profile arenas | 256 KiB | 4 MiB flash |
| Adapter configuration | 8 KiB | 4 MiB flash |
| BTstack bonds | 8 KiB | 4 MiB flash |
| RP2350 terminal sector | 4 KiB | 4 MiB flash |
| Allocated/reserved SRAM, including heap and stacks | 139,616 bytes | 520 KiB |
The executable plus persistent reservations consume 1,069,488 bytes of flash,
leaving 3,124,816 bytes. Allocated SRAM sections leave 392,864 bytes of link-time
The executable plus persistent reservations consume 1,070,448 bytes of flash,
leaving 3,123,856 bytes. Allocated SRAM sections leave 392,864 bytes of link-time
headroom; this is not a runtime heap high-water measurement. Core 0 has a
4 KiB stack, and Core 1 uses a dedicated 16 KiB stack in main SRAM for nested
catalog migration/compaction rather than overflowing its 4 KiB scratch bank.

Binary file not shown.

Binary file not shown.

View file

@ -1314,6 +1314,39 @@ void dispatch_rumble(uni_hid_device_t* device, uint16_t duration_ms,
device->report_parser.play_dual_rumble(device, 0, duration_ms, weak, strong);
}
uint8_t xbox_trigger_magnitude(const SwitchHapticsActuatorFrame& frame) {
uint16_t peak = 0;
for (uint8_t i = 0; i < frame.sample_count && i < 3; ++i) {
if (frame.samples[i].high_amplitude_q15 > peak)
peak = frame.samples[i].high_amplitude_q15;
}
// Impulse triggers are amplitude-only ERMs, not HD actuators. Keep their
// extra response at half scale, including after profile amplification.
if (peak > 32767) peak = 32767;
return static_cast<uint8_t>((static_cast<uint32_t>(peak) * 127u) / 32767u);
}
void dispatch_host_rumble(uni_hid_device_t* device, uint16_t duration_ms,
const ControllerRumbleOutput& rumble) {
const uint8_t weak = rumble.high_frequency_magnitude;
const uint8_t strong = rumble.low_frequency_magnitude;
if (device->vendor_id == 0x045e &&
device->report_parser.play_dual_rumble ==
uni_hid_parser_xboxone_play_dual_rumble) {
const bool hd = rumble.hd.actuators[0].sample_count != 0 ||
rumble.hd.actuators[1].sample_count != 0;
const uint8_t left = hd ? xbox_trigger_magnitude(rumble.hd.actuators[0])
: weak / 2u;
const uint8_t right = hd ? xbox_trigger_magnitude(rumble.hd.actuators[1])
: weak / 2u;
const bool stop = (weak | strong | left | right) == 0;
xboxone_play_quad_rumble(device, 0, stop ? 0 : duration_ms,
left, right, weak, strong);
return;
}
dispatch_rumble(device, (weak | strong) == 0 ? 0 : duration_ms, weak, strong);
}
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
void seed_native_host_rumble() {
HapticsExperimentDiagnostics native;
@ -1584,13 +1617,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
device->report_parser.play_dual_rumble != nullptr) {
__atomic_add_fetch(
&g_rumble_dispatches, 1, __ATOMIC_RELAXED);
const bool stop =
envelope.rumble.low_frequency_magnitude == 0 &&
envelope.rumble.high_frequency_magnitude == 0;
dispatch_rumble(
device, stop ? 0 : envelope.duration_ms,
envelope.rumble.high_frequency_magnitude,
envelope.rumble.low_frequency_magnitude);
dispatch_host_rumble(device, envelope.duration_ms, envelope.rumble);
}
}

View file

@ -64,6 +64,9 @@ void require_clear_completion_pending();
void require_clear_snapshot_published();
void request_repeated_clear_during_disconnect();
gap_connection_type_t gap_connection_types[256]{};
uint8_t xbox_left_trigger = 0;
uint8_t xbox_right_trigger = 0;
unsigned xbox_quad_calls = 0;
struct CoreStopped {};
@ -126,6 +129,21 @@ void register_lookup_device(uni_hid_device_t* candidate) {
} // namespace
void xboxone_play_quad_rumble(uni_hid_device_t* device, uint16_t delay,
uint16_t duration, uint8_t left, uint8_t right,
uint8_t weak, uint8_t strong) {
++xbox_quad_calls;
xbox_left_trigger = left;
xbox_right_trigger = right;
play_rumble(device, delay, duration, weak, strong);
}
void uni_hid_parser_xboxone_play_dual_rumble(
uni_hid_device_t* device, uint16_t delay, uint16_t duration,
uint8_t weak, uint8_t strong) {
xboxone_play_quad_rumble(device, delay, duration, 0, 0, weak, strong);
}
bool uni_hid_device_is_gamepad(const uni_hid_device_t* device) {
return device != nullptr && device->gamepad;
@ -2288,6 +2306,74 @@ void test_host_rumble_mode_duration() {
#endif
}
void test_xbox_trigger_rumble() {
start_pairing_backend();
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
test_adapter_mode = AdapterUsbMode::kSwitchProbe;
#endif
auto controller = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
controller.vendor_id = 0x045e;
controller.product_id = 0x02e0;
controller.report_parser.play_dual_rumble =
uni_hid_parser_xboxone_play_dual_rumble;
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
"Xbox did not become ready");
ControllerRumbleOutput hd{80, 100};
hd.hd.actuators[0].sample_count = 3;
hd.hd.actuators[1].sample_count = 1;
hd.hd.actuators[0].samples[1].high_amplitude_q15 = 16384;
bluepad32_input_backend_queue_rumble(0, hd);
process_rumble_timer(&g_rumble_timer);
require(xbox_left_trigger == 63 && xbox_right_trigger == 0 &&
controller.last_low == 80 && controller.last_high == 100 &&
controller.last_rumble_duration_ms == 50,
"left high-band substep must drive only left trigger, preserving grips");
hd = {};
hd.hd.actuators[0].sample_count = 1;
hd.hd.actuators[1].sample_count = 1;
hd.hd.actuators[0].samples[0].low_amplitude_q15 = 32767;
hd.hd.actuators[1].samples[0].high_amplitude_q15 = 65535;
bluepad32_input_backend_queue_rumble(0, hd);
process_rumble_timer(&g_rumble_timer);
require(xbox_left_trigger == 0 && xbox_right_trigger == 127 &&
controller.last_rumble_duration_ms == 50,
"right trigger-only effect must not stop or overflow after amplification");
dispatch_rumble(&controller, 75, 100, 100);
require(xbox_left_trigger == 0 && xbox_right_trigger == 0,
"local feedback must clear trigger vibration");
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
test_adapter_mode = AdapterUsbMode::kXInput;
#endif
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{200, 180});
process_rumble_timer(&g_rumble_timer);
require(xbox_left_trigger == 90 && xbox_right_trigger == 90 &&
controller.last_rumble_duration_ms == host_rumble_duration_ms(),
"conventional high-frequency rumble must feed both triggers");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{});
process_rumble_timer(&g_rumble_timer);
require(xbox_left_trigger == 0 && xbox_right_trigger == 0 &&
controller.last_high == 0 && controller.last_low == 0 &&
controller.last_rumble_duration_ms == 0,
"explicit stop must stop all four motors");
const unsigned calls = xbox_quad_calls;
controller.report_parser.play_dual_rumble = play_rumble;
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{45, 67});
process_rumble_timer(&g_rumble_timer);
require(xbox_quad_calls == calls && controller.last_low == 45 &&
controller.last_high == 67,
"Microsoft VID alone must not route an unrelated parser to Xbox output");
controller.report_parser.play_dual_rumble =
uni_hid_parser_xboxone_play_dual_rumble;
bluepad32_input_backend_queue_rumble(0, hd);
platform_on_device_disconnected(&controller);
process_rumble_timer(&g_rumble_timer);
require(xbox_quad_calls == calls,
"pending Xbox output must not reach a disconnected controller");
}
void test_clear_pairings() {
classic_bond_count = 1;
classic_bonds[0][0] = 0x10;
@ -2775,6 +2861,8 @@ int main(int argc, char** argv) {
test_protocol_neutral_analog_state();
} else if (scenario == "rumble-mode") {
test_host_rumble_mode_duration();
} else if (scenario == "xbox-rumble") {
test_xbox_trigger_rumble();
} else if (scenario == "clear-pairings") {
test_clear_pairings();
} else if (scenario == "configuration-timer") {

View file

@ -165,6 +165,11 @@ struct uni_hid_device_s {
uni_circular_buffer_t outgoing_buffer;
};
void uni_hid_parser_xboxone_play_dual_rumble(
uni_hid_device_t*, uint16_t, uint16_t, uint8_t, uint8_t);
void xboxone_play_quad_rumble(
uni_hid_device_t*, uint16_t, uint16_t, uint8_t, uint8_t, uint8_t, uint8_t);
struct uni_platform {
const char* name;
void (*init)(int, const char**);

View file

@ -90,6 +90,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
]
)
subprocess.run(command, check=True, cwd=root)
subprocess.run([str(executable), "xbox-rumble"], check=True, cwd=root)
if native:
subprocess.run([str(executable), "native-stateful"], check=True, cwd=root)
subprocess.run(