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 |
|
||||
| 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.
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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**);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue