feat: add Xbox impulse-trigger rumble mapping
This commit is contained in:
parent
dcc97e05ba
commit
4e48b7a9e7
9 changed files with 136 additions and 11 deletions
12
README.md
12
README.md
|
|
@ -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.
Binary file not shown.
Binary file not shown.
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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**);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue