diff --git a/README.md b/README.md index a834f90..aac6bd7 100644 --- a/README.md +++ b/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. diff --git a/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index 5603432..8bcd5b1 100755 Binary files a/firmware/switch-pico-adapter-feasibility.elf and b/firmware/switch-pico-adapter-feasibility.elf differ diff --git a/firmware/switch-pico-adapter-feasibility.uf2 b/firmware/switch-pico-adapter-feasibility.uf2 index c59baa0..3eabf8e 100644 Binary files a/firmware/switch-pico-adapter-feasibility.uf2 and b/firmware/switch-pico-adapter-feasibility.uf2 differ diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index 9d1d765..e99b233 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index 70d9e32..d0b92a5 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index be68b89..b49f426 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -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((static_cast(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); } } diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index b7ac31d..8a74540 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -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") { diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index 2e21249..d64b47f 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -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**); diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index ed25723..8dc9342 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -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(