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

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