Support Bluepad32 gamepads as native Joy-Con pair sources

This commit is contained in:
Joey Yakimowich-Payne 2026-09-12 23:03:15 -06:00
commit a17ca603aa
33 changed files with 2529 additions and 851 deletions

View file

@ -177,9 +177,11 @@ if(SWITCH_PICO_SWITCH2_MEMORY_CAPTURE)
endif()
set(SWITCH2_BRIDGE_WII_INPUT OFF)
set(SWITCH2_BRIDGE_DUALSENSE_INPUT OFF)
set(SWITCH2_BRIDGE_GAMEPAD_INPUT OFF)
set(SWITCH2_BRIDGE_FULL_INPUT OFF)
if(SWITCH_PICO_SWITCH2_USB_BRIDGE)
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_INPUT "JOYCON2" CACHE STRING "Native source: JOYCON2, WII, DUALSENSE or GAMEPAD")
set_property(CACHE SWITCH2_BRIDGE_INPUT PROPERTY STRINGS JOYCON2 WII DUALSENSE GAMEPAD)
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")
@ -195,12 +197,22 @@ if(SWITCH_PICO_SWITCH2_USB_BRIDGE)
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")
if(SWITCH2_BRIDGE_INPUT STREQUAL "DUALSENSE" OR SWITCH2_BRIDGE_INPUT STREQUAL "GAMEPAD")
if(NOT SWITCH2_PROBE_HUB)
message(FATAL_ERROR "Translated full-controller input requires the native R/L HUB")
endif()
set(SWITCH2_BRIDGE_FULL_INPUT ON)
add_compile_definitions(SWITCH2_BRIDGE_FULL_INPUT=1)
if(SWITCH2_BRIDGE_INPUT STREQUAL "DUALSENSE")
if(NOT SWITCH_PICO_ENABLE_CLASSIC)
message(FATAL_ERROR "DualSense input requires Classic Bluetooth")
endif()
set(SWITCH2_BRIDGE_DUALSENSE_INPUT ON)
add_compile_definitions(SWITCH2_BRIDGE_DUALSENSE_INPUT=1)
else()
set(SWITCH2_BRIDGE_GAMEPAD_INPUT ON)
add_compile_definitions(SWITCH2_BRIDGE_GAMEPAD_INPUT=1)
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 "Joy-Con/Wii USB bridge requires BLE and native Joy-Con capture")
@ -214,10 +226,10 @@ if(SWITCH_PICO_SWITCH2_USB_BRIDGE)
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" AND NOT SWITCH2_BRIDGE_DUALSENSE_INPUT)
message(FATAL_ERROR "SWITCH2_BRIDGE_INPUT must be JOYCON2, WII or DUALSENSE")
elseif(NOT SWITCH2_BRIDGE_INPUT STREQUAL "JOYCON2" AND NOT SWITCH2_BRIDGE_FULL_INPUT)
message(FATAL_ERROR "SWITCH2_BRIDGE_INPUT must be JOYCON2, WII, DUALSENSE or GAMEPAD")
endif()
if(NOT SWITCH2_BRIDGE_DUALSENSE_INPUT AND NOT SWITCH2_BRIDGE_IMU_TARGET STREQUAL "BOTH")
if(NOT SWITCH2_BRIDGE_FULL_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)
@ -382,6 +394,10 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_hid_parser_switch2.c
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_switch2_pairing.c
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_switch2_haptics.c)
if(SWITCH2_BRIDGE_FULL_INPUT)
target_sources(bluepad32 PRIVATE
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_hid_parser_native_motion.c)
endif()
endif()
# Firmware sources live under one include root and are grouped by responsibility.
@ -423,16 +439,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)
elseif(SWITCH2_BRIDGE_FULL_INPUT)
target_sources(switch-pico PRIVATE
${SWITCH2_USB_PROBE_DIR}/dualsense_input.cpp
${SWITCH2_USB_PROBE_DIR}/native_gamepad_input.cpp
${SWITCH2_USB_PROBE_DIR}/native_imu.cpp)
endif()
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)
if((SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB) AND NOT SWITCH2_BRIDGE_FULL_INPUT)
target_compile_definitions(switch-pico PRIVATE
SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS_BYTES=${SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS_BYTES})
endif()

View file

@ -879,6 +879,50 @@ 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.
**Any supported gamepad (0.70):** `SWITCH2_BRIDGE_INPUT=GAMEPAD` uses the same
native R/L hub and private identity/calibration captures, but accepts the normal
Bluepad32 gamepad families instead of filtering for a DualSense. Use a separate
private hub build with `SWITCH_PICO_BLUETOOTH_MODE=MIXED` to enable both Classic
and BLE controllers, and disable both `SWITCH_PICO_SWITCH2_MOUSE_CAPTURE` and
`SWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE`. This is one logical controller
feeding one virtual R/L pair, not additional players. An empty source address
requires one uniquely eligible logical controller; multiple eligible sources
fail closed. An explicit address selects that controller (either member of an
existing Switch2 Joy-Con pair). Ordinary AIO pairing, identity, layout and
profile behavior is retained; original Switch Joy-Con grouping is not added.
- Buttons, sticks and profiles work independently of motion capability.
- Calibrated motion providers cover DS4, DS5/Edge, Switch/Joy-Con-compatible
parsers, Switch2 Pro/Joy-Con, PS Move, and Wii/MotionPlus. Motion requires actual
supported, calibrated and fresh acceleration **and** gyro samples. A pad with
absent/invalid sensors remains usable for controls; no IMU is invented.
- Sensor counters advance at parser ingress, not when polled or when buttons
arrive. A paired left Joy-Con cannot refresh the right-owned sensor stream.
Wii acceleration cannot refresh a stalled MotionPlus gyro stream.
- `SWITCH2_BRIDGE_IMU_TARGET=LEFT|RIGHT|BOTH` also applies to `GAMEPAD`; Wii alone
estimates residual bias while stationary. Factory calibration already runs;
caching residual Wii bias across boots without revalidation is not implemented.
- Native cue requests use each source driver's bounded compatibility vibration.
Mono drivers combine the two logical contributions; paired Switch2 Joy-Cons
target their actual halves. This does not promise stereo, HD-waveform fidelity
or physical actuator onset. Completion means driver dispatch (accepted L2CAP
submission for DS5), not a remote application ACK. Missing rumble capability
fails the request rather than claiming a motor response.
- The dedicated `WII` source remains the IR/native-mouse path. `GAMEPAD` does not
synthesize mouse movement or rail buttons.
The private `build-switch2-native-gamepad` image uses mixed Bluetooth and the
unchanged stock USB socket. Software regressions cover real parser calibration,
report integrity/freshness, source selection, split/reset/backpressure, Wii-only
settling and cue lifetimes. DualSense, generic, existing Joy-Con/Wii, and ordinary
AIO mixed/BLE/Classic firmware builds pass. The new generic image has not been
flashed or physically qualified across these controller families.
For sensorless hardware, the checker supports `--input-only`: press real buttons
and keep changing controls on both halves during the run. Neutral fallback
alone cannot qualify. The result explicitly records that IMU was not required;
omit this option to retain the strict dual-IMU check.
With the existing private build configured, qualify on a PC using:
```sh

View file

@ -209,6 +209,15 @@ static inline bool uni_psmove_normalize_imu(
const int32_t gyro_bias_value = uni_psmove_read_calibration_value(
calibration->data, model, gyro_bias[axis]);
#if SWITCH2_BRIDGE_FULL_INPUT
// Complete feature blocks alone do not make erased/degenerate factory
// extrema calibrated. Never label the helper's zero-span fallback IMU.
if (accel_low_value >= accel_high_value ||
uni_psmove_read_calibration_value(calibration->data, model, gyro_high[axis]) <= gyro_bias_value ||
(model == UNI_PSMOVE_IMU_MODEL_ZCM2 &&
uni_psmove_read_calibration_value(calibration->data, model, zcm2_gyro_low[axis]) >= gyro_bias_value))
return false;
#endif
const int32_t gyro_raw =
(uni_psmove_decode_value(model, gyro_first[axis]) +
uni_psmove_decode_value(model, gyro_second[axis])) /

View file

@ -0,0 +1,131 @@
// SPDX-License-Identifier: Apache-2.0
#include "parser/uni_hid_parser_native_motion.h"
#if SWITCH2_BRIDGE_FULL_INPUT
#include <string.h>
#include "parser/uni_hid_parser_wii.h"
#include "sdkconfig.h"
#include "uni_hid_device.h"
// Parser data is already close to its fixed capacity on some families. Keep
// only native-bridge provenance here, not in every normal AIO parser instance.
typedef struct {
uni_hid_device_t* device;
void (*setup)(uni_hid_device_t*);
uint8_t address[6];
uni_native_motion_snapshot_t sample;
uint32_t timestamp;
bool have_timestamp;
} native_motion_t;
static native_motion_t providers[CONFIG_BLUEPAD32_MAX_DEVICES];
static uint32_t sequence;
uint32_t uni_hid_parser_native_motion_next_sequence(void) {
if (++sequence == 0)
++sequence;
return sequence;
}
static native_motion_t* provider(uni_hid_device_t* d) {
if (!d)
return NULL;
for (unsigned i = 0; i < CONFIG_BLUEPAD32_MAX_DEVICES; ++i) {
native_motion_t* p = &providers[i];
if (p->device == d && p->setup == d->report_parser.setup &&
memcmp(p->address, d->conn.btaddr, sizeof(p->address)) == 0)
return p;
}
return NULL;
}
void uni_hid_parser_native_motion_forget(uni_hid_device_t* d) {
for (unsigned i = 0; i < CONFIG_BLUEPAD32_MAX_DEVICES; ++i)
if (providers[i].device == d)
memset(&providers[i], 0, sizeof(providers[i]));
}
void uni_hid_parser_native_motion_reset(uni_hid_device_t* d) {
if (!d)
return;
uni_hid_parser_native_motion_forget(d);
for (unsigned i = 0; i < CONFIG_BLUEPAD32_MAX_DEVICES; ++i) {
native_motion_t* p = &providers[i];
if (p->device)
continue;
p->device = d;
p->setup = d->report_parser.setup;
memcpy(p->address, d->conn.btaddr, sizeof(p->address));
p->sample.report_tracked = true;
return;
}
}
void uni_hid_parser_native_motion_begin(uni_hid_device_t* d) {
native_motion_t* p = provider(d);
if (p)
p->sample.report_valid = false;
}
void uni_hid_parser_native_motion_accept(uni_hid_device_t* d) {
native_motion_t* p = provider(d);
if (p) {
p->sample.report_valid = true;
p->sample.report_sequence = uni_hid_parser_native_motion_next_sequence();
}
}
void uni_hid_parser_native_motion_accel(uni_hid_device_t* d, const int32_t* value) {
native_motion_t* p = provider(d);
if (!p)
return;
p->sample.accel_valid = value != NULL;
if (value) {
memcpy(p->sample.accel_q13, value, sizeof(p->sample.accel_q13));
p->sample.accel_sequence = uni_hid_parser_native_motion_next_sequence();
}
}
void uni_hid_parser_native_motion_gyro(uni_hid_device_t* d, const int32_t* value) {
native_motion_t* p = provider(d);
if (!p)
return;
p->sample.gyro_valid = value != NULL;
if (value) {
memcpy(p->sample.gyro_q10, value, sizeof(p->sample.gyro_q10));
p->sample.gyro_sequence = uni_hid_parser_native_motion_next_sequence();
}
}
bool uni_hid_parser_native_motion_fresh(uni_hid_device_t* d, uint32_t timestamp, uint32_t mask) {
native_motion_t* p = provider(d);
if (!p)
return false;
uint32_t delta = (timestamp - p->timestamp) & mask;
if (p->have_timestamp && (delta == 0 || delta > (mask >> 1)))
return false;
p->timestamp = timestamp;
p->have_timestamp = true;
return true;
}
bool uni_hid_parser_native_motion_snapshot(uni_hid_device_t* d, uni_native_motion_snapshot_t* out) {
if (!out)
return false;
memset(out, 0, sizeof(*out));
if (!d)
return false;
// Wii already owns independent, topology-aware calibration and sample IDs.
// ACK/status/extension packets must not refresh the Remote or MotionPlus.
if (d->report_parser.setup == uni_hid_parser_wii_setup) {
out->accel_valid = uni_hid_parser_wii_accel_snapshot(d, out->accel_q13, &out->accel_sequence);
out->gyro_valid = uni_hid_parser_wii_gyro_snapshot(d, out->gyro_q10, &out->gyro_sequence);
return true;
}
native_motion_t* p = provider(d);
if (!p)
return false;
*out = p->sample;
return true;
}
#endif

View file

@ -0,0 +1,48 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
struct uni_hid_device_s;
typedef struct {
bool report_tracked;
bool report_valid;
uint32_t report_sequence;
bool accel_valid;
bool gyro_valid;
uint32_t accel_sequence;
uint32_t gyro_sequence;
int32_t accel_q13[3];
int32_t gyro_q10[3];
} uni_native_motion_snapshot_t;
#if SWITCH2_BRIDGE_FULL_INPUT
// Bluetooth owner only. Reads never advance sequences or rejuvenate samples.
// False/zero means no motion provider, not an unsupported controls device.
bool uni_hid_parser_native_motion_snapshot(struct uni_hid_device_s* d,
uni_native_motion_snapshot_t* out);
// Parser ingress/lifecycle hooks. Only setup allocates a bounded slot; late
// reports cannot resurrect a retired provider. NULL sensor data invalidates
// that sensor without changing its last sequence. Samples use SDL Q13/Q10 axes.
void uni_hid_parser_native_motion_reset(struct uni_hid_device_s* d);
void uni_hid_parser_native_motion_forget(struct uni_hid_device_s* d);
void uni_hid_parser_native_motion_begin(struct uni_hid_device_s* d);
void uni_hid_parser_native_motion_accept(struct uni_hid_device_s* d);
void uni_hid_parser_native_motion_accel(struct uni_hid_device_s* d, const int32_t* value);
void uni_hid_parser_native_motion_gyro(struct uni_hid_device_s* d, const int32_t* value);
// Test a complete report's wrapping hardware clock, before publishing sensors.
// mask is UINT8_MAX/UINT16_MAX/UINT32_MAX; backwards/duplicate ticks are rejected.
bool uni_hid_parser_native_motion_fresh(struct uni_hid_device_s* d, uint32_t timestamp, uint32_t mask);
// Parser ingress only: process-wide nonzero IDs do not alias across reconnects.
uint32_t uni_hid_parser_native_motion_next_sequence(void);
#endif
#ifdef __cplusplus
}
#endif

View file

@ -6,6 +6,9 @@
// Sensor conversion independently adapted from SDL_hidapi_switch2.c (SDL/Valve).
#include "parser/uni_hid_parser_switch2.h"
#if SWITCH2_BRIDGE_FULL_INPUT
#include "parser/uni_hid_parser_native_motion.h"
#endif
#include <math.h>
#include <stdatomic.h>
@ -125,6 +128,9 @@ typedef struct {
uint8_t memory_length;
sw2_stick_t sticks[2];
int32_t gyro_bias[3];
#if SWITCH2_BRIDGE_FULL_INPUT
bool gyro_calibrated;
#endif
uint8_t extra_buttons, leds;
bool leds_pending;
uint8_t rumble_id, weak, strong;
@ -216,6 +222,9 @@ static void sw2_disarm_timeout(sw2_instance_t* ins) {
}
void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) {
#if SWITCH2_BRIDGE_FULL_INPUT
uni_hid_parser_native_motion_forget(d);
#endif
sw2_instance_t* ins = sw2_instance(d);
if (!ins)
return;
@ -643,13 +652,21 @@ static void sw2_response(sw2_instance_t* ins, const uint8_t* data, uint16_t leng
ins->calibration_done = true;
}
} else if (ins->state == SW2_GYRO_CALIBRATION) {
#if SWITCH2_BRIDGE_FULL_INPUT
ins->gyro_calibrated = true;
#endif
for (unsigned i = 0; i < 3; ++i) {
uint32_t bits = little_endian_read_32(value, 4 * i);
float bias;
memcpy(&bias, &bits, sizeof(bias));
// Erased/invalid flash is not a floating-point sensor value.
if (isfinite(bias) && bias >= -40.0f && bias <= 40.0f)
bool valid = isfinite(bias) && bias >= -40.0f && bias <= 40.0f;
if (valid)
ins->gyro_bias[i] = (int32_t)(bias * (57.295779513f * UNI_IMU_GYRO_RES_PER_DEG_S));
#if SWITCH2_BRIDGE_FULL_INPUT
if (!valid)
ins->gyro_calibrated = false;
#endif
}
}
} else if (ins->state == SW2_PAIR) {
@ -1029,6 +1046,9 @@ void uni_hid_parser_switch2_setup(uni_hid_device_t* d) {
if (!ins || ins->state != SW2_ADMITTED)
return;
ins->handle = d->conn.handle;
#if SWITCH2_BRIDGE_FULL_INPUT
uni_hid_parser_native_motion_reset(d);
#endif
// Standard-compliant 7.5ms minimum; negotiation failure is not setup failure.
int status = gap_update_connection_parameters(ins->handle, 6, 6, 0, 600);
if (status != ERROR_CODE_SUCCESS)
@ -1053,6 +1073,9 @@ bool uni_hid_parser_switch2_identity_address_type(const uni_hid_device_t* d, uin
}
void uni_hid_parser_switch2_init_report(uni_hid_device_t* d) {
#if SWITCH2_BRIDGE_FULL_INPUT
uni_hid_parser_native_motion_begin(d);
#endif
(void)d; // Full snapshots replace state only after their complete length is validated.
}
@ -1066,6 +1089,16 @@ static int32_t sw2_axis(uint16_t raw, const sw2_stick_t* stick, unsigned axis, b
static void sw2_motion(sw2_instance_t* ins, uni_gamepad_t* gp, const uint8_t* report) {
uint32_t timestamp = little_endian_read_32(report, 42);
#if SWITCH2_BRIDGE_FULL_INPUT
if (!timestamp || !uni_hid_parser_native_motion_fresh(ins->device, timestamp, UINT32_MAX))
return;
// Acceleration has a known fixed range and does not need gyro clock
// detection or factory gyro bias. Keep its provenance independent.
gp->accel[0] = uni_imu_scale((int16_t)little_endian_read_16(report, 48), 32767, 8 * UNI_IMU_ACCEL_RES_PER_G);
gp->accel[1] = uni_imu_scale((int16_t)little_endian_read_16(report, 52), 32767, 8 * UNI_IMU_ACCEL_RES_PER_G);
gp->accel[2] = -uni_imu_scale((int16_t)little_endian_read_16(report, 50), 32767, 8 * UNI_IMU_ACCEL_RES_PER_G);
uni_hid_parser_native_motion_accel(ins->device, gp->accel);
#endif
if (!timestamp || (timestamp == ins->sensor_last && !ins->gyro_full_scale))
return;
ins->sensor_last = timestamp;
@ -1099,18 +1132,30 @@ static void sw2_motion(sw2_instance_t* ins, uni_gamepad_t* gp, const uint8_t* re
static const uint8_t axes[3] = {0, 2, 1};
for (unsigned i = 0; i < 3; ++i) {
unsigned axis = axes[i];
#if !SWITCH2_BRIDGE_FULL_INPUT
int32_t accel = (int16_t)little_endian_read_16(report, 48 + 2 * axis);
#endif
int32_t gyro = (int16_t)little_endian_read_16(report, 54 + 2 * axis);
#if !SWITCH2_BRIDGE_FULL_INPUT
gp->accel[i] = uni_imu_scale(accel, 32767, 8 * UNI_IMU_ACCEL_RES_PER_G);
#endif
gp->gyro[i] = uni_imu_scale(gyro, 32767, ins->gyro_full_scale) - ins->gyro_bias[axis];
if (i == 2) {
#if !SWITCH2_BRIDGE_FULL_INPUT
gp->accel[i] = -gp->accel[i];
#endif
gp->gyro[i] = -gp->gyro[i];
}
}
#if SWITCH2_BRIDGE_FULL_INPUT
uni_hid_parser_native_motion_gyro(ins->device, ins->gyro_calibrated ? gp->gyro : NULL);
#endif
}
void uni_hid_parser_switch2_parse_input_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) {
#if SWITCH2_BRIDGE_FULL_INPUT
uni_hid_parser_native_motion_begin(d);
#endif
sw2_instance_t* ins = sw2_instance(d);
if (!ins || ins->state != SW2_READY || !report || len != SW2_REPORT_SIZE)
return;
@ -1148,6 +1193,9 @@ void uni_hid_parser_switch2_parse_input_report(uni_hid_device_t* d, const uint8_
}
sw2_motion(ins, gp, report);
d->controller.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
#if SWITCH2_BRIDGE_FULL_INPUT
uni_hid_parser_native_motion_accept(d);
#endif
}
void uni_hid_parser_switch2_set_player_leds(uni_hid_device_t* d, uint8_t leds) {

File diff suppressed because it is too large Load diff

View file

@ -36,8 +36,9 @@
#include <uni.h>
extern "C" {
#include "parser/uni_hid_parser_wii.h"
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
#if SWITCH2_BRIDGE_FULL_INPUT
#include "parser/uni_hid_parser_ds5.h"
#include "parser/uni_hid_parser_native_motion.h"
#endif
}
#include "parser/uni_hid_parser_switch2.h"
@ -257,19 +258,30 @@ constexpr WiiCuePattern kWiiCuePatterns[8] = {
constexpr uint32_t kWiiCueDeadlineMs = 2000;
#endif
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
struct DualSenseIngress {
uint32_t report_sequence = 0;
#if SWITCH2_BRIDGE_FULL_INPUT
struct NativeGamepadIngress {
uint32_t received_us = 0;
uint32_t motion_sequence = 0;
uint32_t motion_received_us = 0;
uint32_t accel_sequence = 0;
uint32_t gyro_sequence = 0;
uint32_t accel_received_us = 0;
uint32_t gyro_received_us = 0;
bool has_report = false;
bool motion_valid = false;
bool accel_valid = false;
bool gyro_valid = false;
int32_t accel_q13[3]{};
int32_t gyro_q10[3]{};
};
struct DualSenseCue {
// Physical parser counters survive logical pairing/reselection epochs. They
// are retired only with the Bluetooth connection, never by a snapshot getter.
struct NativeGamepadReportIngress {
uni_hid_device_t* device = nullptr;
uint32_t report_sequence = 0;
uint32_t accel_sequence = 0;
uint32_t gyro_sequence = 0;
};
struct NativeGamepadCue {
uint64_t token = 0;
uint32_t connection_generation = 0;
uint32_t requested_ms = 0;
@ -282,20 +294,20 @@ struct DualSenseCue {
bool in_flight = false;
};
struct DualSenseMotorOutput {
struct NativeGamepadMotorOutput {
uint32_t deadline_ms = 0;
uint8_t magnitude[2]{};
bool owned = false;
};
// Same bounded pulse vocabulary as Wii, with real per-motor magnitudes.
// Same bounded pulse vocabulary as Wii, with source-driver motor magnitudes.
// These are compatibility-vibration approximations, not uploaded HD waveforms.
struct DualSenseCuePattern {
struct NativeGamepadCuePattern {
uint16_t phases_ms[7];
uint8_t count;
uint8_t magnitude;
};
constexpr DualSenseCuePattern kDualSenseCuePatterns[8] = {
constexpr NativeGamepadCuePattern kNativeGamepadCuePatterns[8] = {
{{0}, 0, 0},
{{1000}, 1, 160},
{{100, 180, 100, 180, 100, 180, 100}, 7, 200},
@ -305,7 +317,7 @@ constexpr DualSenseCuePattern kDualSenseCuePatterns[8] = {
{{60}, 1, 96},
{{120}, 1, 220},
};
constexpr uint32_t kDualSenseCueDeadlineMs = 2000;
constexpr uint32_t kNativeGamepadCueDeadlineMs = 2000;
#endif
@ -345,9 +357,9 @@ struct BackendSlot {
#ifdef SWITCH2_BRIDGE_WII_INPUT
WiiMotionIngress wii_motion;
#endif
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
DualSenseIngress dualsense_motion;
DualSenseMotorOutput dualsense_output;
#if SWITCH2_BRIDGE_FULL_INPUT
NativeGamepadIngress native_motion;
NativeGamepadMotorOutput native_output;
#endif
#ifdef SWITCH_PICO_WII_IR_GYRO
WiiAimSource wii_aim;
@ -436,79 +448,97 @@ void retire_wii_slot(uint8_t slot_index) {
}
#endif
#if SWITCH2_BRIDGE_FULL_INPUT
bool g_native_explicit_address = false;
uint8_t g_native_address[6]{};
uint8_t g_native_slot = 0xff;
uint32_t g_native_generation = 0;
Bluepad32NativeGamepadSnapshot g_native_snapshot{};
NativeGamepadCue g_native_cues[2]{};
uint64_t g_next_native_token = 1;
uni_hid_device_t* g_native_pending_devices[kSlotCount]{};
NativeGamepadReportIngress g_native_reports[kSlotCount]{};
bool native_device_allowed(const uni_hid_device_t* device) {
if (device == nullptr || !uni_hid_device_is_gamepad(device)) return false;
#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);
return device->controller_type == CONTROLLER_TYPE_PS5Controller &&
device->report_parser.parse_input_report == uni_hid_parser_ds5_parse_input_report;
#else
return true;
#endif
}
bool eligible_dualsense(const BackendSlot& slot) {
return slot.active && slot.companion == nullptr && dualsense_source_matches(slot.device);
bool native_address_matches(const uni_hid_device_t* device) {
return device != nullptr && memcmp(device->conn.btaddr, g_native_address, 6) == 0;
}
void cancel_dualsense_cue_locked(DualSenseCue& cue) {
bool eligible_native_gamepad(const BackendSlot& slot) {
return slot.active && native_device_allowed(slot.device) &&
(slot.companion == nullptr || native_device_allowed(slot.companion)) &&
(!g_native_explicit_address || native_address_matches(slot.device) ||
native_address_matches(slot.companion));
}
uni_hid_device_t* native_rumble_target(const BackendSlot& slot, uint8_t side) {
// Paired Joy-Cons have a real left owner and right companion. A solo pad's
// driver owns its motor topology, including mono OR/max mixing.
return slot.companion != nullptr && side == 0 ? slot.companion : slot.device;
}
bool native_rumble_capable(const BackendSlot& slot, uint8_t side) {
const uni_hid_device_t* device = native_rumble_target(slot, side);
return device != nullptr && device->report_parser.play_dual_rumble != nullptr;
}
void cancel_native_cue_locked(NativeGamepadCue& 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) {
void refresh_native_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 (!eligible_native_gamepad(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 &&
if (!reselection && selected == g_native_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;
g_slots[selected].connection_generation == g_native_generation)) return;
for (NativeGamepadCue& cue : g_native_cues) cancel_native_cue_locked(cue);
g_native_snapshot = {};
g_native_slot = selected;
g_native_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;
g_native_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;
slot.native_motion = {};
}
}
void retire_dualsense_slot(uint8_t index) {
if (g_dualsense_slot == index) {
g_dualsense_slot = 0xff;
g_dualsense_generation = 0;
g_dualsense_snapshot = {};
void retire_native_slot(uint8_t index) {
if (g_native_slot == index) {
g_native_slot = 0xff;
g_native_generation = 0;
g_native_snapshot = {};
}
for (DualSenseCue& cue : g_dualsense_cues)
for (NativeGamepadCue& cue : g_native_cues)
if (cue.slot == index) cue = {};
g_slots[index].dualsense_motion = {};
g_slots[index].dualsense_output = {};
g_slots[index].native_motion = {};
g_slots[index].native_output = {};
}
bool dualsense_cue_current(const DualSenseCue& cue) {
return cue.slot < kSlotCount && cue.slot == g_dualsense_slot &&
cue.connection_generation == g_dualsense_generation &&
bool native_cue_current(const NativeGamepadCue& cue) {
return cue.slot < kSlotCount && cue.slot == g_native_slot &&
cue.connection_generation == g_native_generation &&
cue.connection_generation == g_slots[cue.slot].connection_generation;
}
#endif
@ -615,8 +645,8 @@ 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;
#if SWITCH2_BRIDGE_FULL_INPUT
for (const auto* pending : g_native_pending_devices) physical_count += pending != nullptr;
#endif
critical_section_exit(&g_state_lock);
return physical_count < kSlotCount;
@ -661,7 +691,7 @@ int reserve_device_slot(uni_hid_device_t* device) {
}
const int tracked = slot_for_device(device);
if (g_retired_devices[physical_index] == device) {
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
#if SWITCH2_BRIDGE_FULL_INPUT
return -1;
#else
if (uni_hid_parser_switch2_is_ble_device(device)) return -1;
@ -678,7 +708,7 @@ int reserve_device_slot(uni_hid_device_t* device) {
return -1;
}
}
#if !SWITCH2_BRIDGE_DUALSENSE_INPUT
#if !SWITCH2_BRIDGE_FULL_INPUT
if (g_slots[physical_index].device == nullptr) {
return physical_index;
}
@ -1374,8 +1404,8 @@ 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 SWITCH2_BRIDGE_FULL_INPUT
for (const auto* pending : g_native_pending_devices) {
if (pending != nullptr) {
++physical_count;
all_ready = false;
@ -1437,23 +1467,28 @@ void publish_device_state(uint8_t slot, uni_hid_device_t* device,
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 = {
#if SWITCH2_BRIDGE_FULL_INPUT
if (slot == g_native_slot && target.native_motion.has_report &&
target.connection_generation == g_native_generation) {
const NativeGamepadIngress& motion = target.native_motion;
g_native_snapshot.slot = slot;
g_native_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));
g_native_snapshot.state_generation = target.state_generation;
g_native_snapshot.received_us = motion.received_us;
g_native_snapshot.battery = device->controller.battery;
g_native_snapshot.requires_stationary_bias =
device->controller_type == CONTROLLER_TYPE_WiiController;
g_native_snapshot.accel_valid = motion.accel_valid;
g_native_snapshot.gyro_valid = motion.gyro_valid;
g_native_snapshot.accel_sequence = motion.accel_sequence;
g_native_snapshot.gyro_sequence = motion.gyro_sequence;
g_native_snapshot.accel_received_us = motion.accel_received_us;
g_native_snapshot.gyro_received_us = motion.gyro_received_us;
memcpy(g_native_snapshot.accel_q13, motion.accel_q13, sizeof(motion.accel_q13));
memcpy(g_native_snapshot.gyro_q10, motion.gyro_q10, sizeof(motion.gyro_q10));
}
#endif
g_macro_capture.observe(slot, target.connection_generation,
@ -1468,8 +1503,8 @@ 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));
#if SWITCH2_BRIDGE_FULL_INPUT
retire_native_slot(static_cast<uint8_t>(&slot - g_slots));
#endif
#ifdef SWITCH2_BRIDGE_WII_INPUT
retire_wii_slot(static_cast<uint8_t>(&slot - g_slots));
@ -2007,8 +2042,8 @@ 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));
#if SWITCH2_BRIDGE_FULL_INPUT
retire_native_slot(static_cast<uint8_t>(&slot - g_slots));
#endif
#ifdef SWITCH2_BRIDGE_WII_INPUT
retire_wii_slot(static_cast<uint8_t>(&slot - g_slots));
@ -2577,7 +2612,7 @@ 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 SWITCH2_BRIDGE_FULL_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
@ -2726,9 +2761,10 @@ void dispatch_wii_cue(const WiiCueDispatch& command) {
}
#endif
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
struct DualSenseCueDispatch {
#if SWITCH2_BRIDGE_FULL_INPUT
struct NativeGamepadCueDispatch {
uni_hid_device_t* device = nullptr;
uni_hid_device_t* companion = nullptr;
uint32_t connection_generation = 0;
uint32_t prepared_ms = 0;
uint64_t token[2]{};
@ -2737,27 +2773,27 @@ struct DualSenseCueDispatch {
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,
// Source drivers use a shared finite timer (or one per paired half). Recompute
// both contributions at each boundary; the shortest ON remainder protects a
// mono actuator too. Absolute timelines skip missed pulses, never queue them.
bool prepare_native_cues(uint8_t index, uint32_t now_ms,
bool local_active, bool local_dispatch,
DualSenseCueDispatch* command) {
NativeGamepadCueDispatch* command) {
BackendSlot& slot = g_slots[index];
DualSenseMotorOutput& previous = slot.dualsense_output;
NativeGamepadMotorOutput& previous = slot.native_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];
NativeGamepadCue& cue = g_native_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 (!native_cue_current(cue) ||
(cue.result == 0 && now_ms - cue.requested_ms >= kNativeGamepadCueDeadlineMs) ||
(cue.active && now_ms - cue.started_ms >= kNativeGamepadCueDeadlineMs))
cancel_native_cue_locked(cue);
if (local_active || local_dispatch) {
if (cue.active) cancel_dualsense_cue_locked(cue);
if (cue.active) cancel_native_cue_locked(cue);
busy |= cue.result == 0;
continue;
}
@ -2766,7 +2802,7 @@ bool prepare_dualsense_cues(uint8_t index, uint32_t now_ms,
command->token[side] = cue.token;
pending |= cue.result == 0;
if (cue.sample_id == 0) continue;
const DualSenseCuePattern& pattern = kDualSenseCuePatterns[cue.sample_id];
const NativeGamepadCuePattern& pattern = kNativeGamepadCuePatterns[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])
@ -2795,13 +2831,14 @@ bool prepare_dualsense_cues(uint8_t index, uint32_t now_ms,
(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);
(!native_rumble_capable(slot, 0) && !native_rumble_capable(slot, 1))) {
for (NativeGamepadCue& cue : g_native_cues)
if (cue.slot == index) cancel_native_cue_locked(cue);
previous = {};
return false;
}
command->device = slot.device;
command->companion = slot.companion;
command->connection_generation = slot.connection_generation;
command->prepared_ms = now_ms;
command->duration_ms = duration;
@ -2809,52 +2846,88 @@ bool prepare_dualsense_cues(uint8_t index, uint32_t now_ms,
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;
if (command->token[side] != 0) g_native_cues[side].in_flight = true;
return true;
}
void dispatch_dualsense_cues(const DualSenseCueDispatch& command) {
bool submit_native_rumble(uni_hid_device_t* device, uint16_t duration,
uint8_t right, uint8_t left) {
if (device == nullptr || device->report_parser.play_dual_rumble == nullptr)
return false;
if (device->controller_type == CONTROLLER_TYPE_PS5Controller &&
device->report_parser.parse_input_report == uni_hid_parser_ds5_parse_input_report) {
return uni_hid_parser_ds5_bridge_rumble(device, duration, right, left);
}
// Existing finite-duration dispatch is the strongest observable result
// most drivers expose. It is not transport acceptance or a remote ACK.
dispatch_rumble(device, duration, right, left);
return true;
}
void dispatch_native_cues(const NativeGamepadCueDispatch& 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);
const bool paired = command.companion != nullptr;
bool submitted[2]{};
uint32_t dispatch_ms = btstack_run_loop_get_time_ms();
for (uint8_t target = 0; target < (paired ? 2 : 1); ++target) {
critical_section_enter_blocking(&g_state_lock);
bool current = slot.active && slot.device == command.device &&
slot.companion == command.companion &&
slot.connection_generation == command.connection_generation;
for (uint8_t side = 0; side < 2; ++side) {
if (paired && side != target) continue;
const NativeGamepadCue& cue = g_native_cues[side];
if (command.token[side] != 0)
current &= cue.token == command.token[side] && cue.in_flight &&
cue.result != -1 && native_cue_current(cue);
}
critical_section_exit(&g_state_lock);
// No backend lock crosses a driver call. Recheck every real target:
// cancel/reselection or a stall during R dispatch must not send stale L.
dispatch_ms = btstack_run_loop_get_time_ms();
const uint32_t delay = dispatch_ms - command.prepared_ms;
const uint16_t duration = command.duration_ms > delay
? static_cast<uint16_t>(command.duration_ms - delay) : 0;
current &= delay < kRumblePollIntervalMs &&
(command.duration_ms == 0 || duration != 0);
if (!current) continue;
uni_hid_device_t* device = paired && target == 0
? command.companion : command.device;
const uint8_t right = command.magnitude[paired ? target : 0];
const uint8_t left = command.magnitude[paired ? target : 1];
if (!submit_native_rumble(
device, (right | left) == 0 ? 0 : duration, right, left)) continue;
__atomic_add_fetch(&g_rumble_dispatches, 1, __ATOMIC_RELAXED);
if (paired) submitted[target] = true;
else submitted[0] = submitted[1] = true;
critical_section_enter_blocking(&g_state_lock);
if (slot.active && slot.device == command.device &&
slot.companion == command.companion) {
// Retain each actual submission even if USB canceled/reselected
// during its driver call, or the other half cannot be submitted.
NativeGamepadMotorOutput& output = slot.native_output;
if (paired) output.magnitude[target] = command.magnitude[target];
else {
output.magnitude[0] = command.magnitude[0];
output.magnitude[1] = command.magnitude[1];
}
output.owned = (output.magnitude[0] | output.magnitude[1]) != 0;
output.deadline_ms = output.owned
? command.prepared_ms + command.duration_ms : 0;
}
critical_section_exit(&g_state_lock);
}
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];
NativeGamepadCue& cue = g_native_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) {
if (!native_cue_current(cue) ||
(cue.result == 0 && dispatch_ms - cue.requested_ms >= kNativeGamepadCueDeadlineMs)) {
cancel_native_cue_locked(cue);
} else if (submitted[side] && 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;
@ -2890,6 +2963,9 @@ void process_wii_orientation(uint8_t slot_index) {
invalidate_slot(slot);
slot.gamepad = {};
slot.extra_buttons = 0;
#if SWITCH2_BRIDGE_FULL_INPUT
refresh_native_source_locked();
#endif
critical_section_exit(&g_state_lock);
// The setter can synchronously re-enter the platform ready callback, so
@ -3020,8 +3096,8 @@ 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{};
#if SWITCH2_BRIDGE_FULL_INPUT
NativeGamepadCueDispatch native_dispatch{};
#endif
critical_section_enter_blocking(&g_state_lock);
@ -3157,18 +3233,18 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
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(
#if SWITCH2_BRIDGE_FULL_INPUT
const bool native_owns_rumble = prepare_native_cues(
slot_index, now_ms, local_feedback_active,
profile_rumble_dispatch || feedback_dispatch, &dualsense_dispatch);
profile_rumble_dispatch || feedback_dispatch, &native_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
#if SWITCH2_BRIDGE_FULL_INPUT
&& !native_owns_rumble
#endif
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
&& !(xinput_host_mode &&
@ -3246,8 +3322,8 @@ 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);
#if SWITCH2_BRIDGE_FULL_INPUT
dispatch_native_cues(native_dispatch);
#endif
}
@ -3459,6 +3535,9 @@ bool merge_joycon_slots(int owner_index, int joining_index,
{owner.companion, static_cast<uint8_t>(owner_index)};
g_joycon_pair_hints[physical_index_for_device(owner.companion)] =
{owner.device, static_cast<uint8_t>(owner_index)};
#if SWITCH2_BRIDGE_FULL_INPUT
refresh_native_source_locked();
#endif
critical_section_exit(&g_state_lock);
apply_slot_lighting(static_cast<uint8_t>(owner_index), owner.device);
apply_slot_lighting(static_cast<uint8_t>(owner_index), owner.companion);
@ -3529,6 +3608,9 @@ bool split_joycon_slot(uint8_t owner_index, bool gesture = false) {
kProfileFeedbackWeakMagnitude,
kProfileFeedbackStrongMagnitude);
}
#if SWITCH2_BRIDGE_FULL_INPUT
refresh_native_source_locked();
#endif
critical_section_exit(&g_state_lock);
apply_slot_lighting(owner_index, owner.device);
apply_slot_lighting(static_cast<uint8_t>(right_index), right.device);
@ -3737,7 +3819,7 @@ void platform_on_device_connected(uni_hid_device_t* device) {
uni_hid_device_disconnect(device);
return;
}
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
#if SWITCH2_BRIDGE_FULL_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);
@ -3748,7 +3830,11 @@ void platform_on_device_connected(uni_hid_device_t* device) {
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;
if (slot_for_device(device) < 0) {
g_native_reports[pending_index] = {};
reset_joycon_connection(device);
}
if (slot_for_device(device) < 0) g_native_pending_devices[pending_index] = device;
critical_section_exit(&g_state_lock);
recompute_connection_status();
#else
@ -3780,23 +3866,30 @@ void platform_on_device_connected(uni_hid_device_t* device) {
}
void platform_on_device_disconnected(uni_hid_device_t* device) {
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
#if SWITCH2_BRIDGE_FULL_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;
if (g_native_pending_devices[pending_index] == device)
g_native_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
#if SWITCH2_BRIDGE_FULL_INPUT
recompute_connection_status();
#endif
return;
}
#if SWITCH2_BRIDGE_FULL_INPUT
// DS4/PSMove and other finite-rumble drivers keep timers in parser_data.
// Retire those timers before Bluepad32 reuses that memory. Call the real
// driver directly: a feedback scheduler must not defer this local teardown.
if (device->report_parser.play_dual_rumble != nullptr)
device->report_parser.play_dual_rumble(device, 0, 0, 0, 0);
#endif
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
switch_native_output_detach(device);
#endif
@ -3826,8 +3919,8 @@ void platform_on_device_disconnected(uni_hid_device_t* device) {
} else {
release_slot(slot);
}
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
refresh_dualsense_source_locked();
#if SWITCH2_BRIDGE_FULL_INPUT
refresh_native_source_locked();
#endif
critical_section_exit(&g_state_lock);
clear_ble_identity_for_device(device);
@ -3863,8 +3956,8 @@ 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)) {
#if SWITCH2_BRIDGE_FULL_INPUT
if (!native_device_allowed(device)) {
critical_section_exit(&g_state_lock);
return UNI_ERROR_INVALID_CONTROLLER;
}
@ -3874,10 +3967,10 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
critical_section_exit(&g_state_lock);
return UNI_ERROR_NO_SLOTS;
}
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
#if SWITCH2_BRIDGE_FULL_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;
if (g_native_pending_devices[pending_index] == device)
g_native_pending_devices[pending_index] = nullptr;
#endif
BackendSlot& pending = g_slots[slot_index];
if (!pending.active) {
@ -3901,8 +3994,8 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
}
became_active = true;
}
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
refresh_dualsense_source_locked();
#if SWITCH2_BRIDGE_FULL_INPUT
refresh_native_source_locked();
#endif
const BackendSlot& current = g_slots[slot_index];
owner = current.device;
@ -3968,28 +4061,45 @@ 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;
#if SWITCH2_BRIDGE_FULL_INPUT
const int physical_index = physical_index_for_device(device);
if (physical_index < 0) {
critical_section_exit(&g_state_lock);
return;
}
uni_native_motion_snapshot_t sensor{};
uni_hid_parser_native_motion_snapshot(device, &sensor);
NativeGamepadReportIngress& ingress = g_native_reports[physical_index];
if (ingress.device != device) ingress = {device, 0, 0, 0};
if (sensor.report_tracked &&
(!sensor.report_valid || sensor.report_sequence == ingress.report_sequence)) {
critical_section_exit(&g_state_lock);
return;
}
ingress.report_sequence = sensor.report_sequence;
NativeGamepadIngress& motion = slot.native_motion;
motion.has_report = true;
motion.received_us = time_us_32();
// Read only the reporting physical device. The right half is the existing
// pair's motion owner; left controls must not refresh or invalidate its IMU.
if (slot.companion == nullptr || slot.companion == device) {
if (!sensor.accel_valid) motion.accel_valid = false;
else if (sensor.accel_sequence != ingress.accel_sequence) {
motion.accel_valid = true;
motion.accel_sequence = sensor.accel_sequence;
motion.accel_received_us = motion.received_us;
memcpy(motion.accel_q13, sensor.accel_q13, sizeof(motion.accel_q13));
}
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));
if (!sensor.gyro_valid) motion.gyro_valid = false;
else if (sensor.gyro_sequence != ingress.gyro_sequence) {
motion.gyro_valid = true;
motion.gyro_sequence = sensor.gyro_sequence;
motion.gyro_received_us = motion.received_us;
memcpy(motion.gyro_q10, sensor.gyro_q10, sizeof(motion.gyro_q10));
}
}
if (sensor.accel_valid) ingress.accel_sequence = sensor.accel_sequence;
if (sensor.gyro_valid) ingress.gyro_sequence = sensor.gyro_sequence;
#endif
#ifdef SWITCH_PICO_WII_IR
uni_wii_ir_snapshot_t infrared{};
@ -4323,8 +4433,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;
#if SWITCH2_BRIDGE_FULL_INPUT
g_native_pending_devices[slot_index] = nullptr;
g_native_reports[slot_index] = {};
#endif
}
g_joycon_mode = JoyConMode::kPaired;
@ -4531,42 +4642,42 @@ 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 SWITCH2_BRIDGE_FULL_INPUT
void bluepad32_input_backend_select_native_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);
g_native_explicit_address = address != nullptr;
if (address != nullptr) memcpy(g_native_address, address, 6);
else memset(g_native_address, 0, sizeof(g_native_address));
refresh_native_source_locked(true);
critical_section_exit(&g_state_lock);
}
void bluepad32_input_backend_dualsense_snapshot(Bluepad32DualSenseBridgeSnapshot* output) {
void bluepad32_input_backend_native_snapshot(Bluepad32NativeGamepadSnapshot* output) {
if (output == nullptr) return;
*output = {};
if (!g_initialized) return;
critical_section_enter_blocking(&g_state_lock);
*output = g_dualsense_snapshot;
*output = g_native_snapshot;
critical_section_exit(&g_state_lock);
}
bool bluepad32_input_backend_dualsense_sample_request(
bool bluepad32_input_backend_native_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 &&
NativeGamepadCue& cue = g_native_cues[instance];
const uint8_t index = g_native_slot;
const bool accepted = index < kSlotCount && g_next_native_token != 0 &&
native_rumble_capable(g_slots[index], instance) &&
!cue.in_flight && (sample_id == 0 || (cue.result != 0 && !cue.active));
if (accepted) {
cue = {};
cue.token = g_next_dualsense_token++;
cue.token = g_next_native_token++;
cue.slot = index;
cue.connection_generation = g_dualsense_generation;
cue.connection_generation = g_native_generation;
cue.requested_ms = btstack_run_loop_get_time_ms();
cue.sample_id = sample_id;
cue.result = 0;
@ -4576,16 +4687,16 @@ bool bluepad32_input_backend_dualsense_sample_request(
return accepted;
}
int bluepad32_input_backend_dualsense_sample_result(uint8_t instance, uint64_t token) {
int bluepad32_input_backend_native_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];
NativeGamepadCue& cue = g_native_cues[instance];
int result = -1;
if (cue.token == token && !cue.consumed) {
if (!dualsense_cue_current(cue) ||
if (!native_cue_current(cue) ||
(cue.result == 0 &&
btstack_run_loop_get_time_ms() - cue.requested_ms >= kDualSenseCueDeadlineMs))
cancel_dualsense_cue_locked(cue);
btstack_run_loop_get_time_ms() - cue.requested_ms >= kNativeGamepadCueDeadlineMs))
cancel_native_cue_locked(cue);
result = cue.result;
if (result != 0) cue.consumed = true;
}
@ -4593,10 +4704,10 @@ int bluepad32_input_backend_dualsense_sample_result(uint8_t instance, uint64_t t
return result;
}
void bluepad32_input_backend_dualsense_sample_cancel(uint8_t instance) {
void bluepad32_input_backend_native_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]);
cancel_native_cue_locked(g_native_cues[instance]);
critical_section_exit(&g_state_lock);
}
#endif

View file

@ -108,34 +108,39 @@ 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 {
#if SWITCH2_BRIDGE_FULL_INPUT
// One logical gamepad, calibrated SDL axes before legacy int16 conversion.
// Sensor receipt times advance independently, only on actual parser ingress.
struct Bluepad32NativeGamepadSnapshot {
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;
bool accel_valid = false;
bool gyro_valid = false;
bool requires_stationary_bias = false;
uint32_t accel_sequence = 0;
uint32_t gyro_sequence = 0;
uint32_t accel_received_us = 0;
uint32_t gyro_received_us = 0;
int32_t accel_q13[3]{};
int32_t gyro_q10[3]{};
};
// nullptr selects the uniquely eligible ready DualSense; ambiguity fails closed.
// nullptr selects the uniquely eligible ready logical gamepad; 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.
void bluepad32_input_backend_select_native_source(const uint8_t address[6]);
void bluepad32_input_backend_native_snapshot(Bluepad32NativeGamepadSnapshot* output);
// Instance 0 is R, 1 is L. Samples 0..7 are bounded compatibility cues, not HD
// haptics. A side stop removes only that side's contribution. Mono drivers combine
// both contributions on their one actuator; this does not promise stereo output.
// 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(
bool bluepad32_input_backend_native_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);
int bluepad32_input_backend_native_sample_result(uint8_t instance, uint64_t token);
void bluepad32_input_backend_native_sample_cancel(uint8_t instance);
#endif
// Side-effect-free raw input snapshot for management telemetry. Unlike the

View file

@ -3,12 +3,5 @@
#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

@ -85,8 +85,12 @@ enum {
typedef enum {
CONTROLLER_TYPE_UnknownController = 0,
CONTROLLER_TYPE_XBoxOneController = 32,
CONTROLLER_TYPE_PS4Controller = 34,
CONTROLLER_TYPE_WiiController = 35,
CONTROLLER_TYPE_PS5Controller = 46,
CONTROLLER_TYPE_SwitchProController = 38,
CONTROLLER_TYPE_PS5Controller = 45,
CONTROLLER_TYPE_PSMoveController = 56,
} uni_controller_type_t;
typedef enum {

View file

@ -1,291 +0,0 @@
// 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

@ -5,6 +5,7 @@
#include <string.h>
#include "parser/uni_hid_parser_ds5.h"
#include "parser/uni_hid_parser_native_motion.h"
#include "uni_hid_device.h"
#include "uni_utils.h"
@ -59,6 +60,14 @@ uni_hid_device_t* uni_hid_device_create_virtual(uni_hid_device_t* d) { (void)d;
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; }
// The common accessor must not select a different family in this fixture.
void uni_hid_parser_wii_setup(uni_hid_device_t* d) { (void)d; assert(false); }
bool uni_hid_parser_wii_accel_snapshot(uni_hid_device_t* d, int32_t v[3], uint32_t* seq) {
(void)d; (void)v; (void)seq; assert(false); return false;
}
bool uni_hid_parser_wii_gyro_snapshot(uni_hid_device_t* d, int32_t v[3], uint32_t* seq) {
(void)d; (void)v; (void)seq; assert(false); return false;
}
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,
@ -144,11 +153,12 @@ static void input(uint8_t bytes[78], uint32_t timestamp) {
put32(bytes + 29, timestamp);
seal(bytes, 78, 0xa1);
}
static uni_ds5_bridge_snapshot_t feed(uint8_t* bytes, uint16_t len, bool admitted) {
static uni_native_motion_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);
uni_native_motion_snapshot_t snapshot;
assert(uni_hid_parser_native_motion_snapshot(&device, &snapshot));
assert(snapshot.report_tracked && snapshot.report_valid == admitted);
return snapshot;
}
@ -178,27 +188,32 @@ int main(void) {
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);
uni_native_motion_snapshot_t snapshot = feed(report, sizeof(report), true);
assert(!snapshot.accel_valid && !snapshot.gyro_valid);
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.
assert(!snapshot.accel_valid && !snapshot.gyro_valid); // Calibration alone is not fresh motion.
input(report, 101);
snapshot = feed(report, sizeof(report), true);
assert(snapshot.motion_valid && snapshot.motion_sequence == 1);
assert(snapshot.accel_valid && snapshot.gyro_valid);
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);
uni_native_motion_snapshot_t common;
assert(uni_hid_parser_native_motion_snapshot(&device, &common));
assert(common.accel_valid && common.gyro_valid &&
common.accel_q13[1] == 8192 && common.gyro_q10[0] == 1024);
const uint32_t common_sequence = common.accel_sequence;
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);
uni_native_motion_snapshot_t polled;
assert(uni_hid_parser_native_motion_snapshot(&device, &polled) && polled.report_sequence == report_sequence);
snapshot = feed(report, sizeof(report), true);
assert(!snapshot.motion_valid && snapshot.motion_sequence == 1);
assert(snapshot.accel_sequence == common_sequence && !snapshot.accel_valid && !snapshot.gyro_valid);
input(report, 99);
snapshot = feed(report, sizeof(report), true);
assert(!snapshot.motion_valid && snapshot.motion_sequence == 1);
assert(snapshot.accel_sequence == common_sequence && !snapshot.accel_valid && !snapshot.gyro_valid);
input(report, 102);
feed(report, 77, false);
report[9] ^= 0x20;
@ -206,7 +221,7 @@ int main(void) {
feed(NULL, 0, false);
input(report, 102);
snapshot = feed(report, sizeof(report), true);
assert(snapshot.motion_valid && snapshot.motion_sequence == 2);
assert(snapshot.accel_valid && snapshot.gyro_valid && snapshot.accel_sequence != common_sequence);
// A real uint32 sensor-clock wrap is forward progress, not a duplicate.
uni_hid_parser_ds5_setup(&device);
@ -214,10 +229,12 @@ int main(void) {
feature(calib, sizeof(calib));
input(report, UINT32_MAX - 15);
snapshot = feed(report, sizeof(report), true);
assert(snapshot.motion_valid && snapshot.motion_sequence == 1);
assert(snapshot.accel_valid && snapshot.gyro_valid);
assert(uni_hid_parser_native_motion_snapshot(&device, &common));
assert(common.accel_valid && common.accel_sequence != common_sequence);
input(report, 16);
snapshot = feed(report, sizeof(report), true);
assert(snapshot.motion_valid && snapshot.motion_sequence == 2);
assert(snapshot.accel_valid && snapshot.gyro_valid && snapshot.accel_sequence != common.accel_sequence);
const unsigned before_busy = output_count;
transport_available = false;
@ -236,7 +253,9 @@ int main(void) {
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));
assert(output_count == sent);
assert(!uni_hid_parser_native_motion_snapshot(&device, &common));
assert(!common.report_valid && !common.accel_valid && !common.gyro_valid);
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);

View file

@ -0,0 +1,641 @@
// Reuse the backend's transport/storage fixture; these scenarios exercise only
// the native gamepad 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_native_motion_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_tracked = true;
sensor.metadata.report_valid = true;
++sensor.metadata.report_sequence;
if (fresh_motion) {
++sensor.metadata.accel_sequence;
++sensor.metadata.gyro_sequence;
}
sensor.metadata.accel_valid = fresh_motion;
sensor.metadata.gyro_valid = fresh_motion;
sensor.metadata.accel_q13[1] = 8193;
sensor.metadata.gyro_q10[2] = -123456;
pad.controller.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
pad.controller.gamepad.buttons = BUTTON_A | BUTTON_SHOULDER_L;
// Parser init_report may clear legacy fields; native precision comes from
// the independent sensor provider rather than cached controller values.
pad.controller.gamepad.accel[1] = 0;
pad.controller.gamepad.gyro[2] = 0;
pad.controller.battery = 176;
platform_on_controller_data(&pad, &pad.controller);
}
Bluepad32NativeGamepadSnapshot bridge_snapshot() {
Bluepad32NativeGamepadSnapshot result{};
bluepad32_input_backend_native_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;
#if !SWITCH2_BRIDGE_DUALSENSE_INPUT
ordinary.gamepad = false;
#endif
require(platform_on_device_ready(&ordinary) == UNI_ERROR_INVALID_CONTROLLER,
"an ineligible controller must not enter dedicated output slots");
bluepad32_input_backend_select_native_source(ordinary.conn.btaddr);
require(!bridge_snapshot().controller.active, "an ineligible device cannot become the native source");
platform_on_device_disconnected(&ordinary);
bluepad32_input_backend_select_native_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.accel_valid && initial.gyro_valid &&
!initial.requires_stationary_bias && initial.accel_q13[1] == 8193 &&
initial.gyro_q10[2] == -123456 && initial.gyro_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.accel_sequence == initial.accel_sequence &&
snapshot.gyro_sequence == initial.gyro_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.gyro_received_us == 100000 &&
!snapshot.accel_valid && !snapshot.gyro_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_native_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_native_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_native_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);
#if !SWITCH2_BRIDGE_DUALSENSE_INPUT
unrelated.gamepad = false;
#endif
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_native_sample_request(0, 6, &right) &&
bluepad32_input_backend_native_sample_request(1, 1, &left) && right != left &&
bluepad32_input_backend_native_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_native_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_native_sample_result(0, right) == 1 &&
bluepad32_input_backend_native_sample_result(1, right) == -1 &&
bluepad32_input_backend_native_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_native_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_native_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_native_sample_result(1, stop) == 1 &&
bluepad32_input_backend_native_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_native_sample_request(0, 1, &right), "pending timeout cue must queue");
now_ms += 2000;
process_rumble_timer(&g_rumble_timer);
require(bluepad32_input_backend_native_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_native_sample_request(0, 1, &token), "race cue must queue");
during_dualsense_dispatch = [] { bluepad32_input_backend_native_sample_cancel(0); };
process_rumble_timer(&g_rumble_timer);
during_dualsense_dispatch = nullptr;
require(bluepad32_input_backend_native_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_native_sample_request(1, 1, &token), "reselection race must queue");
during_dualsense_dispatch = [] { bluepad32_input_backend_select_native_source(nullptr); };
process_rumble_timer(&g_rumble_timer);
during_dualsense_dispatch = nullptr;
require(bluepad32_input_backend_native_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_native_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_native_sample_result(0, token) == -1 && replacement.rumble_calls == 0,
"old tokens and deferred stops must never enter a replacement connection");
}
void report_gamepad(uni_hid_device_t& pad) {
pad.controller.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
platform_on_controller_data(&pad, &pad.controller);
}
SensorFixture& motion_fixture(uni_hid_device_t& pad, bool tracked = true) {
SensorFixture& fixture = sensors[pad.idx];
fixture = {};
fixture.device = &pad;
fixture.valid = true;
fixture.metadata.report_tracked = tracked;
fixture.metadata.report_valid = true;
fixture.metadata.report_sequence = 1;
fixture.metadata.accel_sequence = 1;
fixture.metadata.gyro_sequence = 1;
fixture.metadata.accel_valid = true;
fixture.metadata.gyro_valid = true;
fixture.metadata.accel_q13[1] = 8193;
fixture.metadata.gyro_q10[2] = -123456;
return fixture;
}
void sensorless_admission() {
start_pairing_backend();
auto xbox = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
xbox.controller_type = CONTROLLER_TYPE_XBoxOneController;
xbox.report_parser.play_dual_rumble = nullptr;
platform_on_device_connected(&xbox);
require(platform_on_device_ready(&xbox) == UNI_ERROR_SUCCESS,
"normal gamepad admission must not require a motion parser");
xbox.controller.gamepad.buttons = BUTTON_A;
xbox.controller.gamepad.accel[1] = 8192;
xbox.controller.gamepad.gyro[0] = 65536;
now_ms = 100;
report_gamepad(xbox);
const auto initial = bridge_snapshot();
uint64_t cue = 99;
require(initial.controller.active && initial.slot == 0 &&
initial.controller.state.button_south && !initial.accel_valid &&
!initial.gyro_valid && !initial.requires_stationary_bias &&
initial.accel_sequence == 0 && initial.gyro_sequence == 0 &&
!bluepad32_input_backend_native_sample_request(0, 1, &cue) && cue == 0,
"sensorless controls remain live without invented IMU or rumble capability");
auto generic = device(0, true, UNI_BT_CONN_PROTOCOL_BLE);
platform_on_device_connected(&generic);
require(platform_on_device_ready(&generic) == UNI_ERROR_SUCCESS,
"unknown-family normal AIO gamepads must not face a native brand whitelist");
report_gamepad(generic);
require(!bridge_snapshot().controller.active, "two logical gamepads are ambiguous");
bluepad32_input_backend_select_native_source(xbox.conn.btaddr);
now_ms = 110;
report_gamepad(xbox);
require(bridge_snapshot().controller.active && bridge_snapshot().slot == 0 &&
controller_identity_equal(bridge_snapshot().controller.identity, initial.controller.identity),
"explicit sensorless selection retains its real identity and logical slot");
platform_on_device_disconnected(&xbox);
report_gamepad(generic);
require(!bridge_snapshot().controller.active, "explicit selection cannot migrate on disconnect");
bluepad32_input_backend_select_native_source(nullptr);
generic.controller.gamepad.buttons = BUTTON_B;
report_gamepad(generic);
require(bridge_snapshot().controller.active && bridge_snapshot().controller.state.button_east &&
!bridge_snapshot().controller.state.button_south,
"unique sensorless reselection must publish only the new source's controls");
}
void independent_motion() {
start_pairing_backend();
auto ds4 = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
ds4.controller_type = CONTROLLER_TYPE_PS4Controller;
require(platform_on_device_ready(&ds4) == UNI_ERROR_SUCCESS, "DS4 controls must be admitted");
auto& ds = motion_fixture(ds4).metadata;
ds4.controller.gamepad.buttons = BUTTON_A;
now_ms = 100;
report_gamepad(ds4);
const auto first = bridge_snapshot();
require(first.accel_valid && first.gyro_valid && !first.requires_stationary_bias &&
first.accel_q13[1] == 8193 && first.gyro_q10[2] == -123456,
"calibrated DS4 motion keeps raw precision without Wii settling");
now_ms = 110;
++ds.report_sequence;
++ds.accel_sequence;
report_gamepad(ds4);
require(bridge_snapshot().accel_received_us == 110000 &&
bridge_snapshot().gyro_received_us == 100000 && bridge_snapshot().gyro_valid,
"acceleration ingress must not refresh an unchanged gyro");
now_ms = 120;
ds.report_valid = false;
ds4.controller.gamepad.buttons = 0;
report_gamepad(ds4);
require(bridge_snapshot().controller.state.button_south &&
bridge_snapshot().received_us == 110000,
"a tracked partial/malformed report cannot publish phantom releases");
ds.report_valid = true;
++ds.report_sequence;
ds.gyro_valid = false;
report_gamepad(ds4);
require(!bridge_snapshot().gyro_valid && bridge_snapshot().accel_valid,
"gyro capability loss must not suppress working acceleration or controls");
bluepad32_input_backend_select_native_source(nullptr);
++ds.report_sequence;
ds.gyro_valid = true;
report_gamepad(ds4);
require(bridge_snapshot().controller.active && !bridge_snapshot().accel_valid &&
!bridge_snapshot().gyro_valid,
"reselection cannot rehabilitate cached sensor sequences");
++ds.report_sequence;
++ds.gyro_sequence;
report_gamepad(ds4);
require(bridge_snapshot().gyro_valid && !bridge_snapshot().accel_valid,
"each sensor must earn freshness separately in a new source epoch");
platform_on_device_disconnected(&ds4);
auto pro = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
pro.controller_type = CONTROLLER_TYPE_SwitchProController;
require(platform_on_device_ready(&pro) == UNI_ERROR_SUCCESS, "Switch Pro controls must be admitted");
auto& sw = motion_fixture(pro).metadata;
sw.accel_valid = sw.gyro_valid = false;
pro.controller.gamepad.buttons = BUTTON_B;
report_gamepad(pro);
require(bridge_snapshot().controller.state.button_east && !bridge_snapshot().gyro_valid,
"Switch controls must not wait for motion calibration");
++sw.report_sequence;
++sw.accel_sequence;
++sw.gyro_sequence;
sw.accel_valid = sw.gyro_valid = true;
report_gamepad(pro);
require(bridge_snapshot().accel_valid && bridge_snapshot().gyro_valid &&
!bridge_snapshot().requires_stationary_bias,
"validated Switch motion starts without Wii stationary settling");
platform_on_device_disconnected(&pro);
auto remote = wii_device(2);
require(platform_on_device_ready(&remote) == UNI_ERROR_SUCCESS, "Wii controls must be admitted");
auto& wii = motion_fixture(remote, false).metadata;
wii.gyro_valid = false;
remote.controller.gamepad.buttons = BUTTON_A;
now_ms = 200;
report_gamepad(remote);
require(bridge_snapshot().controller.state.button_south && bridge_snapshot().accel_valid &&
!bridge_snapshot().gyro_valid && bridge_snapshot().requires_stationary_bias,
"Wii without MotionPlus remains acceleration-capable, not a fabricated full IMU");
now_ms = 210;
++wii.gyro_sequence;
wii.gyro_valid = true;
report_gamepad(remote);
require(bridge_snapshot().gyro_valid && bridge_snapshot().gyro_received_us == 210000 &&
bridge_snapshot().accel_received_us == 200000 &&
bridge_snapshot().requires_stationary_bias,
"Wii MotionPlus ingress must retain independent acceleration age and Wii bias policy");
}
void paired_source() {
start_pairing_backend();
auto left = switch2_device(2, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
ready_switch2(left);
ready_switch2(right);
auto& r = motion_fixture(right).metadata;
auto& l = motion_fixture(left).metadata;
right.controller.gamepad.buttons = BUTTON_A;
left.controller.gamepad.dpad = DPAD_LEFT;
now_ms = 100;
report_gamepad(right);
const auto initial = bridge_snapshot();
require_pair_owner(initial.controller.identity, left, right);
require(initial.controller.active && initial.slot == 0 && initial.gyro_valid &&
!slot_snapshot(1).active && !slot_snapshot(2).active,
"an existing Joy-Con pair is one logical native source, not auto ambiguity");
now_ms = 110;
l.gyro_q10[2] = 654321;
report_gamepad(left);
require(bridge_snapshot().controller.state.button_south &&
bridge_snapshot().controller.state.dpad_left &&
bridge_snapshot().received_us == 110000 &&
bridge_snapshot().gyro_received_us == 100000 &&
bridge_snapshot().gyro_q10[2] == initial.gyro_q10[2],
"left controls merge without refreshing or replacing the right motion owner");
bluepad32_input_backend_select_native_source(right.conn.btaddr);
++l.report_sequence;
report_gamepad(left);
require(bridge_snapshot().controller.active && !bridge_snapshot().gyro_valid,
"either member address selects the logical pair, not that member's cached IMU");
++r.report_sequence;
report_gamepad(right);
require(!bridge_snapshot().gyro_valid, "reselection cannot freshen the right parser cache");
++r.report_sequence;
++r.accel_sequence;
++r.gyro_sequence;
report_gamepad(right);
require(bridge_snapshot().gyro_valid, "new right samples restore paired motion");
uint64_t rc, lc, stop;
require(bluepad32_input_backend_native_sample_request(0, 6, &rc) &&
bluepad32_input_backend_native_sample_request(1, 1, &lc),
"both real halves expose their own driver capability");
process_rumble_timer(&g_rumble_timer);
require(right.last_high == 96 && right.last_low == 96 &&
left.last_high == 160 && left.last_low == 160 &&
bluepad32_input_backend_native_sample_result(0, rc) == 1 &&
bluepad32_input_backend_native_sample_result(1, lc) == 1,
"paired cues must dispatch only each half's contribution to its real driver");
now_ms = 120;
require(bluepad32_input_backend_native_sample_request(0, 0, &stop), "right stop must queue");
process_rumble_timer(&g_rumble_timer);
require(right.last_rumble_duration_ms == 0 && left.last_rumble_duration_ms == 990 &&
bluepad32_input_backend_native_sample_result(0, stop) == 1,
"stopping one paired side preserves the other side's original finite deadline");
bluepad32_input_backend_select_native_source(nullptr);
set_runtime_joycon_mode(JoyConMode::kIndividual);
require(!bridge_snapshot().controller.active &&
bluepad32_input_backend_native_sample_result(1, lc) == -1,
"live split retires the pair immediately and fails auto selection closed");
bluepad32_input_backend_select_native_source(right.conn.btaddr);
++r.report_sequence;
++r.accel_sequence;
++r.gyro_sequence;
report_gamepad(right);
require(bridge_snapshot().controller.active &&
controller_identity_equal(bridge_snapshot().controller.identity, identity_for_device(&right)),
"explicit selection after a split retains the real surviving member identity");
}
void pair_cue_races() {
start_pairing_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
ready_switch2(left);
ready_switch2(right);
left.report_parser.play_dual_rumble = nullptr;
uint64_t rc, lc;
require(!bluepad32_input_backend_native_sample_request(1, 1, &lc) && lc == 0,
"a paired half without a rumble driver cannot borrow its sibling's capability");
left.report_parser.play_dual_rumble = play_rumble;
right.report_parser.play_dual_rumble = [](
uni_hid_device_t* pad, uint16_t delay, uint16_t duration, uint8_t weak, uint8_t strong) {
require(state_lock_depth == 0, "paired driver dispatch must not hold the shared lock");
play_rumble(pad, delay, duration, weak, strong);
bluepad32_input_backend_native_sample_cancel(1);
};
require(bluepad32_input_backend_native_sample_request(0, 1, &rc) &&
bluepad32_input_backend_native_sample_request(1, 1, &lc), "paired race cues must queue");
const int before = left.rumble_calls;
process_rumble_timer(&g_rumble_timer);
require(left.rumble_calls == before && right.last_rumble_duration_ms == 1000 &&
bluepad32_input_backend_native_sample_result(0, rc) == 1 &&
bluepad32_input_backend_native_sample_result(1, lc) == -1,
"canceling L during R dispatch must not submit stale L or retire the accepted R cue");
bluepad32_input_backend_native_sample_cancel(0);
process_rumble_timer(&g_rumble_timer);
const int before_stall = left.rumble_calls;
require(right.last_rumble_duration_ms == 0, "partial pair submission retains a real stop obligation");
right.report_parser.play_dual_rumble = [](
uni_hid_device_t* pad, uint16_t delay, uint16_t duration, uint8_t weak, uint8_t strong) {
play_rumble(pad, delay, duration, weak, strong);
now_ms += 2000;
bluepad32_input_backend_select_native_source(nullptr);
};
require(bluepad32_input_backend_native_sample_request(0, 1, &rc) &&
bluepad32_input_backend_native_sample_request(1, 1, &lc), "reselection race cues must queue");
process_rumble_timer(&g_rumble_timer);
right.report_parser.play_dual_rumble = play_rumble;
require(left.rumble_calls == before_stall &&
bluepad32_input_backend_native_sample_result(0, rc) == -1 &&
bluepad32_input_backend_native_sample_result(1, lc) == -1,
"reselection and a dispatch stall cannot submit the old epoch's remaining half");
process_rumble_timer(&g_rumble_timer);
require(right.last_rumble_duration_ms == 0, "reselection cannot orphan a partial pair output");
}
uint8_t mono_magnitude = 0;
void observe_mono_rumble(uni_hid_device_t* pad, uint16_t delay, uint16_t duration,
uint8_t weak, uint8_t strong) {
require(state_lock_depth == 0 && delay == 0, "mono driver dispatch is immediate and outside the lock");
mono_magnitude = duration == 0 ? 0 : (weak > strong ? weak : strong);
play_rumble(pad, delay, duration, mono_magnitude, 0);
}
void mono_rumble() {
start_pairing_backend();
auto move = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
move.controller_type = CONTROLLER_TYPE_PSMoveController;
move.report_parser.play_dual_rumble = observe_mono_rumble;
require(platform_on_device_ready(&move) == UNI_ERROR_SUCCESS, "mono-rumble gamepad must connect");
uint64_t right, left;
require(bluepad32_input_backend_native_sample_request(0, 1, &right) &&
bluepad32_input_backend_native_sample_request(1, 7, &left),
"mono devices accept independent logical contributions, not separate actuators");
process_rumble_timer(&g_rumble_timer);
require(mono_magnitude == 220 && move.last_rumble_duration_ms == 120,
"one mono actuator combines both contributions with the shortest finite boundary");
bluepad32_input_backend_native_sample_cancel(1);
now_ms = 40;
process_rumble_timer(&g_rumble_timer);
require(mono_magnitude == 160 && move.last_rumble_duration_ms == 960 &&
bluepad32_input_backend_native_sample_result(1, left) == -1,
"canceling one mono contribution must preserve the other's remaining pulse");
now_ms = 1000;
process_rumble_timer(&g_rumble_timer);
require(mono_magnitude == 0, "the last mono contribution must stop at its original deadline");
require(bluepad32_input_backend_native_sample_request(0, 1, &right), "new mono cue must queue");
const int calls = move.rumble_calls;
now_ms += 2000;
process_rumble_timer(&g_rumble_timer);
require(move.rumble_calls == calls && bluepad32_input_backend_native_sample_result(0, right) == -1,
"expired undispatched mono work cannot replay after a timer stall");
require(bluepad32_input_backend_native_sample_request(1, 1, &left), "disconnect cue must queue");
process_rumble_timer(&g_rumble_timer);
require(mono_magnitude == 160, "disconnect regression must own a real active driver pulse");
platform_on_device_disconnected(&move);
require(mono_magnitude == 0 && move.last_rumble_duration_ms == 0,
"disconnect must retire the source driver's timer before parser memory reuse");
auto replacement = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
replacement.report_parser.play_dual_rumble = observe_mono_rumble;
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS, "new mono connection must succeed");
process_rumble_timer(&g_rumble_timer);
require(replacement.rumble_calls == 0 &&
bluepad32_input_backend_native_sample_result(1, left) == -1,
"retired mono work 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_native_motion_snapshot(
uni_hid_device_t* pad, uni_native_motion_snapshot_t* out) {
*out = {};
for (const auto& fixture : sensors) {
if (fixture.device != pad) continue;
*out = fixture.metadata;
if (!fixture.valid) out->report_valid = false;
return fixture.valid;
}
// DS5 must be guarded even before its first good full report.
out->report_tracked =
pad->report_parser.parse_input_report == uni_hid_parser_ds5_parse_input_report;
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 if (scenario == "sensorless-admission") sensorless_admission();
else if (scenario == "independent-motion") independent_motion();
else if (scenario == "paired-source") paired_source();
else if (scenario == "pair-cue-races") pair_cue_races();
else if (scenario == "mono-rumble") mono_rumble();
else require(false, "unknown native gamepad scenario");
return 0;
}

View file

@ -0,0 +1,264 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "parser/uni_hid_parser_ds4.h"
#include "parser/uni_hid_parser_psmove.h"
#include "parser/uni_hid_parser_switch.h"
#include "parser/uni_hid_parser_native_motion.h"
#include "uni_hid_device.h"
#include "uni_utils.h"
// Real staged parsers and normalization. Only radio, clock and platform edges
// are substituted; fixtures enter public setup/feature/input APIs.
static uint8_t command[128];
static unsigned command_len, ready_count;
void uni_log(const char* fmt, ...) { (void)fmt; }
void printf_hexdump(const void* data, int len) { (void)data; (void)len; }
void uni_hid_device_send_ctrl_report(uni_hid_device_t* d, const uint8_t* bytes, uint16_t len) {
(void)d; assert(len <= sizeof(command)); memcpy(command, bytes, len); command_len = len;
}
void uni_hid_device_send_intr_report(uni_hid_device_t* d, const uint8_t* bytes, uint16_t len) {
uni_hid_device_send_ctrl_report(d, bytes, len);
}
int l2cap_can_send_packet_now(uint16_t cid) { (void)cid; return 1; }
int l2cap_send(uint16_t cid, uint8_t* bytes, uint16_t len) {
(void)cid; uni_hid_device_send_intr_report(NULL, bytes, len); return ERROR_CODE_SUCCESS;
}
uint8_t l2cap_request_can_send_now_event(uint16_t cid) { (void)cid; return 0; }
uint32_t btstack_run_loop_get_time_ms(void) { return 0; }
void btstack_run_loop_set_timer(btstack_timer_source_t* t, uint32_t ms) { (void)t; (void)ms; }
void btstack_run_loop_add_timer(btstack_timer_source_t* t) { (void)t; }
bool btstack_run_loop_remove_timer(btstack_timer_source_t* t) { (void)t; return false; }
void btstack_run_loop_set_timer_context(btstack_timer_source_t* t, void* p) { t->context = p; }
void btstack_run_loop_set_timer_handler(btstack_timer_source_t* t, void (*fn)(btstack_timer_source_t*)) { t->process = fn; }
void* btstack_run_loop_get_timer_context(btstack_timer_source_t* t) { return t->context; }
bool uni_hid_device_set_ready_complete(uni_hid_device_t* d) { (void)d; ++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; }
void uni_hid_device_set_product_id(uni_hid_device_t* d, uint16_t pid) { d->product_id = pid; }
void uni_hid_device_set_vendor_id(uni_hid_device_t* d, uint16_t vid) { d->vendor_id = vid; }
uint8_t uni_hid_parser_hat_to_dpad(uint8_t hat) { return hat == 0 ? DPAD_UP : 0; }
void uni_hid_parser_wii_setup(uni_hid_device_t* d) { (void)d; assert(false); }
bool uni_hid_parser_wii_accel_snapshot(uni_hid_device_t* d, int32_t v[3], uint32_t* s) {
(void)d; (void)v; (void)s; assert(false); return false;
}
bool uni_hid_parser_wii_gyro_snapshot(uni_hid_device_t* d, int32_t v[3], uint32_t* s) {
(void)d; (void)v; (void)s; assert(false); return false;
}
static void put16(uint8_t* bytes, int value) {
bytes[0] = (uint16_t)value; bytes[1] = (uint16_t)value >> 8;
}
static uni_native_motion_snapshot_t snapshot(uni_hid_device_t* d) {
uni_native_motion_snapshot_t result;
assert(uni_hid_parser_native_motion_snapshot(d, &result));
return result;
}
static void ds4_seal(uint8_t bytes[78]) {
const uint8_t transaction = 0xa1;
uint32_t crc = ~uni_crc32_le(uni_crc32_le(UINT32_MAX, &transaction, 1), bytes, 74);
for (unsigned i = 0; i < 4; ++i) bytes[74 + i] = crc >> (8 * i);
}
static void ds4_input(uni_hid_device_t* d, uint8_t bytes[78], uint16_t tick) {
put16(bytes + 12, tick);
ds4_seal(bytes);
uni_hid_parser_ds4_init_report(d);
uni_hid_parser_ds4_parse_input_report(d, bytes, 78);
}
static void ds4_provenance(void) {
uni_hid_device_t d = {0};
d.report_parser.setup = uni_hid_parser_ds4_setup;
uni_hid_parser_ds4_setup(&d);
uint8_t bytes[78] = {0x11};
bytes[7] = 0x28; // Cross, neutral hat.
put16(bytes + 19, 8192); // Gyro Z.
put16(bytes + 23, 8192); // Acceleration Y.
ds4_input(&d, bytes, 65530);
uni_native_motion_snapshot_t first = snapshot(&d);
assert(first.report_valid && !first.accel_valid && !first.gyro_valid);
assert(d.controller.gamepad.buttons & BUTTON_A);
uint8_t calibration[37] = {2};
for (unsigned i = 0; i < 3; ++i) {
put16(calibration + 7 + 2 * i, 100);
put16(calibration + 13 + 2 * i, -100);
put16(calibration + 23 + 4 * i, 8192);
put16(calibration + 25 + 4 * i, -8192);
}
put16(calibration + 19, 100); put16(calibration + 21, 100);
uni_hid_parser_ds4_parse_feature_report(&d, calibration, 36);
ds4_input(&d, bytes, 65531);
assert(!snapshot(&d).accel_valid);
uni_hid_parser_ds4_parse_feature_report(&d, calibration, 37);
ds4_input(&d, bytes, 65531);
assert(!snapshot(&d).accel_valid); // Calibration cannot rejuvenate a cached tick.
ds4_input(&d, bytes, 2); // Wrapped sensor clock is forward progress.
first = snapshot(&d);
assert(first.accel_valid && first.gyro_valid && first.accel_q13[1] == 8192);
ds4_input(&d, bytes, 2);
assert(snapshot(&d).accel_sequence == first.accel_sequence);
ds4_input(&d, bytes, 1);
assert(snapshot(&d).gyro_sequence == first.gyro_sequence);
bytes[7] ^= 0x20; // Corrupt input must not be admitted as button release.
uni_hid_parser_ds4_parse_input_report(&d, bytes, 78);
assert(!snapshot(&d).report_valid);
assert(snapshot(&d).accel_sequence == first.accel_sequence);
uint8_t buttons[10] = {1};
uni_hid_parser_ds4_init_report(&d);
uni_hid_parser_ds4_parse_input_report(&d, buttons, sizeof(buttons));
assert(snapshot(&d).report_valid && snapshot(&d).accel_sequence == first.accel_sequence);
// Valid accel calibration with degenerate gyro extrema is accel-only.
memset(calibration + 7, 0, 12);
uni_hid_parser_ds4_parse_feature_report(&d, calibration, 37);
ds4_input(&d, bytes, 3);
assert(snapshot(&d).accel_valid && !snapshot(&d).gyro_valid);
uni_hid_parser_native_motion_forget(&d);
uni_hid_parser_ds4_setup(&d); // Same pointer, new parser lifetime.
assert(!snapshot(&d).report_valid && !snapshot(&d).accel_valid);
uni_hid_parser_ds4_parse_feature_report(&d, calibration, 37);
ds4_input(&d, bytes, 3);
assert(snapshot(&d).accel_sequence != first.accel_sequence);
uni_hid_parser_native_motion_forget(&d);
}
static void move_calibration(uni_hid_device_t* d, bool zcm2, bool valid) {
uint8_t blob[143] = {0};
const uint8_t lo1[] = {0x0a, 0x24, 0x14}, hi1[] = {0x16, 0x1e, 0x08};
const uint8_t lo2[] = {0x08, 0x16, 0x24}, hi2[] = {0x02, 0x10, 0x1e};
const uint8_t bias1[] = {0x2a, 0x2c, 0x2e}, high1[] = {0x46, 0x50, 0x5a};
const uint8_t bias2[] = {0x26, 0x28, 0x2a}, high2[] = {0x30, 0x38, 0x40}, low2[] = {0x42, 0x4a, 0x52};
for (unsigned i = 0; i < 3; ++i) {
int center = zcm2 ? 0 : 0x8000;
put16(blob + (zcm2 ? lo2[i] : lo1[i]), center - (valid ? 1000 : 0));
put16(blob + (zcm2 ? hi2[i] : hi1[i]), center + (valid ? 1000 : 0));
put16(blob + (zcm2 ? bias2[i] : bias1[i]), center);
put16(blob + (zcm2 ? high2[i] : high1[i]), center + 1000);
if (zcm2) put16(blob + low2[i], -1000);
}
blob[0] = 0x10;
uni_hid_parser_psmove_parse_feature_report(d, blob, 49);
uint8_t continuation[49] = {0x10, zcm2 ? 0x81 : 1};
memcpy(continuation + 2, blob + 49, 47);
uni_hid_parser_psmove_parse_feature_report(d, continuation, 49);
if (!zcm2) {
continuation[1] = 0x82;
memcpy(continuation + 2, blob + 96, 47);
uni_hid_parser_psmove_parse_feature_report(d, continuation, 49);
}
}
static void move_provenance(bool zcm2) {
uni_hid_device_t d = {0};
d.report_parser.setup = uni_hid_parser_psmove_setup;
d.product_id = zcm2 ? 0x0c5e : 0x03d5;
uni_hid_parser_psmove_setup(&d);
uint8_t report[49] = {1};
report[2] = 0x40; // Cross.
report[43] = 1;
for (unsigned i = 0; i < 12; ++i)
put16(report + 13 + 2 * i, (zcm2 ? 0 : 0x8000) + 1000);
uni_hid_parser_psmove_parse_input_report(&d, report, sizeof(report));
assert(snapshot(&d).report_valid && !snapshot(&d).accel_valid);
move_calibration(&d, zcm2, false);
report[43] = 2;
uni_hid_parser_psmove_parse_input_report(&d, report, sizeof(report));
assert(!snapshot(&d).accel_valid && !snapshot(&d).gyro_valid);
move_calibration(&d, zcm2, true);
uni_hid_parser_psmove_parse_input_report(&d, report, sizeof(report));
assert(!snapshot(&d).accel_valid);
report[43] = 3;
uni_hid_parser_psmove_parse_input_report(&d, report, sizeof(report));
uni_native_motion_snapshot_t first = snapshot(&d);
assert(first.accel_valid && first.gyro_valid && first.accel_q13[0] == 8192);
assert(first.gyro_q10[0] == (zcm2 ? 540 : 480) * 1024);
uni_hid_parser_psmove_init_report(&d);
uni_hid_parser_psmove_parse_input_report(&d, report, sizeof(report));
assert(snapshot(&d).gyro_sequence == first.gyro_sequence);
report[43] = 2;
uni_hid_parser_psmove_parse_input_report(&d, report, sizeof(report));
assert(snapshot(&d).accel_sequence == first.accel_sequence);
uni_hid_parser_psmove_parse_input_report(&d, report, 43);
assert(!snapshot(&d).report_valid && snapshot(&d).gyro_sequence == first.gyro_sequence);
uni_hid_parser_native_motion_forget(&d);
}
static void switch_setup(uni_hid_device_t* d, uint8_t type, bool calibrated) {
memset(d, 0, sizeof(*d));
d->report_parser.setup = uni_hid_parser_switch_setup;
d->conn.interrupt_cid = 0x40;
unsigned ready_before = ready_count;
uni_hid_parser_switch_setup(d);
for (unsigned step = 0; ready_count == ready_before && step < 12; ++step) {
assert(command_len >= 12);
uint8_t reply[49] = {0x21};
reply[13] = 0x80;
reply[14] = command[11];
if (reply[14] == 2) reply[17] = type;
if (reply[14] == 0x10) {
memcpy(reply + 15, command + 12, 5);
// Centered sticks, nonzero spans; no user calibration magic.
if (little_endian_read_32(reply, 15) == 0x603d || little_endian_read_32(reply, 15) == 0x6046)
memset(reply + 20, 0x80, reply[19]);
if (little_endian_read_32(reply, 15) == 0x6020) {
for (unsigned i = 0; i < 3; ++i) {
put16(reply + 26 + 2 * i, calibrated ? 16384 : 0);
put16(reply + 38 + 2 * i, calibrated ? 13371 : 0);
}
// Truncation with a convincing declared length cannot certify sensors.
uni_hid_parser_switch_parse_input_report(d, reply, 22);
assert(!snapshot(d).accel_valid);
}
}
uni_hid_parser_switch_parse_input_report(d, reply, sizeof(reply));
}
assert(ready_count != ready_before);
}
static void switch_provenance(uint8_t type, bool calibrated) {
uni_hid_device_t d;
switch_setup(&d, type, calibrated);
uint8_t report[49] = {0x30, 254};
memset(report + 6, 0x80, 6);
put16(report + 37, 4096); // Latest accelerometer sample, native X.
put16(report + 43, 1000); // Latest gyro sample, native X.
uni_hid_parser_switch_parse_input_report(&d, report, sizeof(report));
uni_native_motion_snapshot_t first = snapshot(&d);
assert(first.report_tracked && first.report_valid);
if (type == 0x0b || !calibrated) {
assert(!first.accel_valid && !first.gyro_valid);
} else {
assert(first.accel_valid && first.gyro_valid && first.accel_q13[2] == -8192);
uni_hid_parser_switch_parse_input_report(&d, report, sizeof(report));
assert(snapshot(&d).accel_sequence == first.accel_sequence);
report[1] = 1;
uni_hid_parser_switch_parse_input_report(&d, report, sizeof(report));
assert(snapshot(&d).gyro_sequence != first.gyro_sequence);
first = snapshot(&d);
report[1] = 0;
uni_hid_parser_switch_parse_input_report(&d, report, sizeof(report));
assert(snapshot(&d).gyro_sequence == first.gyro_sequence);
}
uni_hid_parser_switch_parse_input_report(&d, report, 48);
assert(!snapshot(&d).report_valid && snapshot(&d).accel_sequence == first.accel_sequence);
uint8_t buttons[12] = {0x3f};
uni_hid_parser_switch_parse_input_report(&d, buttons, sizeof(buttons));
assert(snapshot(&d).report_valid && snapshot(&d).accel_sequence == first.accel_sequence);
uni_hid_parser_switch_teardown(&d);
assert(!uni_hid_parser_native_motion_snapshot(&d, &first));
}
int main(void) {
ds4_provenance();
move_provenance(false);
move_provenance(true);
switch_provenance(3, true);
switch_provenance(1, true);
switch_provenance(2, true);
switch_provenance(3, false);
switch_provenance(0x0b, false);
uni_hid_device_t sensorless = {0};
uni_native_motion_snapshot_t absent;
memset(&absent, 0xff, sizeof(absent));
assert(!uni_hid_parser_native_motion_snapshot(&sensorless, &absent));
assert(!absent.report_tracked && !absent.accel_valid && !absent.gyro_valid && absent.gyro_sequence == 0);
puts("Native DS4, Move, Switch motion provenance and sensorless absence passed");
return 0;
}

View file

@ -13,7 +13,7 @@
namespace {
uint64_t now_us = 1000000;
uint32_t stage;
Bluepad32DualSenseBridgeSnapshot source;
Bluepad32NativeGamepadSnapshot source;
ControllerProfile profile;
bool alternating_shortcut;
bool shortcut_phase;
@ -32,11 +32,11 @@ 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_select_native_source(const uint8_t*) {}
void bluepad32_input_backend_native_snapshot(Bluepad32NativeGamepadSnapshot* out) { *out = source; }
bool bluepad32_input_backend_native_sample_request(uint8_t, uint8_t, uint64_t*) { return false; }
int bluepad32_input_backend_native_sample_result(uint8_t, uint64_t) { return -1; }
void bluepad32_input_backend_native_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; }
@ -94,8 +94,9 @@ void publish(bool motion = true) {
source.received_us = time_us_32();
++source.state_generation;
if (motion) {
source.motion_received_us = time_us_32();
++source.motion_sequence;
source.accel_received_us = source.gyro_received_us = time_us_32();
++source.accel_sequence;
++source.gyro_sequence;
}
}
uint32_t peek(uint8_t instance) {
@ -210,7 +211,7 @@ void independent_backpressure_and_resets() {
}
void real_motion_admission_and_loss() {
source.motion_valid = true;
source.accel_valid = source.gyro_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.
@ -262,11 +263,11 @@ void real_motion_admission_and_loss() {
source.gyro_q10[1] -= 90 * 1024;
publish();
const uint32_t obsolete = peek(0);
source.motion_valid = false;
source.accel_valid = source.gyro_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;
source.accel_valid = source.gyro_valid = true;
publish(); pair();
assert(imu_length(0) == 30);
for (unsigned i = 0; i < 38; ++i) { publish(false); pair(); }
@ -275,7 +276,7 @@ void real_motion_admission_and_loss() {
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.
source.accel_valid = source.gyro_valid = false; // New epochs require fresh reports.
assert(!probe_controller_input_commit_native_report(0, old_right));
assert(!probe_controller_input_commit_native_report(1, old_left));
pair();
@ -299,7 +300,7 @@ void selected_motion_target_keeps_both_control_halves() {
source.controller.connection_generation = 99;
source.controller.state.button_south = true;
source.controller.state.dpad_up = true;
source.motion_valid = true;
source.accel_valid = source.gyro_valid = true;
source.accel_q13[1] = 8192;
profile = controller_profile_default(controller_identity_global(), 0);
calibrate(0, 2048, 2048, 2047, 2047, 2048, 2048);
@ -318,6 +319,43 @@ void selected_motion_target_keeps_both_control_halves() {
no_mouse_or_rails();
}
void wii_bias_and_independent_sensor_freshness() {
++source.controller.connection_generation;
source.requires_stationary_bias = true;
source.gyro_q10[1] = 2 * 1024;
publish(); pair();
assert(controls[0].active && controls[1].active);
assert(reports[0][2] == 0x01 && reports[1][2] == 0x08);
assert(imu_length(0) == 0 && imu_length(1) == 0);
for (unsigned i = 0; i < 400; ++i) { publish(); pair(); }
for (uint8_t instance = 0; instance < 2; ++instance) {
assert(imu_length(instance) == ((SWITCH2_BRIDGE_IMU_TARGET_MASK & (1u << instance)) ? 30 : 0));
}
// Accelerometer-only reports must not refresh a stalled MotionPlus stream.
for (unsigned i = 0; i < 38; ++i) {
publish(false);
source.accel_received_us = time_us_32();
++source.accel_sequence;
pair();
}
assert(controls[0].active && controls[1].active);
assert(imu_length(0) == 0 && imu_length(1) == 0);
source.gyro_valid = false;
publish(); pair();
assert(reports[0][2] == 0x01 && reports[1][2] == 0x08);
assert(imu_length(0) == 0 && imu_length(1) == 0);
// Returning real Wii sensors must settle again, not reuse the old bias.
source.gyro_valid = true;
publish(); pair();
assert(imu_length(0) == 0 && imu_length(1) == 0);
// A factory-calibrated source switching policy must initialize immediately.
source.requires_stationary_bias = false;
publish(); pair();
for (uint8_t instance = 0; instance < 2; ++instance) {
assert(imu_length(instance) == ((SWITCH2_BRIDGE_IMU_TARGET_MASK & (1u << instance)) ? 30 : 0));
}
}
} // namespace
int main() {
@ -331,5 +369,6 @@ int main() {
independent_backpressure_and_resets();
if (SWITCH2_BRIDGE_IMU_TARGET_MASK == 3) real_motion_admission_and_loss();
selected_motion_target_keeps_both_control_halves();
wii_bias_and_independent_sensor_freshness();
return 0;
}

View file

@ -4,9 +4,19 @@
#include "protocol_fixture.h"
#include "parser/uni_hid_parser_switch2.h"
#include "parser/uni_hid_parser_native_motion.h"
#include "parser/uni_switch2_pairing.h"
#include "sdkconfig.h"
#if SWITCH2_BRIDGE_FULL_INPUT
void uni_hid_parser_wii_setup(uni_hid_device_t* d) { (void)d; assert(false); }
bool uni_hid_parser_wii_accel_snapshot(uni_hid_device_t* d, int32_t v[3], uint32_t* s) {
(void)d; (void)v; (void)s; assert(false); return false;
}
bool uni_hid_parser_wii_gyro_snapshot(uni_hid_device_t* d, int32_t v[3], uint32_t* s) {
(void)d; (void)v; (void)s; assert(false); return false;
}
#endif
#define PEERS CONFIG_BLUEPAD32_MAX_DEVICES
#define SERVICE_START 0x100
#define INPUT_HANDLE 0x104
@ -367,7 +377,10 @@ static unsigned response_data(struct fixture_peer* peer, uint8_t* out, bool eras
if (address == 0x13000) {
little_endian_store_16(out, 16 + 18, UNI_SW2_NINTENDO_VID);
little_endian_store_16(out, 16 + 20, peer->device.product_id);
} else if (address != 0x13044) {
} else if (address == 0x13044) {
if (erased)
memset(out + 16, 0xff, length);
} else {
if (erased)
memset(out + 16, 0xff, length);
else {
@ -657,8 +670,19 @@ static void test_calibration_physical_inputs_and_sensor_units(void) {
}
assert(gp->accel[0] == 8192 && gp->accel[1] == -8192 && gp->accel[2] == -16384);
assert(gp->gyro[0] >= 2041740 && gp->gyro[0] <= 2041750);
#if SWITCH2_BRIDGE_FULL_INPUT
uni_native_motion_snapshot_t first, duplicate;
assert(uni_hid_parser_native_motion_snapshot(&peer->device, &first));
assert(first.accel_valid && first.gyro_valid && first.accel_q13[2] == -16384);
#endif
notify(peer, INPUT_HANDLE, report, sizeof(report)); // Repeated sensor sample must not become zero.
#if SWITCH2_BRIDGE_FULL_INPUT
assert(uni_hid_parser_native_motion_snapshot(&peer->device, &duplicate));
assert(duplicate.accel_sequence == first.accel_sequence && duplicate.gyro_sequence == first.gyro_sequence);
assert(duplicate.gyro_q10[0] >= 2041740);
#else
assert(gp->gyro[0] >= 2041740);
#endif
reset();
peer = connect_peer(UNI_SW2_JOYCON_L_PID, false);
discover(peer);
@ -672,6 +696,59 @@ static void test_calibration_physical_inputs_and_sensor_units(void) {
assert(uni_hid_parser_switch2_extra_buttons(&peer->device) == (UNI_SW2_BUTTON_LEFT_SL | UNI_SW2_BUTTON_LEFT_SR));
}
#if SWITCH2_BRIDGE_FULL_INPUT
static void test_native_independent_motion_and_lifetime(void) {
const uint16_t products[] = {UNI_SW2_PRO_PID, UNI_SW2_JOYCON_L_PID, UNI_SW2_JOYCON_R_PID};
uint32_t previous_sequence = 0;
for (unsigned product = 0; product < 3; ++product) {
for (unsigned invalid_bias = 0; invalid_bias < 2; ++invalid_bias) {
reset();
struct fixture_peer* peer = connect_peer(products[product], false);
discover(peer);
subscribe_response(peer);
for (unsigned step = 0; step < 10; ++step) {
if (peer->command[0] == 2 && little_endian_read_32(peer->command, 12) == 0x13044)
break;
acknowledge(peer, false);
}
assert(little_endian_read_32(peer->command, 12) == 0x13044);
acknowledge(peer, invalid_bias);
finish_setup(peer);
uni_native_motion_snapshot_t native;
assert(uni_hid_parser_native_motion_snapshot(&peer->device, &native));
assert(!native.report_valid && !native.accel_valid && !native.gyro_valid);
uint8_t report[63] = {0};
little_endian_store_16(report, 48, 4096);
little_endian_store_16(report, 54, 32767);
little_endian_store_32(report, 42, 1000);
notify(peer, INPUT_HANDLE, report, sizeof(report));
assert(uni_hid_parser_native_motion_snapshot(&peer->device, &native));
assert(native.report_valid && native.accel_valid && native.accel_q13[0] == 8192);
assert(!native.gyro_valid && native.accel_sequence != previous_sequence);
for (unsigned sample = 1; sample < 46; ++sample) {
advance(10);
little_endian_store_32(report, 42, 1000 + sample * 10000);
notify(peer, INPUT_HANDLE, report, sizeof(report));
}
assert(uni_hid_parser_native_motion_snapshot(&peer->device, &native));
assert(native.accel_valid && native.gyro_valid == !invalid_bias);
uint32_t accel = native.accel_sequence, gyro = native.gyro_sequence;
little_endian_store_32(report, 42, 1000); // Regressed sensor clock.
notify(peer, INPUT_HANDLE, report, sizeof(report));
assert(uni_hid_parser_native_motion_snapshot(&peer->device, &native));
assert(native.accel_sequence == accel && native.gyro_sequence == gyro);
uni_hid_parser_switch2_parse_input_report(&peer->device, report, 62);
assert(uni_hid_parser_native_motion_snapshot(&peer->device, &native));
assert(!native.report_valid && native.accel_sequence == accel);
previous_sequence = accel;
uni_hid_parser_switch2_teardown(&peer->device);
assert(!uni_hid_parser_native_motion_snapshot(&peer->device, &native));
assert(!native.accel_valid && !native.gyro_valid);
}
}
}
#endif
static uint64_t rumble_frame(const struct fixture_peer* peer) {
uint64_t value = 0;
for (unsigned i = 0; i < 5; ++i)
@ -1223,6 +1300,9 @@ static void test_neutral_budget_waits_for_success_and_stale_active_completion(vo
}
int main(void) {
#if SWITCH2_BRIDGE_FULL_INPUT
test_native_independent_motion_and_lifetime();
#endif
test_connected_callback_rejection();
test_advertisement_bounds_and_admission();
test_discovery_metadata_and_rejection_isolation();

View file

@ -1,5 +1,6 @@
#pragma once
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
@ -23,6 +24,12 @@ typedef struct btstack_timer_source {
#define HID_MESSAGE_TYPE_DATA 0x0a
#define HID_REPORT_TYPE_OUTPUT 0x02
#define btstack_min(a, b) ((a) < (b) ? (a) : (b))
#define btstack_max(a, b) ((a) > (b) ? (a) : (b))
static inline uint32_t little_endian_read_32(const uint8_t* bytes, int offset) {
return (uint32_t)bytes[offset] | ((uint32_t)bytes[offset + 1] << 8) |
((uint32_t)bytes[offset + 2] << 16) | ((uint32_t)bytes[offset + 3] << 24);
}
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);

View file

@ -32,7 +32,7 @@ def test_dualsense_parser_native(tmp_path: Path) -> None:
"-fdata-sections",
"-DENABLE_BLE",
"-DENABLE_CLASSIC",
"-DSWITCH2_BRIDGE_DUALSENSE_INPUT=1",
"-DSWITCH2_BRIDGE_FULL_INPUT=1",
"-DHID_MESSAGE_TYPE_GET_REPORT=4",
"-DHID_REPORT_TYPE_FEATURE=3",
f"-I{root / 'tests' / 'switch_parser_native_stubs'}",
@ -40,6 +40,9 @@ def test_dualsense_parser_native(tmp_path: Path) -> None:
f"-I{component / 'include'}",
str(root / "tests" / "dualsense_parser_native_test.c"),
str(component / "parser" / "uni_hid_parser_ds5.c"),
str(
root / "bluepad32_config" / "parser" / "uni_hid_parser_native_motion.c"
),
str(component / "uni_utils.c"),
"-Wl,--gc-sections",
"-o",

View file

@ -4,15 +4,18 @@ import shutil
import subprocess
from pathlib import Path
import pytest
def test_dualsense_backend_native(tmp_path: Path) -> None:
@pytest.mark.parametrize("source", ("GAMEPAD", "DUALSENSE"))
def test_native_gamepad_backend_native(tmp_path: Path, source: str) -> 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"
executable = tmp_path / "native_gamepad_backend_test"
firmware = root / "src" / "firmware"
sources = [
root / "tests" / "dualsense_backend_test.cpp",
root / "tests" / "native_gamepad_backend_test.cpp",
firmware / "profile" / "controller_profile.cpp",
firmware / "profile" / "controller_profile_transform.cpp",
firmware / "profile" / "controller_synthetic_input.cpp",
@ -34,7 +37,8 @@ def test_dualsense_backend_native(tmp_path: Path) -> None:
"-DSWITCH_PICO_USB_OUTPUT_MODES=1",
"-DSWITCH_PICO_ENABLE_BLE=1",
"-DSWITCH_PICO_ENABLE_CLASSIC=1",
"-DSWITCH2_BRIDGE_DUALSENSE_INPUT=1",
"-DSWITCH2_BRIDGE_FULL_INPUT=1",
f"-DSWITCH2_BRIDGE_{source}_INPUT=1",
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
f"-I{firmware}",
f"-I{root / 'bluepad32_config'}",
@ -52,3 +56,12 @@ def test_dualsense_backend_native(tmp_path: Path) -> None:
"cue-races",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)
if source == "GAMEPAD":
for scenario in (
"sensorless-admission",
"independent-motion",
"paired-source",
"pair-cue-races",
"mono-rumble",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)

View file

@ -0,0 +1,58 @@
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_native_gamepad_motion_parser_provenance(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 / "native_gamepad_motion_parser_test"
subprocess.run(
[
compiler,
"-std=gnu11",
"-O1",
"-Wall",
"-Wextra",
"-ffunction-sections",
"-fdata-sections",
"-DENABLE_BLE",
"-DENABLE_CLASSIC",
"-DSWITCH2_BRIDGE_FULL_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" / "native_gamepad_motion_parser_test.c"),
str(
root / "bluepad32_config" / "parser" / "uni_hid_parser_native_motion.c"
),
str(component / "parser" / "uni_hid_parser_ds4.c"),
str(component / "parser" / "uni_hid_parser_psmove.c"),
str(component / "parser" / "uni_hid_parser_switch.c"),
str(component / "uni_circular_buffer.c"),
str(component / "uni_utils.c"),
str(component / "controller" / "uni_gamepad.c"),
"-Wl,--gc-sections",
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -8,14 +8,14 @@ import pytest
@pytest.mark.parametrize("imu_target", [1, 2, 3], ids=["right", "left", "both"])
def test_dualsense_native_bridge_mapping_motion_and_backpressure(
def test_native_gamepad_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"
executable = tmp_path / "switch2_native_gamepad_bridge_test"
subprocess.run(
[
compiler,
@ -25,7 +25,8 @@ def test_dualsense_native_bridge_mapping_motion_and_backpressure(
"-Werror",
"-pedantic",
"-DSWITCH_PICO_SWITCH2_USB_BRIDGE=1",
"-DSWITCH2_BRIDGE_DUALSENSE_INPUT=1",
"-DSWITCH2_BRIDGE_GAMEPAD_INPUT=1",
"-DSWITCH2_BRIDGE_FULL_INPUT=1",
"-DSWITCH2_BRIDGE_SOURCE_AUTO=1",
"-DSWITCH2_PROBE_HUB=1",
f"-DSWITCH2_BRIDGE_IMU_TARGET_MASK={imu_target}",
@ -35,9 +36,9 @@ def test_dualsense_native_bridge_mapping_motion_and_backpressure(
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 / "tests" / "switch2_native_gamepad_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_gamepad_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"),

View file

@ -13,7 +13,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
from prepare_bluepad32 import prepare_bluepad32
def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None:
@pytest.mark.parametrize("full_input", [False, True])
def test_switch2_parser_protocol_and_lifecycle(
tmp_path: Path, full_input: bool
) -> 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"
@ -24,12 +27,17 @@ def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None:
if sdk_path := os.environ.get("PICO_SDK_PATH"):
sdk_candidates.insert(0, Path(sdk_path))
btstack = next(
(sdk / "lib" / "btstack" / "src" for sdk in sdk_candidates
if (sdk / "lib" / "btstack" / "src" / "ble" / "gatt_client.h").is_file()),
(
sdk / "lib" / "btstack" / "src"
for sdk in sdk_candidates
if (sdk / "lib" / "btstack" / "src" / "ble" / "gatt_client.h").is_file()
),
None,
)
if btstack is None:
pytest.skip("Pico SDK BTstack headers required; configure firmware or set PICO_SDK_PATH")
pytest.skip(
"Pico SDK BTstack headers required; configure firmware or set PICO_SDK_PATH"
)
prepared = prepare_bluepad32(
root / "external" / "bluepad32",
root / "patches" / "bluepad32-sdl3-imu.patch",
@ -48,6 +56,7 @@ def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None:
"-fdata-sections",
"-DENABLE_BLE",
"-DENABLE_CLASSIC",
f"-DSWITCH2_BRIDGE_FULL_INPUT={int(full_input)}",
f"-I{root / 'tests' / 'switch2_parser_native_stubs'}",
f"-I{root / 'bluepad32_config'}",
f"-I{component / 'include'}",
@ -58,6 +67,9 @@ def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None:
str(root / "tests" / "switch2_parser_native_test.c"),
str(root / "bluepad32_config" / "parser" / "uni_hid_parser_switch2.c"),
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
str(
root / "bluepad32_config" / "parser" / "uni_hid_parser_native_motion.c"
),
str(btstack / "btstack_util.c"),
"-Wl,--gc-sections",
"-o",

View file

@ -5,12 +5,17 @@ import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
from prepare_bluepad32 import prepare_bluepad32
def test_wii_parser_native_motion_and_lifecycle(tmp_path: Path) -> None:
@pytest.mark.parametrize("full_input", [False, True])
def test_wii_parser_native_motion_and_lifecycle(
tmp_path: Path, full_input: bool
) -> 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"
@ -32,11 +37,15 @@ def test_wii_parser_native_motion_and_lifecycle(tmp_path: Path) -> None:
"-fdata-sections",
"-DENABLE_BLE",
"-DENABLE_CLASSIC",
f"-DSWITCH2_BRIDGE_FULL_INPUT={int(full_input)}",
f"-I{root / 'tests' / 'switch_parser_native_stubs'}",
f"-I{root / 'bluepad32_config'}",
f"-I{component / 'include'}",
str(root / "tests" / "wii_parser_native_test.c"),
str(component / "parser" / "uni_hid_parser_wii.c"),
str(
root / "bluepad32_config" / "parser" / "uni_hid_parser_native_motion.c"
),
str(component / "controller" / "uni_gamepad.c"),
"-Wl,--gc-sections",
"-o",

View file

@ -5,6 +5,7 @@
#include <string.h>
#include "parser/uni_hid_parser_wii.h"
#include "parser/uni_hid_parser_native_motion.h"
#include "uni_hid_device.h"
// The real staged parser and gamepad definitions are linked. Only transport and
@ -501,11 +502,21 @@ static void accelerometer_snapshot_requires_fresh_calibrated_reports(void) {
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
expect_vector(acceleration, -8192, 2048, -4096);
const uint32_t first = sequence;
#if SWITCH2_BRIDGE_FULL_INPUT
uni_native_motion_snapshot_t native;
assert(uni_hid_parser_native_motion_snapshot(&f.device, &native));
assert(native.accel_valid && !native.gyro_valid && !native.report_tracked);
expect_vector(native.accel_q13, -8192, 2048, -4096);
#endif
send_ack(0x16, 0);
const uint8_t short_report[5] = {0x31};
feed(short_report, sizeof(short_report));
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
assert(sequence == first);
#if SWITCH2_BRIDGE_FULL_INPUT
assert(uni_hid_parser_native_motion_snapshot(&f.device, &native));
assert(native.accel_sequence == first && !native.gyro_valid);
#endif
send_core_and_accel();
assert(uni_hid_parser_wii_accel_snapshot(&f.device, acceleration, &sequence));
assert(sequence != first); // Identical readings can still be fresh.
@ -528,6 +539,12 @@ static void gyro_snapshot_advances_only_on_calibrated_motionplus_packets(void) {
assert(uni_hid_parser_wii_gyro_snapshot(&f.device, gyro, &sequence));
expect_vector(gyro, 360 * 1024, -120 * 1024, 40 * 1024);
const uint32_t first = sequence;
#if SWITCH2_BRIDGE_FULL_INPUT
uni_native_motion_snapshot_t native;
assert(uni_hid_parser_native_motion_snapshot(&f.device, &native));
assert(native.accel_valid && native.gyro_valid);
expect_vector(native.gyro_q10, 360 * 1024, -120 * 1024, 40 * 1024);
#endif
send_nunchuk_controls(true);
send_ack(0x16, 0);
send_status(true);
@ -537,6 +554,10 @@ static void gyro_snapshot_advances_only_on_calibrated_motionplus_packets(void) {
assert(uni_hid_parser_wii_gyro_snapshot(&f.device, gyro, &sequence));
assert(sequence == first);
expect_vector(gyro, 360 * 1024, -120 * 1024, 40 * 1024);
#if SWITCH2_BRIDGE_FULL_INPUT
assert(uni_hid_parser_native_motion_snapshot(&f.device, &native));
assert(native.gyro_sequence == first);
#endif
send_motion(7760, 8200, 7560, false, true, false);
assert(uni_hid_parser_wii_gyro_snapshot(&f.device, gyro, &sequence));
assert(sequence != first); // Equal values do not mean a duplicate packet.
@ -592,10 +613,23 @@ static void gyro_snapshot_invalidates_on_topology_and_teardown(void) {
uni_hid_parser_wii_teardown(&f.device);
assert(!uni_hid_parser_wii_gyro_snapshot(&f.device, gyro, &sequence));
assert(!uni_hid_parser_wii_rumble_ready(&f.device));
#if SWITCH2_BRIDGE_FULL_INPUT
uni_native_motion_snapshot_t retired;
assert(uni_hid_parser_native_motion_snapshot(&f.device, &retired));
assert(!retired.accel_valid && !retired.gyro_valid);
#endif
f.ready_count = 0; // A new parser connection gets its own ready notification.
uni_hid_parser_wii_setup(&f.device);
finish_setup();
assert(!uni_hid_parser_wii_gyro_snapshot(&f.device, gyro, &sequence));
#if SWITCH2_BRIDGE_FULL_INPUT
send_motion(7760, 8200, 7560, false, true, false);
// An already-active MP first resolves its downstream topology after reconnect.
finish_setup();
send_motion(7760, 8200, 7560, false, true, false);
assert(uni_hid_parser_native_motion_snapshot(&f.device, &retired));
assert(retired.gyro_valid && retired.gyro_sequence != first);
#endif
}
static void setup_read_and_write_errors_leave_buttons_ready(void) {
@ -808,6 +842,11 @@ static void legacy_plain_nunchuk_and_wii_u_pro(void) {
assert(f.device.controller.gamepad.misc_buttons == MISC_BUTTON_START);
expect_vector(f.device.controller.gamepad.accel, 0, 0, 0);
expect_vector(f.device.controller.gamepad.gyro, 0, 0, 0);
#if SWITCH2_BRIDGE_FULL_INPUT
uni_native_motion_snapshot_t native;
assert(uni_hid_parser_native_motion_snapshot(&f.device, &native));
assert(!native.accel_valid && !native.gyro_valid);
#endif
}
static void plus_selects_vertical_without_disabling_motion(void) {

View file

@ -2,8 +2,8 @@
"""Bounded, non-pairing qualification of the switch-pico native Joy-Con USB hub.
Requires Linux, PyUSB/libusb, and the existing sudo -n setfacl permission policy.
Uses the already-paired real R/L donors; it cannot wake or pair them. The JSON
capture is created exclusively before USB access and retains partial failures.
Uses already-paired R/L donors or one full gamepad; it cannot wake or pair them.
The JSON capture is created exclusively before USB access and retains failures.
No reset, configuration change, pairing exchange, profile access, flash write,
or HID output is sent. Motor sample playback requires --rumble-sample explicitly.
"""
@ -37,14 +37,17 @@ MODELS = {
}
def model_references(build_dir: Path) -> dict[str, dict[str, Any]]:
def model_references(
build_dir: Path, *, require_imu: bool = True
) -> dict[str, dict[str, Any]]:
cache = {}
for line in (build_dir / "CMakeCache.txt").read_text().splitlines():
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"
require_imu
and cache.get("SWITCH2_BRIDGE_INPUT") in ("DUALSENSE", "GAMEPAD")
and cache.get("SWITCH2_BRIDGE_IMU_TARGET", "BOTH") != "BOTH"
):
raise ValueError(
@ -145,6 +148,7 @@ class Check:
"duration_seconds": args.duration,
"usb_timeout_ms": args.usb_timeout_ms,
"rumble_sample": args.rumble_sample,
"require_imu": not args.input_only,
},
"safety": {
"pairing_writes": False,
@ -170,6 +174,7 @@ class Check:
side: {
"packets": 0,
"valid_native_imu": 0,
"valid_native_packets": 0,
"imu_counter_changes": 0,
"wrong_side": 0,
"unexpected_report": 0,
@ -186,6 +191,7 @@ class Check:
"first_valid_seconds": None,
"last_valid_seconds": None,
"last_counter_change_seconds": None,
"last_control_change_seconds": None,
}
for side in SIDES
},
@ -697,15 +703,29 @@ class Check:
rejection = f"native report is {len(packet)} bytes, expected 64"
else:
payload = packet[1:]
stream["valid_native_packets"] += 1
controls = (payload[2:4], payload[5:8])
sample["buttons_hex"], sample["stick_hex"] = (
part.hex() for part in controls
)
if any(controls[0]):
stream["buttons_nonzero"] += 1
if side in self.last_controls and self.last_controls[side] != controls:
stream["control_changes"] += 1
stream["last_control_change_seconds"] = self.elapsed()
self.last_controls[side] = controls
length_offset = 14 if side == "L" else 15
length = payload[length_offset]
key = str(length)
stream["imu_lengths"][key] = stream["imu_lengths"].get(key, 0) + 1
if length == 0:
stream["zero_length_imu"] += 1
rejection = (
"zero-length IMU: inactive donor/neutral fallback is not live input"
)
if self.args.input_only:
if len(stream["samples"]) < 4:
stream["samples"].append(sample)
stream["last_sample"] = sample
else:
rejection = "zero-length IMU: inactive donor/neutral fallback is not live motion"
else:
try:
block = (
@ -743,18 +763,6 @@ class Check:
self.last_counter[side] = counter
if len(self.imu_evidence[side]) < 512:
self.imu_evidence[side].add(block)
controls = (payload[2:4], payload[5:8])
sample["buttons_hex"], sample["stick_hex"] = (
part.hex() for part in controls
)
if any(controls[0]):
stream["buttons_nonzero"] += 1
if (
side in self.last_controls
and self.last_controls[side] != controls
):
stream["control_changes"] += 1
self.last_controls[side] = controls
format_key = f"{decoded['format']:02x}"
first_format = format_key not in stream["imu_formats"]
stream["imu_formats"][format_key] = (
@ -783,7 +791,10 @@ class Check:
def counts(self) -> dict[str, int]:
return {
side: self.result["streams"][side]["valid_native_imu"] for side in SIDES
side: self.result["streams"][side][
"valid_native_packets" if self.args.input_only else "valid_native_imu"
]
for side in SIDES
}
def donors_ready(self) -> None:
@ -793,23 +804,31 @@ class Check:
self.poll_pair(until, bool(iteration % 2))
iteration += 1
if all(
stream["valid_native_imu"] >= 3 and stream["imu_counter_changes"] >= 2
(stream["buttons_nonzero"] > 0 and stream["control_changes"] >= 2)
if self.args.input_only
else (
stream["valid_native_imu"] >= 3
and stream["imu_counter_changes"] >= 2
)
for stream in self.result["streams"].values()
):
return
details = "; ".join(
f"{side}: valid={stream['valid_native_imu']}, counter_changes={stream['imu_counter_changes']}, zero_imu={stream['zero_length_imu']}, invalid={stream['invalid']}, timeouts={stream['timeouts']}"
f"{side}: imu={stream['valid_native_imu']}, counter_changes={stream['imu_counter_changes']}, buttons={stream['buttons_nonzero']}, control_changes={stream['control_changes']}, zero_imu={stream['zero_length_imu']}, invalid={stream['invalid']}, timeouts={stream['timeouts']}"
for side, stream in self.result["streams"].items()
)
raise RuntimeError(
f"donors did not produce fresh decodable native IMU during the manual-wake window; {details}; no pairing/wake/reset was attempted"
f"sources did not satisfy the requested live-input evidence during the manual-input window; {details}; no pairing/wake/reset was attempted"
)
def active(self) -> None:
until = min(self.deadline, time.monotonic() + self.args.duration)
initial_counts = self.counts()
initial_changes = {
side: self.result["streams"][side]["imu_counter_changes"] for side in SIDES
side: self.result["streams"][side][
"control_changes" if self.args.input_only else "imu_counter_changes"
]
for side in SIDES
}
next_query = time.monotonic()
round_number = 0
@ -851,10 +870,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"
shared_source = self.models["R"].get("source_mode") in ("DUALSENSE", "GAMEPAD")
self.result["imu_isolation"] = {
"sample_limit_per_side": 512,
"policy": "shared_physical_source"
"policy": "not_required_input_only"
if self.args.input_only
else "shared_physical_source"
if shared_source
else "independent_physical_sources",
"identical_blocks_seen_on_both_sides": len(shared),
@ -865,7 +886,7 @@ class Check:
side: len(blocks - shared) for side, blocks in self.imu_evidence.items()
},
}
for side in SIDES:
for side in () if self.args.input_only else SIDES:
evidence = (
self.imu_evidence[side]
if shared_source
@ -881,18 +902,26 @@ class Check:
for side in SIDES:
stream = self.result["streams"][side]
if (
stream["valid_native_imu"] - initial_counts[side] < 3
or stream["imu_counter_changes"] - initial_changes[side] < 2
self.counts()[side] - initial_counts[side] < 3
or stream[
"control_changes" if self.args.input_only else "imu_counter_changes"
]
- initial_changes[side]
< 2
):
self.error(
"insufficient fresh native donor IMU during active control/bulk reads",
"insufficient fresh controller transitions"
if self.args.input_only
else "insufficient fresh native source IMU during active control/bulk reads",
side,
)
last_change = stream["last_counter_change_seconds"]
last_change = stream[
"last_control_change_seconds"
if self.args.input_only
else "last_counter_change_seconds"
]
if last_change is None or self.elapsed() - last_change > 2:
self.error(
"donor IMU stopped advancing before streaming finished", side
)
self.error("source stopped advancing before streaming finished", side)
if stream["wrong_side"] or stream["unexpected_report"] or stream["invalid"]:
self.error(
f"rejected wrong-side={stream['wrong_side']}, unexpected={stream['unexpected_report']}, malformed={stream['invalid']} HID packets",
@ -938,7 +967,9 @@ class Check:
import usb.util
with self.stage("references"):
self.models = model_references(self.args.build_dir)
self.models = model_references(
self.args.build_dir, require_imu=not self.args.input_only
)
self.core, self.util = usb.core, usb.util
with self.stage("discovery"):
self.discover()
@ -1044,6 +1075,11 @@ def main() -> int:
default=500,
help="per control/bulk transfer timeout, 20..3000 ms (default: 500)",
)
parser.add_argument(
"--input-only",
action="store_true",
help="qualify controllers without IMU; requires real button presses and continued control changes on both halves",
)
parser.add_argument(
"--rumble-sample",
type=int,

View file

@ -13,8 +13,8 @@
#include <inttypes.h>
extern "C" int probe_debug_printf(const char* format, ...);
#endif
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
#include "dualsense_input.h"
#if SWITCH2_BRIDGE_FULL_INPUT
#include "native_gamepad_input.h"
#endif
#if SWITCH2_BRIDGE_WII_INPUT
#include <math.h>
@ -26,9 +26,9 @@ extern "C" int probe_debug_printf(const char* format, ...);
#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"
#elif SWITCH2_BRIDGE_FULL_INPUT
#if SWITCH2_BRIDGE_DUALSENSE_INPUT && !SWITCH_PICO_ENABLE_CLASSIC
#error "The DualSense source requires Classic Bluetooth"
#endif
#elif !SWITCH_PICO_ENABLE_BLE || !SWITCH_PICO_SWITCH2_MOUSE_CAPTURE || \
!SWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE
@ -36,7 +36,7 @@ extern "C" int probe_debug_printf(const char* format, ...);
#endif
namespace {
#if !SWITCH2_BRIDGE_DUALSENSE_INPUT
#if !SWITCH2_BRIDGE_FULL_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
@ -56,7 +56,7 @@ bool g_start_attempted;
bool g_flash_ready;
#if SWITCH2_BRIDGE_WII_INPUT
probe_controller_input g_input;
#elif !SWITCH2_BRIDGE_DUALSENSE_INPUT
#elif !SWITCH2_BRIDGE_FULL_INPUT
probe_controller_input g_inputs[PROBE_CONTROLLER_COUNT];
uint32_t g_received_times[PROBE_CONTROLLER_COUNT];
#endif
@ -382,9 +382,9 @@ extern "C" void probe_controller_input_clock_init(void) {
extern "C" void probe_controller_input_init(void) {
if (g_initialized) return;
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
#if SWITCH2_BRIDGE_FULL_INPUT
bluepad32_input_backend_init();
probe_dualsense_input_init();
probe_native_gamepad_input_init();
#elif SWITCH2_BRIDGE_WII_INPUT
bluepad32_input_backend_init();
bluepad32_input_backend_select_wii_source(kSourceAddress);
@ -484,17 +484,17 @@ extern "C" void probe_controller_input_set_native_features(uint8_t features) {
}
#endif
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
#if SWITCH2_BRIDGE_FULL_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);
probe_native_gamepad_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_DUALSENSE_INPUT
probe_dualsense_input_set_native_stream(instance, enabled && g_flash_ready);
#if SWITCH2_BRIDGE_FULL_INPUT
probe_native_gamepad_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();
@ -508,8 +508,8 @@ 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_DUALSENSE_INPUT
return probe_dualsense_input_peek_native_report(instance, now_ms, report);
#if SWITCH2_BRIDGE_FULL_INPUT
return probe_native_gamepad_input_peek_native_report(instance, now_ms, report);
#elif SWITCH2_BRIDGE_WII_INPUT
(void)now_ms;
return prepare_wii_report(report);
@ -520,8 +520,8 @@ 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_DUALSENSE_INPUT
return probe_dualsense_input_commit_native_report(instance, serial);
#if SWITCH2_BRIDGE_FULL_INPUT
return probe_native_gamepad_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;
@ -543,8 +543,8 @@ extern "C" bool probe_controller_input_play_sample(uint8_t instance, uint8_t sam
if (token != nullptr) *token = 0;
return false;
}
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
return bluepad32_input_backend_dualsense_sample_request(instance, sample_id, token);
#if SWITCH2_BRIDGE_FULL_INPUT
return bluepad32_input_backend_native_sample_request(instance, sample_id, token);
#elif SWITCH2_BRIDGE_WII_INPUT
return bluepad32_input_backend_wii_sample_request(sample_id, token);
#else
@ -555,9 +555,9 @@ 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_DUALSENSE_INPUT
#if SWITCH2_BRIDGE_FULL_INPUT
(void)now_ms;
return bluepad32_input_backend_dualsense_sample_result(instance, token);
return bluepad32_input_backend_native_sample_result(instance, token);
#elif SWITCH2_BRIDGE_WII_INPUT
(void)now_ms;
return bluepad32_input_backend_wii_sample_result(token);
@ -568,8 +568,8 @@ 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_DUALSENSE_INPUT
bluepad32_input_backend_dualsense_sample_cancel(instance);
#if SWITCH2_BRIDGE_FULL_INPUT
bluepad32_input_backend_native_sample_cancel(instance);
#elif SWITCH2_BRIDGE_WII_INPUT
bluepad32_input_backend_wii_sample_cancel();
#else
@ -584,8 +584,8 @@ extern "C" void probe_controller_input_poll(uint8_t instance, uint32_t now_ms,
*out = {};
return;
}
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
probe_dualsense_input_poll(instance, now_ms, out);
#if SWITCH2_BRIDGE_FULL_INPUT
probe_native_gamepad_input_poll(instance, now_ms, out);
return;
#elif SWITCH2_BRIDGE_WII_INPUT
poll_wii_source(now_ms);
@ -618,7 +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
#if !SWITCH2_BRIDGE_FULL_INPUT
*out = g_input;
#endif
}

View file

@ -47,7 +47,7 @@ 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
#if SWITCH2_BRIDGE_FULL_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]);
@ -55,7 +55,7 @@ void probe_controller_input_set_full_stick_calibration(uint8_t instance, const u
// 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;
// Full-controller mode splits one supported gamepad 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);

View file

@ -1,13 +0,0 @@
#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,7 +319,7 @@ 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
#elif SWITCH2_BRIDGE_FULL_INPUT
probe_controller_input_set_full_stick_calibration(instance, stick_calibration);
#endif
protocol->read_memory = read_memory;
@ -487,8 +487,8 @@ 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",
#elif SWITCH2_BRIDGE_FULL_INPUT
probe_debug_printf("[PROBE] Native gamepad cue dispatched itf=%u token=%" PRIu64 "\n",
instance, reply->deferred_token);
#else
probe_debug_printf("[PROBE] Source sample ACK itf=%u token=%" PRIu64 "\n",
@ -528,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 || SWITCH2_BRIDGE_DUALSENSE_INPUT
#if SWITCH2_BRIDGE_WII_INPUT || SWITCH2_BRIDGE_FULL_INPUT
input[8] = (uint8_t)(0x30 | ((protocol->enabled_features & 0x20) ? 8 : 0));
#endif
probe_protocol_gate_native_report(protocol, input);
@ -792,6 +792,8 @@ int main(void) {
probe_controller_input_init();
#if SWITCH2_BRIDGE_WII_INPUT
probe_debug_printf("[PROBE] UART0 GP0=TX, 115200 8N1; selected Wii IR/MotionPlus source enabled\n");
#elif SWITCH2_BRIDGE_FULL_INPUT
probe_debug_printf("[PROBE] UART0 GP0=TX, 115200 8N1; one supported gamepad feeds the native R/L pair\n");
#else
probe_debug_printf("[PROBE] UART0 GP0=TX, 115200 8N1; %u selected Joy-Con Bluetooth source(s)\n",
PROBE_CONTROLLER_COUNT);
@ -819,6 +821,10 @@ int main(void) {
#if SWITCH2_BRIDGE_WII_INPUT
probe_debug_printf("[PROBE] Wii IR drives native mouse movement; buttons retain profile mapping; keep Wii still for MotionPlus calibration\n");
probe_debug_printf("[PROBE] Hold BOOTSEL2s for pairing; Wii cue feedback uses bounded ERM patterns, not HD audio waveforms\n");
#elif SWITCH2_BRIDGE_FULL_INPUT
probe_debug_printf("[PROBE] Full gamepad controls on R/L; IMU mask=%u; only Wii requires settling\n",
(unsigned)SWITCH2_BRIDGE_IMU_TARGET_MASK);
probe_debug_printf("[PROBE] Hold BOOTSEL 2s for Bluetooth pairing (never clears pairings); cues use source capabilities\n");
#else
probe_debug_printf("[PROBE] Live Joy-Con buttons/stick/native mouse; hold BOOTSEL 2s for Bluetooth pairing (never clears pairings)\n");
#endif

View file

@ -1,6 +1,6 @@
#include "dualsense_input.h"
#include "native_gamepad_input.h"
#if SWITCH2_BRIDGE_DUALSENSE_INPUT
#if SWITCH2_BRIDGE_FULL_INPUT
#include <limits.h>
#include <string.h>
@ -11,7 +11,7 @@
#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"
#error "A full gamepad source requires the native R/L USB hub"
#endif
static_assert(PROBE_CONTROLLER_COUNT == 2);
extern "C" int probe_debug_printf(const char* format, ...);
@ -40,15 +40,17 @@ struct Child {
uint32_t pending_token = 0;
uint32_t pending_us = 0;
uint32_t pending_ticks = 0;
uint32_t pending_motion_sequence = 0;
uint32_t pending_accel_sequence = 0;
uint32_t pending_gyro_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_accel_sequence = 0;
uint32_t committed_gyro_sequence = 0;
uint32_t committed_ticks = 0;
};
Child g_children[PROBE_CONTROLLER_COUNT];
Bluepad32DualSenseBridgeSnapshot g_source;
Bluepad32NativeGamepadSnapshot g_source;
ControllerProfileTransformResult g_mapped;
ProbeNativeMotion g_motion;
bool g_active;
@ -78,8 +80,10 @@ void discard_output(Child& child) {
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;
bool sensors_fresh(const Bluepad32NativeGamepadSnapshot& source, uint32_t now_us) {
return source.accel_valid && source.gyro_valid &&
now_us - source.accel_received_us < kSensorDeadlineUs &&
now_us - source.gyro_received_us < kSensorDeadlineUs;
}
void unpack_stick_pair(const uint8_t* bytes, uint16_t pair[2]) {
@ -147,7 +151,7 @@ void lose_source(uint32_t now_ms) {
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);
bluepad32_input_backend_native_sample_cancel(i);
}
g_sensor_status = -1;
}
@ -156,8 +160,8 @@ void lose_source(uint32_t now_ms) {
}
void refresh(uint32_t now_ms) {
Bluepad32DualSenseBridgeSnapshot source;
bluepad32_input_backend_dualsense_snapshot(&source);
Bluepad32NativeGamepadSnapshot source;
bluepad32_input_backend_native_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);
@ -174,13 +178,15 @@ void refresh(uint32_t now_ms) {
// 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;
source.accel_sequence == g_source.accel_sequence && source.gyro_sequence == g_source.gyro_sequence &&
source.accel_received_us == g_source.accel_received_us && source.gyro_received_us == g_source.gyro_received_us &&
source.accel_valid == g_source.accel_valid && source.gyro_valid == g_source.gyro_valid &&
source.requires_stationary_bias == g_source.requires_stationary_bias) 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);
probe_debug_printf("[PROBE] Native gamepad source active in slot %u\n", source.slot);
}
g_source = source;
g_active = true;
@ -194,9 +200,12 @@ void refresh(uint32_t now_ms) {
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;
sample.accel_valid = source.accel_valid;
sample.gyro_valid = source.gyro_valid;
sample.accel_sequence = source.accel_sequence;
sample.gyro_sequence = source.gyro_sequence;
sample.accel_us = source.accel_received_us;
sample.gyro_us = source.gyro_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.
@ -206,12 +215,15 @@ void refresh(uint32_t now_ms) {
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;
g_motion.update(now_us, source.controller.connection_generation, sample,
source.requires_stationary_bias ? ProbeNativeMotionBias::kEstimateStationary :
ProbeNativeMotionBias::kAlreadyCalibrated);
const int status = !sensors_fresh(g_source, 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");
probe_debug_printf("[PROBE] Native gamepad IMU %s\n", status == 2 ? "ready" :
status == 1 ? (source.requires_stationary_bias ? "Wii calibrating: keep still" :
"waiting for a usable acceleration sample") : "waiting for supported fresh sensors");
}
for (uint8_t i = 0; i < PROBE_CONTROLLER_COUNT; ++i) {
// Latest-only: a blocked endpoint never queues obsolete controls/IMU.
@ -221,15 +233,15 @@ void refresh(uint32_t now_ms) {
}
} // namespace
void probe_dualsense_input_init() {
void probe_native_gamepad_input_init() {
#if SWITCH2_BRIDGE_SOURCE_AUTO
bluepad32_input_backend_select_dualsense_source(nullptr);
bluepad32_input_backend_select_native_source(nullptr);
#else
bluepad32_input_backend_select_dualsense_source(kSourceAddress);
bluepad32_input_backend_select_native_source(kSourceAddress);
#endif
}
void probe_dualsense_input_set_stick_calibration(uint8_t instance, const uint8_t calibration[9]) {
void probe_native_gamepad_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;
@ -248,30 +260,31 @@ void probe_dualsense_input_set_stick_calibration(uint8_t instance, const uint8_t
pack_controls(instance);
}
void probe_dualsense_input_set_native_stream(uint8_t instance, bool enabled) {
void probe_native_gamepad_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);
if (!enabled) bluepad32_input_backend_native_sample_cancel(instance);
}
void probe_dualsense_input_poll(uint8_t instance, uint32_t now_ms, probe_controller_input* out) {
void probe_native_gamepad_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]) {
uint32_t probe_native_gamepad_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);
g_motion.ready() && sensors_fresh(g_source, now_us) &&
(!child.have_committed_motion || child.committed_accel_sequence != g_source.accel_sequence ||
child.committed_gyro_sequence != g_source.gyro_sequence);
if (child.pending_token && (now_us - child.pending_us >= kOutputDeadlineUs ||
child.pending_motion != motion_ready)) child.pending_token = 0;
if (!child.pending_token) {
@ -293,7 +306,8 @@ uint32_t probe_dualsense_input_peek_native_report(uint8_t instance, uint32_t now
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_accel_sequence = g_source.accel_sequence;
child.pending_gyro_sequence = g_source.gyro_sequence;
child.pending_us = now_us;
child.pending_token = ++g_report_token;
}
@ -301,27 +315,28 @@ uint32_t probe_dualsense_input_peek_native_report(uint8_t instance, uint32_t now
return child.pending_token;
}
bool probe_dualsense_input_commit_native_report(uint8_t instance, uint32_t token) {
bool probe_native_gamepad_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);
Bluepad32NativeGamepadSnapshot source;
bluepad32_input_backend_native_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 ||
source.accel_sequence != g_source.accel_sequence || source.gyro_sequence != g_source.gyro_sequence ||
source.accel_received_us != g_source.accel_received_us || source.gyro_received_us != g_source.gyro_received_us ||
source.accel_valid != g_source.accel_valid || source.gyro_valid != g_source.gyro_valid ||
source.requires_stationary_bias != g_source.requires_stationary_bias ||
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_motion && !sensors_fresh(source, now_us))) 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_accel_sequence = child.pending_accel_sequence;
child.committed_gyro_sequence = child.pending_gyro_sequence;
child.committed_ticks = child.pending_ticks;
}
++child.counter;

View file

@ -0,0 +1,13 @@
#pragma once
#include "controller_input.h"
#if SWITCH2_BRIDGE_FULL_INPUT
// Core 0 only. One coherent profile/motion evaluation feeds both native children.
void probe_native_gamepad_input_init();
void probe_native_gamepad_input_set_stick_calibration(uint8_t instance, const uint8_t calibration[9]);
void probe_native_gamepad_input_set_native_stream(uint8_t instance, bool enabled);
void probe_native_gamepad_input_poll(uint8_t instance, uint32_t now_ms, probe_controller_input* out);
uint32_t probe_native_gamepad_input_peek_native_report(uint8_t instance, uint32_t now_ms, uint8_t report[63]);
bool probe_native_gamepad_input_commit_native_report(uint8_t instance, uint32_t token);
#endif

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" AND NOT SWITCH2_BRIDGE_DUALSENSE_INPUT))
message(FATAL_ERROR "Native R/L output requires JOYCON2 input, or DUALSENSE with HUB")
OR (NOT SWITCH2_BRIDGE_INPUT STREQUAL "JOYCON2" AND NOT SWITCH2_BRIDGE_FULL_INPUT))
message(FATAL_ERROR "Native R/L output requires JOYCON2 input or a full-controller HUB source")
endif()
set(probe_composite 0)
if(SWITCH2_PROBE_COMPOSITE)
@ -54,10 +54,10 @@ set(SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS "" CACHE STRING
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_BRIDGE_DUALSENSE_INPUT AND SWITCH2_BRIDGE_SOURCE_ADDRESS STREQUAL "")
if(SWITCH2_BRIDGE_FULL_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)
elseif((SWITCH2_PROBE_COMPOSITE OR SWITCH2_PROBE_HUB) AND NOT SWITCH2_BRIDGE_FULL_INPUT)
list(APPEND probe_source_fields SWITCH2_BRIDGE_SECOND_SOURCE_ADDRESS)
endif()
set(probe_source_addresses "")
@ -367,11 +367,11 @@ function(switch2_usb_probe_configure target)
else()
pico_set_program_name(${target} "Switch 2 USB initialization capture")
endif()
if(SWITCH2_PROBE_HUB AND SWITCH2_BRIDGE_DUALSENSE_INPUT)
if(SWITCH2_PROBE_HUB AND SWITCH2_BRIDGE_FULL_INPUT)
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
pico_set_program_version(${target} "0.69-native-hub-imu-trace")
pico_set_program_version(${target} "0.70-native-gamepad-trace")
else()
pico_set_program_version(${target} "0.69-native-hub-imu")
pico_set_program_version(${target} "0.70-native-gamepad")
endif()
elseif(SWITCH2_PROBE_HUB)
pico_set_program_version(${target} "0.67-native-hub-latency")