From ab70536fdf7529e917e2912d5993c0e98d604955 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 12 Sep 2026 22:06:50 -0600 Subject: [PATCH] Add DualSense native pair and Wii-only IMU settling --- CMakeLists.txt | 52 +- README.md | 95 +++- .../parser/uni_hid_parser_switch2.c | 14 +- .../parser/uni_hid_parser_switch2.h | 2 +- patches/bluepad32-sdl3-imu.patch | 319 +++++++++++- .../input/bluepad32_input_backend.cpp | 485 +++++++++++++++++- src/firmware/input/bluepad32_input_backend.h | 30 ++ src/firmware/input/switch2_mouse_capture.cpp | 47 +- src/firmware/input/switch2_mouse_capture.h | 8 +- .../parser/uni_hid_parser_ds5.h | 14 + tests/bluepad32_native_stubs/uni.h | 2 + tests/dualsense_backend_test.cpp | 291 +++++++++++ tests/dualsense_parser_native_test.c | 262 ++++++++++ tests/switch2_dualsense_bridge_test.cpp | 335 ++++++++++++ tests/switch2_mouse_bridge_test.cpp | 58 +++ tests/switch2_native_imu_test.cpp | 2 +- tests/switch2_wii_bridge_test.cpp | 4 +- tests/switch_parser_native_stubs/btstack.h | 1 + tests/switch_parser_native_test.c | 9 + tests/test_dualsense_backend_native.py | 54 ++ tests/test_dualsense_parser_native.py | 51 ++ tests/test_switch2_dualsense_bridge_native.py | 57 ++ tools/native_joycon_hub_check.py | 24 +- tools/switch2_usb_probe/controller_input.cpp | 64 ++- tools/switch2_usb_probe/controller_input.h | 12 +- tools/switch2_usb_probe/dualsense_input.cpp | 330 ++++++++++++ tools/switch2_usb_probe/dualsense_input.h | 13 + tools/switch2_usb_probe/main.c | 7 +- tools/switch2_usb_probe/native_imu.cpp | 13 +- tools/switch2_usb_probe/native_imu.h | 18 +- tools/switch2_usb_probe/probe_build.cmake | 21 +- 31 files changed, 2617 insertions(+), 77 deletions(-) create mode 100644 tests/bluepad32_native_stubs/parser/uni_hid_parser_ds5.h create mode 100644 tests/dualsense_backend_test.cpp create mode 100644 tests/dualsense_parser_native_test.c create mode 100644 tests/switch2_dualsense_bridge_test.cpp create mode 100644 tests/test_dualsense_backend_native.py create mode 100644 tests/test_dualsense_parser_native.py create mode 100644 tests/test_switch2_dualsense_bridge_native.py create mode 100644 tools/switch2_usb_probe/dualsense_input.cpp create mode 100644 tools/switch2_usb_probe/dualsense_input.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 5023f22..16d4a87 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -176,27 +176,49 @@ if(SWITCH_PICO_SWITCH2_MEMORY_CAPTURE) add_compile_definitions(SWITCH_PICO_SWITCH2_MEMORY_CAPTURE=1) endif() set(SWITCH2_BRIDGE_WII_INPUT OFF) +set(SWITCH2_BRIDGE_DUALSENSE_INPUT OFF) if(SWITCH_PICO_SWITCH2_USB_BRIDGE) - if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32" - OR NOT SWITCH_PICO_ENABLE_BLE - OR NOT SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + set(SWITCH2_BRIDGE_INPUT "JOYCON2" CACHE STRING "Native bridge source: JOYCON2, WII or DUALSENSE") + set_property(CACHE SWITCH2_BRIDGE_INPUT PROPERTY STRINGS JOYCON2 WII DUALSENSE) + set(SWITCH2_BRIDGE_IMU_TARGET "BOTH" CACHE STRING "Translated native-pair IMU target: LEFT, RIGHT or BOTH") + set_property(CACHE SWITCH2_BRIDGE_IMU_TARGET PROPERTY STRINGS LEFT RIGHT BOTH) + if(SWITCH2_BRIDGE_IMU_TARGET STREQUAL "RIGHT") + set(SWITCH2_BRIDGE_IMU_TARGET_MASK 1) + elseif(SWITCH2_BRIDGE_IMU_TARGET STREQUAL "LEFT") + set(SWITCH2_BRIDGE_IMU_TARGET_MASK 2) + elseif(SWITCH2_BRIDGE_IMU_TARGET STREQUAL "BOTH") + set(SWITCH2_BRIDGE_IMU_TARGET_MASK 3) + else() + message(FATAL_ERROR "SWITCH2_BRIDGE_IMU_TARGET must be LEFT, RIGHT or BOTH") + endif() + add_compile_definitions(SWITCH2_BRIDGE_IMU_TARGET_MASK=${SWITCH2_BRIDGE_IMU_TARGET_MASK}) + if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") + message(FATAL_ERROR "Switch 2 USB bridge requires BLUEPAD32") + endif() + if(SWITCH2_BRIDGE_INPUT STREQUAL "DUALSENSE") + if(NOT SWITCH2_PROBE_HUB OR NOT SWITCH_PICO_ENABLE_CLASSIC) + message(FATAL_ERROR "DualSense native R/L bridge requires HUB and Classic Bluetooth") + endif() + set(SWITCH2_BRIDGE_DUALSENSE_INPUT ON) + add_compile_definitions(SWITCH2_BRIDGE_DUALSENSE_INPUT=1) + elseif(NOT SWITCH_PICO_ENABLE_BLE OR NOT SWITCH_PICO_SWITCH2_MOUSE_CAPTURE OR NOT SWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE) - message(FATAL_ERROR - "Switch 2 USB bridge requires BLUEPAD32 with BLE and native Joy-Con mouse capture") + message(FATAL_ERROR "Joy-Con/Wii USB bridge requires BLE and native Joy-Con capture") endif() if(SWITCH_PICO_WII_IR_MOUSE OR SWITCH_PICO_WII_IR_GYRO) message(FATAL_ERROR "Switch 2 USB bridge owns USB; Wii IR USB experiments cannot be combined") endif() - set(SWITCH2_BRIDGE_INPUT "JOYCON2" CACHE STRING "Native bridge source: JOYCON2 or WII") - set_property(CACHE SWITCH2_BRIDGE_INPUT PROPERTY STRINGS JOYCON2 WII) if(SWITCH2_BRIDGE_INPUT STREQUAL "WII") if(NOT SWITCH_PICO_ENABLE_CLASSIC) message(FATAL_ERROR "Wii native bridge requires Bluetooth MIXED mode") endif() set(SWITCH2_BRIDGE_WII_INPUT ON) add_compile_definitions(SWITCH2_BRIDGE_WII_INPUT=1 SWITCH_PICO_WII_IR=1) - elseif(NOT SWITCH2_BRIDGE_INPUT STREQUAL "JOYCON2") - message(FATAL_ERROR "SWITCH2_BRIDGE_INPUT must be JOYCON2 or WII") + elseif(NOT SWITCH2_BRIDGE_INPUT STREQUAL "JOYCON2" AND NOT SWITCH2_BRIDGE_DUALSENSE_INPUT) + message(FATAL_ERROR "SWITCH2_BRIDGE_INPUT must be JOYCON2, WII or DUALSENSE") + endif() + if(NOT SWITCH2_BRIDGE_DUALSENSE_INPUT AND NOT SWITCH2_BRIDGE_IMU_TARGET STREQUAL "BOTH") + message(FATAL_ERROR "LEFT/RIGHT IMU routing requires a translated full-controller native pair") endif() add_compile_definitions(SWITCH_PICO_SWITCH2_USB_BRIDGE=1) endif() @@ -401,10 +423,16 @@ if(SWITCH_PICO_SWITCH2_USB_BRIDGE) endif() target_include_directories(switch-pico PRIVATE ${CMAKE_CURRENT_LIST_DIR}/external) target_sources(switch-pico PRIVATE ${SWITCH2_USB_PROBE_DIR}/native_imu.cpp ${LIBOGC_IR_GENERATED}) + elseif(SWITCH2_BRIDGE_DUALSENSE_INPUT) + target_sources(switch-pico PRIVATE + ${SWITCH2_USB_PROBE_DIR}/dualsense_input.cpp + ${SWITCH2_USB_PROBE_DIR}/native_imu.cpp) endif() - target_compile_definitions(switch-pico PRIVATE - SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES=${SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES}) - if(SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB) + if(NOT SWITCH2_BRIDGE_SOURCE_AUTO) + target_compile_definitions(switch-pico PRIVATE + SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES=${SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES}) + endif() + if((SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB) AND NOT SWITCH2_BRIDGE_DUALSENSE_INPUT) target_compile_definitions(switch-pico PRIVATE SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS_BYTES=${SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS_BYTES}) endif() diff --git a/README.md b/README.md index 2e48a67..438b2d5 100644 --- a/README.md +++ b/README.md @@ -759,11 +759,13 @@ wiring on GPIO pins. Each child retains its own native HID/vendor interfaces, EP1/EP2 state, identity, protocol state and pairing bank. This mode requires `SWITCH_PICO_SWITCH2_USB_BRIDGE=ON`, -`SWITCH2_BRIDGE_INPUT=JOYCON2`, `SWITCH2_PROBE_SIDE=RIGHT`, -`SWITCH2_PROBE_COMPOSITE=OFF`, and `SWITCH_PICO_SYS_CLOCK_MHZ=240`. -Configure both private donor captures and source addresses as for the paired -native probe. Bluetooth runs cooperatively on Core 0; Core 1 is reserved for -USB observation. Receive PID state is selected before accepting OUT traffic. +`SWITCH2_PROBE_SIDE=RIGHT`, `SWITCH2_PROBE_COMPOSITE=OFF`, and +`SWITCH_PICO_SYS_CLOCK_MHZ=240`. `SWITCH2_BRIDGE_INPUT=JOYCON2` forwards the +two selected physical Joy-Cons; `DUALSENSE` translates one full DualSense into +the same virtual pair. Both require the private R/L identity/factory captures. +Joy-Con input additionally requires BLE/native capture and both source addresses. +Bluetooth runs cooperatively on Core 0; Core 1 is reserved for USB observation. +Receive PID state is selected before accepting OUT traffic. Transmit payloads are prepared outside the bank lock and published by Core 0; unavailable IN buffers NAK rather than expose another device's packet. @@ -794,6 +796,89 @@ with some noticeable input lag. This is console smoke-test evidence, not a latency measurement or exhaustive compatibility test. This mode does not add arbitrary full-controller splitting or continuous USB HD-rumble forwarding. +**Queue latency follow-up:** `0.67-native-hub-latency` coalesces adjacent analog/ +IMU-only Joy-Con updates when buttons, status, opaque fields and IMU format are +unchanged and neither packet contains relative mouse motion. Discrete transitions +and mouse packets keep their order, and a peeked packet is pinned until commit. +A reproducible 125Hz producer/62.5Hz consumer simulation of the actual capture +code reduced maximum queue age from 252ms to 4ms (mean 129.968ms to 4ms). +This is a same-format continuous-state workload, not measured Bluetooth-to-Switch +latency; different formats, discrete events and sustained mouse traffic still +use the bounded FIFO. The console lag improvement remains to be compared. + +**One DualSense, two native halves:** use a separate private build configured +with `SWITCH2_BRIDGE_INPUT=DUALSENSE` and Classic Bluetooth enabled. The trial +uses `SWITCH_PICO_BLUETOOTH_MODE=CLASSIC` with +`SWITCH_PICO_SWITCH2_MOUSE_CAPTURE=OFF` and +`SWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE=OFF`. It does not require Joy-Con +donors. Empty `SWITCH2_BRIDGE_SOURCE_ADDRESS` selects the uniquely eligible +ready DualSense/Edge; an explicit address filters that source. Multiple eligible +pads fail closed instead of mixing players. The secondary source address is unused. + +In the dedicated DualSense mode, transport connections awaiting classification +count against Bluetooth capacity but do not reserve logical player/colour slots. +Only a supported PS5-parser source can enter those slots. Logical allocation +uses the first free slot rather than the Bluetooth device index, so an earlier +Pro Controller reconnect cannot move the first DualSense to the second colour. +Unsupported ready devices are disconnected without deleting their bonds; +normal AIO admission and slot assignment are unchanged. + +The existing profile transform runs once for the full pad. R gets face buttons, +right stick/shoulder/trigger, plus and home; L gets the D-pad, left stick/shoulder/ +trigger, minus and capture. Each uses its own advertised stick calibration. +One shared motion integrator consumes only fresh, complete, CRC-checked and +factory-calibrated DS5 sensor data. From 0.69, already-calibrated native sources +initialize from their first usable fresh sensor pair; the extra stationary +bias-estimation period is explicitly Wii-only. Invalid/stale sensors still +withhold IMU while controls remain available. Each USB half has independent +peek/commit, reset and backpressure state. No mouse movement or rail presses +are invented. + +Built-in cue requests become bounded compatibility vibration on the corresponding +DualSense actuator, not Joy-Con HD waveforms or adaptive-trigger effects. +Completion means accepted L2CAP submission to the source driver, **not** a +DualSense application ACK or measured motor onset. Stop attempts are bounded; a persistently +blocked OFF path disconnects the stuck link without deleting its bond. + +`0.67-native-hub-dualsense` built and was flashed with current persistent storage +verified unchanged. Its first PC run passed hub/child enumeration, native control +reads, initialization and bulk isolation, but had no real DualSense input and +therefore failed live-IMU qualification. Physical controls, native motion axes, +motor feel and Switch acceptance for this source remain pending. For a new bond, +open the Pico's two-second BOOTSEL pairing window, release it, then hold +DualSense Create + PS. Previously bonded pads normally reconnect with PS. + +`0.68-native-hub-slot` fixes the observed red/second-slot case: the trace showed +a Pro Controller connecting first and the DualSense using Bluetooth index 1. +The regression reproduces that ordering and verifies logical slot 0 and its +lightbar colour, stable identity across a new Bluetooth index, unrelated +connection churn, pending-capacity accounting and rejection of late ready +callbacks. The update was flashed after a fresh full backup, with persistent +storage verified unchanged. USB transport checks pass; the post-update physical +DualSense reconnect and steady lightbar colour still need observation. + +The subsequent Tears of the Kingdom wire trace showed IMU on both completed +USB endpoints after the delayed startup: 38 sampled R blocks and 37 L blocks +decoded with changing counters and quaternions. The Wii-style stationary gate +had delayed readiness until about 30 seconds after boot in that run. Firmware +0.69 removes that extra gate for factory-calibrated sources; the regression +checks first-sample output even while rotating, fresh-data recovery, and the +unchanged Wii settling behavior. Wii factory calibration is already used, but +its residual gyro bias still needs estimation; cached per-device bias without +revalidation can drift and is not implemented here. + +Translated full-controller builds expose `SWITCH2_BRIDGE_IMU_TARGET`: +`LEFT`, `RIGHT`, or `BOTH` (default). For example, configure the existing private +DualSense build with `-DSWITCH2_BRIDGE_IMU_TARGET=RIGHT` and rebuild/reflash. +This routes only IMU; both halves retain their controls. It consumes no controller +chord and changes no saved profile or pairing. The full dual-IMU PC checker +requires `BOTH`; use the USB-completion UART trace for single-target comparisons. + +For the DualSense trial, pass `--build-dir build-switch2-native-dualsense` to +the checker below. Its configured shared-source policy permits identical IMU +blocks across the halves while retaining per-child identity, report-ID, +fresh-counter and control/bulk isolation checks. + With the existing private build configured, qualify on a PC using: ```sh diff --git a/bluepad32_config/parser/uni_hid_parser_switch2.c b/bluepad32_config/parser/uni_hid_parser_switch2.c index 042c8af..24fb903 100644 --- a/bluepad32_config/parser/uni_hid_parser_switch2.c +++ b/bluepad32_config/parser/uni_hid_parser_switch2.c @@ -116,7 +116,7 @@ typedef struct { uint8_t command_data[32], rumble_data[33], cccd_data[2]; uint8_t command_length; bool command_pending, command_sent, command_acked; -#if SWITCH_PICO_SWITCH2_USB_BRIDGE +#if SWITCH_PICO_SWITCH2_USB_BRIDGE && SWITCH_PICO_SWITCH2_MOUSE_CAPTURE uint64_t sample_token; #endif uint8_t step, calibration_slot; @@ -219,7 +219,7 @@ void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) { sw2_instance_t* ins = sw2_instance(d); if (!ins) return; -#if SWITCH_PICO_SWITCH2_USB_BRIDGE +#if SWITCH_PICO_SWITCH2_USB_BRIDGE && SWITCH_PICO_SWITCH2_MOUSE_CAPTURE if (ins->sample_token) { switch_pico_switch2_sample_result(d->product_id, ins->address, ins->sample_token, -1, btstack_run_loop_get_time_ms()); @@ -349,7 +349,7 @@ static bool sw2_transient_write_error(uint8_t status) { static void sw2_try_command(sw2_instance_t* ins) { if (!ins->command_pending || ins->command_sent || ins->query != SW2_QUERY_NONE) return; -#if SWITCH_PICO_SWITCH2_USB_BRIDGE +#if SWITCH_PICO_SWITCH2_USB_BRIDGE && SWITCH_PICO_SWITCH2_MOUSE_CAPTURE if (ins->sample_token && !switch_pico_switch2_sample_result(ins->device->product_id, ins->address, ins->sample_token, 0, btstack_run_loop_get_time_ms())) { @@ -482,7 +482,7 @@ static void sw2_continue(sw2_instance_t* ins) { break; #endif case SW2_FEATURES: { -#if SWITCH_PICO_SWITCH2_USB_BRIDGE +#if SWITCH_PICO_SWITCH2_USB_BRIDGE && SWITCH_PICO_SWITCH2_MOUSE_CAPTURE // Match the console's complete native feature set, including the // status associated with bit 5, rather than a mouse-only capture. const uint8_t features[4] = {sw2_mouse_capture_enabled(ins) ? 0x37 : 0x04, 0, 0, 0}; @@ -511,7 +511,7 @@ static void sw2_complete_command(sw2_instance_t* ins) { return; ins->command_pending = false; sw2_disarm_timeout(ins); -#if SWITCH_PICO_SWITCH2_USB_BRIDGE +#if SWITCH_PICO_SWITCH2_USB_BRIDGE && SWITCH_PICO_SWITCH2_MOUSE_CAPTURE if (ins->sample_token) { switch_pico_switch2_sample_result(ins->device->product_id, ins->address, ins->sample_token, 1, btstack_run_loop_get_time_ms()); @@ -590,7 +590,7 @@ static void sw2_response(sw2_instance_t* ins, const uint8_t* data, uint16_t leng sw2_fail(ins, "negative application ACK", data[5]); return; } -#if SWITCH_PICO_SWITCH2_USB_BRIDGE +#if SWITCH_PICO_SWITCH2_USB_BRIDGE && SWITCH_PICO_SWITCH2_MOUSE_CAPTURE // 0A/02 has an empty application ACK, not a returned sample ID or status // payload. Never interpret a truncated/extended response as its success. if (ins->sample_token && (length != 8 || data[4] != 0x10 || data[6] || data[7])) @@ -1511,7 +1511,7 @@ static void sw2_output_tick(btstack_timer_source_t* timer) { if (ins->state != SW2_READY) return; // A blocked setup command rescheduled itself, or awaits its ACK. uint32_t now = btstack_run_loop_get_time_ms(); -#if SWITCH_PICO_SWITCH2_USB_BRIDGE +#if SWITCH_PICO_SWITCH2_USB_BRIDGE && SWITCH_PICO_SWITCH2_MOUSE_CAPTURE if (!ins->command_pending && ins->query == SW2_QUERY_NONE) { uint8_t sample_id; if (switch_pico_switch2_sample_take(ins->device->product_id, ins->address, now, diff --git a/bluepad32_config/parser/uni_hid_parser_switch2.h b/bluepad32_config/parser/uni_hid_parser_switch2.h index 4b8e0b7..5aa5503 100644 --- a/bluepad32_config/parser/uni_hid_parser_switch2.h +++ b/bluepad32_config/parser/uni_hid_parser_switch2.h @@ -37,7 +37,7 @@ void switch_pico_switch2_mouse_report(uint16_t product_id, const uint8_t address const uint8_t* report, uint16_t length, uint32_t received_ms); #endif -#if SWITCH_PICO_SWITCH2_USB_BRIDGE +#if SWITCH_PICO_SWITCH2_USB_BRIDGE && SWITCH_PICO_SWITCH2_MOUSE_CAPTURE // BTstack-core mailbox hooks. Take only when READY with no command/query in // flight. Result 0 checks ownership before a deferred write, 1 records verified // command completion, and -1 fails it. False means canceled/stale/wrong source. diff --git a/patches/bluepad32-sdl3-imu.patch b/patches/bluepad32-sdl3-imu.patch index db7f137..1df1b7f 100644 --- a/patches/bluepad32-sdl3-imu.patch +++ b/patches/bluepad32-sdl3-imu.patch @@ -518,14 +518,154 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds4.c b/src/componen int32_t calib_data = mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom); ctl->gamepad.accel[i] = calib_data; +diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_ds5.h b/src/components/bluepad32/include/parser/uni_hid_parser_ds5.h +--- a/src/components/bluepad32/include/parser/uni_hid_parser_ds5.h ++++ b/src/components/bluepad32/include/parser/uni_hid_parser_ds5.h +@@ -7,6 +7,7 @@ + #define UNI_HID_PARSER_DS5_H + + #include ++#include + + #include "parser/uni_hid_parser.h" + +@@ -33,6 +34,25 @@ + uint8_t weak_magnitude, + uint8_t strong_magnitude); + void uni_hid_parser_ds5_device_dump(struct uni_hid_device_s* d); ++ ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++typedef struct { ++ uint32_t report_sequence; ++ uint32_t motion_sequence; ++ bool motion_valid; ++} uni_ds5_bridge_snapshot_t; ++// Only the Bluetooth owner may call this. False means no complete CRC-checked ++// input report; motion_valid additionally requires factory calibration and a ++// forward-moving sensor timestamp. Counters never advance on accessor reads. ++bool uni_hid_parser_ds5_bridge_snapshot(struct uni_hid_device_s* d, uni_ds5_bridge_snapshot_t* out); ++// Bounded native bridge writer: false means not submitted (never queued). ++// The existing duration timer retries OFF for at most 2 seconds, then closes ++// a stuck connection without deleting its bond. No application ACK exists. ++bool uni_hid_parser_ds5_bridge_rumble(struct uni_hid_device_s* d, uint16_t duration_ms, ++ uint8_t right, uint8_t left); ++// Disconnect/delete retire delayed and duration timers before parser memory reuse. ++void uni_hid_parser_ds5_bridge_teardown(struct uni_hid_device_s* d); ++#endif + + // Unique to DualSense. Not part of the "hid_parser" interface + // Warning: Adaptive trigger API is experimental. It might change in the future without further notice. diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds5.c b/src/components/bluepad32/parser/uni_hid_parser_ds5.c --- a/src/components/bluepad32/parser/uni_hid_parser_ds5.c +++ b/src/components/bluepad32/parser/uni_hid_parser_ds5.c -@@ -487,17 +487,17 @@ +@@ -106,6 +106,14 @@ + uint32_t fw_version; + uint16_t update_version; + bool use_vibration2; ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ uni_ds5_bridge_snapshot_t bridge; ++ uint32_t sensor_timestamp; ++ bool have_sensor_timestamp; ++ bool calibration_valid; ++ bool input_valid; ++ uint32_t bridge_stop_deadline_ms; ++#endif + + struct ds5_calibration_data gyro_calib_data[3]; + struct ds5_calibration_data accel_calib_data[3]; +@@ -228,7 +236,29 @@ + _Static_assert(sizeof(ds5_feature_report_calibration_t) == DS5_FEATURE_REPORT_CALIBRATION_SIZE, "Invalid size"); + + static ds5_instance_t* get_ds5_instance(uni_hid_device_t* d); ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++// Bluetooth HID CRC includes its transaction byte, omitted by the parser API. ++static bool ds5_bridge_crc_valid(const uint8_t* report, uint16_t len, uint8_t transaction) { ++ uint32_t crc = uni_crc32_le(0xffffffff, &transaction, 1); ++ crc = ~uni_crc32_le(crc, report, len - 4); ++ const uint8_t* expected = &report[len - 4]; ++ return crc == ((uint32_t)expected[0] | ((uint32_t)expected[1] << 8) | ++ ((uint32_t)expected[2] << 16) | ((uint32_t)expected[3] << 24)); ++} ++ ++bool uni_hid_parser_ds5_bridge_snapshot(uni_hid_device_t* d, uni_ds5_bridge_snapshot_t* out) { ++ if (d == NULL || out == NULL || d->controller_type != CONTROLLER_TYPE_PS5Controller || ++ d->report_parser.setup != uni_hid_parser_ds5_setup) ++ return false; ++ const ds5_instance_t* ins = get_ds5_instance(d); ++ if (ins->state != DS5_STATE_READY || !ins->input_valid) ++ return false; ++ *out = ins->bridge; ++ return true; ++} ++#endif + static void ds5_send_output_report(uni_hid_device_t* d, ds5_output_report_t* out); ++static void ds5_prepare_output_report(uni_hid_device_t* d, ds5_output_report_t* out); + static void ds5_send_enable_lightbar_report(uni_hid_device_t* d); + static void ds5_request_pairing_info_report(uni_hid_device_t* d); + static void ds5_request_firmware_version_report(uni_hid_device_t* d); +@@ -390,6 +420,9 @@ + + void uni_hid_parser_ds5_init_report(uni_hid_device_t* d) { + uni_controller_t* ctl = &d->controller; ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ get_ds5_instance(d)->input_valid = false; ++#endif + memset(ctl, 0, sizeof(*ctl)); + + ctl->klass = UNI_CONTROLLER_CLASS_GAMEPAD; +@@ -423,10 +456,19 @@ + + void uni_hid_parser_ds5_parse_feature_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { + ds5_instance_t* ins = get_ds5_instance(d); ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ if (report == NULL || len == 0) ++ return; ++#endif + uint8_t report_id = report[0]; + + switch (report_id) { + case DS5_FEATURE_REPORT_PAIRING_INFO: ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ if (len != DS5_FEATURE_REPORT_PAIRING_INFO_SIZE || ++ !ds5_bridge_crc_valid(report, len, 0xa3)) ++ break; ++#endif + if (len != DS5_FEATURE_REPORT_PAIRING_INFO_SIZE) { + loge("DS5: Unexpected pairing info size: got %d, want: %d\n", len, + DS5_FEATURE_REPORT_PAIRING_INFO_SIZE); +@@ -444,6 +486,11 @@ + break; + + case DS5_FEATURE_REPORT_FIRMWARE_VERSION: { ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ if (len != DS5_FEATURE_REPORT_FIRMWARE_VERSION_SIZE || ++ !ds5_bridge_crc_valid(report, len, 0xa3)) ++ break; ++#endif + if (len != DS5_FEATURE_REPORT_FIRMWARE_VERSION_SIZE) { + loge("DS5: Unexpected firmware version size: got %d, want: %d\n", len, + DS5_FEATURE_REPORT_FIRMWARE_VERSION_SIZE); +@@ -476,6 +523,11 @@ + int speed_2x; + int range_2g; + ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ if (len != DS5_FEATURE_REPORT_CALIBRATION_SIZE || ++ !ds5_bridge_crc_valid(report, len, 0xa3)) ++ break; ++#endif + if (len != DS5_FEATURE_REPORT_CALIBRATION_SIZE) { + loge("DS5: Unexpected calibration size: got %d, want: %d\n", len, DS5_FEATURE_REPORT_CALIBRATION_SIZE); + /* fallthrough */ +@@ -487,25 +539,38 @@ // Set gyroscope calibration and normalization parameters. // Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s. speed_2x = r->gyro_speed_plus + r->gyro_speed_minus; - ins->gyro_calib_data[0].bias = 0; ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ ins->calibration_valid = speed_2x > 0; ++ ins->bridge.motion_valid = false; ++#endif + ins->gyro_calib_data[0].bias = r->gyro_pitch_bias; ins->gyro_calib_data[0].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S; ins->gyro_calib_data[0].sens_denom = @@ -542,7 +682,47 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds5.c b/src/componen ins->gyro_calib_data[2].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S; ins->gyro_calib_data[2].sens_denom = abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias); -@@ -617,12 +617,12 @@ + ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ // Both extrema are measured relative to the same factory bias. ++ ins->gyro_calib_data[0].sens_denom = ++ abs(r->gyro_pitch_plus - r->gyro_pitch_bias) + abs(r->gyro_pitch_minus - r->gyro_pitch_bias); ++#endif + // Sanity check gyro calibration data. This is needed to prevent crashes + // during report handling of virtual, clone or broken devices not implementing + // calibration data properly. + for (size_t i = 0; i < ARRAY_SIZE(ins->gyro_calib_data); i++) { ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ if (ins->gyro_calib_data[i].sens_denom <= 0) ++ ins->calibration_valid = false; ++#endif + if (ins->gyro_calib_data[i].sens_denom == 0) { + loge("Invalid gyro calibration data for axis (%d), disabling calibration for axis = %d\n", i); + ins->gyro_calib_data[i].bias = 0; +@@ -535,6 +600,10 @@ + // during report handling of virtual, clone or broken devices not implementing calibration + // data properly. + for (size_t i = 0; i < ARRAY_SIZE(ins->accel_calib_data); i++) { ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ if (ins->accel_calib_data[i].sens_denom <= 0) ++ ins->calibration_valid = false; ++#endif + if (ins->accel_calib_data[i].sens_denom == 0) { + loge("Invalid accelerometer calibration data for axis (%d), disabling calibration for axis=%d\n", + i); +@@ -557,6 +626,11 @@ + void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { + ds5_instance_t* ins = get_ds5_instance(d); + ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ ins->input_valid = false; ++ if (report == NULL || len != 78 || !ds5_bridge_crc_valid(report, len, 0xa1)) ++ return; ++#endif + // Don't process reports until state is ready. Prevents possible div-by-0 on calibration + // and ignores other warnings. + if (ins->state != DS5_STATE_READY) +@@ -617,12 +691,12 @@ ctl->gamepad.buttons |= BUTTON_THUMB_R; // Thumb R if (r->buttons[2] & 0x01) ctl->gamepad.misc_buttons |= MISC_BUTTON_SYSTEM; // PS @@ -558,7 +738,7 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds5.c b/src/componen int32_t calib_data = mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom); ctl->gamepad.gyro[i] = calib_data; -@@ -630,7 +630,7 @@ +@@ -630,12 +704,29 @@ // Accel for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) { @@ -567,6 +747,119 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds5.c b/src/componen int32_t calib_data = mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom); ctl->gamepad.accel[i] = calib_data; + } + ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ ins->input_valid = true; ++ if (++ins->bridge.report_sequence == 0) ++ ++ins->bridge.report_sequence; ++ const uint32_t timestamp = r->sensor_timestamp; ++ const uint32_t delta = timestamp - ins->sensor_timestamp; ++ ins->bridge.motion_valid = false; ++ if (!ins->have_sensor_timestamp || (delta != 0 && delta < 0x80000000u)) { ++ ins->sensor_timestamp = timestamp; ++ ins->have_sensor_timestamp = true; ++ if (ins->calibration_valid) { ++ if (++ins->bridge.motion_sequence == 0) ++ ++ins->bridge.motion_sequence; ++ ins->bridge.motion_valid = true; ++ } ++ } ++#endif + // Value goes from 0 to 10. Make it from 0 to 250. + // The +1 is to avoid having a value of 0, which means "battery unavailable". + ctl->battery = (r->status & DS5_STATUS_BATTERY_CAPACITY) * 25 + 1; +@@ -721,6 +812,69 @@ + } + } + ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++static void on_ds5_bridge_rumble_off(btstack_timer_source_t* timer) { ++ uni_hid_device_t* d = timer->context; ++ ds5_instance_t* ins = get_ds5_instance(d); ++ if (uni_hid_parser_ds5_bridge_rumble(d, 0, 0, 0)) ++ return; ++ if ((int32_t)(btstack_run_loop_get_time_ms() - ins->bridge_stop_deadline_ms) >= 0) { ++ // A stuck output path must not leave a live vibrating connection. ++ // Disconnect preserves its bond; lifecycle teardown retires this timer. ++ uni_hid_device_disconnect(d); ++ return; ++ } ++ btstack_run_loop_set_timer(timer, 5); ++ btstack_run_loop_add_timer(timer); ++} ++ ++bool uni_hid_parser_ds5_bridge_rumble(uni_hid_device_t* d, uint16_t duration_ms, ++ uint8_t right, uint8_t left) { ++ if (d == NULL || d->report_parser.setup != uni_hid_parser_ds5_setup || duration_ms > 1000) ++ return false; ++ ds5_instance_t* ins = get_ds5_instance(d); ++ if (ins->state != DS5_STATE_READY || d->conn.interrupt_cid == 0 || ++ !l2cap_can_send_packet_now(d->conn.interrupt_cid)) ++ return false; ++ ds5_output_report_t out = { ++ .valid_flag0 = DS5_FLAG0_HAPTICS_SELECT, ++ .motor_right = duration_ms == 0 ? 0 : right, ++ .motor_left = duration_ms == 0 ? 0 : left, ++ }; ++ if (ins->use_vibration2) ++ out.valid_flag2 |= DS5_FLAG2_COMPATIBLE_VIBRATION2; ++ else ++ out.valid_flag0 |= DS5_FLAG0_COMPATIBLE_VIBRATION; ++ ds5_prepare_output_report(d, &out); ++ // Never put a timed ON packet in the generic FIFO. A later retry evaluates ++ // the current cue phase; it cannot drain a backlog of expired vibration. ++ if (l2cap_send(d->conn.interrupt_cid, (uint8_t*)&out, sizeof(out)) != ERROR_CODE_SUCCESS) ++ return false; ++ btstack_run_loop_remove_timer(&ins->rumble_timer_delayed_start); ++ btstack_run_loop_remove_timer(&ins->rumble_timer_duration); ++ ins->rumble_state = duration_ms == 0 ? DS5_STATE_RUMBLE_DISABLED : DS5_STATE_RUMBLE_IN_PROGRESS; ++ if (duration_ms != 0) { ++ ins->bridge_stop_deadline_ms = btstack_run_loop_get_time_ms() + duration_ms + 2000; ++ ins->rumble_timer_duration.context = d; ++ ins->rumble_timer_duration.process = on_ds5_bridge_rumble_off; ++ btstack_run_loop_set_timer(&ins->rumble_timer_duration, duration_ms); ++ btstack_run_loop_add_timer(&ins->rumble_timer_duration); ++ } ++ return true; // Source transport submission, never an application ACK. ++} ++ ++void uni_hid_parser_ds5_bridge_teardown(uni_hid_device_t* d) { ++ if (d == NULL || d->report_parser.setup != uni_hid_parser_ds5_setup) ++ return; ++ ds5_instance_t* ins = get_ds5_instance(d); ++ btstack_run_loop_remove_timer(&ins->rumble_timer_delayed_start); ++ btstack_run_loop_remove_timer(&ins->rumble_timer_duration); ++ ins->rumble_state = DS5_STATE_RUMBLE_DISABLED; ++ ins->input_valid = false; ++ ins->bridge.motion_valid = false; ++} ++#endif ++ + void uni_hid_parser_ds5_device_dump(uni_hid_device_t* d) { + ds5_instance_t* ins = get_ds5_instance(d); + logi("\tDS5: FW version: %#x, HW version: %#x, update version: %#x, use vibration2: %d\n", ins->fw_version, +@@ -734,7 +888,7 @@ + return (ds5_instance_t*)&d->parser_data[0]; + } + +-static void ds5_send_output_report(uni_hid_device_t* d, ds5_output_report_t* out) { ++static void ds5_prepare_output_report(uni_hid_device_t* d, ds5_output_report_t* out) { + ds5_instance_t* ins = get_ds5_instance(d); + + out->transaction_type = (HID_MESSAGE_TYPE_DATA << 4) | HID_REPORT_TYPE_OUTPUT; +@@ -748,7 +902,10 @@ + ins->output_seq = 0; + + out->crc32 = ~uni_crc32_le(0xffffffff, (uint8_t*)out, sizeof(*out) - 4); +- ++} ++ ++static void ds5_send_output_report(uni_hid_device_t* d, ds5_output_report_t* out) { ++ ds5_prepare_output_report(d, out); + uni_hid_device_send_intr_report(d, (uint8_t*)out, sizeof(*out)); + } + diff --git a/src/components/bluepad32/parser/uni_hid_parser_psmove.c b/src/components/bluepad32/parser/uni_hid_parser_psmove.c --- a/src/components/bluepad32/parser/uni_hid_parser_psmove.c +++ b/src/components/bluepad32/parser/uni_hid_parser_psmove.c @@ -2939,7 +3232,7 @@ diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad3 #include "parser/uni_hid_parser_wii.h" #include "parser/uni_hid_parser_xboxone.h" #include "platform/uni_platform.h" -@@ -447,6 +448,13 @@ +@@ -447,6 +448,17 @@ return; } @@ -2949,11 +3242,15 @@ diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad3 + uni_hid_parser_switch2_teardown(d); + if (d->report_parser.setup == uni_hid_parser_wii_setup) + uni_hid_parser_wii_teardown(d); ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ if (d->report_parser.setup == uni_hid_parser_ds5_setup) ++ uni_hid_parser_ds5_bridge_teardown(d); ++#endif + // Disconnect child first if (d->child) uni_hid_device_disconnect(d->child); -@@ -465,9 +473,9 @@ +@@ -465,9 +477,9 @@ // Cleanup if (!uni_hid_device_is_virtual_device(d)) { type = gap_get_connection_type(d->conn.handle); @@ -2965,7 +3262,7 @@ diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad3 uni_bt_bredr_disconnect(d); else loge("uni_hid_device_disconnect: Unknown GAP connection type: %d\n", type); -@@ -490,6 +498,13 @@ +@@ -490,6 +502,17 @@ loge("uni_hid_device_delete: invalid hid device: NULL\n"); return; } @@ -2975,11 +3272,15 @@ diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad3 + uni_hid_parser_switch2_teardown(d); + if (d->report_parser.setup == uni_hid_parser_wii_setup) + uni_hid_parser_wii_teardown(d); ++#if SWITCH2_BRIDGE_DUALSENSE_INPUT ++ if (d->report_parser.setup == uni_hid_parser_ds5_setup) ++ uni_hid_parser_ds5_bridge_teardown(d); ++#endif + // Delete child first if (d->child) -@@ -655,6 +670,7 @@ +@@ -655,6 +678,7 @@ d->report_parser.setup = uni_hid_parser_psmove_setup; d->report_parser.init_report = uni_hid_parser_psmove_init_report; d->report_parser.parse_input_report = uni_hid_parser_psmove_parse_input_report; @@ -2987,7 +3288,7 @@ diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad3 d->report_parser.set_lightbar_color = uni_hid_parser_psmove_set_lightbar_color; d->report_parser.play_dual_rumble = uni_hid_parser_psmove_play_dual_rumble; logi("Device detected as PS Move: 0x%02x\n", type); -@@ -718,6 +734,15 @@ +@@ -718,6 +742,15 @@ d->report_parser.device_dump = uni_hid_parser_switch_device_dump; logi("Device detected as Nintendo Switch Pro controller: 0x%02x\n", type); break; @@ -3003,7 +3304,7 @@ diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad3 case CONTROLLER_TYPE_SteamController: d->report_parser.setup = uni_hid_parser_steam_setup; d->report_parser.init_report = uni_hid_parser_steam_init_report; -@@ -809,7 +834,11 @@ +@@ -809,7 +842,11 @@ return; } diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 9e5eef4..1a6d8dc 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -36,6 +36,9 @@ #include extern "C" { #include "parser/uni_hid_parser_wii.h" +#if SWITCH2_BRIDGE_DUALSENSE_INPUT +#include "parser/uni_hid_parser_ds5.h" +#endif } #include "parser/uni_hid_parser_switch2.h" #include "parser/uni_switch2_haptics.h" @@ -254,6 +257,57 @@ constexpr WiiCuePattern kWiiCuePatterns[8] = { constexpr uint32_t kWiiCueDeadlineMs = 2000; #endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT +struct DualSenseIngress { + uint32_t report_sequence = 0; + uint32_t received_us = 0; + uint32_t motion_sequence = 0; + uint32_t motion_received_us = 0; + bool has_report = false; + bool motion_valid = false; + int32_t accel_q13[3]{}; + int32_t gyro_q10[3]{}; +}; + +struct DualSenseCue { + uint64_t token = 0; + uint32_t connection_generation = 0; + uint32_t requested_ms = 0; + uint32_t started_ms = 0; + uint8_t slot = 0xff; + uint8_t sample_id = 0; + int result = -1; + bool active = false; + bool consumed = false; + bool in_flight = false; +}; + +struct DualSenseMotorOutput { + uint32_t deadline_ms = 0; + uint8_t magnitude[2]{}; + bool owned = false; +}; + +// Same bounded pulse vocabulary as Wii, with real per-motor magnitudes. +// These are compatibility-vibration approximations, not uploaded HD waveforms. +struct DualSenseCuePattern { + uint16_t phases_ms[7]; + uint8_t count; + uint8_t magnitude; +}; +constexpr DualSenseCuePattern kDualSenseCuePatterns[8] = { + {{0}, 0, 0}, + {{1000}, 1, 160}, + {{100, 180, 100, 180, 100, 180, 100}, 7, 200}, + {{25, 90, 25}, 3, 96}, + {{100, 140, 100}, 3, 220}, + {{70, 120, 70}, 3, 160}, + {{60}, 1, 96}, + {{120}, 1, 220}, +}; +constexpr uint32_t kDualSenseCueDeadlineMs = 2000; +#endif + // Security Manager identity events arrive before Bluepad32 publishes a ready @@ -291,6 +345,10 @@ struct BackendSlot { #ifdef SWITCH2_BRIDGE_WII_INPUT WiiMotionIngress wii_motion; #endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + DualSenseIngress dualsense_motion; + DualSenseMotorOutput dualsense_output; +#endif #ifdef SWITCH_PICO_WII_IR_GYRO WiiAimSource wii_aim; #endif @@ -378,6 +436,83 @@ void retire_wii_slot(uint8_t slot_index) { } #endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT +bool g_dualsense_explicit_address = false; +uint8_t g_dualsense_address[6]{}; +uint8_t g_dualsense_slot = 0xff; +uint32_t g_dualsense_generation = 0; +Bluepad32DualSenseBridgeSnapshot g_dualsense_snapshot{}; +DualSenseCue g_dualsense_cues[2]{}; +uint64_t g_next_dualsense_token = 1; +uni_hid_device_t* g_dualsense_pending_devices[kSlotCount]{}; + +bool dualsense_source_matches(const uni_hid_device_t* device) { + return device != nullptr && + device->controller_type == CONTROLLER_TYPE_PS5Controller && + device->report_parser.parse_input_report == uni_hid_parser_ds5_parse_input_report && + (!g_dualsense_explicit_address || + memcmp(device->conn.btaddr, g_dualsense_address, 6) == 0); +} + +bool eligible_dualsense(const BackendSlot& slot) { + return slot.active && slot.companion == nullptr && dualsense_source_matches(slot.device); +} + +void cancel_dualsense_cue_locked(DualSenseCue& cue) { + cue.active = false; + cue.result = -1; + // The slot's last motor output remains owned until the timer replaces it. +} + +void refresh_dualsense_source_locked(bool reselection = false) { + uint8_t selected = 0xff; + for (uint8_t index = 0; index < kSlotCount; ++index) { + if (!eligible_dualsense(g_slots[index])) continue; + if (selected != 0xff) { + selected = 0xff; // Never blend or choose by connection order. + break; + } + selected = index; + } + if (!reselection && selected == g_dualsense_slot && + (selected == 0xff || + g_slots[selected].connection_generation == g_dualsense_generation)) return; + for (DualSenseCue& cue : g_dualsense_cues) cancel_dualsense_cue_locked(cue); + g_dualsense_snapshot = {}; + g_dualsense_slot = selected; + g_dualsense_generation = 0; + if (selected != 0xff) { + BackendSlot& slot = g_slots[selected]; + g_macro_capture.disconnect(selected, slot.connection_generation, time_us_32()); + // A missed inactive snapshot must still retire the adapter's old epoch. + g_dualsense_generation = ++slot.connection_generation; + ++slot.state_generation; + slot.dualsense_motion.has_report = false; + slot.dualsense_motion.motion_valid = false; + slot.dualsense_motion.received_us = 0; + slot.dualsense_motion.motion_received_us = 0; + } +} + +void retire_dualsense_slot(uint8_t index) { + if (g_dualsense_slot == index) { + g_dualsense_slot = 0xff; + g_dualsense_generation = 0; + g_dualsense_snapshot = {}; + } + for (DualSenseCue& cue : g_dualsense_cues) + if (cue.slot == index) cue = {}; + g_slots[index].dualsense_motion = {}; + g_slots[index].dualsense_output = {}; +} + +bool dualsense_cue_current(const DualSenseCue& cue) { + return cue.slot < kSlotCount && cue.slot == g_dualsense_slot && + cue.connection_generation == g_dualsense_generation && + cue.connection_generation == g_slots[cue.slot].connection_generation; +} +#endif + // These fields are only read or written by the BTstack execution context. btstack_timer_source_t g_rumble_timer{}; btstack_timer_source_t g_configuration_timer{}; @@ -480,6 +615,9 @@ bool has_free_slot() { physical_count += slot.device != nullptr; physical_count += slot.companion != nullptr; } + #if SWITCH2_BRIDGE_DUALSENSE_INPUT + for (const auto* pending : g_dualsense_pending_devices) physical_count += pending != nullptr; + #endif critical_section_exit(&g_state_lock); return physical_count < kSlotCount; } @@ -522,9 +660,12 @@ int reserve_device_slot(uni_hid_device_t* device) { return -1; } const int tracked = slot_for_device(device); - if (g_retired_devices[physical_index] == device && - uni_hid_parser_switch2_is_ble_device(device)) { + if (g_retired_devices[physical_index] == device) { +#if SWITCH2_BRIDGE_DUALSENSE_INPUT return -1; +#else + if (uni_hid_parser_switch2_is_ble_device(device)) return -1; +#endif } if (tracked >= 0) { return tracked; @@ -537,9 +678,11 @@ int reserve_device_slot(uni_hid_device_t* device) { return -1; } } +#if !SWITCH2_BRIDGE_DUALSENSE_INPUT if (g_slots[physical_index].device == nullptr) { return physical_index; } +#endif for (uint8_t index = 0; index < kSlotCount; ++index) { if (g_slots[index].device == nullptr) { return index; @@ -1231,6 +1374,15 @@ ConnectionStatus compute_connection_status() { all_ready = all_ready && (!has_device || slot.active); any_connecting = any_connecting || (!slot.active && has_device); } + #if SWITCH2_BRIDGE_DUALSENSE_INPUT + for (const auto* pending : g_dualsense_pending_devices) { + if (pending != nullptr) { + ++physical_count; + all_ready = false; + any_connecting = true; + } + } + #endif critical_section_exit(&g_state_lock); if (all_ready && physical_count == kSlotCount) { @@ -1284,6 +1436,25 @@ void publish_device_state(uint8_t slot, uni_hid_device_t* device, memcpy(g_wii_snapshot.accel_q13, motion.accel_q13, sizeof(motion.accel_q13)); memcpy(g_wii_snapshot.gyro_q10, motion.gyro_q10, sizeof(motion.gyro_q10)); } +#endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + if (slot == g_dualsense_slot && target.dualsense_motion.has_report && + target.connection_generation == g_dualsense_generation) { + const DualSenseIngress& motion = target.dualsense_motion; + g_dualsense_snapshot.slot = slot; + g_dualsense_snapshot.controller = { + target.active, target.connection_generation, target.identity, + target.pre_hotkey_button_mask, target.state, + target.accelerometer, target.nunchuk_accelerometer}; + g_dualsense_snapshot.state_generation = target.state_generation; + g_dualsense_snapshot.received_us = motion.received_us; + g_dualsense_snapshot.battery = device->controller.battery; + g_dualsense_snapshot.motion_valid = motion.motion_valid; + g_dualsense_snapshot.motion_sequence = motion.motion_sequence; + g_dualsense_snapshot.motion_received_us = motion.motion_received_us; + memcpy(g_dualsense_snapshot.accel_q13, motion.accel_q13, sizeof(motion.accel_q13)); + memcpy(g_dualsense_snapshot.gyro_q10, motion.gyro_q10, sizeof(motion.gyro_q10)); + } #endif g_macro_capture.observe(slot, target.connection_generation, time_us_32(), target.state); @@ -1297,6 +1468,9 @@ void publish_all_neutral() { wii_ir_pointer_reset(); #endif for (BackendSlot& slot : g_slots) { +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + retire_dualsense_slot(static_cast(&slot - g_slots)); +#endif #ifdef SWITCH2_BRIDGE_WII_INPUT retire_wii_slot(static_cast(&slot - g_slots)); retire_wii_motion(slot.wii_motion); @@ -1833,6 +2007,9 @@ void reset_slot_hotkeys(BackendSlot& slot) { slot.connection_generation, time_us_32()); slot.wii_orientation_pending = false; slot.pending_wii_orientation = {}; +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + retire_dualsense_slot(static_cast(&slot - g_slots)); +#endif #ifdef SWITCH2_BRIDGE_WII_INPUT retire_wii_slot(static_cast(&slot - g_slots)); retire_wii_motion(slot.wii_motion); @@ -2400,6 +2577,16 @@ void process_configuration_timer(btstack_timer_source_t* timer) { void dispatch_rumble(uni_hid_device_t* device, uint16_t duration_ms, uint8_t weak, uint8_t strong) { +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + if (device->controller_type == CONTROLLER_TYPE_PS5Controller && + device->report_parser.parse_input_report == uni_hid_parser_ds5_parse_input_report) { + // Local feedback shares the bounded writer. Its stale compatibility + // packets must not remain queued to overtake a later native cue. + uni_hid_parser_ds5_bridge_rumble( + device, duration_ms > 1000 ? 1000 : duration_ms, weak, strong); + return; + } +#endif #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE if (switch_native_output_feedback(device, strong, weak, duration_ms)) return; #endif @@ -2539,6 +2726,144 @@ void dispatch_wii_cue(const WiiCueDispatch& command) { } #endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT +struct DualSenseCueDispatch { + uni_hid_device_t* device = nullptr; + uint32_t connection_generation = 0; + uint32_t prepared_ms = 0; + uint64_t token[2]{}; + uint16_t duration_ms = 0; + uint8_t magnitude[2]{}; + uint8_t slot = 0xff; +}; + +// One DS5 driver timer controls both motors. Recompute a combined packet at +// each boundary; use the shortest ON remainder, then refresh the surviving +// side. Absolute cue timelines skip missed pulses, never accumulate a backlog. +bool prepare_dualsense_cues(uint8_t index, uint32_t now_ms, + bool local_active, bool local_dispatch, + DualSenseCueDispatch* command) { + BackendSlot& slot = g_slots[index]; + DualSenseMotorOutput& previous = slot.dualsense_output; + bool busy = false; + bool pending = false; + uint16_t duration = UINT16_MAX; + uint8_t magnitude[2]{}; + for (uint8_t side = 0; side < 2; ++side) { + DualSenseCue& cue = g_dualsense_cues[side]; + if (cue.slot != index) continue; + if (!dualsense_cue_current(cue) || + (cue.result == 0 && now_ms - cue.requested_ms >= kDualSenseCueDeadlineMs) || + (cue.active && now_ms - cue.started_ms >= kDualSenseCueDeadlineMs)) + cancel_dualsense_cue_locked(cue); + if (local_active || local_dispatch) { + if (cue.active) cancel_dualsense_cue_locked(cue); + busy |= cue.result == 0; + continue; + } + if (cue.in_flight) return true; + if (cue.result != 0 && !cue.active) continue; + command->token[side] = cue.token; + pending |= cue.result == 0; + if (cue.sample_id == 0) continue; + const DualSenseCuePattern& pattern = kDualSenseCuePatterns[cue.sample_id]; + uint32_t elapsed = cue.active ? now_ms - cue.started_ms : 0; + uint8_t phase = 0; + while (phase < pattern.count && elapsed >= pattern.phases_ms[phase]) + elapsed -= pattern.phases_ms[phase++]; + if (phase == pattern.count) { + cue.active = false; + command->token[side] = 0; + continue; + } + busy = true; + if ((phase & 1u) == 0) { + magnitude[side] = pattern.magnitude; + const uint16_t remaining = static_cast(pattern.phases_ms[phase] - elapsed); + if (remaining < duration) duration = remaining; + } + } + if (local_active || local_dispatch) { + // Higher-priority feedback replaces our finite timer; do not stop it. + if (local_dispatch) previous = {}; + return busy || previous.owned; + } + if (duration == UINT16_MAX) duration = 0; + const uint32_t deadline = duration == 0 ? 0 : now_ms + duration; + const bool changed = magnitude[0] != previous.magnitude[0] || + magnitude[1] != previous.magnitude[1] || + (duration != 0 && deadline != previous.deadline_ms); + if (!pending && !changed) return busy || previous.owned; + if (!slot.active || slot.device == nullptr || + slot.device->report_parser.play_dual_rumble == nullptr) { + for (DualSenseCue& cue : g_dualsense_cues) + if (cue.slot == index) cancel_dualsense_cue_locked(cue); + previous = {}; + return false; + } + command->device = slot.device; + command->connection_generation = slot.connection_generation; + command->prepared_ms = now_ms; + command->duration_ms = duration; + command->magnitude[0] = magnitude[0]; + command->magnitude[1] = magnitude[1]; + command->slot = index; + for (uint8_t side = 0; side < 2; ++side) + if (command->token[side] != 0) g_dualsense_cues[side].in_flight = true; + return true; +} + +void dispatch_dualsense_cues(const DualSenseCueDispatch& command) { + if (command.device == nullptr) return; + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[command.slot]; + bool current = slot.active && slot.device == command.device && + slot.connection_generation == command.connection_generation; + for (uint8_t side = 0; side < 2; ++side) { + const DualSenseCue& cue = g_dualsense_cues[side]; + if (command.token[side] != 0) + current &= cue.token == command.token[side] && cue.in_flight && + cue.result != -1 && dualsense_cue_current(cue); + } + critical_section_exit(&g_state_lock); + // BTstack owns parser/lifecycle callbacks (Core 0 in HUB). No backend lock + // crosses a driver call. Account for time spent in higher-priority output. + const uint32_t dispatch_ms = btstack_run_loop_get_time_ms(); + const uint32_t delay = dispatch_ms - command.prepared_ms; + current &= delay < kRumblePollIntervalMs; + const uint16_t duration = command.duration_ms > delay + ? static_cast(command.duration_ms - delay) : 0; + current &= command.duration_ms == 0 || duration != 0; + if (current) { + current = uni_hid_parser_ds5_bridge_rumble( + command.device, duration, command.magnitude[0], command.magnitude[1]); + if (current) __atomic_add_fetch(&g_rumble_dispatches, 1, __ATOMIC_RELAXED); + } + critical_section_enter_blocking(&g_state_lock); + if (current && slot.active && slot.device == command.device) { + // Retain ownership even if USB canceled/reselected during dispatch: + // the next timer must stop/replace exactly the packet just submitted. + slot.dualsense_output = { + duration == 0 ? 0 : dispatch_ms + duration, + {command.magnitude[0], command.magnitude[1]}, duration != 0}; + } + for (uint8_t side = 0; side < 2; ++side) { + DualSenseCue& cue = g_dualsense_cues[side]; + if (command.token[side] == 0 || cue.token != command.token[side]) continue; + cue.in_flight = false; + if (!dualsense_cue_current(cue) || + (cue.result == 0 && dispatch_ms - cue.requested_ms >= kDualSenseCueDeadlineMs)) { + cancel_dualsense_cue_locked(cue); + } else if (current && cue.result == 0) { + cue.result = 1; // Accepted source submission, never a native ACK. + cue.started_ms = command.prepared_ms; + cue.active = cue.sample_id != 0; + } + } + critical_section_exit(&g_state_lock); +} +#endif + // Core 1 only. The mailbox carries values, never a parser pointer supplied by // Core 0. Revalidate after lifecycle/topology work and before touching the parser. void process_wii_orientation(uint8_t slot_index) { @@ -2695,6 +3020,9 @@ void process_rumble_timer(btstack_timer_source_t* timer) { #ifdef SWITCH2_BRIDGE_WII_INPUT WiiCueDispatch wii_cue_dispatch{}; #endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + DualSenseCueDispatch dualsense_dispatch{}; +#endif critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; @@ -2828,12 +3156,20 @@ void process_rumble_timer(btstack_timer_source_t* timer) { const bool wii_cue_owns_rumble = prepare_wii_cue( slot_index, now_ms, local_feedback_active, profile_rumble_dispatch || feedback_dispatch, &wii_cue_dispatch); +#endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + const bool dualsense_owns_rumble = prepare_dualsense_cues( + slot_index, now_ms, local_feedback_active, + profile_rumble_dispatch || feedback_dispatch, &dualsense_dispatch); #endif if (!profile_rumble_dispatch && !feedback_dispatch && !local_feedback_active && slot.rumble_pending #ifdef SWITCH2_BRIDGE_WII_INPUT && !wii_cue_owns_rumble #endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + && !dualsense_owns_rumble +#endif #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT && !(xinput_host_mode && haptics_experiment_gameplay_owns(slot.device)) @@ -2909,6 +3245,9 @@ void process_rumble_timer(btstack_timer_source_t* timer) { } #ifdef SWITCH2_BRIDGE_WII_INPUT dispatch_wii_cue(wii_cue_dispatch); +#endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + dispatch_dualsense_cues(dualsense_dispatch); #endif } @@ -3398,6 +3737,21 @@ void platform_on_device_connected(uni_hid_device_t* device) { uni_hid_device_disconnect(device); return; } +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + // Classification completes after connection. Reserve transport capacity, + // not a player/color slot, until a supported source reaches ready. + const int pending_index = physical_index_for_device(device); + if (pending_index < 0) { + uni_hid_device_disconnect(device); + return; + } + critical_section_enter_blocking(&g_state_lock); + g_retired_devices[pending_index] = nullptr; + g_switch2_interval_requests[pending_index] = {}; + if (slot_for_device(device) < 0) g_dualsense_pending_devices[pending_index] = device; + critical_section_exit(&g_state_lock); + recompute_connection_status(); +#else const ControllerIdentity connection_identity = identity_for_device(device); critical_section_enter_blocking(&g_state_lock); const int physical_index = physical_index_for_device(device); @@ -3422,11 +3776,25 @@ void platform_on_device_connected(uni_hid_device_t* device) { } else { uni_hid_device_disconnect(device); } +#endif } void platform_on_device_disconnected(uni_hid_device_t* device) { +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + const int pending_index = physical_index_for_device(device); + if (pending_index >= 0) { + critical_section_enter_blocking(&g_state_lock); + if (g_dualsense_pending_devices[pending_index] == device) + g_dualsense_pending_devices[pending_index] = nullptr; + g_retired_devices[pending_index] = device; + critical_section_exit(&g_state_lock); + } +#endif const int slot_index = slot_for_device(device); if (slot_index < 0) { + #if SWITCH2_BRIDGE_DUALSENSE_INPUT + recompute_connection_status(); + #endif return; } #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE @@ -3458,6 +3826,9 @@ void platform_on_device_disconnected(uni_hid_device_t* device) { } else { release_slot(slot); } +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + refresh_dualsense_source_locked(); +#endif critical_section_exit(&g_state_lock); clear_ble_identity_for_device(device); if (survivor != nullptr) { @@ -3492,11 +3863,22 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { uni_hid_device_t* companion = nullptr; ControllerIdentity connection_identity = identity_for_device(device); critical_section_enter_blocking(&g_state_lock); + #if SWITCH2_BRIDGE_DUALSENSE_INPUT + if (!dualsense_source_matches(device)) { + critical_section_exit(&g_state_lock); + return UNI_ERROR_INVALID_CONTROLLER; + } + #endif int slot_index = reserve_device_slot(device); if (slot_index < 0) { critical_section_exit(&g_state_lock); return UNI_ERROR_NO_SLOTS; } + #if SWITCH2_BRIDGE_DUALSENSE_INPUT + const int pending_index = physical_index_for_device(device); + if (g_dualsense_pending_devices[pending_index] == device) + g_dualsense_pending_devices[pending_index] = nullptr; + #endif BackendSlot& pending = g_slots[slot_index]; if (!pending.active) { const int partner_index = joycon_partner_slot(device, slot_index); @@ -3519,6 +3901,9 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { } became_active = true; } +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + refresh_dualsense_source_locked(); +#endif const BackendSlot& current = g_slots[slot_index]; owner = current.device; companion = current.companion; @@ -3583,6 +3968,29 @@ void platform_on_controller_data(uni_hid_device_t* device, critical_section_exit(&g_state_lock); return; } +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + if (device->controller_type == CONTROLLER_TYPE_PS5Controller) { + uni_ds5_bridge_snapshot_t sensor{}; + DualSenseIngress& motion = slot.dualsense_motion; + if (!uni_hid_parser_ds5_bridge_snapshot(device, &sensor) || + sensor.report_sequence == motion.report_sequence) { + critical_section_exit(&g_state_lock); + return; + } + motion.has_report = true; + motion.report_sequence = sensor.report_sequence; + motion.received_us = time_us_32(); + motion.motion_valid = sensor.motion_valid && + sensor.motion_sequence != motion.motion_sequence; + if (sensor.motion_valid && sensor.motion_sequence != motion.motion_sequence) { + motion.motion_sequence = sensor.motion_sequence; + motion.motion_received_us = motion.received_us; + motion.motion_valid = true; + memcpy(motion.accel_q13, controller->gamepad.accel, sizeof(motion.accel_q13)); + memcpy(motion.gyro_q10, controller->gamepad.gyro, sizeof(motion.gyro_q10)); + } + } +#endif #ifdef SWITCH_PICO_WII_IR uni_wii_ir_snapshot_t infrared{}; const bool have_infrared = @@ -3915,6 +4323,9 @@ void bluepad32_input_backend_init() { g_joycon_pair_hints[slot_index] = {}; g_joycon_gestures[slot_index] = {}; g_joycon_overrides[slot_index] = {}; + #if SWITCH2_BRIDGE_DUALSENSE_INPUT + g_dualsense_pending_devices[slot_index] = nullptr; + #endif } g_joycon_mode = JoyConMode::kPaired; g_joycon_reconcile_requested = false; @@ -4120,6 +4531,76 @@ void bluepad32_input_backend_snapshot(uint8_t slot_index, g_last_snapshot_generation[slot_index] = state_generation; } +#if SWITCH2_BRIDGE_DUALSENSE_INPUT +void bluepad32_input_backend_select_dualsense_source(const uint8_t address[6]) { + if (!g_initialized) return; + critical_section_enter_blocking(&g_state_lock); + g_dualsense_explicit_address = address != nullptr; + if (address != nullptr) memcpy(g_dualsense_address, address, 6); + else memset(g_dualsense_address, 0, sizeof(g_dualsense_address)); + refresh_dualsense_source_locked(true); + critical_section_exit(&g_state_lock); +} + +void bluepad32_input_backend_dualsense_snapshot(Bluepad32DualSenseBridgeSnapshot* output) { + if (output == nullptr) return; + *output = {}; + if (!g_initialized) return; + critical_section_enter_blocking(&g_state_lock); + *output = g_dualsense_snapshot; + critical_section_exit(&g_state_lock); +} + +bool bluepad32_input_backend_dualsense_sample_request( + uint8_t instance, uint8_t sample_id, uint64_t* token) { + if (token == nullptr) return false; + *token = 0; + if (!g_initialized || instance >= 2 || sample_id >= 8) return false; + critical_section_enter_blocking(&g_state_lock); + DualSenseCue& cue = g_dualsense_cues[instance]; + const uint8_t index = g_dualsense_slot; + const bool accepted = index < kSlotCount && g_next_dualsense_token != 0 && + g_slots[index].device->report_parser.play_dual_rumble != nullptr && + !cue.in_flight && (sample_id == 0 || (cue.result != 0 && !cue.active)); + if (accepted) { + cue = {}; + cue.token = g_next_dualsense_token++; + cue.slot = index; + cue.connection_generation = g_dualsense_generation; + cue.requested_ms = btstack_run_loop_get_time_ms(); + cue.sample_id = sample_id; + cue.result = 0; + *token = cue.token; + } + critical_section_exit(&g_state_lock); + return accepted; +} + +int bluepad32_input_backend_dualsense_sample_result(uint8_t instance, uint64_t token) { + if (!g_initialized || instance >= 2 || token == 0) return -1; + critical_section_enter_blocking(&g_state_lock); + DualSenseCue& cue = g_dualsense_cues[instance]; + int result = -1; + if (cue.token == token && !cue.consumed) { + if (!dualsense_cue_current(cue) || + (cue.result == 0 && + btstack_run_loop_get_time_ms() - cue.requested_ms >= kDualSenseCueDeadlineMs)) + cancel_dualsense_cue_locked(cue); + result = cue.result; + if (result != 0) cue.consumed = true; + } + critical_section_exit(&g_state_lock); + return result; +} + +void bluepad32_input_backend_dualsense_sample_cancel(uint8_t instance) { + if (!g_initialized || instance >= 2) return; + critical_section_enter_blocking(&g_state_lock); + cancel_dualsense_cue_locked(g_dualsense_cues[instance]); + critical_section_exit(&g_state_lock); +} +#endif + #ifdef SWITCH2_BRIDGE_WII_INPUT void bluepad32_input_backend_select_wii_source(const uint8_t address[6]) { // Selection is configuration, not a live Core 0 parser mutation. diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index aa85b9d..7786fd2 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -108,6 +108,36 @@ int bluepad32_input_backend_wii_sample_result(uint64_t token); void bluepad32_input_backend_wii_sample_cancel(); #endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT +// One physical DualSense/Edge, calibrated SDL axes before legacy conversion. +// Receipt times and motion sequences advance only on admitted parser reports. +struct Bluepad32DualSenseBridgeSnapshot { + uint8_t slot = 0xff; + Bluepad32SlotSnapshot controller{}; + uint32_t state_generation = 0; + uint32_t received_us = 0; + uint8_t battery = 0; + bool motion_valid = false; + uint32_t motion_sequence = 0; + uint32_t motion_received_us = 0; + int32_t accel_q13[3]{}; + int32_t gyro_q10[3]{}; +}; + +// nullptr selects the uniquely eligible ready DualSense; ambiguity fails closed. +// Reselection invalidates input and cue tokens without modifying pairings. +void bluepad32_input_backend_select_dualsense_source(const uint8_t address[6]); +void bluepad32_input_backend_dualsense_snapshot(Bluepad32DualSenseBridgeSnapshot* output); +// Instance 0 is R/right motor, 1 is L/left motor. Samples 0..7 are bounded +// compatibility-motor cues, not HD haptics. Zero stops only the requested side. +// Result: 0 pending, 1 source-driver dispatch, -1 retired/failed/consumed. +// Dispatch is NOT an application ACK or proof of physical actuator onset. +bool bluepad32_input_backend_dualsense_sample_request( + uint8_t instance, uint8_t sample_id, uint64_t* token); +int bluepad32_input_backend_dualsense_sample_result(uint8_t instance, uint64_t token); +void bluepad32_input_backend_dualsense_sample_cancel(uint8_t instance); +#endif + // Side-effect-free raw input snapshot for management telemetry. Unlike the // report-path snapshot, reading this does not consume motion samples. struct Bluepad32PlaytestSnapshot { diff --git a/src/firmware/input/switch2_mouse_capture.cpp b/src/firmware/input/switch2_mouse_capture.cpp index 2408389..cf7ce94 100644 --- a/src/firmware/input/switch2_mouse_capture.cpp +++ b/src/firmware/input/switch2_mouse_capture.cpp @@ -42,6 +42,7 @@ struct Source { NativeReport native_reports[kNativeReportCapacity]; uint8_t native_head; uint8_t native_count; + uint32_t native_borrowed_serial; bool native_stream; bool source_active; Sample sample; @@ -62,6 +63,28 @@ Source* selected_source(uint16_t product_id, const uint8_t address[6]) { void clear_native_reports(Source& source) { source.native_head = 0; source.native_count = 0; + source.native_borrowed_serial = 0; +} + +bool can_replace_native_tail(const Source& source, const NativeReport& tail, + const uint8_t* report) { + // A handed-out packet is immutable until commit. Keep discrete transitions + // and every relative mouse packet; only continuous state can be superseded. + if (tail.serial == source.native_borrowed_serial || + memcmp(tail.report + 2, report + 2, 3) != 0 || + tail.report[8] != report[8] || tail.report[13] != report[13] || + (source.input_product_id == UNI_SW2_JOYCON_R_PID && tail.report[14] != report[14])) + return false; + for (unsigned i = 9; i < 13; ++i) + if (tail.report[i] != 0 || report[i] != 0) return false; + const unsigned length_offset = source.input_product_id == UNI_SW2_JOYCON_L_PID ? 14u : 15u; + const unsigned motion_length = report[length_offset]; + if ((motion_length != 30u && motion_length != 40u) || + tail.report[length_offset] != motion_length) return false; + const unsigned tail_offset = length_offset + 1u + motion_length; + if (memcmp(tail.report + tail_offset, report + tail_offset, + SWITCH2_MOUSE_CAPTURE_NATIVE_INPUT_SIZE - tail_offset) != 0) return false; + return true; } bool source_fresh(const Source& source, uint32_t now_ms) { @@ -177,13 +200,21 @@ extern "C" void switch_pico_switch2_mouse_report( #if SWITCH_PICO_SWITCH2_USB_BRIDGE source.source_active = true; if (source.native_stream && g_total_records != UINT32_MAX) { - if (source.native_count == kNativeReportCapacity) clear_native_reports(source); - NativeReport& packet = - source.native_reports[(source.native_head + source.native_count) % kNativeReportCapacity]; - memcpy(packet.report, report, sizeof(packet.report)); - packet.serial = g_total_records; - packet.received_ms = received_ms; - ++source.native_count; + NativeReport* packet = nullptr; + if (source.native_count != 0) { + NativeReport& tail = source.native_reports[ + (source.native_head + source.native_count - 1u) % kNativeReportCapacity]; + if (can_replace_native_tail(source, tail, report)) packet = &tail; + } + if (packet == nullptr) { + if (source.native_count == kNativeReportCapacity) clear_native_reports(source); + packet = &source.native_reports[ + (source.native_head + source.native_count) % kNativeReportCapacity]; + ++source.native_count; + } + memcpy(packet->report, report, sizeof(packet->report)); + packet->serial = g_total_records; + packet->received_ms = received_ms; } #endif memcpy(source.latest_input.buttons, report + 2, sizeof(source.latest_input.buttons)); @@ -295,6 +326,7 @@ uint32_t switch2_mouse_capture_peek_native_report( const NativeReport& packet = source.native_reports[source.native_head]; memcpy(report, packet.report, sizeof(packet.report)); serial = packet.serial; + source.native_borrowed_serial = serial; } critical_section_exit(&g_lock); return serial; @@ -309,6 +341,7 @@ bool switch2_mouse_capture_commit_native_report(uint8_t instance, uint32_t seria if (accepted) { source.native_head = static_cast((source.native_head + 1) % kNativeReportCapacity); --source.native_count; + source.native_borrowed_serial = 0; } critical_section_exit(&g_lock); return accepted; diff --git a/src/firmware/input/switch2_mouse_capture.h b/src/firmware/input/switch2_mouse_capture.h index 47bf6bf..ed7e434 100644 --- a/src/firmware/input/switch2_mouse_capture.h +++ b/src/firmware/input/switch2_mouse_capture.h @@ -70,12 +70,16 @@ bool switch2_mouse_capture_latest_input(uint8_t instance, uint32_t after_serial, Switch2MouseCaptureInput* output); #if SWITCH_PICO_SWITCH2_USB_BRIDGE -// Core 0 explicitly enables the selected source's ordered native 07/08 relay. +// Core 0 explicitly enables the selected source's native 07/08 relay. // Starts disabled; false clears the FIFO, repeated true preserves it. Enable // never replays earlier capture-ring/latest-input data. Selection disables it; // teardown and capture-serial exhaustion clear it without changing selection. // Only exact selected Joy-Con 63-byte native payloads (07 left, 08 right) queue. -// The 32-entry FIFO is independent of the raw capture ring; overflow discards +// Adjacent, unborrowed updates with unchanged buttons/status/opaque fields and +// zero relative mouse motion coalesce to the newest analog/IMU state. Different +// IMU formats, discrete transitions and mouse packets retain their FIFO order. +// Peek pins the head until commit; coalescing cannot change a submitted packet. +// The 32-entry bound is independent of the raw capture ring; overflow discards // queued history and retains only the arriving packet. void switch2_mouse_capture_set_native_stream(uint8_t instance, bool enabled); diff --git a/tests/bluepad32_native_stubs/parser/uni_hid_parser_ds5.h b/tests/bluepad32_native_stubs/parser/uni_hid_parser_ds5.h new file mode 100644 index 0000000..d9d5686 --- /dev/null +++ b/tests/bluepad32_native_stubs/parser/uni_hid_parser_ds5.h @@ -0,0 +1,14 @@ +#pragma once +#include +#include +#include + +typedef struct { + uint32_t report_sequence; + uint32_t motion_sequence; + bool motion_valid; +} uni_ds5_bridge_snapshot_t; + +void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t*, const uint8_t*, uint16_t); +bool uni_hid_parser_ds5_bridge_rumble(uni_hid_device_t*, uint16_t, uint8_t, uint8_t); +bool uni_hid_parser_ds5_bridge_snapshot(uni_hid_device_t*, uni_ds5_bridge_snapshot_t*); diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index bbc07b2..57fa5d5 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -86,6 +86,7 @@ enum { typedef enum { CONTROLLER_TYPE_UnknownController = 0, CONTROLLER_TYPE_WiiController = 35, + CONTROLLER_TYPE_PS5Controller = 46, } uni_controller_type_t; typedef enum { @@ -140,6 +141,7 @@ struct uni_report_parser_t { uni_set_player_leds_t set_player_leds; uni_set_lightbar_color_t set_lightbar_color; uni_play_dual_rumble_t play_dual_rumble; + void (*parse_input_report)(uni_hid_device_t*, const uint8_t*, uint16_t); }; enum uni_bt_conn_protocol_t { diff --git a/tests/dualsense_backend_test.cpp b/tests/dualsense_backend_test.cpp new file mode 100644 index 0000000..3f1dfec --- /dev/null +++ b/tests/dualsense_backend_test.cpp @@ -0,0 +1,291 @@ +// Reuse the backend's transport/storage fixture; these scenarios exercise only +// the native DualSense contract, not a second implementation of its scheduler. +#define main backend_fixture_main +#include "bluepad32_backend_lifecycle_test.cpp" +#undef main + +namespace { +struct SensorFixture { + uni_hid_device_t* device = nullptr; + uni_ds5_bridge_snapshot_t metadata{}; + bool valid = false; +}; +SensorFixture sensors[4]; +void (*during_dualsense_dispatch)() = nullptr; +bool dualsense_transport_available = true; + +void observe_dualsense_rumble(uni_hid_device_t* target, uint16_t delay, + uint16_t duration, uint8_t right, uint8_t left) { + require(state_lock_depth == 0, "DS5 driver dispatch must not hold the shared state lock"); + play_rumble(target, delay, duration, right, left); + if (during_dualsense_dispatch) during_dualsense_dispatch(); +} + +uni_hid_device_t dualsense(int index) { + auto result = device(index, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + result.vendor_id = 0x054c; + result.product_id = index == 0 ? 0x0ce6 : 0x0df2; + result.controller_type = CONTROLLER_TYPE_PS5Controller; + result.report_parser.parse_input_report = uni_hid_parser_ds5_parse_input_report; + result.report_parser.play_dual_rumble = observe_dualsense_rumble; + return result; +} + +void report_dualsense(uni_hid_device_t& pad, bool fresh_motion = true) { + SensorFixture& sensor = sensors[pad.idx]; + sensor.device = &pad; + sensor.valid = true; + ++sensor.metadata.report_sequence; + if (fresh_motion) ++sensor.metadata.motion_sequence; + sensor.metadata.motion_valid = fresh_motion; + pad.controller.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + pad.controller.gamepad.buttons = BUTTON_A | BUTTON_SHOULDER_L; + pad.controller.gamepad.accel[1] = 8193; + pad.controller.gamepad.gyro[2] = -123456; + pad.controller.battery = 176; + platform_on_controller_data(&pad, &pad.controller); +} + +Bluepad32DualSenseBridgeSnapshot bridge_snapshot() { + Bluepad32DualSenseBridgeSnapshot result{}; + bluepad32_input_backend_dualsense_snapshot(&result); + return result; +} + +void source_isolation() { + start_pairing_backend(); + auto ordinary = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + ordinary.vendor_id = 0x054c; + ordinary.product_id = 0x0ce6; + require(platform_on_device_ready(&ordinary) == UNI_ERROR_INVALID_CONTROLLER, + "an unsupported parser must not enter the dedicated DualSense output slots"); + bluepad32_input_backend_select_dualsense_source(ordinary.conn.btaddr); + require(!bridge_snapshot().controller.active, "VID/PID/address alone must never select a non-PS5 parser"); + platform_on_device_disconnected(&ordinary); + bluepad32_input_backend_select_dualsense_source(nullptr); + auto first = dualsense(0); + auto second = dualsense(1); + require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS, "first DS5 must connect"); + now_ms = 100; + report_dualsense(first); + const auto initial = bridge_snapshot(); + require(initial.slot == 0 && initial.controller.active && initial.controller.state.button_south && + initial.battery == 176 && initial.motion_valid && initial.accel_q13[1] == 8193 && + initial.gyro_q10[2] == -123456 && initial.motion_received_us == 100000, + "native snapshot must preserve coherent physical controls and calibrated precision"); + now_ms = 120; + platform_on_controller_data(&first, &first.controller); + bluepad32_input_backend_report_sent(0); + auto snapshot = bridge_snapshot(); + require(snapshot.state_generation == initial.state_generation && snapshot.received_us == 100000 && + snapshot.motion_sequence == initial.motion_sequence, + "polling/cached callbacks and USB consumption must not freshen input or motion"); + report_dualsense(first, false); + snapshot = bridge_snapshot(); + require(snapshot.received_us == 120000 && snapshot.motion_received_us == 100000 && !snapshot.motion_valid, + "a controls-only admission must not refresh a duplicate sensor timestamp"); + sensors[0].valid = false; + first.controller.gamepad.buttons = 0; + platform_on_controller_data(&first, &first.controller); + require(bridge_snapshot().controller.state.button_south, + "malformed parser input must not publish an invented button release"); + uint64_t old_token; + require(bluepad32_input_backend_dualsense_sample_request(0, 1, &old_token), "first source cue must queue"); + require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "Edge must connect"); + report_dualsense(second); + require(!bridge_snapshot().controller.active && + bluepad32_input_backend_dualsense_sample_result(0, old_token) == -1, + "auto ambiguity must fail closed and retire source work immediately"); + platform_on_device_disconnected(&second); + platform_on_controller_data(&first, &first.controller); + require(!bridge_snapshot().controller.active, "returning to a source cannot resurrect cached state"); + report_dualsense(first); + snapshot = bridge_snapshot(); + require(snapshot.controller.active && snapshot.controller.connection_generation != initial.controller.connection_generation, + "a missed ambiguous interval still needs a new adapter epoch"); + platform_on_device_connected(&second); + require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "Edge reconnect must succeed"); + bluepad32_input_backend_select_dualsense_source(first.conn.btaddr); + report_dualsense(first); + report_dualsense(second); + require(bridge_snapshot().slot == 0, "explicit source must ignore another live PS5"); + platform_on_device_disconnected(&first); + require(!bridge_snapshot().controller.active, "disconnect must not migrate an explicit source"); +} + +void stable_logical_slot() { + start_pairing_backend(); + auto unrelated = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto pad = dualsense(1); + pad.report_parser.set_lightbar_color = set_lightbar; + // An earlier, still-unclassified transport connection must not reserve player 1. + platform_on_device_connected(&unrelated); + platform_on_device_connected(&pad); + require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "DS5 must complete setup"); + now_ms = 100; + report_dualsense(pad); + const auto first = bridge_snapshot(); + const auto color = switch_pro_get_slot_light_color(0); + require(first.controller.active && first.slot == 0 && + pad.lightbar_red == color.red && pad.lightbar_green == color.green && + pad.lightbar_blue == color.blue, + "a transport-index-1 DualSense must own logical slot 0 and its lightbar color"); + require(platform_on_device_ready(&unrelated) == UNI_ERROR_INVALID_CONTROLLER, + "dedicated DualSense mode must reject an unrelated ready controller"); + platform_on_device_disconnected(&unrelated); + platform_on_device_connected(&unrelated); + require(platform_on_device_ready(&unrelated) == UNI_ERROR_INVALID_CONTROLLER, + "remembered unrelated reconnects must remain outside logical slots"); + platform_on_device_disconnected(&unrelated); + require(bridge_snapshot().slot == 0 && + bridge_snapshot().controller.connection_generation == first.controller.connection_generation && + pad.lightbar_calls == 1, + "unrelated connection churn must not rebind or recolor the active DualSense"); + + platform_on_device_disconnected(&pad); + auto reconnected = dualsense(2); + memcpy(reconnected.conn.btaddr, pad.conn.btaddr, sizeof(pad.conn.btaddr)); + reconnected.report_parser.set_lightbar_color = set_lightbar; + platform_on_device_connected(&reconnected); + require(platform_on_device_ready(&reconnected) == UNI_ERROR_SUCCESS, "DS5 reconnect must succeed"); + now_ms = 200; + report_dualsense(reconnected); + const auto next = bridge_snapshot(); + require(next.controller.active && next.slot == 0 && + controller_identity_equal(next.controller.identity, first.controller.identity) && + next.controller.connection_generation != first.controller.connection_generation && + reconnected.lightbar_red == color.red && reconnected.lightbar_green == color.green && + reconnected.lightbar_blue == color.blue, + "reusing another Bluetooth index must preserve identity and the first logical slot"); + auto aborted = dualsense(3); + platform_on_device_connected(&aborted); + platform_on_device_disconnected(&aborted); + require(platform_on_device_ready(&aborted) == UNI_ERROR_NO_SLOTS, + "a late ready callback must not resurrect an unassigned disconnected transport"); + require(bridge_snapshot().controller.active && bridge_snapshot().slot == 0, + "an aborted second setup must not disturb the active source"); + auto pending0 = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto pending1 = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + auto pending3 = device(3, true, UNI_BT_CONN_PROTOCOL_BR_EDR); + platform_on_device_connected(&pending0); + platform_on_device_connected(&pending1); + platform_on_device_connected(&pending3); + require(!incoming_connections && !scanning_enabled, + "unclassified transports still consume physical connection capacity"); + platform_on_device_disconnected(&pending0); + require(incoming_connections && bridge_snapshot().slot == 0 && + bridge_snapshot().controller.connection_generation == next.controller.connection_generation, + "freeing pending transport capacity must not move the logical source"); + platform_on_device_disconnected(&pending1); + platform_on_device_disconnected(&pending3); +} + +void cue_lifetime() { + start_pairing_backend(); + auto pad = dualsense(0); + require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "DS5 must connect"); + uint64_t right, left, stop; + require(bluepad32_input_backend_dualsense_sample_request(0, 6, &right) && + bluepad32_input_backend_dualsense_sample_request(1, 1, &left) && right != left && + bluepad32_input_backend_dualsense_sample_result(0, right) == 0 && pad.rumble_calls == 0, + "independent acceptance is not driver completion"); + dualsense_transport_available = false; + process_rumble_timer(&g_rumble_timer); + require(bluepad32_input_backend_dualsense_sample_result(0, right) == 0 && pad.rumble_calls == 0, + "a busy source driver must not count as dispatch completion"); + dualsense_transport_available = true; + process_rumble_timer(&g_rumble_timer); + require(pad.last_high == 96 && pad.last_low == 160 && pad.last_rumble_duration_ms == 60 && + bluepad32_input_backend_dualsense_sample_result(0, right) == 1 && + bluepad32_input_backend_dualsense_sample_result(1, right) == -1 && + bluepad32_input_backend_dualsense_sample_result(1, left) == 1, + "combined output must route R weak/right and L strong/left with the shortest safe timer"); + now_ms = 60; + process_rumble_timer(&g_rumble_timer); + require(pad.last_high == 0 && pad.last_low == 160 && pad.last_rumble_duration_ms == 940, + "ending right must preserve only the left pulse's original remaining lifetime"); + require(bluepad32_input_backend_dualsense_sample_request(0, 3, &right), "right can restart independently"); + process_rumble_timer(&g_rumble_timer); + now_ms = 85; + process_rumble_timer(&g_rumble_timer); + require(pad.last_high == 0 && pad.last_low == 160, "right gap must not stop the left motor"); + now_ms = 175; + process_rumble_timer(&g_rumble_timer); + require(pad.last_high == 96 && pad.last_low == 160, "later pulse must resume after its gap"); + require(bluepad32_input_backend_dualsense_sample_request(1, 0, &stop), "left stop must replace only left"); + process_rumble_timer(&g_rumble_timer); + require(pad.last_high == 96 && pad.last_low == 0 && + bluepad32_input_backend_dualsense_sample_result(1, stop) == 1 && + bluepad32_input_backend_dualsense_sample_result(1, left) == -1, + "a side stop needs actual dispatch and cannot stop its sibling"); + now_ms = 200; + process_rumble_timer(&g_rumble_timer); + require(pad.last_rumble_duration_ms == 0, "final pulse expiry must release both motors"); + const int stopped = pad.rumble_calls; + now_ms = 5000; + process_rumble_timer(&g_rumble_timer); + require(pad.rumble_calls == stopped, "expired pulses must never replay after a stall"); + require(bluepad32_input_backend_dualsense_sample_request(0, 1, &right), "pending timeout cue must queue"); + now_ms += 2000; + process_rumble_timer(&g_rumble_timer); + require(bluepad32_input_backend_dualsense_sample_result(0, right) == -1 && pad.rumble_calls == stopped, + "an undispatched expired cue must fail without producing a late pulse"); +} + +void cue_races() { + start_pairing_backend(); + auto pad = dualsense(0); + require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "DS5 must connect"); + uint64_t token; + require(bluepad32_input_backend_dualsense_sample_request(0, 1, &token), "race cue must queue"); + during_dualsense_dispatch = [] { bluepad32_input_backend_dualsense_sample_cancel(0); }; + process_rumble_timer(&g_rumble_timer); + during_dualsense_dispatch = nullptr; + require(bluepad32_input_backend_dualsense_sample_result(0, token) == -1, + "cancellation during dispatch must defeat a late completion"); + process_rumble_timer(&g_rumble_timer); + require(pad.last_rumble_duration_ms == 0, "in-flight cancellation must retain a bounded stop obligation"); + require(bluepad32_input_backend_dualsense_sample_request(1, 1, &token), "reselection race must queue"); + during_dualsense_dispatch = [] { bluepad32_input_backend_select_dualsense_source(nullptr); }; + process_rumble_timer(&g_rumble_timer); + during_dualsense_dispatch = nullptr; + require(bluepad32_input_backend_dualsense_sample_result(1, token) == -1, + "reselection must retire an in-flight token even for the same physical source"); + process_rumble_timer(&g_rumble_timer); + require(pad.last_rumble_duration_ms == 0, "reselection cannot orphan the just-dispatched motor"); + require(bluepad32_input_backend_dualsense_sample_request(0, 1, &token), "disconnect race must queue"); + platform_on_device_disconnected(&pad); + auto replacement = dualsense(0); + require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS, "replacement must connect"); + process_rumble_timer(&g_rumble_timer); + require(bluepad32_input_backend_dualsense_sample_result(0, token) == -1 && replacement.rumble_calls == 0, + "old tokens and deferred stops must never enter a replacement connection"); +} +} // namespace + +extern "C" void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t*, const uint8_t*, uint16_t) {} +extern "C" bool uni_hid_parser_ds5_bridge_rumble( + uni_hid_device_t* pad, uint16_t duration, uint8_t right, uint8_t left) { + if (!dualsense_transport_available) return false; + observe_dualsense_rumble(pad, 0, duration, right, left); + return true; +} +extern "C" bool uni_hid_parser_ds5_bridge_snapshot(uni_hid_device_t* pad, uni_ds5_bridge_snapshot_t* out) { + for (const auto& fixture : sensors) { + if (fixture.device != pad || !fixture.valid) continue; + *out = fixture.metadata; + return true; + } + return false; +} + +int main(int argc, char** argv) { + require(argc == 2, "scenario required"); + const std::string scenario = argv[1]; + if (scenario == "source-isolation") source_isolation(); + else if (scenario == "cue-lifetime") cue_lifetime(); + else if (scenario == "cue-races") cue_races(); + else if (scenario == "stable-logical-slot") stable_logical_slot(); + else require(false, "unknown DualSense scenario"); + return 0; +} diff --git a/tests/dualsense_parser_native_test.c b/tests/dualsense_parser_native_test.c new file mode 100644 index 0000000..ff248ba --- /dev/null +++ b/tests/dualsense_parser_native_test.c @@ -0,0 +1,262 @@ +#include +#include +#include +#include +#include + +#include "parser/uni_hid_parser_ds5.h" +#include "uni_hid_device.h" +#include "uni_utils.h" + +// Real staged DS5 parser; substitute only the radio, virtual mouse and clock. +static uni_hid_device_t device; +static unsigned ready_count; +static uint8_t requested_feature; +static uint8_t output[79]; +static unsigned output_count; +static uint32_t now_ms; +static bool transport_available = true; +static int send_status = ERROR_CODE_SUCCESS; +static unsigned disconnect_count; +static struct { + btstack_timer_source_t* timer; + uint32_t deadline; + bool active; +} timers[2]; + +void uni_log(const char* fmt, ...) { (void)fmt; } +void uni_hid_device_send_ctrl_report(uni_hid_device_t* d, const uint8_t* bytes, uint16_t len) { + assert(d == &device && len == 2 && bytes[0] == 0x43); + requested_feature = bytes[1]; +} +void uni_hid_device_send_intr_report(uni_hid_device_t* d, const uint8_t* bytes, uint16_t len) { + assert(d == &device && len == sizeof(output)); + memcpy(output, bytes, len); + ++output_count; +} +uint32_t btstack_run_loop_get_time_ms(void) { return now_ms; } +int l2cap_can_send_packet_now(uint16_t cid) { + assert(cid == device.conn.interrupt_cid); + return transport_available; +} +int l2cap_send(uint16_t cid, uint8_t* bytes, uint16_t len) { + assert(cid == device.conn.interrupt_cid); + if (send_status != ERROR_CODE_SUCCESS) return send_status; + uni_hid_device_send_intr_report(&device, bytes, len); + return ERROR_CODE_SUCCESS; +} +void uni_hid_device_disconnect(uni_hid_device_t* d) { + assert(d == &device); + ++disconnect_count; + uni_hid_parser_ds5_bridge_teardown(d); +} +bool uni_hid_device_set_ready_complete(uni_hid_device_t* d) { + assert(d == &device); + ++ready_count; + return true; +} +uni_hid_device_t* uni_hid_device_create_virtual(uni_hid_device_t* d) { (void)d; return NULL; } +void uni_hid_device_set_cod(uni_hid_device_t* d, uint32_t cod) { (void)d; (void)cod; } +void uni_hid_device_connect(uni_hid_device_t* d) { (void)d; } +void uni_hid_device_process_controller(uni_hid_device_t* d) { (void)d; } +uint8_t uni_hid_parser_hat_to_dpad(uint8_t hat) { + const uint8_t values[8] = {DPAD_UP, DPAD_UP | DPAD_RIGHT, DPAD_RIGHT, + DPAD_RIGHT | DPAD_DOWN, DPAD_DOWN, DPAD_DOWN | DPAD_LEFT, + DPAD_LEFT, DPAD_LEFT | DPAD_UP}; + return hat < 8 ? values[hat] : 0; +} + +void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t ms) { + for (unsigned i = 0; i < 2; ++i) { + if (timers[i].timer != timer && timers[i].timer != NULL) continue; + timers[i].timer = timer; + timers[i].deadline = now_ms + ms; + return; + } + assert(false); +} +void btstack_run_loop_add_timer(btstack_timer_source_t* timer) { + for (unsigned i = 0; i < 2; ++i) + if (timers[i].timer == timer) { timers[i].active = true; return; } + assert(false); +} +bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer) { + for (unsigned i = 0; i < 2; ++i) { + if (timers[i].timer != timer) continue; + const bool was_active = timers[i].active; + timers[i].active = false; + return was_active; + } + return false; +} +static void advance(uint32_t time) { + now_ms = time; + for (unsigned i = 0; i < 2; ++i) { + if (!timers[i].active || (int32_t)(now_ms - timers[i].deadline) < 0) continue; + timers[i].active = false; + timers[i].timer->process(timers[i].timer); + } +} +static void put16(uint8_t* bytes, int16_t value) { + bytes[0] = (uint16_t)value; + bytes[1] = (uint16_t)value >> 8; +} +static void put32(uint8_t* bytes, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) bytes[i] = value >> (8 * i); +} +static void seal(uint8_t* bytes, size_t size, uint8_t transaction) { + uint32_t crc = uni_crc32_le(UINT32_MAX, &transaction, 1); + crc = ~uni_crc32_le(crc, bytes, size - 4); + put32(bytes + size - 4, crc); +} +static void feature(uint8_t* bytes, uint16_t len) { + seal(bytes, len, 0xa3); + uni_hid_parser_ds5_parse_feature_report(&device, bytes, len); +} +static void calibration(uint8_t bytes[41], bool fallback) { + memset(bytes, 0, 41); + bytes[0] = 5; + const int16_t bias[3] = {10, -20, 30}; + for (unsigned axis = 0; axis < 3; ++axis) { + put16(bytes + 1 + axis * 2, bias[axis]); + put16(bytes + 7 + axis * 4, bias[axis] + 100); + put16(bytes + 9 + axis * 4, bias[axis] - 100); + put16(bytes + 23 + axis * 4, 8192); + put16(bytes + 25 + axis * 4, -8192); + } + put16(bytes + 19, 100); + put16(bytes + 21, 100); + if (fallback) { + put16(bytes + 23, 0); + put16(bytes + 25, 0); + } +} +static void input(uint8_t bytes[78], uint32_t timestamp) { + memset(bytes, 0, 78); + bytes[0] = 0x31; + bytes[2] = bytes[3] = bytes[4] = bytes[5] = 127; + bytes[9] = 0x28; // Cross + neutral hat. + bytes[11] = 0x02; // Touchpad click, not mute. + put16(bytes + 17, 11); + put16(bytes + 19, -20); + put16(bytes + 21, 30); + put16(bytes + 25, 8192); + put32(bytes + 29, timestamp); + seal(bytes, 78, 0xa1); +} +static uni_ds5_bridge_snapshot_t feed(uint8_t* bytes, uint16_t len, bool admitted) { + uni_hid_parser_ds5_init_report(&device); + uni_hid_parser_ds5_parse_input_report(&device, bytes, len); + uni_ds5_bridge_snapshot_t snapshot = {0}; + assert(uni_hid_parser_ds5_bridge_snapshot(&device, &snapshot) == admitted); + return snapshot; +} + +int main(void) { + device.controller_type = CONTROLLER_TYPE_PS5Controller; + device.product_id = 0x0df2; // Edge takes the real PS5 path. + device.report_parser.setup = uni_hid_parser_ds5_setup; + device.conn.interrupt_cid = 0x40; + uni_hid_parser_ds5_setup(&device); + assert(requested_feature == 9); + uint8_t pairing[20] = {9}; + feature(pairing, sizeof(pairing)); + assert(requested_feature == 0x20); + uint8_t firmware[64] = {0x20}; + feature(firmware, sizeof(firmware)); + assert(requested_feature == 5); + uint8_t calib[41]; + calibration(calib, false); + seal(calib, sizeof(calib), 0xa3); + uni_hid_parser_ds5_parse_feature_report(&device, calib, 40); + assert(ready_count == 0); // A partial feature cannot initialize calibration. + calib[25] ^= 1; + uni_hid_parser_ds5_parse_feature_report(&device, calib, sizeof(calib)); + assert(ready_count == 0); // Nor can a full feature with a corrupt CRC. + calibration(calib, true); + feature(calib, sizeof(calib)); + assert(ready_count == 1); + uint8_t report[78]; + input(report, 100); + uni_ds5_bridge_snapshot_t snapshot = feed(report, sizeof(report), true); + assert(!snapshot.motion_valid && snapshot.motion_sequence == 0); + assert(device.controller.gamepad.buttons & BUTTON_A); // Controls survive fallback. + calibration(calib, false); + feature(calib, sizeof(calib)); + snapshot = feed(report, sizeof(report), true); + assert(!snapshot.motion_valid && snapshot.motion_sequence == 0); // Calibration alone is not fresh motion. + input(report, 101); + snapshot = feed(report, sizeof(report), true); + assert(snapshot.motion_valid && snapshot.motion_sequence == 1); + assert(device.controller.gamepad.gyro[0] == 1024 && device.controller.gamepad.gyro[1] == 0); + assert(device.controller.gamepad.accel[1] == 8192); + assert(device.controller.gamepad.misc_buttons & MISC_BUTTON_CAPTURE); + const uint32_t report_sequence = snapshot.report_sequence; + uni_ds5_bridge_snapshot_t polled; + assert(uni_hid_parser_ds5_bridge_snapshot(&device, &polled) && polled.report_sequence == report_sequence); + snapshot = feed(report, sizeof(report), true); + assert(!snapshot.motion_valid && snapshot.motion_sequence == 1); + input(report, 99); + snapshot = feed(report, sizeof(report), true); + assert(!snapshot.motion_valid && snapshot.motion_sequence == 1); + input(report, 102); + feed(report, 77, false); + report[9] ^= 0x20; + feed(report, sizeof(report), false); + feed(NULL, 0, false); + input(report, 102); + snapshot = feed(report, sizeof(report), true); + assert(snapshot.motion_valid && snapshot.motion_sequence == 2); + + // A real uint32 sensor-clock wrap is forward progress, not a duplicate. + uni_hid_parser_ds5_setup(&device); + calibration(calib, false); + feature(calib, sizeof(calib)); + input(report, UINT32_MAX - 15); + snapshot = feed(report, sizeof(report), true); + assert(snapshot.motion_valid && snapshot.motion_sequence == 1); + input(report, 16); + snapshot = feed(report, sizeof(report), true); + assert(snapshot.motion_valid && snapshot.motion_sequence == 2); + + const unsigned before_busy = output_count; + transport_available = false; + assert(!uni_hid_parser_ds5_bridge_rumble(&device, 60, 31, 217) && output_count == before_busy); + transport_available = true; + send_status = BTSTACK_ACL_BUFFERS_FULL; + assert(!uni_hid_parser_ds5_bridge_rumble(&device, 60, 31, 217) && output_count == before_busy); + send_status = ERROR_CODE_SUCCESS; + assert(uni_hid_parser_ds5_bridge_rumble(&device, 60, 31, 217)); + assert(output[6] == 31 && output[7] == 217); // Wire motor right/left, not callback echoes. + advance(59); + assert(output[6] == 31 && output[7] == 217); + advance(60); + assert(output[6] == 0 && output[7] == 0); // Real parser's finite duration timer stops both. + uni_hid_parser_ds5_play_dual_rumble(&device, 100, 1000, 90, 0); + const unsigned sent = output_count; + uni_hid_parser_ds5_bridge_teardown(&device); + advance(2000); + assert(output_count == sent && !uni_hid_parser_ds5_bridge_snapshot(&device, &snapshot)); + uni_hid_parser_ds5_play_dual_rumble(&device, 0, 1000, 0, 90); + const unsigned active_sent = output_count; + uni_hid_parser_ds5_bridge_teardown(&device); + advance(4000); + assert(output_count == active_sent); // No timer callback into reused parser memory. + assert(uni_hid_parser_ds5_bridge_rumble(&device, 60, 31, 217)); + transport_available = false; + advance(4060); + assert(output[6] == 31 && output[7] == 217 && disconnect_count == 0); + transport_available = true; + advance(4065); + assert(output[6] == 0 && output[7] == 0 && disconnect_count == 0); + assert(uni_hid_parser_ds5_bridge_rumble(&device, 60, 31, 217)); + transport_available = false; + advance(4125); + advance(6125); + assert(disconnect_count == 1); // OFF cannot stall forever on a live link. + const unsigned after_disconnect = output_count; + advance(9000); + assert(output_count == after_disconnect); + puts("DualSense calibrated admission and bounded driver lifetime passed"); + return 0; +} diff --git a/tests/switch2_dualsense_bridge_test.cpp b/tests/switch2_dualsense_bridge_test.cpp new file mode 100644 index 0000000..0fe9c89 --- /dev/null +++ b/tests/switch2_dualsense_bridge_test.cpp @@ -0,0 +1,335 @@ +#include +#include +#include +#include + +#include "controller_input.h" +#include "input/bluepad32_input_backend.h" +#include "model.h" +#include "pico/stdlib.h" +#include "platform/pico/bootsel_pairing_button.h" +#include "profile/controller_profile_runtime.h" + +namespace { +uint64_t now_us = 1000000; +uint32_t stage; +Bluepad32DualSenseBridgeSnapshot source; +ControllerProfile profile; +bool alternating_shortcut; +bool shortcut_phase; +probe_controller_input controls[2]; +uint8_t reports[2][63]; +} + +uint32_t time_us_32() { return static_cast(now_us); } +absolute_time_t get_absolute_time() { return now_us; } +uint32_t to_ms_since_boot(absolute_time_t time) { return static_cast(time / 1000); } +void system_clock_initialize() {} +extern "C" int probe_debug_printf(const char*, ...) { return 0; } +BootselPairingButtonEvent bootsel_pairing_button_task() { return BootselPairingButtonEvent::kNone; } +void bluepad32_input_backend_init() { stage = 1; } +void bluepad32_input_backend_start() { stage = 2; } +void bluepad32_input_backend_poll() {} +void bluepad32_input_backend_diagnostics(Bluepad32BackendDiagnostics* out) { *out = {}; out->initialization_stage = stage; } +void bluepad32_input_backend_open_pairing_window() {} +void bluepad32_input_backend_select_dualsense_source(const uint8_t*) {} +void bluepad32_input_backend_dualsense_snapshot(Bluepad32DualSenseBridgeSnapshot* out) { *out = source; } +bool bluepad32_input_backend_dualsense_sample_request(uint8_t, uint8_t, uint64_t*) { return false; } +int bluepad32_input_backend_dualsense_sample_result(uint8_t, uint64_t) { return -1; } +void bluepad32_input_backend_dualsense_sample_cancel(uint8_t) {} +void bluepad32_input_backend_queue_profile_feedback(uint8_t, uint32_t, uint8_t, ControllerProfileConfirmationPolicy) {} +void controller_profile_runtime_reset() { profile = controller_profile_default(controller_identity_global(), 0); } +bool controller_profile_runtime_take_initial_profile_indication(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; } +bool controller_profile_runtime_take_profile_change(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; } +ControllerProfileTransformResult controller_profile_runtime_transform( + uint8_t, const Bluepad32SlotSnapshot& input, uint32_t, AdapterUsbMode) { + if (!input.active) return {}; + auto result = controller_profile_transform(input.state, profile); + if (alternating_shortcut) { + // Model a runtime synthetic transition spanning the two halves. Two + // evaluations for one paired report would expose contradictory states. + shortcut_phase = !shortcut_phase; + result.state.button_system = result.state.button_capture = shortcut_phase; + } + return result; +} + +namespace { +uint32_t now_ms() { return to_ms_since_boot(now_us); } +void put_pair(uint8_t* out, uint16_t x, uint16_t y) { + out[0] = static_cast(x); + out[1] = static_cast((x >> 8) | (y << 4)); + out[2] = static_cast(y >> 4); +} +void calibrate(uint8_t instance, uint16_t x, uint16_t y, uint16_t px, uint16_t py, uint16_t nx, uint16_t ny) { + uint8_t record[9]; + put_pair(record, x, y); + put_pair(record + 3, px, py); + put_pair(record + 6, nx, ny); + probe_controller_input_set_full_stick_calibration(instance, record); +} +uint16_t stick_x(uint8_t instance) { return reports[instance][5] | ((reports[instance][6] & 15u) << 8); } +uint16_t stick_y(uint8_t instance) { return (reports[instance][6] >> 4) | (reports[instance][7] << 4); } +uint8_t imu_length(uint8_t instance) { return reports[instance][probe_model_imu_length_offset(instance)]; } +uint32_t bits(const uint8_t* bytes, unsigned offset, unsigned count) { + uint32_t value = 0; + for (unsigned i = 0; i < count; ++i) value |= uint32_t((bytes[(offset + i) / 8] >> ((offset + i) % 8)) & 1) << i; + return value; +} +void quaternion(uint8_t instance, double out[4]) { + const uint8_t* imu = reports[instance] + probe_model_imu_data_offset(instance); + assert(imu_length(instance) == 30); + const unsigned largest = bits(imu, 32, 3); + assert(largest < 4); + double ratios[3], norm = 1; + for (unsigned i = 0; i < 3; ++i) { + ratios[i] = bits(imu, 35 + 31 * i, 31) / 1073741824.0 - 1; + norm += ratios[i] * ratios[i]; + } + out[largest] = 1 / sqrt(norm); + for (unsigned i = 0; i < 3; ++i) out[(largest + i + 1) & 3] = ratios[i] * out[largest]; +} +void publish(bool motion = true) { + now_us += 4000; + source.received_us = time_us_32(); + ++source.state_generation; + if (motion) { + source.motion_received_us = time_us_32(); + ++source.motion_sequence; + } +} +uint32_t peek(uint8_t instance) { + probe_controller_input_poll(instance, now_ms(), &controls[instance]); + return probe_controller_input_peek_native_report(instance, now_ms(), reports[instance]); +} +void consume(uint8_t instance) { + const uint32_t token = peek(instance); + assert(token && probe_controller_input_commit_native_report(instance, token)); +} +void pair() { consume(0); consume(1); } +void no_mouse_or_rails() { + for (unsigned i = 0; i < 2; ++i) { + assert((reports[i][3] & 0xc0) == 0); + assert(reports[i][9] == 0 && reports[i][10] == 0 && reports[i][11] == 0 && reports[i][12] == 0); + assert(reports[i][13] == 0xff); + } +} + +void mapped_halves_and_calibration() { + source.slot = 2; + source.controller.active = true; + source.controller.connection_generation = 7; + source.controller.identity = controller_identity_global(); + publish(); + // Neither an absent source nor an uncalibrated child masquerades as active. + assert(!peek(0) && !controls[0].active); + calibrate(0, 2000, 2100, 1500, 1400, 1600, 1700); + assert(peek(0)); + assert(!peek(1) && !controls[1].active); + calibrate(1, 1800, 1900, 1700, 1800, 1400, 1500); + pair(); + assert(stick_x(0) == 2000 && stick_y(0) == 2100); + assert(stick_x(1) == 1800 && stick_y(1) == 1900); + // Actual profile transforms can move controls across native children. + profile.button_map[static_cast(ControllerProfileLogicalButton::kSouth)] = + static_cast(ControllerProfileLogicalButton::kDpadRight); + profile.button_map[static_cast(ControllerProfileLogicalButton::kDpadLeft)] = + static_cast(ControllerProfileLogicalButton::kEast); + profile.triggers[0].digital_threshold = 20000; + profile.triggers[1].digital_threshold = 30000; + ControllerState& state = source.controller.state; + state.button_south = state.dpad_left = true; + state.button_left_shoulder = state.button_right_shoulder = true; + state.button_select = state.button_start = true; + state.button_left_stick = state.button_right_stick = true; + state.button_system = state.button_capture = true; + state.left_trigger = 19999; + state.right_trigger = 30000; + state.right_stick_x = INT16_MAX; + state.left_stick_y = INT16_MIN; + publish(); pair(); + assert(reports[0][2] == 0xf2 && reports[1][2] == 0xd2); + assert(reports[0][3] == 1 && reports[1][3] == 1); + assert(stick_x(0) == 3500 && stick_y(0) == 2100); + assert(stick_x(1) == 1800 && stick_y(1) == 3700); + no_mouse_or_rails(); + state = {}; + state.button_west = state.button_north = true; + state.dpad_up = state.dpad_down = true; + state.left_trigger = 20000; + state.right_stick_x = INT16_MIN; + state.left_stick_y = INT16_MAX; + publish(); pair(); + assert(reports[0][2] == 0x0c && reports[1][2] == 0x29); + assert(stick_x(0) == 400 && stick_y(1) == 400); + // A malformed calibration may not spill a 12-bit axis into its neighbor. + const uint32_t left_pending = peek(1); + calibrate(0, 2000, 2100, 3000, 1400, 1600, 1700); + assert(!peek(0)); + assert(probe_controller_input_commit_native_report(1, left_pending)); + calibrate(0, 2000, 2100, 1500, 1400, 1600, 1700); + state = {}; + profile = controller_profile_default(controller_identity_global(), 0); + alternating_shortcut = true; + for (unsigned i = 0; i < 4; ++i) { + publish(); pair(); + assert(reports[0][3] == reports[1][3]); + } + alternating_shortcut = false; +} + +void independent_backpressure_and_resets() { + publish(); + const uint32_t blocked_left = peek(1); + const uint32_t right = peek(0); + uint8_t saved[63]; memcpy(saved, reports[0], sizeof(saved)); + assert(peek(0) == right && memcmp(saved, reports[0], sizeof(saved)) == 0); + assert(!probe_controller_input_commit_native_report(1, right)); + assert(probe_controller_input_commit_native_report(0, right)); + assert(!probe_controller_input_commit_native_report(0, right)); + assert(probe_controller_input_commit_native_report(1, blocked_left)); + publish(); + const uint32_t obsolete = peek(1); + for (unsigned i = 0; i < 40; ++i) { + source.controller.state.dpad_down = (i & 1) != 0; + source.controller.state.button_east = (i & 1) != 0; + publish(); consume(0); + } + const uint32_t latest = peek(1); + assert(latest != obsolete && reports[1][2] == 1); + assert(!probe_controller_input_commit_native_report(1, obsolete)); + probe_controller_input_set_native_stream(0, false); + assert(!peek(0)); + assert(probe_controller_input_commit_native_report(1, latest)); + probe_controller_input_set_native_stream(0, true); + const uint32_t right_pending = peek(0); + probe_controller_input_set_native_stream(1, false); + assert(probe_controller_input_commit_native_report(0, right_pending)); + probe_controller_input_set_native_stream(1, true); + source.controller.state = {}; +} + +void real_motion_admission_and_loss() { + source.motion_valid = true; + source.accel_q13[1] = 8192; // SDL face-up gravity -> native +Z, no mouse mounting. + // These values have already passed the DS5 factory-calibration path. + // Even a controller rotating at connection must not wait for stationary bias estimation. + source.gyro_q10[0] = 0; + source.gyro_q10[1] = 90 * 1024; + source.gyro_q10[2] = 0; + publish(); pair(); + assert(imu_length(0) == 30 && imu_length(1) == 30); + source.gyro_q10[1] = 0; + publish(); pair(); + // Polling and fresh button packets cannot create additional IMU samples. + for (unsigned i = 0; i < 420; ++i) { + publish(false); pair(); + assert(controls[0].active && controls[1].active); + assert(imu_length(0) == 0 && imu_length(1) == 0); + } + publish(); pair(); // Fresh factory-calibrated data recovers without another settling delay. + assert(imu_length(0) == 30 && imu_length(1) == 30); + const uint8_t* right_imu = reports[0] + probe_model_imu_data_offset(0); + const uint8_t* left_imu = reports[1] + probe_model_imu_data_offset(1); + assert(memcmp(right_imu, left_imu, 30) == 0); + assert(bits(right_imu, 128, 32) == 0 && bits(right_imu, 160, 32) == 0); + assert(bits(right_imu, 192, 32) == (1u << 28)); + double initial[4]; quaternion(0, initial); + // A new controls packet with no new IMU cannot emit the old sample again. + source.controller.state.button_east = true; + publish(false); pair(); + assert(reports[0][2] == 2 && imu_length(0) == 0 && imu_length(1) == 0); + // A blocked child's motion is not consumed by the other child's endpoint. + publish(); consume(0); + const uint32_t left_pending = peek(1); + assert(imu_length(1) == 30); + consume(0); assert(imu_length(0) == 0); + probe_controller_input_set_native_stream(0, false); + assert(probe_controller_input_commit_native_report(1, left_pending)); + probe_controller_input_set_native_stream(0, true); + publish(); pair(); + assert(imu_length(0) == 30 && imu_length(1) == 30); // No shared recalibration on USB reset. + // One second of genuine 90dps yaw advances the same rigid orientation once, + // not twice because two virtual endpoints happen to consume it. + source.gyro_q10[1] += 90 * 1024; + for (unsigned i = 0; i < 250; ++i) { publish(); pair(); } + double turned[4]; quaternion(0, turned); + double dot = 0; + for (unsigned i = 0; i < 4; ++i) dot += initial[i] * turned[i]; + assert(fabs(fabs(dot) - sqrt(.5)) < .015); + quaternion(1, initial); + for (unsigned i = 0; i < 4; ++i) assert(fabs(initial[i] - turned[i]) < 1e-8); + source.gyro_q10[1] -= 90 * 1024; + publish(); + const uint32_t obsolete = peek(0); + source.motion_valid = false; + assert(!probe_controller_input_commit_native_report(0, obsolete)); + publish(false); pair(); + assert(controls[0].active && reports[0][2] == 2 && imu_length(0) == 0 && imu_length(1) == 0); + source.motion_valid = true; + publish(); pair(); + assert(imu_length(0) == 30); + for (unsigned i = 0; i < 38; ++i) { publish(false); pair(); } + assert(controls[0].active && reports[0][2] == 2 && imu_length(0) == 0 && imu_length(1) == 0); + publish(); + const uint32_t old_right = peek(0), old_left = peek(1); + // Even a reconnect whose teardown was missed retires both USB identities. + ++source.controller.connection_generation; + source.motion_valid = false; // The backend withholds motion until a new report in the new epoch. + assert(!probe_controller_input_commit_native_report(0, old_right)); + assert(!probe_controller_input_commit_native_report(1, old_left)); + pair(); + assert(controls[0].active && imu_length(0) == 0 && imu_length(1) == 0); + source.controller.active = false; + memset(reports[0], 0x5a, 63); + assert(!peek(0) && !controls[0].active); + for (uint8_t byte : reports[0]) assert(byte == 0x5a); + assert(!peek(1) && !controls[1].active); + source.controller.active = true; + ++source.controller.connection_generation; + publish(); pair(); + now_us += 500000; + assert(!peek(0) && !peek(1)); + assert(!controls[0].active && !controls[1].active); +} +void selected_motion_target_keeps_both_control_halves() { + source = {}; + source.slot = 0; + source.controller.active = true; + source.controller.connection_generation = 99; + source.controller.state.button_south = true; + source.controller.state.dpad_up = true; + source.motion_valid = true; + source.accel_q13[1] = 8192; + profile = controller_profile_default(controller_identity_global(), 0); + calibrate(0, 2048, 2048, 2047, 2047, 2048, 2048); + calibrate(1, 2048, 2048, 2047, 2047, 2048, 2048); + publish(); pair(); + for (uint8_t instance = 0; instance < 2; ++instance) { + assert(controls[instance].active); + const bool enabled = (SWITCH2_BRIDGE_IMU_TARGET_MASK & (1u << instance)) != 0; + assert(imu_length(instance) == (enabled ? 30 : 0)); + } + assert(reports[0][2] == 0x01 && reports[1][2] == 0x08); + source.gyro_q10[1] = 90 * 1024; + publish(); pair(); + assert(imu_length(0) == ((SWITCH2_BRIDGE_IMU_TARGET_MASK & 1) ? 30 : 0)); + assert(imu_length(1) == ((SWITCH2_BRIDGE_IMU_TARGET_MASK & 2) ? 30 : 0)); + no_mouse_or_rails(); +} + +} // namespace + +int main() { + assert(!probe_controller_input_peek_native_report(0, now_ms(), reports[0])); + probe_controller_input_init(); + assert(probe_controller_input_start()); + probe_controller_input_set_native_stream(0, true); + probe_controller_input_set_native_stream(1, true); + assert(!peek(0) && !peek(1)); + mapped_halves_and_calibration(); + independent_backpressure_and_resets(); + if (SWITCH2_BRIDGE_IMU_TARGET_MASK == 3) real_motion_admission_and_loss(); + selected_motion_target_keeps_both_control_halves(); + return 0; +} diff --git a/tests/switch2_mouse_bridge_test.cpp b/tests/switch2_mouse_bridge_test.cpp index 7bbc496..3395091 100644 --- a/tests/switch2_mouse_bridge_test.cpp +++ b/tests/switch2_mouse_bridge_test.cpp @@ -409,6 +409,63 @@ static void test_bounded_overflow() { expect_empty(); // Overflow discarded all prior history, not merely its head. } +static void test_continuous_state_coalescing_preserves_events_and_borrowed_head() { + now = 1500; + disconnect(); + probe_controller_input_set_native_stream(0, true); + const auto first = native_report(0x80, 30, 0, 0); + auto newest = first; + emit(first); + for (unsigned i = 1; i <= 24; ++i) { + now += 8; + newest[0] = static_cast(0x80 + i); + newest[5] = static_cast(first[5] + i); + newest[PROBE_IMU_LENGTH_OFFSET + 1] = static_cast(i); + emit(newest); + } + // No 192ms history of analog/IMU-only updates is replayed to a slow consumer. + assert(probe_controller_input_commit_native_report(0, expect_report(newest))); + expect_empty(); + + emit(first); + const uint32_t borrowed = expect_report(first); + for (unsigned i = 0; i < 6; ++i) { + now += 8; + ++newest[0]; + ++newest[6]; + emit(newest); + } + assert(expect_report(first) == borrowed); + assert(probe_controller_input_commit_native_report(0, borrowed)); + assert(probe_controller_input_commit_native_report(0, expect_report(newest))); + expect_empty(); + + auto pressed = first; + pressed[2] ^= 1; + auto last_pressed = pressed; + ++last_pressed[0]; ++last_pressed[5]; + auto released = last_pressed; + released[2] = first[2]; + auto surface = released; + surface[13] ^= 0x10; + auto opaque_tail = surface; + opaque_tail.back() ^= 0x80; + auto other_format = opaque_tail; + other_format[PROBE_IMU_LENGTH_OFFSET] = 40; + emit(first); emit(pressed); emit(last_pressed); emit(released); + emit(surface); emit(opaque_tail); emit(other_format); + for (const auto& expected : {first, last_pressed, released, surface, opaque_tail, other_format}) + assert(probe_controller_input_commit_native_report(0, expect_report(expected))); + expect_empty(); + + auto mouse = first; + mouse[9] = 7; + emit(first); emit(mouse); emit(first); + for (const auto& expected : {first, mouse, first}) + assert(probe_controller_input_commit_native_report(0, expect_report(expected))); + expect_empty(); +} + static void test_expiry_and_wrapping_clock() { now = 2000; const auto first = native_report(0x61, 30); @@ -580,6 +637,7 @@ int main() { test_selected_source_isolation_and_reconnect(); test_side_switch_and_sample_ownership(); test_bounded_overflow(); + test_continuous_state_coalescing_preserves_events_and_borrowed_head(); test_expiry_and_wrapping_clock(); #if SWITCH2_PROBE_COMPOSITE test_simultaneous_sources(); diff --git a/tests/switch2_native_imu_test.cpp b/tests/switch2_native_imu_test.cpp index f0df928..492bf52 100644 --- a/tests/switch2_native_imu_test.cpp +++ b/tests/switch2_native_imu_test.cpp @@ -193,7 +193,7 @@ struct Rig { ++sample.gyro_sequence; sample.gyro_us = now; } - motion.update(now, generation, sample); + motion.update(now, generation, sample, ProbeNativeMotionBias::kEstimateStationary); } void settle() { for (unsigned i = 0; i < 65; ++i) fresh(); diff --git a/tests/switch2_wii_bridge_test.cpp b/tests/switch2_wii_bridge_test.cpp index 760f156..49755b0 100644 --- a/tests/switch2_wii_bridge_test.cpp +++ b/tests/switch2_wii_bridge_test.cpp @@ -143,7 +143,9 @@ int main() { source.accel_valid = source.gyro_valid = true; source.accel_q13[1] = 8192; // SDL up -> virtual native rail-down +X. memcpy(source.gyro_q10, bias_q10, sizeof(bias_q10)); - for (unsigned i = 0; i < 450; ++i) assert(poll()); + assert(poll()); + assert(controls.active && packet[15] == 0); // Wii alone still estimates residual bias before IMU output. + for (unsigned i = 1; i < 450; ++i) assert(poll()); assert(controls.active && packet[15] == 30 && packet[19] == 0x0c); assert(signed32(packet+32) == (1 << 28)); assert(signed32(packet+36) == 0 && signed32(packet+40) == 0); diff --git a/tests/switch_parser_native_stubs/btstack.h b/tests/switch_parser_native_stubs/btstack.h index 43dc78b..d5936e0 100644 --- a/tests/switch_parser_native_stubs/btstack.h +++ b/tests/switch_parser_native_stubs/btstack.h @@ -27,6 +27,7 @@ typedef struct btstack_timer_source { void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t ms); void btstack_run_loop_add_timer(btstack_timer_source_t* timer); bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer); +uint32_t btstack_run_loop_get_time_ms(void); void btstack_run_loop_set_timer_context(btstack_timer_source_t* timer, void* context); void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)); void* btstack_run_loop_get_timer_context(btstack_timer_source_t* timer); diff --git a/tests/switch_parser_native_test.c b/tests/switch_parser_native_test.c index 6edd911..8053ff0 100644 --- a/tests/switch_parser_native_test.c +++ b/tests/switch_parser_native_test.c @@ -6,6 +6,7 @@ #include "bt/uni_bt_service.h" #include "parser/uni_hid_parser_switch.h" #include "parser/uni_hid_parser_switch2.h" +#include "parser/uni_hid_parser_wii.h" #include "platform/uni_platform.h" #include "uni_hid_device.h" @@ -31,6 +32,14 @@ void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) { (void)d; assert(!"Switch 2 teardown reached a Classic Switch fixture"); } +void uni_hid_parser_wii_setup(uni_hid_device_t* d) { + (void)d; + assert(!"Wii setup reached a Classic Switch fixture"); +} +void uni_hid_parser_wii_teardown(uni_hid_device_t* d) { + (void)d; + assert(!"Wii teardown reached a Classic Switch fixture"); +} static unsigned timer_index(btstack_timer_source_t* timer) { for (unsigned i = 0; i < 32; ++i) { diff --git a/tests/test_dualsense_backend_native.py b/tests/test_dualsense_backend_native.py new file mode 100644 index 0000000..026801f --- /dev/null +++ b/tests/test_dualsense_backend_native.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_dualsense_backend_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + executable = tmp_path / "dualsense_backend_test" + firmware = root / "src" / "firmware" + sources = [ + root / "tests" / "dualsense_backend_test.cpp", + firmware / "profile" / "controller_profile.cpp", + firmware / "profile" / "controller_profile_transform.cpp", + firmware / "profile" / "controller_synthetic_input.cpp", + firmware / "profile" / "controller_profile_runtime.cpp", + firmware / "profile" / "profile_storage.cpp", + firmware / "input" / "wii_swing.cpp", + firmware / "input" / "controller_macro_capture.cpp", + root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c", + ] + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-DSWITCH_PICO_HID_INSTANCE_COUNT=4", + "-DSWITCH_PICO_USB_OUTPUT_MODES=1", + "-DSWITCH_PICO_ENABLE_BLE=1", + "-DSWITCH_PICO_ENABLE_CLASSIC=1", + "-DSWITCH2_BRIDGE_DUALSENSE_INPUT=1", + f"-I{root / 'tests' / 'bluepad32_native_stubs'}", + f"-I{firmware}", + f"-I{root / 'bluepad32_config'}", + *(str(source) for source in sources), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + for scenario in ( + "stable-logical-slot", + "source-isolation", + "cue-lifetime", + "cue-races", + ): + subprocess.run([str(executable), scenario], check=True, cwd=root) diff --git a/tests/test_dualsense_parser_native.py b/tests/test_dualsense_parser_native.py new file mode 100644 index 0000000..fd762a4 --- /dev/null +++ b/tests/test_dualsense_parser_native.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) + +from prepare_bluepad32 import prepare_bluepad32 + + +def test_dualsense_parser_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("cc") or shutil.which("gcc") + assert compiler is not None, "a host C compiler is required" + prepared = prepare_bluepad32( + root / "external" / "bluepad32", + root / "patches" / "bluepad32-sdl3-imu.patch", + tmp_path / "bluepad32-src", + ) + component = prepared / "src" / "components" / "bluepad32" + executable = tmp_path / "dualsense_parser_native_test" + subprocess.run( + [ + compiler, + "-std=gnu11", + "-O1", + "-Wall", + "-Wextra", + "-ffunction-sections", + "-fdata-sections", + "-DENABLE_BLE", + "-DENABLE_CLASSIC", + "-DSWITCH2_BRIDGE_DUALSENSE_INPUT=1", + "-DHID_MESSAGE_TYPE_GET_REPORT=4", + "-DHID_REPORT_TYPE_FEATURE=3", + f"-I{root / 'tests' / 'switch_parser_native_stubs'}", + f"-I{root / 'bluepad32_config'}", + f"-I{component / 'include'}", + str(root / "tests" / "dualsense_parser_native_test.c"), + str(component / "parser" / "uni_hid_parser_ds5.c"), + str(component / "uni_utils.c"), + "-Wl,--gc-sections", + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_switch2_dualsense_bridge_native.py b/tests/test_switch2_dualsense_bridge_native.py new file mode 100644 index 0000000..9c437a8 --- /dev/null +++ b/tests/test_switch2_dualsense_bridge_native.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("imu_target", [1, 2, 3], ids=["right", "left", "both"]) +def test_dualsense_native_bridge_mapping_motion_and_backpressure( + tmp_path: Path, + imu_target: int, +) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + executable = tmp_path / "switch2_dualsense_bridge_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-DSWITCH_PICO_SWITCH2_USB_BRIDGE=1", + "-DSWITCH2_BRIDGE_DUALSENSE_INPUT=1", + "-DSWITCH2_BRIDGE_SOURCE_AUTO=1", + "-DSWITCH2_PROBE_HUB=1", + f"-DSWITCH2_BRIDGE_IMU_TARGET_MASK={imu_target}", + "-DSWITCH_PICO_BLUEPAD32=1", + "-DSWITCH_PICO_ENABLE_CLASSIC=1", + f"-I{root / 'tests' / 'switch2_mouse_bridge_native_stubs'}", + f"-I{root / 'tests' / 'wii_ir_aiming_native_stubs'}", + f"-I{root / 'tools' / 'switch2_usb_probe'}", + f"-I{root / 'src' / 'firmware'}", + str(root / "tests" / "switch2_dualsense_bridge_test.cpp"), + str(root / "tools" / "switch2_usb_probe" / "controller_input.cpp"), + str(root / "tools" / "switch2_usb_probe" / "dualsense_input.cpp"), + str(root / "tools" / "switch2_usb_probe" / "native_imu.cpp"), + str(root / "src" / "firmware" / "core" / "controller_identity.cpp"), + str(root / "src" / "firmware" / "profile" / "controller_profile.cpp"), + str( + root + / "src" + / "firmware" + / "profile" + / "controller_profile_transform.cpp" + ), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tools/native_joycon_hub_check.py b/tools/native_joycon_hub_check.py index 73dbc23..1f23501 100755 --- a/tools/native_joycon_hub_check.py +++ b/tools/native_joycon_hub_check.py @@ -43,6 +43,14 @@ def model_references(build_dir: Path) -> dict[str, dict[str, Any]]: if line.startswith("SWITCH2_") and ":" in line and "=" in line: field, value = line.split("=", 1) cache[field.split(":", 1)[0]] = value + if ( + cache.get("SWITCH2_BRIDGE_INPUT") == "DUALSENSE" + and cache.get("SWITCH2_BRIDGE_IMU_TARGET", "BOTH") != "BOTH" + ): + raise ValueError( + "Full dual-IMU qualification requires SWITCH2_BRIDGE_IMU_TARGET=BOTH; " + "use the USB-completion UART trace for LEFT/RIGHT routing comparisons" + ) models = {} for side, constants in MODELS.items(): prefix = "SWITCH2_PROBE" if side == "R" else "SWITCH2_PROBE_SECOND" @@ -68,6 +76,7 @@ def model_references(build_dir: Path) -> dict[str, dict[str, Any]]: "version": version.hex(), "factory_extension": factory[64:81].hex(), "mac_wire": address[::-1].hex(), + "source_mode": cache.get("SWITCH2_BRIDGE_INPUT", "JOYCON2"), } return models @@ -842,8 +851,12 @@ class Check: "no interleaved control/bulk round was bracketed by valid input from both donors" ) shared = self.imu_evidence["R"] & self.imu_evidence["L"] + shared_source = self.models["R"].get("source_mode") == "DUALSENSE" self.result["imu_isolation"] = { "sample_limit_per_side": 512, + "policy": "shared_physical_source" + if shared_source + else "independent_physical_sources", "identical_blocks_seen_on_both_sides": len(shared), "unique_blocks": { side: len(blocks) for side, blocks in self.imu_evidence.items() @@ -853,9 +866,16 @@ class Check: }, } for side in SIDES: - if len(self.imu_evidence[side] - shared) < 2: + evidence = ( + self.imu_evidence[side] + if shared_source + else self.imu_evidence[side] - shared + ) + if len(evidence) < 2: self.error( - "donor IMU evidence is frozen or duplicated across child devices", + "IMU evidence is frozen" + if shared_source + else "donor IMU evidence is frozen or duplicated across child devices", side, ) for side in SIDES: diff --git a/tools/switch2_usb_probe/controller_input.cpp b/tools/switch2_usb_probe/controller_input.cpp index 8b4c5b5..a01cab4 100644 --- a/tools/switch2_usb_probe/controller_input.cpp +++ b/tools/switch2_usb_probe/controller_input.cpp @@ -13,6 +13,9 @@ #include extern "C" int probe_debug_printf(const char* format, ...); #endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT +#include "dualsense_input.h" +#endif #if SWITCH2_BRIDGE_WII_INPUT #include #include "input/wii_ir_pointer.h" @@ -21,13 +24,19 @@ extern "C" int probe_debug_printf(const char* format, ...); extern "C" int probe_debug_printf(const char* format, ...); #endif -#if !SWITCH_PICO_SWITCH2_USB_BRIDGE || !SWITCH_PICO_BLUEPAD32 || \ - !SWITCH_PICO_ENABLE_BLE || !SWITCH_PICO_SWITCH2_MOUSE_CAPTURE || \ +#if !SWITCH_PICO_SWITCH2_USB_BRIDGE || !SWITCH_PICO_BLUEPAD32 +#error "The controller bridge requires Bluepad32" +#elif SWITCH2_BRIDGE_DUALSENSE_INPUT +#if !SWITCH_PICO_ENABLE_CLASSIC +#error "The DualSense bridge requires Classic Bluetooth" +#endif +#elif !SWITCH_PICO_ENABLE_BLE || !SWITCH_PICO_SWITCH2_MOUSE_CAPTURE || \ !SWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE -#error "The controller bridge requires Bluepad32 BLE and native Switch 2 capture" +#error "The Joy-Con/Wii bridge requires Bluepad32 BLE and native Switch 2 capture" #endif namespace { +#if !SWITCH2_BRIDGE_DUALSENSE_INPUT constexpr uint8_t kSourceAddress[] = {SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES}; static_assert(sizeof(kSourceAddress) == 6, "Select one physical Bluetooth address"); #if SWITCH2_PROBE_COMPOSITE || SWITCH2_PROBE_HUB @@ -35,6 +44,7 @@ constexpr uint8_t kSecondSourceAddress[] = {SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS static_assert(sizeof(kSecondSourceAddress) == 6, "Select the second physical Bluetooth address"); #endif constexpr uint32_t kInputDeadlineMs = 500; +#endif #if !SWITCH2_PROBE_HUB constexpr uint32_t kFlashCoordinationTimeoutMs = 1000; #endif @@ -46,7 +56,7 @@ bool g_start_attempted; bool g_flash_ready; #if SWITCH2_BRIDGE_WII_INPUT probe_controller_input g_input; -#else +#elif !SWITCH2_BRIDGE_DUALSENSE_INPUT probe_controller_input g_inputs[PROBE_CONTROLLER_COUNT]; uint32_t g_received_times[PROBE_CONTROLLER_COUNT]; #endif @@ -280,7 +290,7 @@ void poll_wii_source(uint32_t now_ms) { motion.gyro_dps[0] = static_cast(g_wii.gyro_q10[1]) / 1024.0f; motion.gyro_dps[1] = -static_cast(g_wii.gyro_q10[2]) / 1024.0f; motion.gyro_dps[2] = -static_cast(g_wii.gyro_q10[0]) / 1024.0f; - g_motion.update(now_us, g_wii_generation, motion); + g_motion.update(now_us, g_wii_generation, motion, ProbeNativeMotionBias::kEstimateStationary); WiiIrMouseReport optical{}; (void)wii_ir_mouse_peek(&optical, 0); // Core 1 may publish during the peek. Read the clock after the snapshot. @@ -372,7 +382,10 @@ extern "C" void probe_controller_input_clock_init(void) { extern "C" void probe_controller_input_init(void) { if (g_initialized) return; -#if SWITCH2_BRIDGE_WII_INPUT +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + bluepad32_input_backend_init(); + probe_dualsense_input_init(); +#elif SWITCH2_BRIDGE_WII_INPUT bluepad32_input_backend_init(); bluepad32_input_backend_select_wii_source(kSourceAddress); g_screen_configured = wii_ir_pointer_configure_screen( @@ -471,9 +484,18 @@ extern "C" void probe_controller_input_set_native_features(uint8_t features) { } #endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT +extern "C" void probe_controller_input_set_full_stick_calibration( + uint8_t instance, const uint8_t calibration[9]) { + probe_dualsense_input_set_stick_calibration(instance, calibration); +} +#endif + extern "C" void probe_controller_input_set_native_stream(uint8_t instance, bool enabled) { if (instance >= PROBE_CONTROLLER_COUNT) return; -#if SWITCH2_BRIDGE_WII_INPUT +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + probe_dualsense_input_set_native_stream(instance, enabled && g_flash_ready); +#elif SWITCH2_BRIDGE_WII_INPUT enabled = enabled && g_flash_ready; if (g_native_stream != enabled || !enabled) discard_wii_output(); g_native_stream = enabled; @@ -486,7 +508,9 @@ extern "C" void probe_controller_input_set_native_stream(uint8_t instance, bool extern "C" uint32_t probe_controller_input_peek_native_report( uint8_t instance, uint32_t now_ms, uint8_t report[63]) { if (instance >= PROBE_CONTROLLER_COUNT || !g_flash_ready) return 0; -#if SWITCH2_BRIDGE_WII_INPUT +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + return probe_dualsense_input_peek_native_report(instance, now_ms, report); +#elif SWITCH2_BRIDGE_WII_INPUT (void)now_ms; return prepare_wii_report(report); #else @@ -496,7 +520,9 @@ extern "C" uint32_t probe_controller_input_peek_native_report( extern "C" bool probe_controller_input_commit_native_report(uint8_t instance, uint32_t serial) { if (instance >= PROBE_CONTROLLER_COUNT || !g_flash_ready) return false; -#if SWITCH2_BRIDGE_WII_INPUT +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + return probe_dualsense_input_commit_native_report(instance, serial); +#elif SWITCH2_BRIDGE_WII_INPUT if (!g_native_stream || !serial || serial != g_pending_serial || g_pending_generation != g_wii_generation || !g_wii_active) return false; wii_ir_mouse_commit(g_pending_pointer); @@ -517,7 +543,9 @@ extern "C" bool probe_controller_input_play_sample(uint8_t instance, uint8_t sam if (token != nullptr) *token = 0; return false; } -#if SWITCH2_BRIDGE_WII_INPUT +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + return bluepad32_input_backend_dualsense_sample_request(instance, sample_id, token); +#elif SWITCH2_BRIDGE_WII_INPUT return bluepad32_input_backend_wii_sample_request(sample_id, token); #else return switch2_mouse_capture_request_sample( @@ -527,7 +555,10 @@ extern "C" bool probe_controller_input_play_sample(uint8_t instance, uint8_t sam extern "C" int probe_controller_input_sample_result(uint8_t instance, uint64_t token, uint32_t now_ms) { if (instance >= PROBE_CONTROLLER_COUNT || !g_flash_ready) return -1; -#if SWITCH2_BRIDGE_WII_INPUT +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + (void)now_ms; + return bluepad32_input_backend_dualsense_sample_result(instance, token); +#elif SWITCH2_BRIDGE_WII_INPUT (void)now_ms; return bluepad32_input_backend_wii_sample_result(token); #else @@ -537,7 +568,9 @@ extern "C" int probe_controller_input_sample_result(uint8_t instance, uint64_t t extern "C" void probe_controller_input_cancel_sample(uint8_t instance) { if (instance >= PROBE_CONTROLLER_COUNT) return; -#if SWITCH2_BRIDGE_WII_INPUT +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + bluepad32_input_backend_dualsense_sample_cancel(instance); +#elif SWITCH2_BRIDGE_WII_INPUT bluepad32_input_backend_wii_sample_cancel(); #else switch2_mouse_capture_cancel_sample(instance); @@ -551,7 +584,10 @@ extern "C" void probe_controller_input_poll(uint8_t instance, uint32_t now_ms, *out = {}; return; } -#if SWITCH2_BRIDGE_WII_INPUT +#if SWITCH2_BRIDGE_DUALSENSE_INPUT + probe_dualsense_input_poll(instance, now_ms, out); + return; +#elif SWITCH2_BRIDGE_WII_INPUT poll_wii_source(now_ms); #else probe_controller_input& g_input = g_inputs[instance]; @@ -582,5 +618,7 @@ extern "C" void probe_controller_input_poll(uint8_t instance, uint32_t now_ms, g_input.mouse_surface = 0; } #endif +#if !SWITCH2_BRIDGE_DUALSENSE_INPUT *out = g_input; +#endif } diff --git a/tools/switch2_usb_probe/controller_input.h b/tools/switch2_usb_probe/controller_input.h index 14b723c..cc8cb9b 100644 --- a/tools/switch2_usb_probe/controller_input.h +++ b/tools/switch2_usb_probe/controller_input.h @@ -47,9 +47,16 @@ void probe_controller_input_set_stick_calibration(const uint8_t calibration[9]); // Native feature changes are output barriers, not Bluetooth/IMU resets. void probe_controller_input_set_native_features(uint8_t features); #endif +#if SWITCH2_BRIDGE_DUALSENSE_INPUT +// Supply each child's advertised, validated nine-byte stick record. Native +// output stays unavailable until that child's calibration has been supplied. +void probe_controller_input_set_full_stick_calibration(uint8_t instance, const uint8_t calibration[9]); +#endif // Core0 native07/08 output. Disable discards queued/prepared data; repeated // enable preserves it. Joy-Con mode relays its bounded FIFO; right-only Wii // mode synthesizes fresh calibrated sensors and the selected IR pointer. +// DualSense mode splits one full controller into independent R/L output streams; +// controls remain live while motion is unavailable. Only Wii estimates stationary bias. // No pairing changes. void probe_controller_input_set_native_stream(uint8_t instance, bool enabled); // Copy one63-byte payload without report ID. Returns a boot-unique token, or0 @@ -61,8 +68,9 @@ uint32_t probe_controller_input_peek_native_report(uint8_t instance, uint32_t no bool probe_controller_input_commit_native_report(uint8_t instance, uint32_t serial); // Built-in vibration samples only; raw HD-rumble output is not forwarded. // A nonzero token means queued, not completed. Result:0 pending,1 completion, -// -1 failed/stale. Joy-Con completion is its application ACK; Wii completion is -// actual bounded rumble-driver dispatch (not an HD-waveform fidelity claim). +// -1 failed/stale. Joy-Con completion is its application ACK; Wii/DualSense +// completion is actual bounded rumble-driver dispatch, not a source application +// ACK or an HD-waveform fidelity claim. // Reset cancels the request, never stored pairing. bool probe_controller_input_play_sample(uint8_t instance, uint8_t sample_id, uint64_t* token); int probe_controller_input_sample_result(uint8_t instance, uint64_t token, uint32_t now_ms); diff --git a/tools/switch2_usb_probe/dualsense_input.cpp b/tools/switch2_usb_probe/dualsense_input.cpp new file mode 100644 index 0000000..1b0c80e --- /dev/null +++ b/tools/switch2_usb_probe/dualsense_input.cpp @@ -0,0 +1,330 @@ +#include "dualsense_input.h" + +#if SWITCH2_BRIDGE_DUALSENSE_INPUT +#include +#include + +#include "input/bluepad32_input_backend.h" +#include "model.h" +#include "native_imu.h" +#include "pico/time.h" +#include "profile/controller_profile_runtime.h" + +#if !SWITCH2_PROBE_HUB || SWITCH2_BRIDGE_WII_INPUT +#error "One DualSense requires the native R/L USB hub source mode" +#endif +static_assert(PROBE_CONTROLLER_COUNT == 2); +extern "C" int probe_debug_printf(const char* format, ...); +#ifndef SWITCH2_BRIDGE_IMU_TARGET_MASK +#define SWITCH2_BRIDGE_IMU_TARGET_MASK 3 +#endif +static_assert(SWITCH2_BRIDGE_IMU_TARGET_MASK >= 1 && SWITCH2_BRIDGE_IMU_TARGET_MASK <= 3); + +namespace { +constexpr uint32_t kInputDeadlineUs = 500000; +constexpr uint32_t kSensorDeadlineUs = 150000; +constexpr uint32_t kOutputDeadlineUs = 100000; +#if !SWITCH2_BRIDGE_SOURCE_AUTO +constexpr uint8_t kSourceAddress[] = {SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES}; +static_assert(sizeof(kSourceAddress) == 6); +#endif + +struct Child { + bool enabled = false; + bool calibrated = false; + uint16_t center[2]{}; + uint16_t positive[2]{}; + uint16_t negative[2]{}; + probe_controller_input input{}; + uint8_t counter = 0; + uint32_t pending_token = 0; + uint32_t pending_us = 0; + uint32_t pending_ticks = 0; + uint32_t pending_motion_sequence = 0; + bool pending_motion = false; + uint8_t pending_report[63]{}; + bool have_committed_motion = false; + uint32_t committed_motion_sequence = 0; + uint32_t committed_ticks = 0; +}; +Child g_children[PROBE_CONTROLLER_COUNT]; +Bluepad32DualSenseBridgeSnapshot g_source; +ControllerProfileTransformResult g_mapped; +ProbeNativeMotion g_motion; +bool g_active; +bool g_evaluated; +uint32_t g_evaluated_ms; +uint32_t g_report_token; +int g_sensor_status = -1; +bool g_clock_started; +uint32_t g_clock_us; +uint32_t g_clock_ticks; +uint32_t g_clock_fraction; + +void advance_clock(uint32_t now_us) { + if (!g_clock_started) { + g_clock_started = true; + g_clock_us = now_us; + return; + } + const uint64_t scaled = static_cast(now_us - g_clock_us) * 960u + g_clock_fraction; + g_clock_us = now_us; + g_clock_ticks += static_cast(scaled / 1000000u); + g_clock_fraction = static_cast(scaled % 1000000u); +} + +void discard_output(Child& child) { + child.pending_token = 0; + child.have_committed_motion = false; +} + +bool sensors_fresh(uint32_t now_us) { + return g_source.motion_valid && now_us - g_source.motion_received_us < kSensorDeadlineUs; +} + +void unpack_stick_pair(const uint8_t* bytes, uint16_t pair[2]) { + pair[0] = bytes[0] | (static_cast(bytes[1] & 15) << 8); + pair[1] = (bytes[1] >> 4) | (static_cast(bytes[2]) << 4); +} + +uint16_t calibrated_axis(const Child& child, int16_t value, unsigned axis, bool invert) { + // Same signed endpoint/rounding convention as the native Wii adapter. + const int32_t input = value; + const bool input_positive = input >= 0; + const bool output_positive = input_positive != invert; + const int32_t magnitude = input_positive ? input : -input; + const int32_t denominator = input_positive ? INT16_MAX : 32768; + const int32_t travel = output_positive ? child.positive[axis] : child.negative[axis]; + const int32_t displacement = (magnitude * travel + denominator / 2) / denominator; + return static_cast(child.center[axis] + + (output_positive ? displacement : -displacement)); +} + +void pack_controls(uint8_t instance) { + Child& child = g_children[instance]; + child.input = {}; + child.input.serial = g_source.state_generation; + if (!g_active || !child.calibrated) return; + child.input.active = true; + child.input.native_status = 0x30; // Host feature status is gated per model in main. + child.input.mouse_surface = 0xff; // No optical sensor, clicks, or invented movement. + const ControllerState& state = g_mapped.state; + const bool left = probe_model_is_left(instance); + if (left) { + child.input.buttons[0] = static_cast( + (state.dpad_down ? 0x01 : 0) | (state.dpad_right ? 0x02 : 0) | + (state.dpad_left ? 0x04 : 0) | (state.dpad_up ? 0x08 : 0) | + (state.button_left_shoulder ? 0x10 : 0) | + (state.left_trigger != 0 && state.left_trigger >= g_mapped.left_trigger_digital_threshold ? 0x20 : 0) | + (state.button_select ? 0x40 : 0) | (state.button_left_stick ? 0x80 : 0)); + child.input.buttons[1] = static_cast( + (state.button_capture ? 0x01 : 0) | + ((state.extra_buttons & (1u << 3)) ? 0x80 : 0) | + ((state.extra_buttons & (1u << 4)) ? 0x40 : 0)); + } else { + child.input.buttons[0] = static_cast( + (state.button_south ? 0x01 : 0) | (state.button_east ? 0x02 : 0) | + (state.button_west ? 0x04 : 0) | (state.button_north ? 0x08 : 0) | + (state.button_right_shoulder ? 0x10 : 0) | + (state.right_trigger != 0 && state.right_trigger >= g_mapped.right_trigger_digital_threshold ? 0x20 : 0) | + (state.button_start ? 0x40 : 0) | (state.button_right_stick ? 0x80 : 0)); + child.input.buttons[1] = static_cast( + (state.button_system ? 0x01 : 0) | ((state.extra_buttons & 1) ? 0x10 : 0) | + ((state.extra_buttons & (1u << 5)) ? 0x80 : 0) | + ((state.extra_buttons & (1u << 6)) ? 0x40 : 0)); + } + const uint16_t x = calibrated_axis(child, left ? state.left_stick_x : state.right_stick_x, 0, false); + const uint16_t y = calibrated_axis(child, left ? state.left_stick_y : state.right_stick_y, 1, true); + child.input.stick[0] = static_cast(x); + child.input.stick[1] = static_cast((x >> 8) | (y << 4)); + child.input.stick[2] = static_cast(y >> 4); +} + +void lose_source(uint32_t now_ms) { + if (g_active) { + Bluepad32SlotSnapshot inactive{}; + (void)controller_profile_runtime_transform(g_source.slot, inactive, now_ms, AdapterUsbMode::kSwitch); + g_motion.reset(); + for (uint8_t i = 0; i < PROBE_CONTROLLER_COUNT; ++i) { + discard_output(g_children[i]); + bluepad32_input_backend_dualsense_sample_cancel(i); + } + g_sensor_status = -1; + } + g_active = false; + for (Child& child : g_children) child.input = {}; +} + +void refresh(uint32_t now_ms) { + Bluepad32DualSenseBridgeSnapshot source; + bluepad32_input_backend_dualsense_snapshot(&source); + // Snapshot first: source receipt timestamps must not be ahead of this clock. + const uint32_t now_us = time_us_32(); + advance_clock(now_us); + if (!source.controller.active || source.slot >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT || + now_us - source.received_us >= kInputDeadlineUs) { + lose_source(now_ms); + g_source = source; + g_evaluated = false; + return; + } + const bool changed_connection = !g_active || source.slot != g_source.slot || + source.controller.connection_generation != g_source.controller.connection_generation; + // Both polls and both peeks in a paired output round share one profile and + // motion evaluation. A real publication in the same millisecond still wins. + if (!changed_connection && g_evaluated && g_evaluated_ms == now_ms && + source.state_generation == g_source.state_generation && source.received_us == g_source.received_us && + source.motion_sequence == g_source.motion_sequence && + source.motion_received_us == g_source.motion_received_us && source.motion_valid == g_source.motion_valid) return; + if (changed_connection) { + lose_source(now_ms); + g_motion.reset(); + for (Child& child : g_children) discard_output(child); + probe_debug_printf("[PROBE] DualSense source active in slot %u; using validated factory IMU calibration\n", source.slot); + } + g_source = source; + g_active = true; + g_evaluated = true; + g_evaluated_ms = now_ms; + g_mapped = controller_profile_runtime_transform(source.slot, source.controller, now_ms, AdapterUsbMode::kSwitch); + ControllerProfileRuntimeProfileChangeEvent feedback{}; + if (controller_profile_runtime_take_initial_profile_indication(source.slot, &feedback) || + controller_profile_runtime_take_profile_change(source.slot, &feedback)) { + bluepad32_input_backend_queue_profile_feedback(source.slot, feedback.connection_generation, + feedback.active_profile_number, feedback.policy); + } + ProbeNativeMotionSample sample{}; + sample.accel_valid = sample.gyro_valid = source.motion_valid; + sample.accel_sequence = sample.gyro_sequence = source.motion_sequence; + sample.accel_us = sample.gyro_us = source.motion_received_us; + // SDL -> upright native body [X,-Z,Y], the same physical transform used by + // the Wii adapter before its mouse-mount rotation. Both halves represent + // one rigid, full controller: no solo-Joy-Con or mouse mounting rotation. + sample.accel_g[0] = static_cast(source.accel_q13[0]) / 8192.0f; + sample.accel_g[1] = -static_cast(source.accel_q13[2]) / 8192.0f; + sample.accel_g[2] = static_cast(source.accel_q13[1]) / 8192.0f; + sample.gyro_dps[0] = static_cast(source.gyro_q10[0]) / 1024.0f; + sample.gyro_dps[1] = -static_cast(source.gyro_q10[2]) / 1024.0f; + sample.gyro_dps[2] = static_cast(source.gyro_q10[1]) / 1024.0f; + g_motion.update(now_us, source.controller.connection_generation, sample); + const int status = !sensors_fresh(now_us) ? 0 : g_motion.ready() ? 2 : 1; + if (status != g_sensor_status) { + g_sensor_status = status; + probe_debug_printf("[PROBE] DualSense native IMU %s\n", status == 2 ? "ready" : + status == 1 ? "waiting for a usable acceleration sample" : "waiting for fresh complete sensors"); + } + for (uint8_t i = 0; i < PROBE_CONTROLLER_COUNT; ++i) { + // Latest-only: a blocked endpoint never queues obsolete controls/IMU. + g_children[i].pending_token = 0; + pack_controls(i); + } +} +} // namespace + +void probe_dualsense_input_init() { +#if SWITCH2_BRIDGE_SOURCE_AUTO + bluepad32_input_backend_select_dualsense_source(nullptr); +#else + bluepad32_input_backend_select_dualsense_source(kSourceAddress); +#endif +} + +void probe_dualsense_input_set_stick_calibration(uint8_t instance, const uint8_t calibration[9]) { + if (instance >= PROBE_CONTROLLER_COUNT) return; + Child& child = g_children[instance]; + child.calibrated = false; + discard_output(child); + if (calibration) { + unpack_stick_pair(calibration, child.center); + unpack_stick_pair(calibration + 3, child.positive); + unpack_stick_pair(calibration + 6, child.negative); + child.calibrated = true; + for (unsigned axis = 0; axis < 2; ++axis) { + if (!child.positive[axis] || !child.negative[axis] || + child.center[axis] + child.positive[axis] > 4095 || + child.negative[axis] > child.center[axis]) child.calibrated = false; + } + } + pack_controls(instance); +} + +void probe_dualsense_input_set_native_stream(uint8_t instance, bool enabled) { + if (instance >= PROBE_CONTROLLER_COUNT) return; + Child& child = g_children[instance]; + if (child.enabled != enabled || !enabled) discard_output(child); + child.enabled = enabled; + if (!enabled) bluepad32_input_backend_dualsense_sample_cancel(instance); +} + +void probe_dualsense_input_poll(uint8_t instance, uint32_t now_ms, probe_controller_input* out) { + if (!out) return; + if (instance >= PROBE_CONTROLLER_COUNT) { *out = {}; return; } + refresh(now_ms); + *out = g_children[instance].input; +} + +uint32_t probe_dualsense_input_peek_native_report(uint8_t instance, uint32_t now_ms, uint8_t report[63]) { + if (instance >= PROBE_CONTROLLER_COUNT || !report) return 0; + refresh(now_ms); + Child& child = g_children[instance]; + if (!child.enabled || !child.input.active) return 0; + const uint32_t now_us = time_us_32(); + const bool motion_ready = (SWITCH2_BRIDGE_IMU_TARGET_MASK & (1u << instance)) != 0 && + g_motion.ready() && sensors_fresh(now_us) && + (!child.have_committed_motion || child.committed_motion_sequence != g_source.motion_sequence); + if (child.pending_token && (now_us - child.pending_us >= kOutputDeadlineUs || + child.pending_motion != motion_ready)) child.pending_token = 0; + if (!child.pending_token) { + if (g_report_token == UINT32_MAX) return 0; // Boot-unique, including across children/resets. + memset(child.pending_report, 0, sizeof(child.pending_report)); + child.pending_report[0] = child.counter; + // Bluepad32 zero denotes unknown; do not manufacture a full battery. + const unsigned battery_level = (static_cast(g_source.battery) * 9u + 127u) / 255u; + child.pending_report[1] = static_cast(battery_level << 2); + memcpy(child.pending_report + 2, child.input.buttons, sizeof(child.input.buttons)); + child.pending_report[4] = 7; + memcpy(child.pending_report + 5, child.input.stick, sizeof(child.input.stick)); + child.pending_report[8] = child.input.native_status; + child.pending_report[13] = 0xff; + child.pending_ticks = g_clock_ticks; + const uint32_t elapsed = child.have_committed_motion ? child.pending_ticks - child.committed_ticks : 1; + const uint16_t wire_elapsed = static_cast(elapsed <= 0xfff ? elapsed : 1); + child.pending_motion = motion_ready && probe_native_imu_pack( + g_motion.quaternion(), g_motion.acceleration(), static_cast(child.pending_ticks & 0xfff), + wire_elapsed, 0, child.pending_report + probe_model_imu_data_offset(instance)); + if (child.pending_motion) child.pending_report[probe_model_imu_length_offset(instance)] = 30; + child.pending_motion_sequence = g_source.motion_sequence; + child.pending_us = now_us; + child.pending_token = ++g_report_token; + } + memcpy(report, child.pending_report, sizeof(child.pending_report)); + return child.pending_token; +} + +bool probe_dualsense_input_commit_native_report(uint8_t instance, uint32_t token) { + if (instance >= PROBE_CONTROLLER_COUNT || !token) return false; + Child& child = g_children[instance]; + // Check the live source even when the caller did not poll after a disconnect. + Bluepad32DualSenseBridgeSnapshot source; + bluepad32_input_backend_dualsense_snapshot(&source); + const uint32_t now_us = time_us_32(); + if (!child.enabled || !g_active || child.pending_token != token || + !source.controller.active || source.slot != g_source.slot || + source.controller.connection_generation != g_source.controller.connection_generation || + source.state_generation != g_source.state_generation || + source.motion_sequence != g_source.motion_sequence || + source.motion_received_us != g_source.motion_received_us || + source.motion_valid != g_source.motion_valid || + now_us - source.received_us >= kInputDeadlineUs || now_us - child.pending_us >= kOutputDeadlineUs || + (child.pending_motion && (!source.motion_valid || + now_us - g_source.motion_received_us >= kSensorDeadlineUs))) return false; + child.pending_token = 0; + if (child.pending_motion) { + child.have_committed_motion = true; + child.committed_motion_sequence = child.pending_motion_sequence; + child.committed_ticks = child.pending_ticks; + } + ++child.counter; + return true; +} +#endif diff --git a/tools/switch2_usb_probe/dualsense_input.h b/tools/switch2_usb_probe/dualsense_input.h new file mode 100644 index 0000000..383d71f --- /dev/null +++ b/tools/switch2_usb_probe/dualsense_input.h @@ -0,0 +1,13 @@ +#pragma once + +#include "controller_input.h" + +#if SWITCH2_BRIDGE_DUALSENSE_INPUT +// Core 0 only. One coherent profile/motion evaluation feeds both native children. +void probe_dualsense_input_init(); +void probe_dualsense_input_set_stick_calibration(uint8_t instance, const uint8_t calibration[9]); +void probe_dualsense_input_set_native_stream(uint8_t instance, bool enabled); +void probe_dualsense_input_poll(uint8_t instance, uint32_t now_ms, probe_controller_input* out); +uint32_t probe_dualsense_input_peek_native_report(uint8_t instance, uint32_t now_ms, uint8_t report[63]); +bool probe_dualsense_input_commit_native_report(uint8_t instance, uint32_t token); +#endif diff --git a/tools/switch2_usb_probe/main.c b/tools/switch2_usb_probe/main.c index e93ca3c..0d15879 100644 --- a/tools/switch2_usb_probe/main.c +++ b/tools/switch2_usb_probe/main.c @@ -319,6 +319,8 @@ static void reset_controller_protocol(uint8_t instance) { #if SWITCH2_BRIDGE_WII_INPUT probe_controller_input_set_stick_calibration(stick_calibration); probe_controller_input_set_native_features(0); +#elif SWITCH2_BRIDGE_DUALSENSE_INPUT + probe_controller_input_set_full_stick_calibration(instance, stick_calibration); #endif protocol->read_memory = read_memory; #endif @@ -485,6 +487,9 @@ static void protocol_task(probe_usb_controller* controller, uint32_t now) { #if SWITCH2_BRIDGE_WII_INPUT probe_debug_printf("[PROBE] Wii cue dispatched itf=%u token=%" PRIu64 "\n", instance, reply->deferred_token); +#elif SWITCH2_BRIDGE_DUALSENSE_INPUT + probe_debug_printf("[PROBE] DualSense cue dispatched itf=%u token=%" PRIu64 "\n", + instance, reply->deferred_token); #else probe_debug_printf("[PROBE] Source sample ACK itf=%u token=%" PRIu64 "\n", instance, reply->deferred_token); @@ -523,7 +528,7 @@ static void protocol_task(probe_usb_controller* controller, uint32_t now) { native_serial = probe_controller_input_peek_native_report(instance, now, input); if (!native_serial) return; length = sizeof(input); -#if SWITCH2_BRIDGE_WII_INPUT +#if SWITCH2_BRIDGE_WII_INPUT || SWITCH2_BRIDGE_DUALSENSE_INPUT input[8] = (uint8_t)(0x30 | ((protocol->enabled_features & 0x20) ? 8 : 0)); #endif probe_protocol_gate_native_report(protocol, input); diff --git a/tools/switch2_usb_probe/native_imu.cpp b/tools/switch2_usb_probe/native_imu.cpp index d88886b..4b29c68 100644 --- a/tools/switch2_usb_probe/native_imu.cpp +++ b/tools/switch2_usb_probe/native_imu.cpp @@ -319,11 +319,12 @@ void ProbeNativeMotion::observe_optical_heading(uint32_t now_us, uint32_t refere } void ProbeNativeMotion::update(uint32_t now_us, uint32_t connection_generation, - const ProbeNativeMotionSample& sample) { - if (!have_generation_ || connection_generation != connection_generation_) { + const ProbeNativeMotionSample& sample, ProbeNativeMotionBias bias_mode) { + if (!have_generation_ || connection_generation != connection_generation_ || bias_mode != bias_mode_) { reset(); have_generation_ = true; connection_generation_ = connection_generation; + bias_mode_ = bias_mode; } const uint32_t elapsed_us = have_update_ ? now_us - update_us_ : 0; update_us_ = now_us; @@ -385,6 +386,14 @@ void ProbeNativeMotion::update(uint32_t now_us, uint32_t connection_generation, return; } + if (bias_mode_ == ProbeNativeMotionBias::kAlreadyCalibrated) { + // Trust only the caller's validated calibrated samples, not an estimated + // stationary bias. The first acceleration fixes a relative gravity frame. + for (unsigned i = 0; i < 3; ++i) mean_accel_[i] = acceleration_[i]; + ready_ = initialize_orientation(); + return; + } + const float acceleration_norm_squared = squared_norm(acceleration_); if (acceleration_norm_squared < 0.85f * 0.85f || acceleration_norm_squared > 1.15f * 1.15f) { diff --git a/tools/switch2_usb_probe/native_imu.h b/tools/switch2_usb_probe/native_imu.h index 46ee596..b07db75 100644 --- a/tools/switch2_usb_probe/native_imu.h +++ b/tools/switch2_usb_probe/native_imu.h @@ -29,18 +29,25 @@ struct ProbeNativeMotionSample { float gyro_dps[3]{}; }; +enum class ProbeNativeMotionBias : uint8_t { + kAlreadyCalibrated, + kEstimateStationary, +}; + // Core 0 only. Call update even when sensors are unavailable, and consult ready // before using the orientation. Sequence identities, not polling, admit samples; // repeated sequences cannot refresh timestamps or contribute to calibration. -// Startup requires the user to rest the controller: constant rotation about -// gravity is indistinguishable from an unknown gyro bias without another sensor. -// Fresh near-1g acceleration corrects tilt drift after startup. Heading remains -// gyro-derived unless a reliable optical heading observation is supplied. +// Already-calibrated sources initialize from the first usable fresh sensor pair. +// Wii explicitly requests stationary residual-bias estimation: constant rotation +// about gravity is indistinguishable from an unknown bias without another sensor. +// Fresh near-1g acceleration corrects tilt drift. Heading remains gyro-derived +// unless a reliable optical heading observation is supplied. class ProbeNativeMotion { public: void reset(); void update(uint32_t now_us, uint32_t connection_generation, - const ProbeNativeMotionSample& sample); + const ProbeNativeMotionSample& sample, + ProbeNativeMotionBias bias_mode = ProbeNativeMotionBias::kAlreadyCalibrated); // Call after update, using a full observed sensor-bar pair (never inferred). // Positive optical yaw means aim-right, the negative reference-world turn. // The first sample in each optical generation anchors the current heading; @@ -62,6 +69,7 @@ private: bool normalize_orientation(); bool have_generation_ = false; + ProbeNativeMotionBias bias_mode_ = ProbeNativeMotionBias::kAlreadyCalibrated; bool have_update_ = false; bool seen_accel_sequence_ = false; bool seen_gyro_sequence_ = false; diff --git a/tools/switch2_usb_probe/probe_build.cmake b/tools/switch2_usb_probe/probe_build.cmake index 51d07ff..08ab918 100644 --- a/tools/switch2_usb_probe/probe_build.cmake +++ b/tools/switch2_usb_probe/probe_build.cmake @@ -25,8 +25,8 @@ else() endif() if(SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB) if(NOT SWITCH_PICO_SWITCH2_USB_BRIDGE OR SWITCH2_BRIDGE_WII_INPUT - OR NOT SWITCH2_BRIDGE_INPUT STREQUAL "JOYCON2") - message(FATAL_ERROR "Composite Joy-Con 2 requires SWITCH_PICO_SWITCH2_USB_BRIDGE=ON and SWITCH2_BRIDGE_INPUT=JOYCON2") + OR (NOT SWITCH2_BRIDGE_INPUT STREQUAL "JOYCON2" AND NOT SWITCH2_BRIDGE_DUALSENSE_INPUT)) + message(FATAL_ERROR "Native R/L output requires JOYCON2 input, or DUALSENSE with HUB") endif() set(probe_composite 0) if(SWITCH2_PROBE_COMPOSITE) @@ -51,9 +51,13 @@ set(SWITCH2_BRIDGE_SOURCE_ADDRESS "" CACHE STRING "Primary physical Bluetooth source address (xx:xx:xx:xx:xx:xx)") set(SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS "" CACHE STRING "Secondary left physical Bluetooth source address (xx:xx:xx:xx:xx:xx)") +set(SWITCH2_BRIDGE_SOURCE_AUTO OFF) if(SWITCH_PICO_SWITCH2_USB_BRIDGE OR SWITCH2_PROBE_COMPOSITE) set(probe_source_fields SWITCH2_BRIDGE_SOURCE_ADDRESS) - if(SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB) + if(SWITCH2_BRIDGE_DUALSENSE_INPUT AND SWITCH2_BRIDGE_SOURCE_ADDRESS STREQUAL "") + set(SWITCH2_BRIDGE_SOURCE_AUTO ON) + set(probe_source_fields "") + elseif((SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB) AND NOT SWITCH2_BRIDGE_DUALSENSE_INPUT) list(APPEND probe_source_fields SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS) endif() set(probe_source_addresses "") @@ -74,6 +78,7 @@ if(SWITCH_PICO_SWITCH2_USB_BRIDGE OR SWITCH2_PROBE_COMPOSITE) string(PREPEND ${field}_BYTES "0x") endforeach() endif() +add_compile_definitions(SWITCH2_BRIDGE_SOURCE_AUTO=$) function(switch2_usb_probe_configure target) set(probe_sources @@ -362,8 +367,14 @@ function(switch2_usb_probe_configure target) else() pico_set_program_name(${target} "Switch 2 USB initialization capture") endif() - if(SWITCH2_PROBE_HUB) - pico_set_program_version(${target} "0.66-native-hub-input") + if(SWITCH2_PROBE_HUB AND SWITCH2_BRIDGE_DUALSENSE_INPUT) + if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) + pico_set_program_version(${target} "0.69-native-hub-imu-trace") + else() + pico_set_program_version(${target} "0.69-native-hub-imu") + endif() + elseif(SWITCH2_PROBE_HUB) + pico_set_program_version(${target} "0.67-native-hub-latency") elseif(SWITCH2_PROBE_JOIN_CHORD_GATE) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) pico_set_program_version(${target} "0.37-pair-chord-trace")