Add DualSense native pair and Wii-only IMU settling

This commit is contained in:
Joey Yakimowich-Payne 2026-09-12 22:06:50 -06:00
commit ab70536fdf
31 changed files with 2617 additions and 77 deletions

View file

@ -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()

View file

@ -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

View file

@ -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,

View file

@ -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.

View file

@ -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 <stdint.h>
+#include <stdbool.h>
#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;
}

View file

@ -36,6 +36,9 @@
#include <uni.h>
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<uint8_t>(&slot - g_slots));
#endif
#ifdef SWITCH2_BRIDGE_WII_INPUT
retire_wii_slot(static_cast<uint8_t>(&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<uint8_t>(&slot - g_slots));
#endif
#ifdef SWITCH2_BRIDGE_WII_INPUT
retire_wii_slot(static_cast<uint8_t>(&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<uint16_t>(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<uint16_t>(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.

View file

@ -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 {

View file

@ -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<uint8_t>((source.native_head + 1) % kNativeReportCapacity);
--source.native_count;
source.native_borrowed_serial = 0;
}
critical_section_exit(&g_lock);
return accepted;

View file

@ -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);

View file

@ -0,0 +1,14 @@
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include <uni.h>
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*);

View file

@ -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 {

View file

@ -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;
}

View file

@ -0,0 +1,262 @@
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#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;
}

View file

@ -0,0 +1,335 @@
#include <assert.h>
#include <math.h>
#include <stdint.h>
#include <string.h>
#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<uint32_t>(now_us); }
absolute_time_t get_absolute_time() { return now_us; }
uint32_t to_ms_since_boot(absolute_time_t time) { return static_cast<uint32_t>(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<uint8_t>(x);
out[1] = static_cast<uint8_t>((x >> 8) | (y << 4));
out[2] = static_cast<uint8_t>(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<unsigned>(ControllerProfileLogicalButton::kSouth)] =
static_cast<uint8_t>(ControllerProfileLogicalButton::kDpadRight);
profile.button_map[static_cast<unsigned>(ControllerProfileLogicalButton::kDpadLeft)] =
static_cast<uint8_t>(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;
}

View file

@ -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<uint8_t>(0x80 + i);
newest[5] = static_cast<uint8_t>(first[5] + i);
newest[PROBE_IMU_LENGTH_OFFSET + 1] = static_cast<uint8_t>(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();

View file

@ -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();

View file

@ -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);

View file

@ -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);

View file

@ -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) {

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -13,6 +13,9 @@
#include <inttypes.h>
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 <math.h>
#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<float>(g_wii.gyro_q10[1]) / 1024.0f;
motion.gyro_dps[1] = -static_cast<float>(g_wii.gyro_q10[2]) / 1024.0f;
motion.gyro_dps[2] = -static_cast<float>(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
}

View file

@ -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);

View file

@ -0,0 +1,330 @@
#include "dualsense_input.h"
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
#include <limits.h>
#include <string.h>
#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<uint64_t>(now_us - g_clock_us) * 960u + g_clock_fraction;
g_clock_us = now_us;
g_clock_ticks += static_cast<uint32_t>(scaled / 1000000u);
g_clock_fraction = static_cast<uint32_t>(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<uint16_t>(bytes[1] & 15) << 8);
pair[1] = (bytes[1] >> 4) | (static_cast<uint16_t>(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<uint16_t>(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<uint8_t>(
(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<uint8_t>(
(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<uint8_t>(
(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<uint8_t>(
(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<uint8_t>(x);
child.input.stick[1] = static_cast<uint8_t>((x >> 8) | (y << 4));
child.input.stick[2] = static_cast<uint8_t>(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<float>(source.accel_q13[0]) / 8192.0f;
sample.accel_g[1] = -static_cast<float>(source.accel_q13[2]) / 8192.0f;
sample.accel_g[2] = static_cast<float>(source.accel_q13[1]) / 8192.0f;
sample.gyro_dps[0] = static_cast<float>(source.gyro_q10[0]) / 1024.0f;
sample.gyro_dps[1] = -static_cast<float>(source.gyro_q10[2]) / 1024.0f;
sample.gyro_dps[2] = static_cast<float>(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<unsigned>(g_source.battery) * 9u + 127u) / 255u;
child.pending_report[1] = static_cast<uint8_t>(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<uint16_t>(elapsed <= 0xfff ? elapsed : 1);
child.pending_motion = motion_ready && probe_native_imu_pack(
g_motion.quaternion(), g_motion.acceleration(), static_cast<uint16_t>(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

View file

@ -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

View file

@ -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);

View file

@ -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) {

View file

@ -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;

View file

@ -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=$<BOOL:${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")