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 | | PS Move ZCM1/ZCM2 | Buttons/trigger | Yes | Yes, after calibration |
| Wii Remote | Mode-dependent | Yes | Accelerometer | | Wii Remote | Mode-dependent | Yes | Accelerometer |
| 8BitDo in Switch-compatible Bluetooth mode | Yes | Model-dependent | Yes when the mode exposes IMU | | 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. 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. 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). 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. 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 | | 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 | | Indexed profile arenas | 256 KiB | 4 MiB flash |
| Adapter configuration | 8 KiB | 4 MiB flash | | Adapter configuration | 8 KiB | 4 MiB flash |
| BTstack bonds | 8 KiB | 4 MiB flash | | BTstack bonds | 8 KiB | 4 MiB flash |
| RP2350 terminal sector | 4 KiB | 4 MiB flash | | RP2350 terminal sector | 4 KiB | 4 MiB flash |
| Allocated/reserved SRAM, including heap and stacks | 139,616 bytes | 520 KiB | | Allocated/reserved SRAM, including heap and stacks | 139,616 bytes | 520 KiB |
The executable plus persistent reservations consume 1,069,488 bytes of flash, The executable plus persistent reservations consume 1,070,448 bytes of flash,
leaving 3,124,816 bytes. Allocated SRAM sections leave 392,864 bytes of link-time 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 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 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. 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); 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 #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
void seed_native_host_rumble() { void seed_native_host_rumble() {
HapticsExperimentDiagnostics native; HapticsExperimentDiagnostics native;
@ -1584,13 +1617,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
device->report_parser.play_dual_rumble != nullptr) { device->report_parser.play_dual_rumble != nullptr) {
__atomic_add_fetch( __atomic_add_fetch(
&g_rumble_dispatches, 1, __ATOMIC_RELAXED); &g_rumble_dispatches, 1, __ATOMIC_RELAXED);
const bool stop = dispatch_host_rumble(device, envelope.duration_ms, envelope.rumble);
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);
} }
} }

View file

@ -64,6 +64,9 @@ void require_clear_completion_pending();
void require_clear_snapshot_published(); void require_clear_snapshot_published();
void request_repeated_clear_during_disconnect(); void request_repeated_clear_during_disconnect();
gap_connection_type_t gap_connection_types[256]{}; 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 {}; struct CoreStopped {};
@ -126,6 +129,21 @@ void register_lookup_device(uni_hid_device_t* candidate) {
} // namespace } // 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) { bool uni_hid_device_is_gamepad(const uni_hid_device_t* device) {
return device != nullptr && device->gamepad; return device != nullptr && device->gamepad;
@ -2288,6 +2306,74 @@ void test_host_rumble_mode_duration() {
#endif #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() { void test_clear_pairings() {
classic_bond_count = 1; classic_bond_count = 1;
classic_bonds[0][0] = 0x10; classic_bonds[0][0] = 0x10;
@ -2775,6 +2861,8 @@ int main(int argc, char** argv) {
test_protocol_neutral_analog_state(); test_protocol_neutral_analog_state();
} else if (scenario == "rumble-mode") { } else if (scenario == "rumble-mode") {
test_host_rumble_mode_duration(); test_host_rumble_mode_duration();
} else if (scenario == "xbox-rumble") {
test_xbox_trigger_rumble();
} else if (scenario == "clear-pairings") { } else if (scenario == "clear-pairings") {
test_clear_pairings(); test_clear_pairings();
} else if (scenario == "configuration-timer") { } else if (scenario == "configuration-timer") {

View file

@ -165,6 +165,11 @@ struct uni_hid_device_s {
uni_circular_buffer_t outgoing_buffer; 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 { struct uni_platform {
const char* name; const char* name;
void (*init)(int, const char**); 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(command, check=True, cwd=root)
subprocess.run([str(executable), "xbox-rumble"], check=True, cwd=root)
if native: if native:
subprocess.run([str(executable), "native-stateful"], check=True, cwd=root) subprocess.run([str(executable), "native-stateful"], check=True, cwd=root)
subprocess.run( subprocess.run(