switch-pico/tests/bluepad32_backend_lifecycle_test.cpp
Joey Yakimowich-Payne 9f6dddb790 WIP: checkpoint Wii native IMU and upstream IR bridge
Pause Wii pointing work with firmware 0.33-wii-trace installed. Preserve the pinned libogc IR pipeline, calibrated native IMU, software BOOTSEL, and standard camera sensitivity trial.

Tracking instability, tracking loss, and ineffective vertical movement remain unresolved. Level-2 camera filtering is not hardware-qualified. Nine targeted regression tests pass; firmware and persistent storage were verified after flashing.
2026-09-11 17:05:54 -06:00

6387 lines
304 KiB
C++

#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#include <algorithm>
#include <array>
#endif
#include <uni.h>
#include "bluetooth_transport_config.h"
#include "parser/uni_hid_parser_switch2.h"
#include "parser/uni_switch2_haptics.h"
#include "parser/uni_switch2_pairing.h"
#include "parser/uni_hid_parser_wii.h"
#include "platform/pico/controller_color_config.h"
#include "input/switch2_wake.h"
#include "pico/critical_section.h"
#include "profile/profile_storage.h"
#include "profile/controller_profile_runtime.h"
namespace {
bool incoming_connections = false;
bool scanning_enabled = false;
bool aggregate_scanning_enabled = false;
bool background_scan_parameters = false;
bool classic_scanning_enabled = false;
uni_platform* installed_platform = nullptr;
bool observed_status_led_on = false;
int observed_status_led_writes = 0;
uint32_t now_ms = 0;
struct WiiAccelFixture {
uni_hid_device_t* device = nullptr;
int32_t acceleration[3]{};
uint32_t sequence = 0;
bool valid = false;
};
WiiAccelFixture remote_accel_fixture;
WiiAccelFixture nunchuk_accel_fixture;
#ifdef SWITCH2_BRIDGE_WII_INPUT
WiiAccelFixture gyro_fixture;
bool wii_rumble_ready = true;
#endif
bool wii_accel_fixture_snapshot(const WiiAccelFixture& fixture,
uni_hid_device_t* controller,
int32_t acceleration[3], uint32_t* sequence) {
if (!fixture.valid || fixture.device != controller) return false;
memcpy(acceleration, fixture.acceleration, sizeof(fixture.acceleration));
*sequence = fixture.sequence;
return true;
}
bool bondable = true;
bool ssp_auto_accept = true;
uint8_t accepted_stk_methods = 0xff;
uint16_t link_supervision_timeout = 0;
btstack_packet_handler_t pairing_event_handler = nullptr;
btstack_packet_handler_t identity_event_handler = nullptr;
int confirmation_accepts = 0;
int confirmation_rejections = 0;
int passkey_accepts = 0;
int passkey_rejections = 0;
int delete_key_calls = 0;
bd_addr_t classic_bonds[4]{};
int classic_bond_count = 0;
bd_addr_t ble_bonds[4]{};
int ble_bond_types[4]{};
int ble_bond_count = 0;
bool flash_core_init_result = true;
int flash_core_init_calls = 0;
int core1_launch_calls = 0;
bool expect_configuration_timer_prearmed = false;
uint32_t expected_configuration_timer_add_count = 0;
int cyw43_init_calls = 0;
int uni_init_calls = 0;
void (*during_uni_init)() = nullptr;
int switch2_wake_initializations = 0;
int switch2_wake_requests = 0;
bool switch2_connections_ready = true;
int device_disconnect_calls = 0;
uni_hid_device_t* last_disconnected_device = nullptr;
uni_hid_device_t* lookup_devices[8]{};
size_t lookup_device_count = 0;
uint32_t expected_pending_clear_token = 0;
uint32_t repeated_in_progress_clear_token = 0;
bool repeat_clear_during_disconnect = false;
void require_clear_completion_pending();
void require_clear_snapshot_published();
void request_repeated_clear_during_disconnect();
gap_connection_type_t gap_connection_types[256]{};
uint16_t negotiated_intervals[256]{};
uint16_t pending_intervals[256]{};
unsigned interval_requests[256]{};
bool defer_interval_updates = false;
bool reject_interval_updates = false;
uint8_t xbox_left_trigger = 0;
uint8_t xbox_right_trigger = 0;
unsigned xbox_quad_calls = 0;
unsigned ordinary_smp_requests = 0;
bd_addr_t switch2_pairings[UNI_SWITCH2_PAIRING_CAPACITY]{};
uint8_t switch2_pairing_types[UNI_SWITCH2_PAIRING_CAPACITY]{};
uint8_t switch2_pairing_count = 0;
bool switch2_clear_succeeds = true;
struct Switch2HostEvent {
uni_hid_device_t* device;
uni_switch2_haptics_frame_t frame;
uint32_t received_ms;
uint16_t duration_ms;
uint8_t weak;
uint8_t strong;
bool hd;
};
std::vector<Switch2HostEvent> switch2_host_events;
uint32_t switch2_output_drops = 0;
struct LocalRumbleEvent {
uni_hid_device_t* device;
uint16_t duration_ms;
uint8_t high;
uint8_t low;
};
std::vector<LocalRumbleEvent> local_rumble_events;
struct WiiModeEvent {
uni_hid_device_t* device;
wii_mode_t mode;
};
std::vector<WiiModeEvent> wii_mode_events;
bool wii_dispatch_active = false;
struct CoreStopped {};
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
void play_rumble(uni_hid_device_t* device, uint16_t,
uint16_t duration_ms, uint8_t high, uint8_t low) {
local_rumble_events.push_back({device, duration_ms, high, low});
++device->rumble_calls;
device->last_high = high;
device->last_low = low;
device->last_rumble_duration_ms = duration_ms;
}
void set_lightbar(uni_hid_device_t* device, uint8_t red, uint8_t green,
uint8_t blue) {
++device->lightbar_calls;
device->lightbar_red = red;
device->lightbar_green = green;
device->lightbar_blue = blue;
}
void set_player_leds(uni_hid_device_t* device, uint8_t leds) {
++device->player_led_calls;
device->player_leds = leds;
}
uni_hid_device_t device(
int idx, bool gamepad = true,
uni_bt_conn_protocol_t protocol = UNI_BT_CONN_PROTOCOL_NONE) {
uni_hid_device_t result{};
result.idx = idx;
result.gamepad = gamepad;
result.conn.protocol = protocol;
result.conn.handle = static_cast<hci_con_handle_t>(0x40 + idx);
negotiated_intervals[result.conn.handle] = 6;
pending_intervals[result.conn.handle] = 0;
result.conn.btaddr[5] = static_cast<uint8_t>(idx + 1);
result.vendor_id = static_cast<uint16_t>(0x1000 + idx);
result.product_id = static_cast<uint16_t>(0x2000 + idx);
result.report_parser.play_dual_rumble = play_rumble;
gap_connection_types[result.conn.handle] =
protocol == UNI_BT_CONN_PROTOCOL_BR_EDR
? GAP_CONNECTION_ACL
: protocol == UNI_BT_CONN_PROTOCOL_BLE
? GAP_CONNECTION_LE
: GAP_CONNECTION_INVALID;
return result;
}
void register_lookup_device(uni_hid_device_t* candidate) {
require(lookup_device_count <
sizeof(lookup_devices) / sizeof(lookup_devices[0]),
"test BLE lookup registry overflow");
lookup_devices[lookup_device_count++] = candidate;
}
} // namespace
void xboxone_play_quad_rumble(uni_hid_device_t* device, uint16_t delay,
uint16_t duration, uint8_t left, uint8_t right,
uint8_t weak, uint8_t strong) {
++xbox_quad_calls;
xbox_left_trigger = left;
xbox_right_trigger = right;
play_rumble(device, delay, duration, weak, strong);
}
void uni_hid_parser_xboxone_play_dual_rumble(
uni_hid_device_t* device, uint16_t delay, uint16_t duration,
uint8_t weak, uint8_t strong) {
xboxone_play_quad_rumble(device, delay, duration, 0, 0, weak, strong);
}
extern "C" void __real_sm_request_pairing(hci_con_handle_t) {
require(SWITCH_PICO_ENABLE_BLE, "disabled BLE transport entered SMP");
++ordinary_smp_requests;
}
extern "C" bool uni_switch2_pairing_get(
uint8_t index, uint8_t* address_type, uint8_t address[6]) {
if (index >= switch2_pairing_count) {
return false;
}
*address_type = switch2_pairing_types[index];
memcpy(address, switch2_pairings[index], sizeof(bd_addr_t));
return true;
}
extern "C" bool uni_switch2_pairing_known(
uint8_t address_type, const uint8_t address[6]) {
for (uint8_t index = 0; index < switch2_pairing_count; ++index) {
if (switch2_pairing_types[index] == address_type &&
memcmp(switch2_pairings[index], address, sizeof(bd_addr_t)) == 0) {
return true;
}
}
return false;
}
extern "C" bool uni_switch2_pairing_clear(void) {
if (!switch2_clear_succeeds) {
return false;
}
switch2_pairing_count = 0;
return true;
}
extern "C" bool uni_hid_parser_switch2_is_ble_device(
const uni_hid_device_t* device) {
return device != nullptr &&
device->conn.protocol == UNI_BT_CONN_PROTOCOL_BLE &&
device->vendor_id == UNI_SW2_NINTENDO_VID &&
(device->product_id == UNI_SW2_PRO_PID ||
device->product_id == UNI_SW2_JOYCON_L_PID ||
device->product_id == UNI_SW2_JOYCON_R_PID);
}
extern "C" bool uni_hid_parser_switch2_queue_haptics(
uni_hid_device_t* device, const uni_switch2_haptics_frame_t* frame,
uint32_t received_ms) {
require(uni_switch2_haptics_valid(frame), "backend emitted invalid physical HD frame");
const bool stop = device->product_id == UNI_SW2_PRO_PID ?
uni_switch2_haptics_is_stop(frame) : [&]() {
uni_switch2_haptics_frame_t stereo = *frame;
stereo.sides[1] = stereo.sides[0];
return uni_switch2_haptics_is_stop(&stereo);
}();
if (device->switch2_host_blocked && !stop) return false;
++device->switch2_host_calls;
switch2_host_events.push_back({device, *frame, received_ms, 0, 0, 0, true});
return true;
}
extern "C" bool uni_hid_parser_switch2_queue_rumble(
uni_hid_device_t* device, uint8_t weak, uint8_t strong,
uint16_t duration_ms, uint32_t received_ms) {
if (device->switch2_host_blocked && (weak | strong) != 0) return false;
++device->switch2_host_calls;
device->last_high = weak;
device->last_low = strong;
device->last_rumble_duration_ms = duration_ms;
switch2_host_events.push_back({device, {}, received_ms, duration_ms, weak, strong, false});
return true;
}
extern "C" void uni_hid_parser_switch2_reset_haptics(uni_hid_device_t* device) {
++device->switch2_haptics_resets;
device->last_high = 0;
device->last_low = 0;
device->last_rumble_duration_ms = 0;
}
extern "C" uint32_t uni_hid_parser_switch2_haptics_dropped(void) {
return switch2_output_drops;
}
extern "C" uint8_t uni_hid_parser_switch2_extra_buttons(
const uni_hid_device_t* device) {
return uni_hid_parser_switch2_is_ble_device(device)
? device->switch2_extra_buttons : 0;
}
extern "C" bool uni_hid_parser_switch2_identity_address_type(
const uni_hid_device_t* device, uint8_t* output) {
if (!uni_hid_parser_switch2_is_ble_device(device) ||
!device->switch2_identity_valid || output == nullptr) {
return false;
}
*output = device->switch2_identity_address_type;
return true;
}
bool uni_hid_device_is_gamepad(const uni_hid_device_t* device) {
return device != nullptr && device->gamepad;
}
int uni_hid_device_get_idx_for_instance(const uni_hid_device_t* device) {
return device == nullptr ? -1 : device->idx;
}
uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle(
hci_con_handle_t handle) {
for (size_t index = 0; index < lookup_device_count; ++index) {
if (lookup_devices[index]->conn.handle == handle) {
return lookup_devices[index];
}
}
return nullptr;
}
uni_hid_device_t* uni_hid_device_get_instance_for_address(const bd_addr_t address) {
for (size_t index = 0; index < lookup_device_count; ++index) {
if (memcmp(lookup_devices[index]->conn.btaddr, address, sizeof(bd_addr_t)) == 0) {
return lookup_devices[index];
}
}
return nullptr;
}
void uni_hid_device_disconnect(uni_hid_device_t* device) {
require_clear_completion_pending();
request_repeated_clear_during_disconnect();
++device_disconnect_calls;
last_disconnected_device = device;
}
void uni_bt_allow_incoming_connections(bool enabled) {
require(!enabled || SWITCH_PICO_ENABLE_CLASSIC,
"disabled Classic transport enabled incoming page scanning");
incoming_connections = enabled;
}
void uni_bt_bredr_scan_start() {
require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic inquiry started");
classic_scanning_enabled = true;
}
void uni_bt_bredr_scan_stop() {
require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic inquiry was accessed");
classic_scanning_enabled = false;
}
void uni_bt_le_scan_start() {
require(SWITCH_PICO_ENABLE_BLE, "disabled BLE scanning started");
scanning_enabled = true;
}
void uni_bt_le_scan_stop() {
require(SWITCH_PICO_ENABLE_BLE, "disabled BLE scanning was accessed");
scanning_enabled = false;
}
void uni_bt_le_set_background_scan(bool enabled) {
require(SWITCH_PICO_ENABLE_BLE, "disabled BLE scan parameters were accessed");
require(!scanning_enabled, "LE scan timing must change only while scanning is stopped");
background_scan_parameters = enabled;
}
void uni_bt_start_scanning_and_autoconnect_unsafe() {
require_clear_completion_pending();
require_clear_snapshot_published();
if (aggregate_scanning_enabled) {
return;
}
aggregate_scanning_enabled = true;
if (SWITCH_PICO_ENABLE_CLASSIC) uni_bt_bredr_scan_start();
if (SWITCH_PICO_ENABLE_BLE) uni_bt_le_scan_start();
}
void uni_bt_stop_scanning_unsafe() {
if (!aggregate_scanning_enabled) {
return;
}
aggregate_scanning_enabled = false;
if (SWITCH_PICO_ENABLE_CLASSIC) uni_bt_bredr_scan_stop();
if (SWITCH_PICO_ENABLE_BLE) uni_bt_le_scan_stop();
}
void uni_bt_del_keys_unsafe() {
require_clear_completion_pending();
++delete_key_calls;
classic_bond_count = 0;
ble_bond_count = 0;
}
gap_connection_type_t gap_get_connection_type(
hci_con_handle_t connection_handle) {
return connection_handle <
sizeof(gap_connection_types) /
sizeof(gap_connection_types[0])
? gap_connection_types[connection_handle]
: GAP_CONNECTION_INVALID;
}
uint16_t gap_le_connection_interval(hci_con_handle_t handle) {
require(handle < 256, "interval read must target a valid fake connection");
return negotiated_intervals[handle];
}
int gap_update_connection_parameters(hci_con_handle_t handle, uint16_t minimum,
uint16_t maximum, uint16_t latency,
uint16_t supervision_timeout) {
require(handle < 256 && gap_get_connection_type(handle) == GAP_CONNECTION_LE,
"interval policy must not update a Classic or invalid link");
require(minimum == maximum && latency == 0 && supervision_timeout >= 100,
"interval request must retain valid supervision and peripheral latency");
++interval_requests[handle];
if (reject_interval_updates) return 0x0c;
if (defer_interval_updates) pending_intervals[handle] = minimum;
else negotiated_intervals[handle] = minimum;
return ERROR_CODE_SUCCESS;
}
int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator) {
iterator->index = 0;
return 1;
}
int gap_link_key_iterator_get_next(
btstack_link_key_iterator_t* iterator, bd_addr_t address,
link_key_t link_key, link_key_type_t* type) {
if (iterator->index >= classic_bond_count) {
return 0;
}
memcpy(address, classic_bonds[iterator->index], sizeof(bd_addr_t));
memset(link_key, iterator->index + 1, sizeof(link_key_t));
*type = 0;
++iterator->index;
return 1;
}
void gap_link_key_iterator_done(btstack_link_key_iterator_t*) {
}
int le_device_db_max_count() {
return 4;
}
void le_device_db_info(
int index, int* address_type, bd_addr_t address, sm_key_t irk) {
if (index < ble_bond_count) {
*address_type = ble_bond_types[index];
memcpy(address, ble_bonds[index], sizeof(bd_addr_t));
if (irk != nullptr) {
memset(irk, index + 1, sizeof(sm_key_t));
}
return;
}
*address_type = BD_ADDR_TYPE_UNKNOWN;
}
void gap_set_bondable_mode(int enabled) {
bondable = enabled != 0;
}
void gap_set_link_supervision_timeout(uint16_t timeout) {
require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic link policy was accessed");
link_supervision_timeout = timeout;
}
void gap_ssp_set_auto_accept(int auto_accept) {
require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic SSP was accessed");
ssp_auto_accept = auto_accept != 0;
}
void sm_set_accepted_stk_generation_methods(uint8_t methods) {
require(SWITCH_PICO_ENABLE_BLE, "uninitialized SM pairing policy was accessed");
accepted_stk_methods = methods;
}
int gap_ssp_confirmation_response(const bd_addr_t) {
require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic confirmation was accepted");
++confirmation_accepts;
return 0;
}
int gap_ssp_confirmation_negative(const bd_addr_t) {
require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic confirmation was accessed");
++confirmation_rejections;
return 0;
}
int gap_ssp_passkey_response(const bd_addr_t, uint32_t) {
require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic passkey was accepted");
++passkey_accepts;
return 0;
}
int gap_ssp_passkey_negative(const bd_addr_t) {
require(SWITCH_PICO_ENABLE_CLASSIC, "disabled Classic passkey was accessed");
++passkey_rejections;
return 0;
}
void hci_add_event_handler(
btstack_packet_callback_registration_t* callback_handler) {
pairing_event_handler = callback_handler->callback;
}
void sm_add_event_handler(
btstack_packet_callback_registration_t* callback_handler) {
require(SWITCH_PICO_ENABLE_BLE, "event handler added to uninitialized SM");
identity_event_handler = callback_handler->callback;
}
uint8_t hci_event_packet_get_type(const uint8_t* packet) {
return packet[0];
}
void copy_event_address(const uint8_t* packet, bd_addr_t address) {
for (size_t index = 0; index < sizeof(bd_addr_t); ++index) {
address[index] = packet[7 - index];
}
}
void hci_event_user_confirmation_request_get_bd_addr(
const uint8_t* packet, bd_addr_t address) {
copy_event_address(packet, address);
}
void hci_event_user_passkey_request_get_bd_addr(
const uint8_t* packet, bd_addr_t address) {
copy_event_address(packet, address);
}
hci_con_handle_t sm_event_handle(const uint8_t* packet) {
return static_cast<hci_con_handle_t>(packet[2]) |
static_cast<hci_con_handle_t>(packet[3] << 8);
}
void copy_sm_event_address(const uint8_t* packet, size_t offset,
bd_addr_t address) {
for (size_t index = 0; index < sizeof(bd_addr_t); ++index) {
address[index] = packet[offset + sizeof(bd_addr_t) - 1 - index];
}
}
hci_con_handle_t sm_event_identity_resolving_started_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
hci_con_handle_t sm_event_identity_resolving_failed_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
hci_con_handle_t sm_event_identity_resolving_succeeded_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
uint8_t sm_event_identity_resolving_succeeded_get_addr_type(
const uint8_t* packet) {
return packet[4];
}
void sm_event_identity_resolving_succeeded_get_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 5, address);
}
uint8_t sm_event_identity_resolving_succeeded_get_identity_addr_type(
const uint8_t* packet) {
return packet[11];
}
void sm_event_identity_resolving_succeeded_get_identity_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 12, address);
}
hci_con_handle_t sm_event_identity_created_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
void sm_event_identity_created_get_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 5, address);
}
uint8_t sm_event_identity_created_get_identity_addr_type(
const uint8_t* packet) {
return packet[11];
}
void sm_event_identity_created_get_identity_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 12, address);
}
hci_con_handle_t sm_event_reencryption_started_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
uint8_t sm_event_reencryption_started_get_addr_type(
const uint8_t* packet) {
return packet[4];
}
void sm_event_reencryption_started_get_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 5, address);
}
hci_con_handle_t sm_event_reencryption_complete_get_handle(
const uint8_t* packet) {
return sm_event_handle(packet);
}
uint8_t sm_event_reencryption_complete_get_addr_type(
const uint8_t* packet) {
return packet[4];
}
void sm_event_reencryption_complete_get_address(
const uint8_t* packet, bd_addr_t address) {
copy_sm_event_address(packet, 5, address);
}
uint8_t sm_event_reencryption_complete_get_status(
const uint8_t* packet) {
return packet[11];
}
void uni_platform_set_custom(uni_platform* platform) {
installed_platform = platform;
}
int uni_init(int, const char**) {
++uni_init_calls;
if (during_uni_init != nullptr) during_uni_init();
return 0;
}
bool flash_safe_execute_core_init() {
++flash_core_init_calls;
return flash_core_init_result;
}
int cyw43_arch_init() {
++cyw43_init_calls;
return 0;
}
void cyw43_arch_gpio_put(int, bool enabled) {
observed_status_led_on = enabled;
++observed_status_led_writes;
}
void multicore_launch_core1_with_stack(void (*)(), uint32_t* stack, size_t size) {
require(stack != nullptr && size % 8 == 0,
"core 1 launch needs an aligned bounded stack");
++core1_launch_calls;
}
void tight_loop_contents() {
throw CoreStopped{};
}
uint32_t btstack_run_loop_get_time_ms() {
return now_ms;
}
uint32_t time_us_32() {
return now_ms * 1000u;
}
void switch2_wake_initialize() {
++switch2_wake_initializations;
if (!SWITCH_PICO_ENABLE_BLE) switch2_connections_ready = true;
}
bool switch2_wake_ready_for_connections() {
return switch2_connections_ready;
}
bool switch2_wake_request() {
++switch2_wake_requests;
return true;
}
void switch2_wake_diagnostics(Switch2WakeDiagnostics*) {
}
#include "core/controller_identity.cpp"
namespace {
unsigned state_lock_depth = 0;
void tracked_state_lock_enter(critical_section_t* lock) {
critical_section_enter_blocking(lock);
++state_lock_depth;
}
void tracked_state_lock_exit(critical_section_t* lock) {
require(state_lock_depth != 0, "state lock exit must match an enter");
--state_lock_depth;
critical_section_exit(lock);
}
} // namespace
#define critical_section_enter_blocking tracked_state_lock_enter
#define critical_section_exit tracked_state_lock_exit
#include "input/bluepad32_input_backend.cpp"
#undef critical_section_enter_blocking
#undef critical_section_exit
extern "C" void uni_hid_parser_wii_set_mode(
uni_hid_device_t* device, wii_mode_t mode) {
require(wii_dispatch_active && state_lock_depth == 0,
"Wii parser mode changes must run in the Bluetooth timer without the state lock");
wii_mode_events.push_back({device, mode});
device->controller_subtype = mode == WII_MODE_VERTICAL
? CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL
: CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL;
// The real parser's report reconfiguration can synchronously announce ready.
require(platform_on_device_ready(device) == UNI_ERROR_SUCCESS,
"Wii mode reconfiguration must retain the ready connection");
}
extern "C" bool uni_hid_parser_wii_accel_snapshot(
uni_hid_device_t* controller, int32_t acceleration[3], uint32_t* sequence) {
return wii_accel_fixture_snapshot(remote_accel_fixture, controller, acceleration, sequence);
}
extern "C" bool uni_hid_parser_wii_nunchuk_accel_snapshot(
uni_hid_device_t* controller, int32_t acceleration[3], uint32_t* sequence) {
return wii_accel_fixture_snapshot(nunchuk_accel_fixture, controller, acceleration, sequence);
}
#ifdef SWITCH2_BRIDGE_WII_INPUT
extern "C" bool uni_hid_parser_wii_gyro_snapshot(
uni_hid_device_t* controller, int32_t gyro[3], uint32_t* sequence) {
return wii_accel_fixture_snapshot(gyro_fixture, controller, gyro, sequence);
}
extern "C" bool uni_hid_parser_wii_rumble_ready(uni_hid_device_t*) {
require(state_lock_depth == 0, "Wii dispatch readiness must be checked on Core 1 outside the state lock");
return wii_rumble_ready;
}
#endif
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
namespace {
std::vector<btstack_timer_source_t*> native_timers;
std::array<uint8_t, 143> last_native_packet{};
uint16_t last_native_cid = 0;
}
void native_test_add_timer(btstack_timer_source_t* timer) {
btstack_run_loop_remove_timer(timer);
timer->due_ms = uint64_t{now_ms} + timer->timeout_ms + 1;
native_timers.push_back(timer);
}
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) {
const auto found = std::find(native_timers.begin(), native_timers.end(), timer);
if (found == native_timers.end()) return 0;
native_timers.erase(found);
return 1;
}
uint64_t time_us_64() { return uint64_t{now_ms} * 1000; }
uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t) { return 143; }
bool l2cap_can_send_packet_now(uint16_t) { return true; }
int hci_number_free_acl_slots_for_handle(uint16_t) { return 8; }
uint8_t l2cap_request_can_send_now_event(uint16_t cid) {
for (const auto& slot : g_slots) {
if (slot.device != nullptr && slot.device->conn.interrupt_cid == cid) {
(void)uni_platform_on_l2cap_can_send_now(slot.device, cid);
return ERROR_CODE_SUCCESS;
}
}
require(false, "native permission targeted a detached connection");
return 1;
}
uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) {
require(size == last_native_packet.size(), "native report size changed");
std::copy(data, data + size, last_native_packet.begin());
last_native_cid = cid;
return ERROR_CODE_SUCCESS;
}
void haptics_transport_probe_prepare() {}
void haptics_transport_probe_begin(uint32_t, uint32_t, uint16_t) {}
void haptics_transport_probe_end() {}
void haptics_transport_probe_timer(uint32_t) {}
void haptics_transport_probe_permission(uint32_t) {}
void haptics_transport_probe_send(uint32_t, uint32_t, bool) {}
#endif
namespace {
uint8_t profile_flash[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE]{};
ProfileStorage runtime_profile_storage;
bool runtime_profile_storage_initialized = false;
bool fail_profile_program = false;
unsigned profile_write_attempts = 0;
unsigned pair_observation_count = 0;
void (*before_pair_seed)(const ControllerIdentity&) = nullptr;
bool runtime_profile_read(void*, uint8_t arena, size_t offset,
uint8_t* output, size_t size) {
require(state_lock_depth == 0, "profile reads must not hold the input state lock");
if (arena >= PROFILE_STORAGE_ARENA_COUNT ||
offset > PROFILE_STORAGE_ARENA_SIZE ||
size > PROFILE_STORAGE_ARENA_SIZE - offset) {
return false;
}
memcpy(output, &profile_flash[arena][offset], size);
return true;
}
bool runtime_profile_erase(void*, uint8_t arena) {
++profile_write_attempts;
require(state_lock_depth == 0, "profile erases must not hold the input state lock");
if (arena >= PROFILE_STORAGE_ARENA_COUNT) return false;
memset(profile_flash[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE);
return true;
}
bool runtime_profile_program(void*, uint8_t arena, size_t offset,
const uint8_t* page, size_t size) {
++profile_write_attempts;
require(state_lock_depth == 0, "profile writes must not hold the input state lock");
if (fail_profile_program || arena >= PROFILE_STORAGE_ARENA_COUNT ||
size != PROFILE_STORAGE_PAGE_SIZE ||
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
offset > PROFILE_STORAGE_ARENA_SIZE - size) {
return false;
}
for (size_t index = 0; index < size; ++index) {
profile_flash[arena][offset + index] &= page[index];
}
return memcmp(&profile_flash[arena][offset], page, size) == 0;
}
ProfileStorageIo runtime_profile_io() {
return {nullptr, PROFILE_STORAGE_ARENA_SIZE,
PROFILE_STORAGE_SECTOR_SIZE, PROFILE_STORAGE_PAGE_SIZE,
runtime_profile_read, runtime_profile_erase, runtime_profile_program};
}
void initialize_runtime_profile_storage() {
if (runtime_profile_storage_initialized) return;
memset(profile_flash, 0xff, sizeof(profile_flash));
require(runtime_profile_storage.initialize(runtime_profile_io()),
"runtime profile catalog must initialize");
runtime_profile_storage_initialized = true;
}
void require_clear_completion_pending() {
if (expected_pending_clear_token != 0) {
require(!bluepad32_input_backend_clear_pairings_completed(
g_pairing_snapshot, expected_pending_clear_token),
"pairing clear token completed before Core1 work finished");
}
}
void require_clear_snapshot_published() {
if (expected_pending_clear_token != 0) {
require(g_pairing_snapshot.status ==
Bluepad32PairingSnapshotStatus::kReady &&
g_pairing_snapshot.record_count == 0,
"pairing clear policy ran before empty snapshot publication");
}
}
void request_repeated_clear_during_disconnect() {
if (repeat_clear_during_disconnect) {
repeat_clear_during_disconnect = false;
repeated_in_progress_clear_token =
bluepad32_input_backend_clear_pairings();
}
}
} // namespace
ControllerIdentity observed_profile_identities[8]{};
size_t observed_profile_identity_count = 0;
AdapterConfiguration runtime_configuration{};
uint32_t runtime_configuration_generation = 1;
void configuration_service_prepare() {}
void configuration_service_initialize_on_storage_core() {}
void configuration_service_task_on_storage_core(uint32_t) {
if (expect_configuration_timer_prearmed) {
require(g_configuration_timer.add_count ==
expected_configuration_timer_add_count,
"configuration work ran before its timer was rearmed");
}
}
void profile_service_prepare() {}
void profile_service_initialize_on_storage_core() {}
void profile_service_task_on_storage_core(uint32_t) {
if (expect_configuration_timer_prearmed) {
require(g_configuration_timer.add_count ==
expected_configuration_timer_add_count,
"profile work ran before its timer was rearmed");
}
}
bool profile_service_observe_identity_on_storage_core(
const ControllerIdentity& identity) {
require(observed_profile_identity_count <
sizeof(observed_profile_identities) /
sizeof(observed_profile_identities[0]),
"profile identity observation fixture overflow");
observed_profile_identities[observed_profile_identity_count++] =
identity;
if (runtime_profile_storage_initialized) {
const ProfileStorageResult result = runtime_profile_storage.ensure_identity(identity);
return result == ProfileStorageResult::kOk ||
result == ProfileStorageResult::kUnchanged;
}
return true;
}
bool profile_service_observe_joycon_pair_on_storage_core(
const ControllerIdentity& identity) {
require(state_lock_depth == 0, "pair admission must release the input state lock");
++pair_observation_count;
if (before_pair_seed != nullptr) before_pair_seed(identity);
initialize_runtime_profile_storage();
const ProfileStorageResult result = runtime_profile_storage.ensure_joycon_pair(identity);
return result == ProfileStorageResult::kOk ||
result == ProfileStorageResult::kUnchanged;
}
void configuration_service_snapshot(ConfigurationServiceSnapshot* output) {
*output = {};
output->state = ConfigurationServiceState::kReady;
output->configuration = runtime_configuration;
output->generation = runtime_configuration_generation;
}
uint32_t configuration_service_reset_generation() {
return 0;
}
uint32_t profile_service_database_generation() {
return runtime_profile_storage.snapshot().generation;
}
void profile_service_active_profile_snapshot(
const ControllerIdentity& identity, ProfileServiceActiveProfileSnapshot* output) {
*output = {};
output->metadata.state = ProfileServiceState::kReady;
output->metadata.generation = profile_service_database_generation();
const auto* owner = runtime_profile_storage.find(identity);
if (owner == nullptr) return;
output->profile_index = owner->active_profile;
output->valid = runtime_profile_storage.get(
identity, owner->active_profile, &output->profile) == ProfileStorageResult::kOk;
}
ConfigurationTransactionStatus profile_service_activate_internal(
uint32_t, const ControllerIdentity& identity, uint8_t profile_index) {
const auto result = runtime_profile_storage.activate(identity, profile_index);
return result == ProfileStorageResult::kOk || result == ProfileStorageResult::kUnchanged
? ConfigurationTransactionStatus::kCommitted
: ConfigurationTransactionStatus::kStorageError;
}
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
AdapterUsbMode test_adapter_mode = AdapterUsbMode::kXInput;
AdapterUsbMode adapter_host_probe_mode() {
return test_adapter_mode;
}
#endif
SwitchRgbColor switch_pro_get_slot_light_color(uint8_t instance) {
static constexpr SwitchRgbColor grips[] = {
{SWITCH_COLOR_SLOT_1_R, SWITCH_COLOR_SLOT_1_G,
SWITCH_COLOR_SLOT_1_B},
{SWITCH_COLOR_SLOT_2_R, SWITCH_COLOR_SLOT_2_G,
SWITCH_COLOR_SLOT_2_B},
{SWITCH_COLOR_SLOT_3_R, SWITCH_COLOR_SLOT_3_G,
SWITCH_COLOR_SLOT_3_B},
{SWITCH_COLOR_SLOT_4_R, SWITCH_COLOR_SLOT_4_G,
SWITCH_COLOR_SLOT_4_B},
};
return instance < sizeof(grips) / sizeof(grips[0])
? switch_pro_calibrate_light_color(grips[instance])
: SwitchRgbColor{};
}
namespace {
bool read_controller_state(uint8_t slot, ControllerState* output) {
Bluepad32SlotSnapshot snapshot{};
bluepad32_input_backend_snapshot(slot, &snapshot);
if (output != nullptr) {
*output = snapshot.state;
}
return snapshot.active;
}
void start_backend() {
bluepad32_input_backend_init();
platform_on_init_complete();
require(incoming_connections == (SWITCH_PICO_ENABLE_CLASSIC != 0) &&
scanning_enabled == (SWITCH_PICO_ENABLE_BLE != 0) &&
classic_scanning_enabled == (SWITCH_PICO_ENABLE_CLASSIC != 0) &&
!bondable && switch2_wake_initializations == 1,
"initialization must scan and admit only the selected transports");
if (SWITCH_PICO_ENABLE_CLASSIC) {
require(link_supervision_timeout == kClassicLinkSupervisionTimeout &&
!ssp_auto_accept && pairing_event_handler != nullptr,
"Classic initialization must retain its reconnect and SSP policy");
} else {
require(pairing_event_handler == nullptr,
"BLE-only initialization registered a Classic pairing handler");
}
if (SWITCH_PICO_ENABLE_BLE) {
require(accepted_stk_methods == 0 && identity_event_handler != nullptr,
"BLE initialization must reject pairing until its window opens");
} else {
require(identity_event_handler == nullptr,
"Classic-only initialization registered an uninitialized SM handler");
}
}
void start_pairing_backend() {
start_backend();
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(g_connection_policy_state == ConnectionPolicyState::Open &&
scanning_enabled == (SWITCH_PICO_ENABLE_BLE != 0) &&
classic_scanning_enabled == (SWITCH_PICO_ENABLE_CLASSIC != 0) &&
incoming_connections == (SWITCH_PICO_ENABLE_CLASSIC != 0),
"test connection setup requires an open pairing window");
}
void dispatch_pairing_event(uint8_t event_type) {
uint8_t packet[8] = {event_type, 6, 1, 2, 3, 4, 5, 6};
pairing_event_handler(HCI_EVENT_PACKET, 0, packet, sizeof(packet));
}
void write_event_address(uint8_t* packet, size_t offset,
const bd_addr_t address) {
for (size_t index = 0; index < sizeof(bd_addr_t); ++index) {
packet[offset + index] =
address[sizeof(bd_addr_t) - 1 - index];
}
}
void dispatch_identity_event(uint8_t event_type,
const uni_hid_device_t& controller,
uint8_t identity_address_type,
const bd_addr_t identity_address,
uint8_t status = ERROR_CODE_SUCCESS) {
uint8_t packet[20]{};
size_t packet_size = 0;
packet[0] = event_type;
packet[2] = static_cast<uint8_t>(controller.conn.handle);
packet[3] = static_cast<uint8_t>(controller.conn.handle >> 8);
switch (event_type) {
case SM_EVENT_IDENTITY_RESOLVING_STARTED:
case SM_EVENT_IDENTITY_RESOLVING_FAILED:
packet_size = 11;
break;
case SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED:
packet_size = sizeof(packet);
packet[4] = BD_ADDR_TYPE_LE_RANDOM;
write_event_address(packet, 5, controller.conn.btaddr);
packet[11] = identity_address_type;
write_event_address(packet, 12, identity_address);
break;
case SM_EVENT_IDENTITY_CREATED:
packet_size = sizeof(packet);
packet[4] = identity_address_type;
write_event_address(packet, 5, identity_address);
packet[11] = identity_address_type;
write_event_address(packet, 12, identity_address);
break;
case SM_EVENT_REENCRYPTION_STARTED:
packet_size = 11;
packet[4] = identity_address_type;
write_event_address(packet, 5, identity_address);
break;
case SM_EVENT_REENCRYPTION_COMPLETE:
packet_size = 12;
packet[4] = identity_address_type;
write_event_address(packet, 5, identity_address);
packet[11] = status;
break;
default:
require(false, "unsupported identity event fixture");
}
packet[1] = static_cast<uint8_t>(packet_size - 2);
identity_event_handler(
HCI_EVENT_PACKET, 0, packet,
static_cast<uint16_t>(packet_size));
}
void require_identity(const ControllerIdentity& actual, bool stable,
ControllerTransport transport, uint8_t address_type,
const bd_addr_t address, uint16_t vendor_id,
uint16_t product_id, const char* message) {
ControllerIdentity expected{};
expected.stable = stable;
expected.transport = transport;
expected.address_type = address_type;
memcpy(expected.address, address, sizeof(expected.address));
expected.vendor_id = vendor_id;
expected.product_id = product_id;
require(controller_identity_equal(actual, expected), message);
}
void test_identity_encoding_contract() {
ControllerIdentity identity{};
identity.stable = true;
identity.transport = ControllerTransport::kBle;
identity.address_type = BD_ADDR_TYPE_LE_RANDOM_IDENTITY;
const bd_addr_t address = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15};
memcpy(identity.address, address, sizeof(address));
identity.vendor_id = 0x1234;
identity.product_id = 0xabcd;
uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{};
const uint8_t expected[CONTROLLER_IDENTITY_ENCODED_SIZE] = {
1, 2, 3, 0, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
0x34, 0x12, 0xcd, 0xab};
require(controller_identity_encode(identity, encoded, sizeof(encoded)) &&
memcmp(encoded, expected, sizeof(expected)) == 0,
"controller identity wire encoding changed");
ControllerIdentity decoded{};
require(controller_identity_decode(encoded, sizeof(encoded), &decoded) &&
controller_identity_equal(identity, decoded),
"controller identity wire round trip failed");
decoded.product_id ^= 1;
require(!controller_identity_equal(identity, decoded),
"controller identity equality must include every field");
encoded[3] = 1;
require(!controller_identity_decode(encoded, sizeof(encoded), &decoded),
"controller identity decoder must reject a nonzero reserved byte");
const ControllerIdentity global = controller_identity_global();
memset(encoded, 0xff, sizeof(encoded));
require(controller_identity_is_global(global) &&
controller_identity_encode(global, encoded,
sizeof(encoded)),
"global identity helper must produce an encodable fallback");
for (uint8_t byte : encoded) {
require(byte == 0,
"global fallback identity must encode as all zeroes");
}
}
void tick_backend_timer(int ticks) {
for (int tick = 0; tick < ticks; ++tick) {
process_rumble_timer(&g_rumble_timer);
}
}
uni_hid_device_t switch2_device(int index, uint16_t product) {
uni_hid_device_t result = device(index, true, UNI_BT_CONN_PROTOCOL_BLE);
result.vendor_id = UNI_SW2_NINTENDO_VID;
result.product_id = product;
result.switch2_identity_valid = true;
result.switch2_identity_address_type = BD_ADDR_TYPE_LE_PUBLIC;
result.report_parser.set_player_leds = set_player_leds;
return result;
}
void ready_switch2(uni_hid_device_t& controller) {
platform_on_device_connected(&controller);
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
"Switch2 physical device must become ready");
}
void remember_switch2(const uni_hid_device_t& controller) {
require(switch2_pairing_count < UNI_SWITCH2_PAIRING_CAPACITY,
"test proprietary trust inventory overflow");
switch2_pairing_types[switch2_pairing_count] = controller.switch2_identity_address_type;
memcpy(switch2_pairings[switch2_pairing_count], controller.conn.btaddr, sizeof(bd_addr_t));
++switch2_pairing_count;
}
Bluepad32SlotSnapshot slot_snapshot(uint8_t index) {
Bluepad32SlotSnapshot result{};
bluepad32_input_backend_snapshot(index, &result);
return result;
}
void require_pair_owner(const ControllerIdentity& identity,
const uni_hid_device_t& left,
const uni_hid_device_t& right) {
ControllerIdentity left_member{};
ControllerIdentity right_member{};
require(controller_identity_is_joycon_pair(identity) &&
controller_identity_joycon_pair_members(
identity, &left_member, &right_member) &&
controller_identity_equal(left_member, identity_for_device(&left)) &&
controller_identity_equal(right_member, identity_for_device(&right)) &&
!controller_identity_equal(identity, left_member) &&
!controller_identity_equal(identity, right_member),
"logical owner must distinguish both typed physical members from their solo owners");
}
void require_active_profile(const ControllerIdentity& identity,
uint8_t expected_index, uint8_t expected_scale) {
const ProfileStorageIdentityIndex* owner = runtime_profile_storage.find(identity);
ControllerProfile profile{};
require(owner != nullptr && owner->active_profile == expected_index &&
runtime_profile_storage.get(identity, owner->active_profile, &profile) ==
ProfileStorageResult::kOk &&
profile.weak_rumble_scale == expected_scale,
"live owner must resolve its independent persisted active profile");
}
void set_runtime_joycon_mode(JoyConMode mode) {
runtime_configuration.joycon_mode = mode;
++runtime_configuration_generation;
process_configuration_timer(&g_configuration_timer);
ConfigurationServiceSnapshot saved{};
configuration_service_snapshot(&saved);
require(saved.configuration.joycon_mode == mode &&
saved.generation == runtime_configuration_generation &&
device_disconnect_calls == 0 && uni_init_calls == 0 &&
core1_launch_calls == 0,
"live mode updates must retain the committed preference without disconnect or restart");
}
void test_switch2_individual_core_start() {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
bluepad32_input_backend_init();
auto right = switch2_device(0, UNI_SW2_JOYCON_R_PID);
auto left = switch2_device(1, UNI_SW2_JOYCON_L_PID);
register_lookup_device(&right);
register_lookup_device(&left);
during_uni_init = []() {
require(installed_platform != nullptr &&
installed_platform->on_device_ready(lookup_devices[0]) == UNI_ERROR_SUCCESS &&
installed_platform->on_device_ready(lookup_devices[1]) == UNI_ERROR_SUCCESS &&
slot_snapshot(0).active && slot_snapshot(1).active &&
pair_observation_count == 0,
"persisted Individual must govern ready callbacks before BTstack initialization completes");
};
bool stopped = false;
try {
core1_main();
} catch (const CoreStopped&) {
stopped = true;
}
during_uni_init = nullptr;
require(stopped && uni_init_calls == 1 && slot_snapshot(0).active && slot_snapshot(1).active,
"Core1 initialization must retain both solo players without an initial pair race");
}
void test_switch2_individual_boot(bool right_first) {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
start_backend();
auto left = switch2_device(right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID);
auto& first = right_first ? right : left;
auto& second = right_first ? left : right;
remember_switch2(left);
remember_switch2(right);
register_lookup_device(&left);
register_lookup_device(&right);
ready_switch2(first);
require(scanning_enabled && background_scan_parameters &&
!classic_scanning_enabled && !bondable &&
accepted_stk_methods == 0 && !switch_pico_switch2_pairing_allowed() &&
platform_on_device_discovered(second.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_SUCCESS,
"Individual must still admit a remembered opposite half without BOOTSEL or authentication");
const auto first_solo = slot_snapshot(0);
platform_on_device_connected(&second);
require(!scanning_enabled &&
platform_on_device_ready(&second) == UNI_ERROR_SUCCESS,
"remembered Individual mate setup must pause scanning and become ready");
process_configuration_timer(&g_configuration_timer);
require(slot_snapshot(0).active && slot_snapshot(1).active &&
slot_snapshot(0).connection_generation == first_solo.connection_generation &&
!scanning_enabled && !classic_scanning_enabled && pair_observation_count == 0,
"persisted Individual must avoid transient boot merging and stop scanning once balanced");
require(controller_identity_equal(slot_snapshot(left.idx).identity, identity_for_device(&left)) &&
controller_identity_equal(slot_snapshot(right.idx).identity, identity_for_device(&right)),
"both Individual players must publish their own solo profile identity");
uni_controller_t data{};
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
data.gamepad.dpad = DPAD_LEFT;
data.gamepad.axis_x = -512;
data.gamepad.axis_y = 100;
left.switch2_extra_buttons = UNI_SW2_BUTTON_LEFT_SL;
platform_on_controller_data(&left, &data);
const auto left_state = slot_snapshot(left.idx).state;
data.gamepad = {};
data.gamepad.buttons = BUTTON_B | BUTTON_THUMB_R;
data.gamepad.axis_rx = 200;
data.gamepad.axis_ry = -512;
right.switch2_extra_buttons = UNI_SW2_BUTTON_RIGHT_SR;
platform_on_controller_data(&right, &data);
const auto right_state = slot_snapshot(right.idx).state;
require(left_state.button_south && !left_state.dpad_left &&
left_state.button_left_shoulder && !left_state.button_right_shoulder &&
left_state.left_stick_x == scale_axis(100) &&
left_state.left_stick_y == INT16_MAX &&
right_state.button_south && right_state.button_left_stick &&
!right_state.button_right_stick && right_state.button_right_shoulder &&
!right_state.button_left_shoulder &&
right_state.left_stick_x == INT16_MAX &&
right_state.left_stick_y == scale_axis(200) &&
slot_snapshot(left.idx).state.left_stick_x == left_state.left_stick_x,
"two Individual halves must keep independent sideways input and rail routing");
Bluepad32PlaytestSnapshot playtest{};
bluepad32_input_backend_playtest_snapshot(left.idx, &playtest);
require(playtest.controller_layout == Bluepad32ControllerLayout::kJoyCon2LeftSolo,
"Individual left must report actual solo topology");
bluepad32_input_backend_playtest_snapshot(right.idx, &playtest);
require(playtest.controller_layout == Bluepad32ControllerLayout::kJoyCon2RightSolo,
"Individual right must report actual solo topology");
bluepad32_input_backend_queue_rumble(left.idx, ControllerRumbleOutput{17, 27});
process_rumble_timer(&g_rumble_timer);
require(left.last_low == 17 && right.last_low == 0,
"Individual player rumble must not fan out to its opposite half");
platform_on_device_disconnected(&second);
require(scanning_enabled && !classic_scanning_enabled &&
platform_on_device_discovered(second.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_SUCCESS,
"losing an Individual half must resume only remembered opposite-half scanning");
ready_switch2(second);
require(!scanning_enabled && !classic_scanning_enabled &&
slot_snapshot(0).active && slot_snapshot(1).active,
"remembered Individual reconnection must stop scanning again without merging");
}
void test_switch2_mode_roundtrip(bool right_first) {
start_backend();
initialize_runtime_profile_storage();
auto left = switch2_device(right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID);
const auto left_identity = identity_for_device(&left);
const auto right_identity = identity_for_device(&right);
auto left_profile = controller_profile_default(left_identity, 2);
left_profile.weak_rumble_scale = 37;
auto right_profile = controller_profile_default(right_identity, 5);
right_profile.weak_rumble_scale = 63;
require(runtime_profile_storage.set(left_identity, 2, left_profile) == ProfileStorageResult::kOk &&
runtime_profile_storage.activate(left_identity, 2) == ProfileStorageResult::kOk &&
runtime_profile_storage.set(right_identity, 5, right_profile) == ProfileStorageResult::kOk &&
runtime_profile_storage.activate(right_identity, 5) == ProfileStorageResult::kOk,
"roundtrip requires independently selected saved solo banks");
ready_switch2(right_first ? right : left);
ready_switch2(right_first ? left : right);
const auto pair_identity = slot_snapshot(0).identity;
auto pair_profile = controller_profile_default(pair_identity, 7);
pair_profile.weak_rumble_scale = 95;
require(runtime_profile_storage.set(pair_identity, 7, pair_profile) == ProfileStorageResult::kOk &&
runtime_profile_storage.activate(pair_identity, 7) == ProfileStorageResult::kOk,
"roundtrip requires a distinct saved composite-bank selection");
auto classic = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
platform_on_device_connected(&classic);
require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS,
"Classic must coexist with either Joy-Con player mode");
uni_controller_t data{};
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
data.gamepad.buttons = BUTTON_Y;
platform_on_controller_data(&classic, &data);
data.gamepad = {};
data.gamepad.dpad = DPAD_UP;
data.gamepad.axis_x = -512;
data.gamepad.axis_y = 100;
left.switch2_extra_buttons = UNI_SW2_BUTTON_GL | UNI_SW2_BUTTON_LEFT_SL;
platform_on_controller_data(&left, &data);
data.gamepad = {};
data.gamepad.buttons = BUTTON_B;
data.gamepad.axis_rx = 200;
data.gamepad.axis_ry = -512;
right.switch2_extra_buttons = UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_RIGHT_SR;
platform_on_controller_data(&right, &data);
bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{71, 81});
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{31, 41});
process_rumble_timer(&g_rumble_timer);
const auto pair_before = slot_snapshot(0);
const auto spare_before = slot_snapshot(1);
const auto classic_before = slot_snapshot(2);
const int classic_calls = classic.rumble_calls;
const unsigned left_resets = left.switch2_haptics_resets;
const unsigned right_resets = right.switch2_haptics_resets;
require(bluepad32_input_backend_capture_start(
0, pair_before.connection_generation, CaptureOptions{}),
"pair capture must start before splitting");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101});
bluepad32_input_backend_queue_profile_feedback(
0, pair_before.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed);
set_runtime_joycon_mode(JoyConMode::kIndividual);
const auto left_solo = slot_snapshot(0);
const auto right_solo = slot_snapshot(1);
require(left_solo.active && right_solo.active &&
left_solo.connection_generation != pair_before.connection_generation &&
right_solo.connection_generation != spare_before.connection_generation &&
controller_identity_equal(left_solo.identity, left_identity) &&
controller_identity_equal(right_solo.identity, right_identity) &&
left_solo.state.button_west && !left_solo.state.dpad_up &&
left_solo.state.left_stick_x == scale_axis(100) &&
left_solo.state.left_stick_y == INT16_MAX &&
right_solo.state.button_south && !right_solo.state.button_east &&
right_solo.state.left_stick_x == INT16_MAX &&
right_solo.state.left_stick_y == scale_axis(200) &&
left_solo.state.motion_sample_count == 0 && right_solo.state.motion_sample_count == 0,
"split must retain both raw halves and immediately restore rotated solo owners at stable slots");
require_active_profile(left_solo.identity, 2, 37);
require_active_profile(right_solo.identity, 5, 63);
Bluepad32CaptureSnapshot capture{};
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kDisconnected &&
left.switch2_haptics_resets > left_resets &&
right.switch2_haptics_resets > right_resets &&
left.last_rumble_duration_ms == 0 && right.last_rumble_duration_ms == 0,
"split must cancel recording and both physical haptics epochs");
const int stops = left.rumble_calls + right.rumble_calls;
bluepad32_input_backend_queue_profile_feedback(
0, pair_before.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed);
process_rumble_timer(&g_rumble_timer);
require(left.rumble_calls + right.rumble_calls == stops &&
left.last_low == 0 && right.last_low == 0 &&
left.player_leds == 1 && right.player_leds == 2 &&
!bluepad32_input_backend_identify(pair_identity),
"retired pair feedback and identify must not leak into Individual players");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{17, 27});
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{47, 57});
process_rumble_timer(&g_rumble_timer);
require(left.last_low == 17 && right.last_low == 47,
"split outputs must route independent player haptics");
const uint8_t capture_slot = right_first ? 1 : 0;
require(bluepad32_input_backend_capture_start(
capture_slot, slot_snapshot(capture_slot).connection_generation, CaptureOptions{}),
"either Individual slot must remain recordable before remerging");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101});
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{111, 121});
bluepad32_input_backend_queue_profile_feedback(
1, right_solo.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed);
before_pair_seed = [](const ControllerIdentity&) {
require(slot_snapshot(0).active && slot_snapshot(1).active &&
!controller_identity_is_joycon_pair(slot_snapshot(0).identity) &&
!controller_identity_is_joycon_pair(slot_snapshot(1).identity),
"live pair storage admission must precede publication of either topology change");
};
set_runtime_joycon_mode(JoyConMode::kPaired);
before_pair_seed = nullptr;
const auto restored = slot_snapshot(0);
require_pair_owner(restored.identity, left, right);
require(restored.connection_generation != left_solo.connection_generation &&
!slot_snapshot(1).active &&
slot_snapshot(1).connection_generation != right_solo.connection_generation &&
!slot_snapshot(1).state.button_south &&
slot_snapshot(1).state.extra_buttons == 0 &&
restored.state.dpad_up && restored.state.button_east &&
restored.state.left_stick_x == INT16_MIN &&
restored.state.right_stick_x == scale_axis(200) &&
restored.state.motion_sample_count == 0 &&
bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kDisconnected &&
left.last_rumble_duration_ms == 0 && right.last_rumble_duration_ms == 0,
"remerge must retain raw pair inputs, cancel either recording, and neutralize the retired solo");
require_active_profile(restored.identity, 7, 95);
require_active_profile(left_identity, 2, 37);
require_active_profile(right_identity, 5, 63);
process_rumble_timer(&g_rumble_timer);
require(left.last_low == 0 && right.last_low == 0 &&
slot_snapshot(2).connection_generation == classic_before.connection_generation &&
slot_snapshot(2).state.button_north &&
classic.rumble_calls == classic_calls && classic.last_low == 71 &&
negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6,
"mode roundtrip must preserve unrelated Classic state, haptics and fast Joy-Con links");
ControllerProfile inactive{};
require(runtime_profile_storage.get(pair_identity, 2, &inactive) == ProfileStorageResult::kOk &&
inactive.weak_rumble_scale == 37,
"existing composite profiles, including inactive seeded rows, must survive mode roundtrips");
}
void test_switch2_mode_two_pairs() {
start_pairing_backend();
auto right0 = switch2_device(0, UNI_SW2_JOYCON_R_PID);
auto right1 = switch2_device(1, UNI_SW2_JOYCON_R_PID);
auto left0 = switch2_device(2, UNI_SW2_JOYCON_L_PID);
auto left1 = switch2_device(3, UNI_SW2_JOYCON_L_PID);
ready_switch2(right0);
ready_switch2(right1);
ready_switch2(left0);
ready_switch2(left1);
const auto pair0 = slot_snapshot(0).identity;
const auto pair1 = slot_snapshot(1).identity;
require(runtime_profile_storage.activate(pair0, 6) == ProfileStorageResult::kOk &&
runtime_profile_storage.activate(pair1, 3) == ProfileStorageResult::kOk,
"two pairs need distinguishable saved selections");
for (unsigned roundtrip = 0; roundtrip < 2; ++roundtrip) {
set_runtime_joycon_mode(JoyConMode::kIndividual);
for (uint8_t slot = 0; slot < 4; ++slot) {
require(slot_snapshot(slot).active, "splitting two pairs must expose all four live players");
}
require(controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left0)) &&
controller_identity_equal(slot_snapshot(1).identity, identity_for_device(&left1)) &&
controller_identity_equal(slot_snapshot(2).identity, identity_for_device(&right0)) &&
controller_identity_equal(slot_snapshot(3).identity, identity_for_device(&right1)) &&
!scanning_enabled && !incoming_connections,
"full-capacity split must choose lowest free slots without moving existing left owners");
set_runtime_joycon_mode(JoyConMode::kPaired);
require_pair_owner(slot_snapshot(0).identity, left0, right0);
require_pair_owner(slot_snapshot(1).identity, left1, right1);
require(!slot_snapshot(2).active && !slot_snapshot(3).active &&
!scanning_enabled && !incoming_connections,
"live pairing hints must restore both exact member combinations and original output slots");
require_active_profile(pair0, 6, UINT8_MAX);
require_active_profile(pair1, 3, UINT8_MAX);
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{11, 21});
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{31, 41});
process_rumble_timer(&g_rumble_timer);
require(left0.last_low == 11 && right0.last_low == 11 &&
left1.last_low == 31 && right1.last_low == 31,
"roundtripped pair membership must remain visible in physical haptic routing");
}
}
void test_switch2_live_pair_failure(bool invalid_identity) {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
start_backend();
initialize_runtime_profile_storage();
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);
uni_controller_t data{};
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
data.gamepad.dpad = DPAD_LEFT;
platform_on_controller_data(&left, &data);
data.gamepad = {};
data.gamepad.buttons = BUTTON_B;
platform_on_controller_data(&right, &data);
const auto left_before = slot_snapshot(0);
const auto right_before = slot_snapshot(1);
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{17, 27});
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{47, 57});
require(bluepad32_input_backend_capture_start(
1, right_before.connection_generation, CaptureOptions{}),
"active solo capture must survive a rejected live merge");
before_pair_seed = [](const ControllerIdentity&) {
require(slot_snapshot(0).active && slot_snapshot(1).active &&
slot_snapshot(0).state.button_south && slot_snapshot(1).state.button_south,
"both active solos must stay intact while a new composite bank is seeded");
};
if (invalid_identity) right.switch2_identity_valid = false;
else fail_profile_program = true;
set_runtime_joycon_mode(JoyConMode::kPaired);
before_pair_seed = nullptr;
Bluepad32CaptureSnapshot capture{};
require(slot_snapshot(0).active && slot_snapshot(1).active &&
slot_snapshot(0).connection_generation == left_before.connection_generation &&
slot_snapshot(1).connection_generation == right_before.connection_generation &&
controller_identity_equal(slot_snapshot(0).identity, left_before.identity) &&
controller_identity_equal(slot_snapshot(1).identity, right_before.identity) &&
slot_snapshot(0).state.button_south && slot_snapshot(1).state.button_south &&
bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kRecording &&
left.switch2_haptics_resets == 0 && right.switch2_haptics_resets == 0,
"failed live pair admission must preserve both solo identities, states, generations and recording");
const unsigned expected_observations = invalid_identity ? 0 : 1;
require(pair_observation_count == expected_observations,
"invalid pair identity must fail before I/O and failed storage admission must be attempted once");
process_rumble_timer(&g_rumble_timer);
require(left.last_low == 17 && right.last_low == 47,
"failed admission must preserve both already-queued solo haptic commands");
for (unsigned tick = 0; tick < 100; ++tick) {
now_ms += 50;
process_configuration_timer(&g_configuration_timer);
}
++runtime_configuration.pairing_window_seconds;
++runtime_configuration_generation;
process_configuration_timer(&g_configuration_timer);
require(pair_observation_count == expected_observations,
"pending Paired preference must not create timer or unrelated-configuration retry storms");
fail_profile_program = false;
right.switch2_identity_valid = true;
if (!invalid_identity) {
require(runtime_profile_storage.initialize(runtime_profile_io()),
"pair failure recovery must replay intact solo profile banks");
}
set_runtime_joycon_mode(JoyConMode::kIndividual);
set_runtime_joycon_mode(JoyConMode::kPaired);
require_pair_owner(slot_snapshot(0).identity, left, right);
require(!slot_snapshot(1).active, "explicit mode retry must recover without Bluetooth reconnect");
}
int live_identity_slot(const ControllerIdentity& identity) {
for (uint8_t index = 0; index < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++index) {
const auto snapshot = slot_snapshot(index);
if (snapshot.active && controller_identity_equal(snapshot.identity, identity)) return index;
}
return -1;
}
int live_pair_slot(const uni_hid_device_t& left, const uni_hid_device_t& right) {
ControllerIdentity pair{};
require(controller_identity_make_joycon_pair(
identity_for_device(&left), identity_for_device(&right), &pair),
"gesture participants must have a valid typed composite identity");
return live_identity_slot(pair);
}
void require_live_solo(const uni_hid_device_t& controller, const char* message) {
require(live_identity_slot(identity_for_device(&controller)) >= 0, message);
}
uni_controller_t gesture_input(bool right, bool trigger = true, bool menu = true) {
uni_controller_t report{};
report.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
if (right) {
report.gamepad.buttons = BUTTON_B | (trigger ? BUTTON_TRIGGER_R : 0);
report.gamepad.misc_buttons = menu ? MISC_BUTTON_START : 0;
report.gamepad.throttle = trigger ? 1023 : 0;
report.gamepad.axis_rx = 200;
report.gamepad.axis_ry = -512;
} else {
report.gamepad.buttons = trigger ? BUTTON_TRIGGER_L : 0;
report.gamepad.misc_buttons = menu ? MISC_BUTTON_SELECT : 0;
report.gamepad.brake = trigger ? 1023 : 0;
report.gamepad.dpad = DPAD_UP;
report.gamepad.axis_x = -512;
report.gamepad.axis_y = 100;
}
return report;
}
bool gesture_profile_consumer_enabled = false;
ControllerState gesture_profile_outputs[BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
void consume_gesture_profiles() {
if (!gesture_profile_consumer_enabled) return;
for (uint8_t index = 0; index < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++index) {
gesture_profile_outputs[index] = controller_profile_runtime_transform(
index, slot_snapshot(index), now_ms, AdapterUsbMode::kXInput).state;
}
}
void gesture_report(uni_hid_device_t& controller, bool trigger = true, bool menu = true) {
auto report = gesture_input(controller.product_id == UNI_SW2_JOYCON_R_PID, trigger, menu);
platform_on_controller_data(&controller, &report);
consume_gesture_profiles();
}
void gesture_tick() {
process_rumble_timer(&g_rumble_timer);
process_configuration_timer(&g_configuration_timer);
consume_gesture_profiles();
}
void gesture_reports(uni_hid_device_t& left, uni_hid_device_t& right) {
gesture_report(left);
gesture_report(right);
gesture_tick();
}
void hold_gesture(uni_hid_device_t& left, uni_hid_device_t& right, uint32_t duration_ms) {
gesture_reports(left, right);
for (uint32_t elapsed = 0; elapsed < duration_ms;) {
const uint32_t step = duration_ms - elapsed < 50 ? duration_ms - elapsed : 50;
elapsed += step;
now_ms += step;
gesture_reports(left, right);
}
}
void release_gesture(uni_hid_device_t& left, uni_hid_device_t& right) {
++now_ms;
gesture_report(left, false, false);
gesture_report(right, false, false);
gesture_tick();
}
void require_gesture_masked(const Bluepad32SlotSnapshot& snapshot) {
const uint16_t menus =
logical_button_bit(ControllerProfileLogicalButton::kSelect) |
logical_button_bit(ControllerProfileLogicalButton::kStart);
require(!snapshot.state.button_select && !snapshot.state.button_start &&
snapshot.state.left_trigger == 0 && snapshot.state.right_trigger == 0 &&
(snapshot.pre_hotkey_button_mask & menus) == 0,
"reserved physical chord must not reach host state or pre-profile hotkeys");
}
void require_gesture_confirmation(size_t first, const uni_hid_device_t& left,
const uni_hid_device_t& right) {
unsigned left_pulses = 0;
unsigned right_pulses = 0;
for (size_t index = first; index < local_rumble_events.size(); ++index) {
const auto& event = local_rumble_events[index];
if ((event.high | event.low) == 0 || event.duration_ms == 0) continue;
require(event.duration_ms <= 150 &&
(event.device == &left || event.device == &right),
"gesture confirmation must be short and restricted to its two physical participants");
if (event.device == &left) ++left_pulses;
else ++right_pulses;
}
require(left_pulses == 1 && right_pulses == 1,
"one successful gesture must acknowledge each physical half exactly once");
}
void test_switch2_gesture_timing() {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
start_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);
const uint32_t configuration_generation = runtime_configuration_generation;
gesture_report(left, true, false);
require(slot_snapshot(0).state.left_trigger == UINT16_MAX,
"a trigger alone is not a reserved chord");
for (unsigned elapsed = 0; elapsed <= 2500; elapsed += 50) {
now_ms = elapsed;
gesture_report(left);
gesture_report(right, true, false);
gesture_tick();
}
require_live_solo(left, "one complete half and one partial half must not join");
require_live_solo(right, "partial participant must retain its solo owner");
require(pair_observation_count == 0, "a partial gesture must never enroll a composite bank");
release_gesture(left, right);
for (unsigned elapsed = 0; elapsed < 500; elapsed += 50) {
now_ms += 50;
gesture_report(left);
gesture_report(right, false, false);
gesture_tick();
}
const uint32_t shared_start = now_ms;
hold_gesture(left, right, 1999);
require_live_solo(left, "two seconds must start at concurrent hold, not the earlier left press");
require(pair_observation_count == 0, "1999ms must not seed or publish a pair");
now_ms = shared_start + 2000;
gesture_report(left);
gesture_tick();
require_live_solo(right, "a fresh left report must not stand in for the right's 2000ms held report");
const size_t confirmation_start = local_rumble_events.size();
gesture_report(right);
gesture_tick();
require(live_pair_slot(left, right) >= 0, "both fresh held spans at 2000ms must join Individual halves");
require_gesture_confirmation(confirmation_start, left, right);
const uint32_t joined_generation = slot_snapshot(live_pair_slot(left, right)).connection_generation;
hold_gesture(left, right, 2500);
gesture_report(left, false, false);
gesture_report(right, false, true);
gesture_tick();
hold_gesture(left, right, 2500);
require(live_pair_slot(left, right) >= 0 &&
slot_snapshot(live_pair_slot(left, right)).connection_generation == joined_generation &&
pair_observation_count == 1,
"held input and one participant's incomplete release must not toggle or reseed again");
release_gesture(left, right);
hold_gesture(left, right, 2000);
require_live_solo(left, "both participants' full release must permit a second toggle");
require_live_solo(right, "second toggle must expose the other physical solo");
require(runtime_configuration.joycon_mode == JoyConMode::kIndividual &&
runtime_configuration_generation == configuration_generation,
"connection gestures must never write the saved adapter preference");
}
void test_switch2_gesture_slot_order(bool left_first, uint8_t lower_slot) {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
start_backend();
auto ordinary = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto left = switch2_device(lower_slot + 1, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(lower_slot, UNI_SW2_JOYCON_R_PID);
if (lower_slot != 0) ready_switch2(ordinary);
// Transport indices can put the left half in the higher player slot,
// even when its ready callback arrives first.
ready_switch2(left_first ? left : right);
ready_switch2(left_first ? right : left);
const auto lower_before = slot_snapshot(lower_slot);
const auto higher_before = slot_snapshot(lower_slot + 1);
const auto unrelated_before = slot_snapshot(0);
hold_gesture(left, right, 2000);
require(live_pair_slot(left, right) == lower_slot &&
!slot_snapshot(lower_slot + 1).active,
"gesture join must retain the lower participating player slot regardless of side or ready order");
const auto joined = slot_snapshot(lower_slot);
require_pair_owner(joined.identity, left, right);
require(joined.connection_generation != lower_before.connection_generation &&
slot_snapshot(lower_slot + 1).connection_generation != higher_before.connection_generation &&
slot_snapshot(lower_slot + 1).state.left_stick_x == 0 &&
slot_snapshot(lower_slot + 1).state.right_stick_x == 0 &&
left.player_leds == (1u << lower_slot) &&
right.player_leds == (1u << lower_slot),
"join must invalidate old player epochs, neutralize the retired slot and light both halves for the retained player");
if (lower_slot != 0) {
require(slot_snapshot(0).active &&
slot_snapshot(0).connection_generation == unrelated_before.connection_generation &&
controller_identity_equal(slot_snapshot(0).identity, unrelated_before.identity),
"gesture join must not take an earlier slot occupied by an unrelated controller");
}
}
void test_switch2_gesture_stale() {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
start_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);
hold_gesture(left, right, 1000);
for (unsigned elapsed = 0; elapsed < 2500; elapsed += 50) {
now_ms += 50;
gesture_report(left);
gesture_tick();
}
require_live_solo(left, "cached right input must not fire after it goes stale");
hold_gesture(left, right, 2200);
require_live_solo(right, "resumed held reports must not rearm a stale attempt without release");
require(pair_observation_count == 0, "stale attempts must not touch pair storage");
release_gesture(left, right);
hold_gesture(left, right, 1000);
now_ms += 1100;
gesture_reports(left, right);
require_live_solo(left, "new reports must not hide a stale inter-report gap from a delayed timer");
hold_gesture(left, right, 2200);
require_live_solo(right, "a delayed-timer stale attempt must also require full release before retry");
release_gesture(left, right);
hold_gesture(left, right, 1900);
now_ms += 100;
gesture_tick();
require_live_solo(left, "fresh cached reports alone cannot establish two full held spans");
gesture_report(left);
gesture_tick();
require_live_solo(right, "a timer and one participant cannot complete the other's held span");
gesture_report(right);
gesture_tick();
require(live_pair_slot(left, right) >= 0, "release and fresh report spans must recover a stale attempt");
}
void test_switch2_gesture_clock_wrap() {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
start_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);
now_ms = UINT32_MAX - 1000u;
hold_gesture(left, right, 1999);
require_live_solo(left, "clock wrap must not shorten the hold");
++now_ms;
gesture_reports(left, right);
require(live_pair_slot(left, right) >= 0, "continuous held reports must complete across millisecond wrap");
}
void configure_gesture_profile(const ControllerIdentity& identity, uint8_t index, uint8_t scale) {
auto profile = controller_profile_default(identity, index);
profile.weak_rumble_scale = scale;
profile.button_map[static_cast<uint8_t>(ControllerProfileLogicalButton::kSelect)] =
static_cast<uint8_t>(ControllerProfileLogicalButton::kNorth);
profile.button_map[static_cast<uint8_t>(ControllerProfileLogicalButton::kStart)] =
static_cast<uint8_t>(ControllerProfileLogicalButton::kCapture);
profile.switching_chord =
logical_button_bit(ControllerProfileLogicalButton::kSelect) |
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
profile.motion_toggle_chord =
logical_button_bit(ControllerProfileLogicalButton::kStart) |
(1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL);
profile.macros[0].trigger_mask = logical_button_bit(ControllerProfileLogicalButton::kSouth);
profile.macros[0].first_step = 0;
profile.macros[0].step_count = 1;
profile.macros[0].mode = ControllerProfileMacroMode::kToggle;
profile.macro_step_count = 1;
for (uint8_t macro = 1; macro < CONTROLLER_PROFILE_MACRO_COUNT; ++macro) {
profile.macros[macro].first_step = 1;
}
profile.macro_steps[0].override_flags = kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 10000;
profile.macro_steps[0].output_button_mask = logical_button_bit(ControllerProfileLogicalButton::kSystem);
require(runtime_profile_storage.set(identity, index, profile) == ProfileStorageResult::kOk &&
runtime_profile_storage.activate(identity, index) == ProfileStorageResult::kOk,
"gesture fixture needs independent profiles with observable macro and reserved-button mappings");
}
void test_switch2_gesture_masking_epochs() {
start_backend();
initialize_runtime_profile_storage();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
const auto left_identity = identity_for_device(&left);
const auto right_identity = identity_for_device(&right);
configure_gesture_profile(left_identity, 2, 37);
configure_gesture_profile(right_identity, 5, 63);
ready_switch2(left);
ready_switch2(right);
const auto pair_identity = slot_snapshot(0).identity;
configure_gesture_profile(pair_identity, 7, 95);
auto ordinary = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
ready_switch2(ordinary);
auto ordinary_report = gesture_input(true, false, false);
ordinary_report.gamepad.buttons = BUTTON_Y;
platform_on_controller_data(&ordinary, &ordinary_report);
const auto ordinary_before = slot_snapshot(2);
bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{71, 81});
gesture_tick();
const int ordinary_rumble_calls = ordinary.rumble_calls;
const unsigned writes_before = profile_write_attempts;
const uint32_t saved_generation = runtime_configuration_generation;
gesture_profile_consumer_enabled = true;
controller_profile_runtime_reset();
release_gesture(left, right);
auto macro_press = gesture_input(true, false, false);
macro_press.gamepad.buttons = BUTTON_A;
++now_ms;
platform_on_controller_data(&right, &macro_press);
consume_gesture_profiles();
require(gesture_profile_outputs[0].button_system, "a real profile macro must be running before the topology epoch");
release_gesture(left, right);
CaptureOptions options{};
options.channels = 0x1f;
options.max_events = 32;
require(bluepad32_input_backend_capture_start(
0, slot_snapshot(0).connection_generation, options),
"pair capture must record the arming path before splitting");
const auto pair_before = slot_snapshot(0);
++now_ms;
gesture_report(left);
require_gesture_masked(slot_snapshot(0));
require(slot_snapshot(0).state.dpad_up && slot_snapshot(0).state.button_east &&
slot_snapshot(0).state.left_stick_x == INT16_MIN &&
slot_snapshot(0).state.right_stick_x == scale_axis(200),
"masking a single physical chord must preserve unrelated buttons, axes and companion input");
gesture_report(right);
gesture_tick();
require_gesture_masked(slot_snapshot(0));
hold_gesture(left, right, 1999);
require(gesture_profile_outputs[0].button_system &&
!gesture_profile_outputs[0].button_north &&
!gesture_profile_outputs[0].button_capture,
"arming must not leak remapped menus or cancel the preexisting macro");
Bluepad32CaptureSnapshot capture{};
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kRecording,
"reserved chord consumption must not interrupt recording before success");
for (uint8_t index = 0; index < capture.event_count; ++index) {
require((capture.events[index].buttons &
(logical_button_bit(ControllerProfileLogicalButton::kSelect) |
logical_button_bit(ControllerProfileLogicalButton::kStart))) == 0 &&
capture.events[index].left_trigger == 0 && capture.events[index].right_trigger == 0,
"recorded macros must never contain reserved gesture components");
}
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101});
bluepad32_input_backend_queue_profile_feedback(
0, pair_before.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed);
const size_t host_events_before = switch2_host_events.size();
const size_t confirmation_start = local_rumble_events.size();
++now_ms;
gesture_reports(left, right);
require_live_solo(left, "Paired default must allow a gesture split");
require_live_solo(right, "gesture split must expose both members");
require_gesture_masked(slot_snapshot(0));
require_gesture_masked(slot_snapshot(1));
require(slot_snapshot(0).state.button_west && slot_snapshot(1).state.button_south &&
slot_snapshot(0).state.left_stick_x == scale_axis(100) &&
slot_snapshot(1).state.left_stick_x == INT16_MAX &&
!gesture_profile_outputs[0].button_system && !gesture_profile_outputs[1].button_system &&
bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kDisconnected &&
switch2_host_events.size() == host_events_before,
"successful split must rotate retained input, cancel macro/capture epochs and discard queued old rumble");
require_gesture_confirmation(confirmation_start, left, right);
bluepad32_input_backend_queue_profile_feedback(
0, pair_before.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed);
now_ms += 200;
gesture_report(left, false, true);
gesture_report(right, true, false);
gesture_tick();
require_gesture_masked(slot_snapshot(0));
require_gesture_masked(slot_snapshot(1));
require_gesture_confirmation(confirmation_start, left, right);
require(slot_snapshot(2).connection_generation == ordinary_before.connection_generation &&
slot_snapshot(2).state.button_north &&
ordinary.rumble_calls == ordinary_rumble_calls && ordinary.last_low == 71,
"gesture confirmation and retired feedback must not disturb unrelated input or rumble");
release_gesture(left, right);
gesture_report(left, true, false);
gesture_report(right, false, true);
require(slot_snapshot(0).state.left_trigger == UINT16_MAX && slot_snapshot(1).state.button_start,
"each half must return its component inputs after both components have released");
release_gesture(left, right);
auto solo_macro_press = gesture_input(false, false, false);
solo_macro_press.gamepad.dpad = DPAD_LEFT;
++now_ms;
platform_on_controller_data(&left, &solo_macro_press);
consume_gesture_profiles();
require(gesture_profile_outputs[0].button_system,
"a real solo profile macro must be running before rejoining");
release_gesture(left, right);
require(bluepad32_input_backend_capture_start(
1, slot_snapshot(1).connection_generation, options),
"the other solo must remain recordable before gesture join");
hold_gesture(left, right, 1999);
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{111, 121});
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{131, 141});
const size_t solo_host_events_before = switch2_host_events.size();
++now_ms;
gesture_reports(left, right);
require(live_pair_slot(left, right) >= 0 && !gesture_profile_outputs[0].button_system &&
!gesture_profile_outputs[1].button_system,
"rejoining must retire both solo macro contexts without reviving the former pair macro");
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kDisconnected &&
switch2_host_events.size() == solo_host_events_before,
"gesture join must disconnect either solo recorder and discard both queued solo rumble epochs");
require_active_profile(pair_identity, 7, 95);
require_active_profile(left_identity, 2, 37);
require_active_profile(right_identity, 5, 63);
require(profile_write_attempts == writes_before &&
runtime_configuration.joycon_mode == JoyConMode::kPaired &&
runtime_configuration_generation == saved_generation,
"gesture roundtrip must preserve all existing profile banks and saved preference without flash writes");
}
void test_switch2_gesture_override_lifetime() {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
start_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
remember_switch2(left);
remember_switch2(right);
register_lookup_device(&left);
register_lookup_device(&right);
ready_switch2(left);
ready_switch2(right);
hold_gesture(left, right, 1900);
platform_on_device_disconnected(&right);
now_ms += 100;
gesture_reports(left, right);
require_live_solo(left, "disconnect during arming must leave the survivor solo");
require(pair_observation_count == 0, "late detached reports cannot complete a pending gesture");
ready_switch2(right);
release_gesture(left, right);
hold_gesture(left, right, 2000);
require(live_pair_slot(left, right) >= 0, "reconnected halves must support a new connection-only join");
auto ordinary = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
ready_switch2(ordinary);
++runtime_configuration.pairing_window_seconds;
++runtime_configuration_generation;
gesture_tick();
require(live_pair_slot(left, right) >= 0, "unrelated ready and configuration reconciliation must retain a join override");
platform_on_device_disconnected(&left);
gesture_tick();
ready_switch2(left);
gesture_tick();
require_live_solo(left, "disconnect must restore Individual for the returning participant");
require_live_solo(right, "disconnect must restore Individual for its still-connected partner");
release_gesture(left, right);
hold_gesture(left, right, 2000);
set_runtime_joycon_mode(JoyConMode::kPaired);
release_gesture(left, right);
hold_gesture(left, right, 2000);
require_live_solo(left, "a gesture must split while the saved default remains Paired");
for (unsigned tick = 0; tick < 20; ++tick) {
now_ms += 50;
gesture_tick();
require(!scanning_enabled && !classic_scanning_enabled &&
platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"forced solos under Paired must stop background mate discovery despite free transport capacity");
}
require_live_solo(left, "background reconciliation must not rematch a manually split pair");
require_live_solo(right, "the second forced solo must stay independent beside an unrelated Classic link");
auto pro = switch2_device(3, UNI_SW2_PRO_PID);
ready_switch2(pro);
gesture_tick();
require_live_solo(right, "new ready events must not immediately undo the forced split");
platform_on_device_disconnected(&right);
gesture_tick();
require(scanning_enabled && background_scan_parameters && !classic_scanning_enabled &&
!bondable && !switch_pico_switch2_pairing_allowed() &&
platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) == UNI_ERROR_SUCCESS,
"participant disconnect must clear the override and resume remembered-mate discovery without fresh pairing");
ready_switch2(right);
gesture_tick();
require(live_pair_slot(left, right) >= 0, "either split participant disconnecting must restore Paired for both");
require(!scanning_enabled && !classic_scanning_enabled,
"default reconnection must stop background discovery after restoring the pair");
release_gesture(left, right);
hold_gesture(left, right, 2000);
platform_on_device_disconnected(&right);
auto replacement = switch2_device(1, UNI_SW2_JOYCON_R_PID);
replacement.conn.btaddr[5] = 0x55;
ready_switch2(replacement);
gesture_tick();
require(live_pair_slot(left, replacement) >= 0 && live_pair_slot(left, right) < 0,
"reusing the physical index for a different identity must not inherit either member's former override");
platform_on_device_disconnected(&right);
gesture_report(right);
gesture_tick();
require(live_pair_slot(left, replacement) >= 0,
"late reports and disconnects from the retired participant must not clear its replacement's topology");
release_gesture(left, replacement);
hold_gesture(left, replacement, 2000);
set_runtime_joycon_mode(JoyConMode::kIndividual);
set_runtime_joycon_mode(JoyConMode::kPaired);
require(live_pair_slot(left, replacement) >= 0, "changing the saved default must clear a forced-solo override");
set_runtime_joycon_mode(JoyConMode::kIndividual);
release_gesture(left, replacement);
hold_gesture(left, replacement, 2000);
require(live_pair_slot(left, replacement) >= 0, "Individual must still permit an explicit current-pair join");
set_runtime_joycon_mode(JoyConMode::kPaired);
set_runtime_joycon_mode(JoyConMode::kIndividual);
require_live_solo(left, "changing the saved default must also clear a forced-pair override");
require_live_solo(replacement, "clearing a forced-pair override must restore both Individual owners");
}
void test_switch2_gesture_two_pairs() {
start_backend();
auto right0 = switch2_device(0, UNI_SW2_JOYCON_R_PID);
auto right1 = switch2_device(1, UNI_SW2_JOYCON_R_PID);
auto left0 = switch2_device(2, UNI_SW2_JOYCON_L_PID);
auto left1 = switch2_device(3, UNI_SW2_JOYCON_L_PID);
ready_switch2(right0);
ready_switch2(right1);
ready_switch2(left0);
ready_switch2(left1);
const auto pair0 = slot_snapshot(live_pair_slot(left0, right0));
const auto pair1 = slot_snapshot(live_pair_slot(left1, right1));
hold_gesture(left0, right1, 2000);
require(live_pair_slot(left0, right0) >= 0 && live_pair_slot(left1, right1) >= 0 &&
slot_snapshot(live_pair_slot(left0, right0)).connection_generation == pair0.connection_generation &&
slot_snapshot(live_pair_slot(left1, right1)).connection_generation == pair1.connection_generation,
"chords from different live pairs must never steal or split either pair's members");
release_gesture(left0, right1);
const size_t first_confirmation = local_rumble_events.size();
hold_gesture(left0, right0, 2000);
require_live_solo(left0, "only the first current pair must split for its own two participants");
require_live_solo(right0, "first pair's right member must become independent");
require(live_pair_slot(left1, right1) >= 0 &&
slot_snapshot(live_pair_slot(left1, right1)).connection_generation == pair1.connection_generation,
"unjoining one pair must not split or invalidate the unrelated pair");
require_gesture_confirmation(first_confirmation, left0, right0);
const size_t second_confirmation = local_rumble_events.size();
hold_gesture(left1, right1, 2000);
require_gesture_confirmation(second_confirmation, left1, right1);
require_live_solo(left1, "second current pair must split independently");
require_live_solo(right1, "second pair's right member must become independent");
release_gesture(left0, right0);
release_gesture(left1, right1);
const auto former_right = slot_snapshot(live_identity_slot(identity_for_device(&right0)));
const auto former_left = slot_snapshot(live_identity_slot(identity_for_device(&left1)));
const size_t confirmation_start = local_rumble_events.size();
hold_gesture(left0, right1, 2000);
require(live_pair_slot(left0, right1) >= 0, "explicit solo participants must be allowed to choose a new cross-pair");
require_gesture_confirmation(confirmation_start, left0, right1);
gesture_tick();
require_live_solo(right0, "cross-joining must not regroup the untouched former right partner");
require_live_solo(left1, "cross-joining must not regroup the untouched former left partner");
require(slot_snapshot(live_identity_slot(identity_for_device(&right0))).connection_generation ==
former_right.connection_generation &&
slot_snapshot(live_identity_slot(identity_for_device(&left1))).connection_generation ==
former_left.connection_generation,
"changing partners must leave former partners' logical epochs intact");
platform_on_device_disconnected(&right1);
auto replacement = switch2_device(1, UNI_SW2_JOYCON_R_PID);
replacement.conn.btaddr[5] = 0x65;
ready_switch2(replacement);
gesture_tick();
require(live_pair_slot(left0, replacement) >= 0,
"a former pair hint must not pin a cleared survivor to a stolen or disconnected member");
require_live_solo(right0, "clearing the current override must preserve the untouched former right's solo override");
require_live_solo(left1, "clearing the current override must preserve the untouched former left's solo override");
hold_gesture(left1, right0, 2000);
require(live_pair_slot(left1, right0) >= 0 && live_pair_slot(left0, replacement) >= 0,
"orphaned former partners must be independently joinable without disturbing the current pair");
}
void test_switch2_gesture_ambiguous() {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
start_backend();
auto left0 = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
auto left1 = switch2_device(2, UNI_SW2_JOYCON_L_PID);
ready_switch2(left0);
ready_switch2(right);
ready_switch2(left1);
const auto spare_before = slot_snapshot(2);
for (unsigned elapsed = 0; elapsed <= 2500; elapsed += 50) {
now_ms = elapsed;
gesture_report(left0);
gesture_report(right);
gesture_report(left1);
gesture_tick();
}
require_live_solo(left0, "two armed left solos must not choose an arbitrary pairing");
require_live_solo(right, "ambiguous solo arming must preserve the only right player");
require_live_solo(left1, "ambiguous solo arming must preserve the other left player");
gesture_report(left1, false, false);
hold_gesture(left0, right, 2200);
require(pair_observation_count == 0 && local_rumble_events.empty(),
"resolving ambiguity without releasing the attempt must not retry storage or acknowledge success");
release_gesture(left0, right);
hold_gesture(left0, right, 2000);
require(live_pair_slot(left0, right) >= 0 &&
slot_snapshot(2).connection_generation == spare_before.connection_generation &&
controller_identity_equal(slot_snapshot(2).identity, spare_before.identity),
"full release must allow one eligible L/R while preserving the unarmed extra solo");
}
void test_switch2_gesture_seed_failure() {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
start_backend();
initialize_runtime_profile_storage();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
const auto left_identity = identity_for_device(&left);
const auto right_identity = identity_for_device(&right);
configure_gesture_profile(left_identity, 2, 37);
configure_gesture_profile(right_identity, 5, 63);
ready_switch2(left);
ready_switch2(right);
// Establish the current USB host mode before testing a gesture failure;
// the adapter variant legitimately resets its initial haptics epoch.
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{});
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{});
release_gesture(left, right);
const unsigned left_resets_before = left.switch2_haptics_resets;
const unsigned right_resets_before = right.switch2_haptics_resets;
const auto left_before = slot_snapshot(0);
const auto right_before = slot_snapshot(1);
CaptureOptions options{};
options.max_duration_ms = 20000;
require(bluepad32_input_backend_capture_start(1, right_before.connection_generation, options),
"failed admission must preserve a live solo capture");
hold_gesture(left, right, 1999);
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{17, 27});
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{47, 57});
fail_profile_program = true;
++now_ms;
gesture_reports(left, right);
Bluepad32CaptureSnapshot capture{};
require_live_solo(left, "failed seed must not retire the left solo");
require_live_solo(right, "failed seed must not retire the right solo");
require(slot_snapshot(0).connection_generation == left_before.connection_generation &&
slot_snapshot(1).connection_generation == right_before.connection_generation &&
slot_snapshot(0).state.button_west && slot_snapshot(1).state.button_south &&
bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kRecording &&
left.switch2_haptics_resets == left_resets_before &&
right.switch2_haptics_resets == right_resets_before &&
left.last_low == 17 && right.last_low == 47 && pair_observation_count == 1,
"seed failure must preserve identities, input, generations, recording and queued independent rumble");
const unsigned failed_writes = profile_write_attempts;
hold_gesture(left, right, 5000);
++runtime_configuration.pairing_window_seconds;
++runtime_configuration_generation;
gesture_tick();
require(pair_observation_count == 1 && profile_write_attempts == failed_writes &&
local_rumble_events.empty(),
"failed held gesture must not retry flash, churn topology or emit confirmation pulses");
fail_profile_program = false;
require(runtime_profile_storage.initialize(runtime_profile_io()),
"catalog replay must retain intact solo banks after failed pair admission");
require_active_profile(left_identity, 2, 37);
require_active_profile(right_identity, 5, 63);
release_gesture(left, right);
hold_gesture(left, right, 2000);
require(live_pair_slot(left, right) >= 0 && pair_observation_count == 2,
"explicit release and retry must recover once profile storage is available");
require_active_profile(slot_snapshot(live_pair_slot(left, right)).identity, 2, 37);
require_active_profile(right_identity, 5, 63);
require(runtime_configuration.joycon_mode == JoyConMode::kIndividual,
"failed and successful connection gestures must both leave the saved preference Individual");
}
void test_switch2_gesture_device_scope() {
start_backend();
auto original_left = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto original_right = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
original_left.vendor_id = original_right.vendor_id = UNI_SW2_NINTENDO_VID;
original_left.product_id = 0x2006;
original_right.product_id = 0x2007;
auto pro = switch2_device(2, UNI_SW2_PRO_PID);
auto other = device(3, true, UNI_BT_CONN_PROTOCOL_BLE);
uni_hid_device_t* controllers[] = {&original_left, &original_right, &pro, &other};
for (auto* controller : controllers) ready_switch2(*controller);
auto report = gesture_input(false);
report.gamepad.buttons |= BUTTON_TRIGGER_R;
report.gamepad.misc_buttons |= MISC_BUTTON_START;
report.gamepad.throttle = 1023;
for (unsigned elapsed = 0; elapsed <= 2500; elapsed += 50) {
now_ms = elapsed;
for (auto* controller : controllers) platform_on_controller_data(controller, &report);
gesture_tick();
}
for (uint8_t index = 0; index < 4; ++index) {
const auto snapshot = slot_snapshot(index);
require(snapshot.active && snapshot.state.button_select && snapshot.state.button_start &&
snapshot.state.left_trigger == UINT16_MAX && snapshot.state.right_trigger == UINT16_MAX,
"original JoyCons, Switch2 Pro and unrelated controllers must retain all chord inputs");
}
require(pair_observation_count == 0 && local_rumble_events.empty(),
"the connection gesture must never operate on non-JoyCon2 controllers");
}
ControllerRumbleOutput ordered_switch2_hd(uint8_t frequency = 40) {
ControllerRumbleOutput rumble{17, 29};
rumble.hd.actuators[0].sample_count = 3;
rumble.hd.actuators[1].sample_count = 2;
rumble.hd.actuators[0].samples[0] = {frequency, 61, 24000, 4000};
rumble.hd.actuators[0].samples[1] = {41, 62, 5000, 25000};
rumble.hd.actuators[0].samples[2] = {42, 63, 20000, 14000};
rumble.hd.actuators[1].samples[0] = {81, 91, 3000, 21000};
rumble.hd.actuators[1].samples[1] = {82, 92, 23000, 7000};
return rumble;
}
void require_switch2_sample(const uint8_t encoded[5],
const SwitchHapticsSample& source) {
uint64_t bits = 0;
for (unsigned index = 0; index < 5; ++index) {
bits |= uint64_t{encoded[index]} << (index * 8);
}
require((bits & 1023u) == 193u + 3u * source.low_frequency_index &&
((bits >> 20) & 1023u) == 289u + 3u * source.high_frequency_index &&
((bits >> 10) & 1023u) ==
((uint32_t{source.low_amplitude_q15} * 29000u / 32767u) >> 6) &&
((bits >> 30) & 1023u) ==
((uint32_t{source.high_amplitude_q15} * 29000u / 32767u) >> 6),
"native physical sample lost its measured frequency or independent band amplitude");
}
void test_switch2_hd_pro() {
start_pairing_backend();
auto pro = switch2_device(0, UNI_SW2_PRO_PID);
ready_switch2(pro);
now_ms = 100;
const auto first = ordered_switch2_hd(40);
const auto second = ordered_switch2_hd(50);
bluepad32_input_backend_queue_rumble(0, first);
now_ms = 101;
bluepad32_input_backend_queue_rumble(0, second);
now_ms = 105;
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 2 && pro.rumble_calls == 0 &&
switch2_host_events[0].received_ms == 100 &&
switch2_host_events[1].received_ms == 101,
"rapid HD commands must bypass the compatibility mailbox in original order and time");
for (unsigned event = 0; event < 2; ++event) {
const auto& input = event == 0 ? first : second;
const auto& output = switch2_host_events[event].frame;
require(output.sides[0].count == 3 && output.sides[1].count == 2,
"Pro output must preserve both native side counts");
for (unsigned side = 0; side < 2; ++side) {
for (unsigned step = 0; step < output.sides[side].count; ++step) {
require_switch2_sample(output.sides[side].samples[step],
input.hd.actuators[side].samples[step]);
}
}
}
ControllerRumbleOutput partial{};
partial.hd.actuators[0].sample_count = 1;
bluepad32_input_backend_queue_rumble(0, first);
bluepad32_input_backend_queue_rumble(0, partial);
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 4 &&
switch2_host_events.back().frame.sides[1].count == 0,
"a silent partial update must neither flush older commands nor invent a right stop");
pro.switch2_host_blocked = true;
bluepad32_input_backend_queue_rumble(0, first);
ControllerRumbleOutput stop{};
stop.hd.actuators[0].sample_count = 1;
stop.hd.actuators[1].sample_count = 1;
bluepad32_input_backend_queue_rumble(0, stop);
now_ms += 100;
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 5 &&
uni_switch2_haptics_is_stop(&switch2_host_events.back().frame),
"explicit native stop must clear older ingress and bypass age/full barriers");
pro.switch2_host_blocked = false;
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{19, 23});
process_rumble_timer(&g_rumble_timer);
require(!switch2_host_events.back().hd && switch2_host_events.back().strong == 19 &&
switch2_host_events.back().weak == 23 && pro.rumble_calls == 0,
"both empty HD sides must use the dedicated conventional host API");
}
void test_switch2_hd_solo(bool right) {
start_pairing_backend();
auto solo = switch2_device(0, right ? UNI_SW2_JOYCON_R_PID : UNI_SW2_JOYCON_L_PID);
ready_switch2(solo);
const auto input = ordered_switch2_hd();
bluepad32_input_backend_queue_rumble(0, input);
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 1 &&
switch2_host_events[0].frame.sides[0].count == 3 &&
switch2_host_events[0].frame.sides[1].count == 0,
"either solo half must receive a mono sequence in physical side zero");
const SwitchHapticsSample expected[] = {
{40, 91, 24000, 21000}, {82, 62, 23000, 25000}, {82, 63, 23000, 14000}};
for (unsigned index = 0; index < 3; ++index) {
require_switch2_sample(switch2_host_events[0].frame.sides[0].samples[index],
expected[index]);
}
auto tie = input;
tie.hd.actuators[0].sample_count = 1;
tie.hd.actuators[1].sample_count = 1;
tie.hd.actuators[1].samples[0].low_amplitude_q15 = 24000;
tie.hd.actuators[1].samples[0].high_amplitude_q15 = 4000;
bluepad32_input_backend_queue_rumble(0, tie);
process_rumble_timer(&g_rumble_timer);
require_switch2_sample(switch2_host_events.back().frame.sides[0].samples[0],
tie.hd.actuators[0].samples[0]);
auto one_side = input;
one_side.hd.actuators[0].sample_count = 0;
bluepad32_input_backend_queue_rumble(0, one_side);
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.back().frame.sides[0].count == 2,
"solo absent source side must not fabricate substeps");
require_switch2_sample(switch2_host_events.back().frame.sides[0].samples[1],
one_side.hd.actuators[1].samples[1]);
}
void test_switch2_hd_pair() {
start_pairing_backend();
auto right = switch2_device(0, UNI_SW2_JOYCON_R_PID);
auto left = switch2_device(1, UNI_SW2_JOYCON_L_PID);
ready_switch2(right);
ready_switch2(left);
right.switch2_host_blocked = true;
now_ms = 10;
const auto input = ordered_switch2_hd();
bluepad32_input_backend_queue_rumble(0, input);
process_rumble_timer(&g_rumble_timer);
now_ms = 15;
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 1 && switch2_host_events[0].device == &left,
"one blocked half must not duplicate acceptance on its ready partner");
right.switch2_host_blocked = false;
now_ms = 20;
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 2 && switch2_host_events[1].device == &right &&
switch2_host_events[1].received_ms == 10,
"paired retry must retain the source timestamp and original missing half");
for (unsigned half = 0; half < 2; ++half) {
const auto& output = switch2_host_events[half].frame;
require(output.sides[0].count == (half == 0 ? 3 : 2) && output.sides[1].count == 0,
"pair stereo must map selected source side onto each physical side zero");
for (unsigned step = 0; step < output.sides[0].count; ++step) {
require_switch2_sample(output.sides[0].samples[step],
input.hd.actuators[half].samples[step]);
}
}
auto right_only = input;
right_only.hd.actuators[0].sample_count = 0;
bluepad32_input_backend_queue_rumble(0, right_only);
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 3 && switch2_host_events.back().device == &right,
"count zero on one paired half must not submit an invented neutral update");
right.switch2_host_blocked = true;
bluepad32_input_backend_queue_rumble(0, input);
process_rumble_timer(&g_rumble_timer);
now_ms += 50;
right.switch2_host_blocked = false;
process_rumble_timer(&g_rumble_timer);
Bluepad32BackendDiagnostics diagnostics{};
bluepad32_input_backend_diagnostics(&diagnostics);
require(switch2_host_events.size() == 4 && diagnostics.switch2_ingress_drops == 1,
"an expired partly accepted pair must drop visibly without late or duplicate delivery");
right.switch2_host_blocked = true;
bluepad32_input_backend_queue_rumble(0, input);
process_rumble_timer(&g_rumble_timer);
ControllerRumbleOutput stop{};
stop.hd.actuators[0].sample_count = 1;
stop.hd.actuators[1].sample_count = 1;
bluepad32_input_backend_queue_rumble(0, stop);
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 7 &&
switch2_host_events[5].device == &left &&
switch2_host_events[6].device == &right &&
switch2_host_events[5].frame.sides[0].count == 1 &&
switch2_host_events[6].frame.sides[0].count == 1,
"paired explicit stop must reach both halves even after partial acceptance and backpressure");
}
void test_switch2_hd_overflow() {
start_pairing_backend();
auto pro = switch2_device(0, UNI_SW2_PRO_PID);
ready_switch2(pro);
now_ms = 100;
for (uint8_t command = 0; command < 17; ++command) {
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(40 + command));
}
Bluepad32BackendDiagnostics diagnostics{};
bluepad32_input_backend_diagnostics(&diagnostics);
require(diagnostics.switch2_ingress_drops == 1 && diagnostics.rumble_pending_slots == 1,
"bounded ingress overflow must expose one oldest-command drop");
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 16,
"one drain must preserve all sixteen surviving commands, not a latest-value mailbox");
for (uint8_t index = 0; index < 16; ++index) {
const auto input = ordered_switch2_hd(41 + index);
require_switch2_sample(switch2_host_events[index].frame.sides[0].samples[0],
input.hd.actuators[0].samples[0]);
}
pro.switch2_host_blocked = true;
now_ms = UINT32_MAX - 20;
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
now_ms = 28; // 49ms across wrap.
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_diagnostics(&diagnostics);
require(diagnostics.rumble_pending_slots == 1 && diagnostics.switch2_ingress_drops == 1,
"backpressure must retain an unexpired command across clock wrap");
now_ms = 29;
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_diagnostics(&diagnostics);
require(diagnostics.rumble_pending_slots == 0 && diagnostics.switch2_ingress_drops == 2 &&
switch2_host_events.size() == 16,
"original 50ms age must release a backpressured ingress head exactly at expiry");
pro.switch2_host_blocked = false;
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
auto& stale = g_slots[0].switch2_ingress;
--stale.commands[stale.head].envelope.connection_generation;
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_diagnostics(&diagnostics);
require(switch2_host_events.size() == 16 && diagnostics.switch2_ingress_drops == 3,
"stale logical generation must be rejected before physical submission");
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
--stale.commands[stale.head].generation;
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_diagnostics(&diagnostics);
require(switch2_host_events.size() == 16 && diagnostics.switch2_ingress_drops == 4,
"stale haptics epoch must not survive a same-connection mode reset");
switch2_output_drops = 7;
bluepad32_input_backend_diagnostics(&diagnostics);
require(diagnostics.switch2_output_drops == 7,
"physical output loss must remain visible separately from ingress loss");
}
void test_switch2_hd_epochs() {
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);
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
process_rumble_timer(&g_rumble_timer);
const unsigned left_resets = left.switch2_haptics_resets;
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
ready_switch2(right);
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 1 && left.switch2_haptics_resets > left_resets &&
right.switch2_haptics_resets != 0,
"merge must reset accepted physical output and drop queued solo commands");
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
process_rumble_timer(&g_rumble_timer);
const unsigned pair_resets = left.switch2_haptics_resets;
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
platform_on_device_disconnected(&right);
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 3 && left.switch2_haptics_resets > pair_resets,
"survivor transition must flush both physical and ingress pair state");
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
platform_on_device_disconnected(&left);
auto replacement = switch2_device(0, UNI_SW2_PRO_PID);
ready_switch2(replacement);
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 3,
"reconnect must not inherit a former logical generation's host output");
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{27, 35});
process_rumble_timer(&g_rumble_timer);
const unsigned mode_resets = replacement.switch2_haptics_resets;
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
test_adapter_mode = AdapterUsbMode::kSwitchProbe;
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 4 && replacement.switch2_haptics_resets > mode_resets,
"mode boundary without new input must neutralize held output and queued old-mode HD");
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(50));
test_adapter_mode = AdapterUsbMode::kXInput;
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{45, 55});
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 5 && !switch2_host_events.back().hd &&
switch2_host_events.back().duration_ms == UINT16_MAX &&
switch2_host_events.back().strong == 45,
"producer-side mode transition must discard old epoch before accepting new host state");
#endif
}
void test_switch2_hd_feedback() {
start_pairing_backend();
auto pro = switch2_device(0, UNI_SW2_PRO_PID);
ready_switch2(pro);
bluepad32_input_backend_queue_profile_feedback(
0, slot_snapshot(0).connection_generation, 2,
ControllerProfileConfirmationPolicy::kRumble);
process_rumble_timer(&g_rumble_timer);
require(pro.rumble_calls == 1, "profile confirmation must retain local-feedback ownership");
now_ms = 10;
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
process_rumble_timer(&g_rumble_timer);
now_ms = 20;
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(50));
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 2 && pro.rumble_calls == 1 &&
switch2_host_events[0].received_ms == 10 &&
switch2_host_events[1].received_ms == 20,
"host substeps must advance during feedback without compatibility dispatch or fresh timestamps");
now_ms = 300;
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 2,
"feedback completion must not replay historical host substeps");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{33, 44});
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 3 && !switch2_host_events.back().hd &&
switch2_host_events.back().duration_ms == host_rumble_duration_ms(),
"conventional host vibration must share the bounded host queue, not local feedback");
now_ms += 1000;
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 3,
"stateful host output must not require backend periodic resubmission");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{});
process_rumble_timer(&g_rumble_timer);
require(switch2_host_events.size() == 4 && switch2_host_events.back().duration_ms == 0 &&
switch2_host_events.back().weak == 0 && switch2_host_events.back().strong == 0,
"conventional all-zero host command must explicitly stop retained state");
pro.switch2_host_blocked = true;
const uint32_t received_ms = now_ms;
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{65, 75});
now_ms += 1000;
process_rumble_timer(&g_rumble_timer);
pro.switch2_host_blocked = false;
process_rumble_timer(&g_rumble_timer);
if (host_rumble_duration_ms() == UINT16_MAX) {
require(switch2_host_events.size() == 5 &&
switch2_host_events.back().received_ms == received_ms &&
switch2_host_events.back().strong == 65,
"held XInput host state must survive backpressure without retimestamping");
} else {
Bluepad32BackendDiagnostics diagnostics{};
bluepad32_input_backend_diagnostics(&diagnostics);
require(switch2_host_events.size() == 4 && diagnostics.switch2_ingress_drops == 1,
"finite conventional host state must expire under feedback/backpressure, not revive");
}
}
void test_switch2_pair_lifecycle(bool right_first) {
start_pairing_backend();
uni_hid_device_t left = switch2_device(
right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID);
uni_hid_device_t right = switch2_device(
right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID);
left.conn.btaddr[0] = 0xc1;
left.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
initialize_runtime_profile_storage();
const ControllerIdentity left_identity = identity_for_device(&left);
const ControllerIdentity right_identity = identity_for_device(&right);
ControllerProfile left_profile = controller_profile_default(left_identity, 2);
left_profile.weak_rumble_scale = 37;
require(runtime_profile_storage.set(left_identity, 2, left_profile) ==
ProfileStorageResult::kOk &&
runtime_profile_storage.activate(left_identity, 2) == ProfileStorageResult::kOk &&
runtime_profile_storage.activate(right_identity, 5) == ProfileStorageResult::kOk,
"solo owners must have independently selected profiles before pairing");
uni_hid_device_t& first = right_first ? right : left;
uni_hid_device_t& second = right_first ? left : right;
ready_switch2(first);
uni_controller_t left_data{};
left_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
left_data.gamepad.dpad = DPAD_UP;
left_data.gamepad.axis_x = -512;
left_data.gamepad.axis_y = 100;
left_data.gamepad.buttons = BUTTON_TRIGGER_L;
left_data.gamepad.accel[0] = 8192;
left.switch2_extra_buttons =
UNI_SW2_BUTTON_GL | UNI_SW2_BUTTON_LEFT_SL | UNI_SW2_BUTTON_LEFT_SR;
uni_controller_t right_data{};
right_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
right_data.gamepad.buttons = BUTTON_B | BUTTON_THUMB_R | BUTTON_TRIGGER_R;
right_data.gamepad.axis_rx = 200;
right_data.gamepad.axis_ry = -512;
right_data.gamepad.accel[2] = 8192;
right.switch2_extra_buttons =
UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_GR |
UNI_SW2_BUTTON_RIGHT_SL | UNI_SW2_BUTTON_RIGHT_SR;
platform_on_controller_data(&first, right_first ? &right_data : &left_data);
const Bluepad32SlotSnapshot solo = slot_snapshot(0);
require(solo.active && solo.state.button_left_shoulder &&
solo.state.button_right_shoulder && solo.state.button_left_stick == right_first &&
solo.state.right_stick_x == 0 && solo.state.right_stick_y == 0 &&
(right_first ? solo.state.button_south : solo.state.button_west),
"solo JoyCon must rotate face controls, stick click and rail shoulders");
Bluepad32PlaytestSnapshot playtest{};
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.controller_layout ==
(right_first ? Bluepad32ControllerLayout::kJoyCon2RightSolo
: Bluepad32ControllerLayout::kJoyCon2LeftSolo),
"playtest must identify the live rotated solo half");
require(solo.state.left_stick_x ==
(right_first ? INT16_MAX : scale_axis(100)) &&
solo.state.left_stick_y ==
(right_first ? scale_axis(200) : INT16_MAX),
"solo JoyCon stick must rotate with its physical sideways orientation");
require(bluepad32_input_backend_capture_start(
0, solo.connection_generation, CaptureOptions{}),
"solo capture must start on its logical generation");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{31, 41});
bluepad32_input_backend_queue_profile_feedback(
0, solo.connection_generation, 8,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
ready_switch2(second);
Bluepad32SlotSnapshot merged = slot_snapshot(0);
require(merged.active && !slot_snapshot(1).active &&
merged.connection_generation != solo.connection_generation,
"either connection order must merge into the first-ready output");
require_pair_owner(merged.identity, left, right);
require_active_profile(merged.identity, 2, 37);
ControllerProfile pair_profile = controller_profile_default(merged.identity, 7);
pair_profile.weak_rumble_scale = 95;
require(runtime_profile_storage.set(merged.identity, 7, pair_profile) ==
ProfileStorageResult::kOk &&
runtime_profile_storage.activate(merged.identity, 7) == ProfileStorageResult::kOk,
"the merged bank must be independently editable");
require_active_profile(left_identity, 2, 37);
require_active_profile(right_identity, 5, UINT8_MAX);
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.controller_layout ==
Bluepad32ControllerLayout::kJoyCon2MergedPair &&
playtest.state.motion_sample_count == 0 &&
controller_identity_equal(playtest.identity, merged.identity),
"playtest must detect a live companion before any merged motion report");
Bluepad32CaptureSnapshot capture{};
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kDisconnected,
"solo recording must not silently cross into the merged generation");
require(!slot_snapshot(1).state.extra_buttons &&
!slot_snapshot(1).state.button_south &&
!slot_snapshot(1).state.button_west &&
left.last_rumble_duration_ms == 0 && right.last_rumble_duration_ms == 0,
"pair merge must neutralize ghost output and stop old feedback");
const int calls_after_merge = left.rumble_calls + right.rumble_calls;
bluepad32_input_backend_queue_profile_feedback(
0, solo.connection_generation, 8,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
process_rumble_timer(&g_rumble_timer);
require(left.rumble_calls + right.rumble_calls == calls_after_merge &&
left.player_leds == 1 && right.player_leds == 1,
"old-generation solo feedback must not reach the pair");
require(!bluepad32_input_backend_identify(left_identity) &&
!bluepad32_input_backend_identify(right_identity) &&
bluepad32_input_backend_identify(merged.identity),
"identify must match the pair owner, not either physical solo owner");
process_rumble_timer(&g_rumble_timer);
require(left.last_low == UINT8_MAX && right.last_low == UINT8_MAX &&
left.player_leds == 1 && right.player_leds == 1,
"identifying the pair owner must reach both physical halves");
now_ms += 150;
process_rumble_timer(&g_rumble_timer);
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, left,
left.switch2_identity_address_type, left.conn.btaddr);
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, right,
right.switch2_identity_address_type, right.conn.btaddr);
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
"physical identity publication must not replace an enrolled composite owner");
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_FAILED, left,
left.switch2_identity_address_type, left.conn.btaddr);
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_STARTED, right,
right.switch2_identity_address_type, right.conn.btaddr);
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
"physical identity resolution resets must not demote a live pair to global");
platform_on_controller_data(&left, &left_data);
platform_on_controller_data(&right, &right_data);
merged = slot_snapshot(0);
require(merged.state.dpad_up && merged.state.button_east &&
merged.state.button_right_stick && !merged.state.button_left_stick &&
!merged.state.button_left_shoulder && !merged.state.button_right_shoulder &&
merged.state.left_stick_x == INT16_MIN &&
merged.state.right_stick_x == scale_axis(200) &&
merged.state.left_trigger == UINT16_MAX &&
merged.state.right_trigger == UINT16_MAX &&
merged.state.extra_buttons == 0x7f &&
merged.state.motion_samples[0].accel_x == -4096 &&
merged.state.motion_samples[0].accel_y == 0,
"merged native controls and extras must combine with right-only aim motion");
bluepad32_input_backend_report_sent(0);
platform_on_controller_data(&left, &left_data);
require(slot_snapshot(0).state.motion_sample_count == 0,
"left reports must not replay the last right motion sample");
uni_hid_device_t ordinary = device(2);
platform_on_device_connected(&ordinary);
require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS,
"ordinary device must coexist with a pair");
uni_controller_t ordinary_data{};
ordinary_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
ordinary_data.gamepad.buttons = BUTTON_Y;
platform_on_controller_data(&ordinary, &ordinary_data);
const Bluepad32SlotSnapshot ordinary_before = slot_snapshot(2);
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{51, 61});
bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{71, 81});
process_rumble_timer(&g_rumble_timer);
require(left.last_low == 51 && right.last_low == 51 &&
left.last_high == 61 && right.last_high == 61 &&
ordinary.last_low == 71 && ordinary.last_high == 81,
"pair feedback must fan out without reaching an ordinary player");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{});
process_rumble_timer(&g_rumble_timer);
require(left.last_rumble_duration_ms == 0 &&
right.last_rumble_duration_ms == 0 && ordinary.last_low == 71,
"pair stop must stop both physical motors only");
bluepad32_input_backend_queue_profile_feedback(
0, merged.connection_generation, 2,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
process_rumble_timer(&g_rumble_timer);
require(left.player_leds == 3 && right.player_leds == 3 &&
left.last_low == UINT8_MAX && right.last_low == UINT8_MAX,
"profile feedback must light and rumble both halves");
now_ms += 300;
process_rumble_timer(&g_rumble_timer);
require(left.player_leds == 1 && right.player_leds == 1,
"profile completion must restore both halves' player indication");
uni_hid_device_t& lost = right_first ? left : right;
uni_hid_device_t& survivor = right_first ? right : left;
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101});
platform_on_device_disconnected(&lost);
const Bluepad32SlotSnapshot detached = slot_snapshot(0);
require(detached.active && !slot_snapshot(1).active &&
detached.connection_generation != merged.connection_generation &&
controller_identity_equal(detached.identity, identity_for_device(&survivor)) &&
detached.state.extra_buttons == survivor.switch2_extra_buttons &&
detached.state.motion_sample_count == 0 &&
!detached.state.dpad_up && !detached.state.button_east &&
(right_first ? detached.state.button_south : detached.state.button_west),
"either half detach must immediately publish only the rotated survivor and own profile");
require_active_profile(detached.identity, right_first ? 5 : 2,
right_first ? UINT8_MAX : 37);
require(!bluepad32_input_backend_identify(merged.identity),
"an offline pair bank must not identify its surviving solo member");
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.controller_layout ==
(right_first ? Bluepad32ControllerLayout::kJoyCon2RightSolo
: Bluepad32ControllerLayout::kJoyCon2LeftSolo),
"playtest must stop presenting a pair immediately after companion loss");
require(survivor.last_rumble_duration_ms == 0 &&
slot_snapshot(2).connection_generation == ordinary_before.connection_generation &&
slot_snapshot(2).state.button_north,
"detach must cancel survivor feedback without changing unrelated player state");
const int survivor_calls = survivor.rumble_calls;
platform_on_controller_data(&lost, right_first ? &left_data : &right_data);
require(platform_on_device_ready(&lost) == UNI_ERROR_NO_SLOTS,
"late ready for a detached half must not resurrect its old generation");
bluepad32_input_backend_queue_profile_feedback(
0, merged.connection_generation, 8,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
process_rumble_timer(&g_rumble_timer);
require(survivor.rumble_calls == survivor_calls &&
slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons,
"late detached input and generation-bound feedback must be ignored");
uni_hid_device_t replacement = switch2_device(lost.idx, lost.product_id);
memcpy(replacement.conn.btaddr, lost.conn.btaddr, sizeof(bd_addr_t));
replacement.switch2_identity_address_type = lost.switch2_identity_address_type;
left_profile.weak_rumble_scale = 61;
require(runtime_profile_storage.set(left_identity, 2, left_profile) ==
ProfileStorageResult::kOk,
"changing the source solo must remain independent while detached");
ready_switch2(replacement);
platform_on_device_disconnected(&lost);
require(slot_snapshot(0).active && !slot_snapshot(1).active &&
slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons &&
slot_snapshot(0).connection_generation != detached.connection_generation,
"replacement must re-pair without stale presses or a late old disconnect");
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
"rejoining the same typed physical members must restore the same pair owner");
require_active_profile(slot_snapshot(0).identity, 7, 95);
const uint32_t clear_token = bluepad32_input_backend_clear_pairings();
process_rumble_timer(&g_rumble_timer);
Bluepad32PairingSnapshot cleared{};
bluepad32_input_backend_pairing_snapshot(&cleared);
require(bluepad32_input_backend_clear_pairings_completed(cleared, clear_token) &&
device_disconnect_calls == 3 &&
!slot_snapshot(0).active && !slot_snapshot(2).active &&
!switch_pico_switch2_pairing_allowed(),
"clear must disconnect every physical half and ordinary device, not just outputs");
}
void test_switch2_multiple_pairs() {
start_pairing_backend();
uni_hid_device_t right0 = switch2_device(0, UNI_SW2_JOYCON_R_PID);
uni_hid_device_t right1 = switch2_device(1, UNI_SW2_JOYCON_R_PID);
uni_hid_device_t left0 = switch2_device(2, UNI_SW2_JOYCON_L_PID);
uni_hid_device_t left1 = switch2_device(3, UNI_SW2_JOYCON_L_PID);
ready_switch2(right0);
ready_switch2(right1);
ready_switch2(left0);
ready_switch2(left1);
require(slot_snapshot(0).active && slot_snapshot(1).active &&
!slot_snapshot(2).active && !slot_snapshot(3).active &&
g_connection_policy_state == ConnectionPolicyState::Paused &&
!scanning_enabled && !incoming_connections,
"two deterministic pairs must consume four physical resources but only two outputs");
require_pair_owner(slot_snapshot(0).identity, left0, right0);
require_pair_owner(slot_snapshot(1).identity, left1, right1);
uni_controller_t data{};
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
data.gamepad.buttons = BUTTON_A;
right0.switch2_extra_buttons = UNI_SW2_BUTTON_C;
platform_on_controller_data(&right0, &data);
data.gamepad.buttons = BUTTON_Y;
right1.switch2_extra_buttons = UNI_SW2_BUTTON_GR;
platform_on_controller_data(&right1, &data);
require(slot_snapshot(0).state.button_south && !slot_snapshot(0).state.button_north &&
slot_snapshot(0).state.extra_buttons == UNI_SW2_BUTTON_C &&
slot_snapshot(1).state.button_north && !slot_snapshot(1).state.button_south &&
slot_snapshot(1).state.extra_buttons == UNI_SW2_BUTTON_GR,
"two pairs must not cross-contaminate normal or extra source input");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{11, 21});
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{31, 41});
process_rumble_timer(&g_rumble_timer);
require(left0.last_low == 11 && right0.last_low == 11 &&
left1.last_low == 31 && right1.last_low == 31 &&
left0.player_leds == 1 && right0.player_leds == 1 &&
left1.player_leds == 2 && right1.player_leds == 2,
"multiple pairs must retain isolated rumble and player lighting");
const Bluepad32SlotSnapshot other_pair = slot_snapshot(1);
platform_on_device_disconnected(&right0);
uni_hid_device_t ordinary = device(0);
platform_on_device_connected(&ordinary);
require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS &&
slot_snapshot(0).active && slot_snapshot(2).active &&
!slot_snapshot(3).active &&
controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left0)),
"reusing freed physical index must allocate a free output, not evict the surviving half");
data.gamepad.buttons = BUTTON_B;
platform_on_controller_data(&ordinary, &data);
bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{91, 101});
process_rumble_timer(&g_rumble_timer);
require(slot_snapshot(2).state.button_east && !slot_snapshot(0).state.button_east &&
ordinary.last_low == 91 && left1.last_low == 31 && right1.last_low == 31 &&
slot_snapshot(1).connection_generation == other_pair.connection_generation &&
slot_snapshot(1).state.button_north,
"remapped ordinary physical index must isolate input and feedback from both pairs");
}
void test_switch2_pair_admission_failure(bool right_first) {
start_pairing_backend();
initialize_runtime_profile_storage();
auto left = switch2_device(right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID);
auto& first = right_first ? right : left;
auto& second = right_first ? left : right;
ready_switch2(first);
const ControllerIdentity solo_identity = identity_for_device(&first);
require(runtime_profile_storage.activate(solo_identity, 3) == ProfileStorageResult::kOk,
"the first solo must have an existing independent active profile");
uni_controller_t data{};
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
data.gamepad.buttons = BUTTON_TRIGGER_L | BUTTON_TRIGGER_R;
platform_on_controller_data(&first, &data);
const auto solo = slot_snapshot(0);
require(bluepad32_input_backend_capture_start(
0, solo.connection_generation, CaptureOptions{}),
"the first solo must remain recordable during pair admission");
bluepad32_input_backend_queue_profile_feedback(
0, solo.connection_generation, 2, ControllerProfileConfirmationPolicy::kRumble);
const int initial_rumble_calls = first.rumble_calls;
platform_on_device_connected(&second);
second.switch2_identity_valid = false;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
pair_observation_count == 0 &&
first.rumble_calls == initial_rumble_calls,
"an unstable member must reject pairing before storage or solo output changes");
second.switch2_identity_valid = true;
first.switch2_identity_valid = false;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
pair_observation_count == 0 &&
controller_identity_equal(slot_snapshot(0).identity, solo_identity),
"an unresolved first-ready member must not merge using its stale solo owner");
first.switch2_identity_valid = true;
require(runtime_profile_storage.ensure_identity(identity_for_device(&second)) ==
ProfileStorageResult::kOk,
"preexisting member rows must allow exercising failure of the actual pair seed");
before_pair_seed = [](const ControllerIdentity&) {
const auto first_solo = slot_snapshot(0);
Bluepad32CaptureSnapshot capture{};
require(first_solo.active &&
first_solo.identity.transport == ControllerTransport::kBle &&
!slot_snapshot(1).active &&
bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kRecording,
"persistent pair setup must precede any visible solo topology change");
};
fail_profile_program = true;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
pair_observation_count == 1,
"pair seed I/O failure must explicitly reject the second ready callback");
const auto rejected = slot_snapshot(0);
require(rejected.active && !slot_snapshot(1).active &&
rejected.connection_generation == solo.connection_generation &&
controller_identity_equal(rejected.identity, solo_identity) &&
rejected.state.left_trigger == solo.state.left_trigger &&
first.rumble_calls == initial_rumble_calls &&
first.player_leds == 1 && second.player_leds == 0,
"seed failure must leave the first solo's owner, input, generation and outputs intact");
ControllerIdentity pair_identity{};
require(controller_identity_make_joycon_pair(
identity_for_device(&left), identity_for_device(&right), &pair_identity) &&
runtime_profile_storage.find(pair_identity) == nullptr,
"a failed pair seed must not expose a partial persistent owner");
process_rumble_timer(&g_rumble_timer);
require(first.last_low == UINT8_MAX && second.rumble_calls == 0,
"a rejected merge must retain the first solo's queued generation-bound feedback");
fail_profile_program = false;
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED,
"a retry before catalog replay must not expose a failed seed");
require(runtime_profile_storage.initialize(runtime_profile_io()),
"storage replay must recover intact member banks after admission failure");
require_active_profile(solo_identity, 3, UINT8_MAX);
require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS,
"a replayed catalog must allow the pending physical member to merge on retry");
before_pair_seed = nullptr;
require_pair_owner(slot_snapshot(0).identity, left, right);
require_active_profile(slot_snapshot(0).identity, right_first ? 0 : 3, UINT8_MAX);
}
void test_switch2_pair_member_replacement() {
start_pairing_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
right.conn.btaddr[0] = 0xc2;
ready_switch2(left);
ready_switch2(right);
const ControllerIdentity original = slot_snapshot(0).identity;
require(runtime_profile_storage.activate(original, 6) == ProfileStorageResult::kOk,
"the original physical pair must have a distinct saved selection");
platform_on_device_disconnected(&right);
auto replacement = switch2_device(1, UNI_SW2_JOYCON_R_PID);
replacement.conn.btaddr[5] = 0x35;
ready_switch2(replacement);
const ControllerIdentity replaced = slot_snapshot(0).identity;
require_pair_owner(replaced, left, replacement);
require(!controller_identity_equal(original, replaced) &&
!bluepad32_input_backend_identify(original),
"swapping only the right member must select a different pair bank and owner");
require_active_profile(replaced, 0, UINT8_MAX);
require(runtime_profile_storage.activate(replaced, 4) == ProfileStorageResult::kOk,
"the replacement pair bank must be independently selectable");
platform_on_device_disconnected(&replacement);
ready_switch2(right);
require(controller_identity_equal(slot_snapshot(0).identity, original),
"restoring the original right member must restore the original pair key");
require_active_profile(slot_snapshot(0).identity, 6, UINT8_MAX);
require_active_profile(replaced, 4, UINT8_MAX);
platform_on_device_disconnected(&right);
auto typed_right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
memcpy(typed_right.conn.btaddr, right.conn.btaddr, sizeof(bd_addr_t));
typed_right.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
ready_switch2(typed_right);
const ControllerIdentity retyped = slot_snapshot(0).identity;
require_pair_owner(retyped, left, typed_right);
require(!controller_identity_equal(retyped, original),
"a member's address type must participate in the pair key even with the same MAC");
require_active_profile(retyped, 0, UINT8_MAX);
require_active_profile(original, 6, UINT8_MAX);
}
void test_switch2_admission() {
start_backend();
require(!switch_pico_switch2_pairing_allowed(),
"idle scanning must not grant fresh proprietary pairing");
bluepad32_input_backend_open_pairing_window();
require(!switch_pico_switch2_pairing_allowed(),
"pending Core0 request must not grant pairing before policy consumes it");
process_rumble_timer(&g_rumble_timer);
require(switch_pico_switch2_pairing_allowed(),
"consumed explicit pairing window must permit proprietary pairing");
now_ms = g_pairing_window_deadline_ms;
require(!switch_pico_switch2_pairing_allowed(),
"fresh pairing must close at its deadline even before timer processing");
uni_hid_device_t pro = switch2_device(0, UNI_SW2_PRO_PID);
uni_hid_device_t ordinary = device(1, true, UNI_BT_CONN_PROTOCOL_BLE);
register_lookup_device(&pro);
register_lookup_device(&ordinary);
__wrap_sm_request_pairing(pro.conn.handle);
require(device_disconnect_calls == 1 && last_disconnected_device == &pro &&
ordinary_smp_requests == 0 && delete_key_calls == 0,
"Switch2 GATT auth failure must disconnect without SMP or bond deletion");
__wrap_sm_request_pairing(ordinary.conn.handle);
__wrap_sm_request_pairing(0x99);
require(ordinary_smp_requests == 2 && device_disconnect_calls == 1,
"ordinary and unknown handles must preserve standard SMP behavior");
require(identity_for_device(&pro).stable &&
controller_identity_is_global(identity_for_device(&ordinary)),
"only parser-validated proprietary public identity may bypass SMP resolution");
pro.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
pro.conn.btaddr[0] = 0xc1;
require(identity_for_device(&pro).stable &&
identity_for_device(&pro).address_type == BD_ADDR_TYPE_LE_RANDOM,
"parser-validated static random identity must retain its address type");
pro.switch2_identity_valid = false;
pro.conn.btaddr[0] = 0x41;
require(controller_identity_is_global(identity_for_device(&pro)),
"unvalidated proprietary RPA must remain on the global profile");
pro.switch2_identity_valid = true;
pro.conn.btaddr[0] = 0xc1;
ready_switch2(pro);
uni_controller_t input{};
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
input.gamepad.buttons = BUTTON_B;
input.gamepad.axis_x = 511;
input.gamepad.axis_ry = -512;
pro.switch2_extra_buttons =
UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_GL | UNI_SW2_BUTTON_GR;
platform_on_controller_data(&pro, &input);
require(slot_snapshot(0).state.button_east &&
slot_snapshot(0).state.left_stick_x == INT16_MAX &&
slot_snapshot(0).state.right_stick_y == INT16_MIN &&
slot_snapshot(0).state.extra_buttons == 7,
"Pro2 must preserve vertical normal controls and ingest remappable extras");
}
void test_switch2_radio_policy(bool individual) {
start_backend();
if (individual) set_runtime_joycon_mode(JoyConMode::kIndividual);
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
auto classic = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto other_ble = device(3, true, UNI_BT_CONN_PROTOCOL_BLE);
negotiated_intervals[other_ble.conn.handle] = 24;
ready_switch2(left);
require(negotiated_intervals[left.conn.handle] == 6,
"a single Switch2 link must retain fast scheduling");
ready_switch2(right);
require(incoming_connections &&
negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6,
"paired and individual Joy-Cons must retain fast links before Classic arrival");
platform_on_device_connected(&other_ble);
require(platform_on_device_ready(&other_ble) == UNI_ERROR_SUCCESS,
"unrelated BLE controller must join");
platform_on_device_connected(&classic);
require(negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6 &&
negotiated_intervals[other_ble.conn.handle] == 24,
"Classic setup must not slow Joy-Cons or retime unrelated BLE");
require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS,
"Classic controller must complete setup alongside the pair");
const auto pair = slot_snapshot(0);
platform_on_device_disconnected(&classic);
require(negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6 &&
slot_snapshot(0).connection_generation == pair.connection_generation,
"Classic departure must preserve fast Joy-Con links without rebinding the pair");
platform_on_device_connected(&classic);
require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS,
"Classic reconnect must complete");
platform_on_device_disconnected(&left);
require(negotiated_intervals[right.conn.handle] == 6,
"a single surviving Switch2 link must return to fast scheduling even with Classic");
left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
ready_switch2(left);
require(negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6,
"physical index and handle reuse must retain fast Joy-Con links");
negotiated_intervals[left.conn.handle] = 24;
negotiated_intervals[right.conn.handle] = 24;
now_ms += 1000;
process_configuration_timer(&g_configuration_timer);
require(negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6,
"peer-negotiated slow Joy-Con links must reconcile to the fast default");
platform_on_device_disconnected(&right);
right = switch2_device(1, UNI_SW2_PRO_PID);
negotiated_intervals[right.conn.handle] = 24;
ready_switch2(right);
require(negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6 &&
negotiated_intervals[other_ble.conn.handle] == 24,
"Switch2 Pro must join at the fast interval without retiming unrelated BLE");
platform_on_device_disconnected(&left);
left = switch2_device(0, UNI_SW2_PRO_PID);
negotiated_intervals[left.conn.handle] = 24;
ready_switch2(left);
require(negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6,
"multiple Switch2 Pro links must remain fast alongside Classic");
}
void test_switch2_radio_settling() {
start_backend();
auto classic = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto left = switch2_device(0, UNI_SW2_PRO_PID);
auto right = switch2_device(1, UNI_SW2_PRO_PID);
platform_on_device_connected(&classic);
require(platform_on_device_ready(&classic) == UNI_ERROR_SUCCESS,
"Classic-first connection must become ready");
negotiated_intervals[left.conn.handle] = 24;
negotiated_intervals[right.conn.handle] = 24;
defer_interval_updates = true;
now_ms = UINT32_MAX - 10;
ready_switch2(left);
platform_on_device_connected(&right);
require(interval_requests[right.conn.handle] == 0,
"pending Switch2 setup must retain ownership of its initial interval");
require(platform_on_device_ready(&right) == UNI_ERROR_SUCCESS,
"second Switch2 connection must become ready");
require(pending_intervals[left.conn.handle] == 6 &&
pending_intervals[right.conn.handle] == 6,
"both slow Switch2 Pro links must request the fast interval");
const unsigned deferred_attempts = interval_requests[left.conn.handle];
now_ms += 999;
process_configuration_timer(&g_configuration_timer);
require(interval_requests[left.conn.handle] == deferred_attempts &&
negotiated_intervals[left.conn.handle] == 24,
"an accepted asynchronous request must be allowed to settle across clock wrap");
platform_on_device_disconnected(&right);
negotiated_intervals[left.conn.handle] = pending_intervals[left.conn.handle];
process_configuration_timer(&g_configuration_timer);
require(negotiated_intervals[left.conn.handle] == 6 &&
interval_requests[left.conn.handle] == deferred_attempts,
"a late fast-interval acknowledgement must survive another controller leaving");
defer_interval_updates = false;
reject_interval_updates = true;
negotiated_intervals[left.conn.handle] = 24;
right = switch2_device(1, UNI_SW2_PRO_PID);
negotiated_intervals[right.conn.handle] = 24;
ready_switch2(right);
const unsigned left_attempts = interval_requests[left.conn.handle];
const unsigned right_attempts = interval_requests[right.conn.handle];
now_ms += 999;
process_configuration_timer(&g_configuration_timer);
require(interval_requests[left.conn.handle] == left_attempts &&
interval_requests[right.conn.handle] == right_attempts,
"rejected negotiations must not flood the HCI command queue");
reject_interval_updates = false;
++now_ms;
process_configuration_timer(&g_configuration_timer);
require(negotiated_intervals[left.conn.handle] == 6 &&
negotiated_intervals[right.conn.handle] == 6,
"transiently rejected fast intervals must recover without reconnect or pairing");
}
void test_switch2_mate_reconnect() {
start_backend();
auto right = switch2_device(0, UNI_SW2_JOYCON_R_PID);
auto left = switch2_device(1, UNI_SW2_JOYCON_L_PID);
left.conn.btaddr[0] = 0xc1;
left.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
remember_switch2(right);
remember_switch2(left);
register_lookup_device(&right);
register_lookup_device(&left);
ready_switch2(right);
require(scanning_enabled && background_scan_parameters &&
!classic_scanning_enabled && incoming_connections &&
!bondable && accepted_stk_methods == 0 &&
!switch_pico_switch2_pairing_allowed(),
"first remembered half must seek its mate with low-duty BLE and authentication closed");
dispatch_pairing_event(HCI_EVENT_USER_CONFIRMATION_REQUEST);
dispatch_pairing_event(HCI_EVENT_USER_PASSKEY_REQUEST);
require(confirmation_accepts == 0 && passkey_accepts == 0 &&
confirmation_rejections == 1 && passkey_rejections == 1,
"mate scanning must not authorize new Classic authentication");
bd_addr_t missing = {1, 2, 3, 4, 5, 6};
require(platform_on_device_discovered(missing, "Joy-Con 2 (L)", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"a device name without a prepared parser candidate cannot authorize passive discovery");
const auto reject_left = [&]() {
require(platform_on_device_discovered(left.conn.btaddr, "Joy-Con 2 (L)", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE &&
scanning_enabled && !classic_scanning_enabled &&
!switch_pico_switch2_pairing_allowed(),
"rejected mate candidates must leave the bounded passive policy unchanged");
};
++left.conn.btaddr[5];
reject_left();
--left.conn.btaddr[5];
left.product_id = UNI_SW2_JOYCON_R_PID;
reject_left();
left.product_id = UNI_SW2_PRO_PID;
reject_left();
left.product_id = UNI_SW2_JOYCON_L_PID;
left.conn.protocol = UNI_BT_CONN_PROTOCOL_BR_EDR;
reject_left();
left.conn.protocol = UNI_BT_CONN_PROTOCOL_BLE;
++left.vendor_id;
reject_left();
--left.vendor_id;
left.switch2_identity_valid = false;
reject_left();
left.switch2_identity_valid = true;
left.switch2_identity_address_type = BD_ADDR_TYPE_LE_PUBLIC;
reject_left();
left.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
require(platform_on_device_discovered(left.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_SUCCESS,
"remembered opposite static-random half must be admitted outside the pairing window");
// The parser stops GAP scan before it has a backend slot to reserve.
uni_bt_le_scan_stop();
platform_on_device_connected(&left);
require(!scanning_enabled && !classic_scanning_enabled,
"remembered mate setup must pause scan even when GAP already stopped it");
platform_on_device_disconnected(&left);
require(scanning_enabled && background_scan_parameters && !classic_scanning_enabled,
"failed setup must resume remembered mate scanning");
uni_bt_le_scan_stop();
platform_on_device_connected(&left);
require(!scanning_enabled && !classic_scanning_enabled && incoming_connections,
"mate setup must pause background scanning while reserving physical capacity");
require(platform_on_device_ready(&left) == UNI_ERROR_SUCCESS &&
slot_snapshot(0).active && !slot_snapshot(1).active &&
!scanning_enabled && !classic_scanning_enabled,
"ready remembered mate must merge without a pairing window and stop background scanning");
require_pair_owner(slot_snapshot(0).identity, left, right);
auto ordinary = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
platform_on_device_connected(&ordinary);
require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS &&
!scanning_enabled && !classic_scanning_enabled,
"a complete pair plus incoming Classic controller must not keep LE scanning");
platform_on_device_disconnected(&left);
require(scanning_enabled && background_scan_parameters &&
!classic_scanning_enabled && slot_snapshot(2).active,
"losing a half must resume its mate scan without disturbing an ordinary controller");
}
void test_switch2_mate_pending() {
start_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(3, UNI_SW2_JOYCON_R_PID);
remember_switch2(left);
remember_switch2(right);
register_lookup_device(&right);
ready_switch2(left);
auto first = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto second = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto third = device(3, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
platform_on_device_connected(&first);
platform_on_device_connected(&second);
require(!scanning_enabled && !classic_scanning_enabled && incoming_connections &&
platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"any pending setup must pause scanning and reject otherwise valid mates");
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
!scanning_enabled,
"one resolved setup must not resume mate scanning while another is pending");
platform_on_device_disconnected(&second);
require(scanning_enabled && background_scan_parameters &&
!classic_scanning_enabled &&
platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_SUCCESS,
"last pending setup failure must resume passive mate discovery and admission");
platform_on_device_connected(&second);
require(!scanning_enabled &&
platform_on_device_ready(&second) == UNI_ERROR_SUCCESS &&
scanning_enabled && !classic_scanning_enabled,
"last pending setup becoming ready must resume the unmatched half scan");
platform_on_device_connected(&third);
require(platform_on_device_ready(&third) == UNI_ERROR_SUCCESS &&
!scanning_enabled && !classic_scanning_enabled && !incoming_connections &&
platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"four physical controllers must stop mate scanning even with an unmatched half");
platform_on_device_disconnected(&third);
require(scanning_enabled && background_scan_parameters && incoming_connections,
"free physical capacity must restore the waiting half scan");
switch2_clear_succeeds = false;
bluepad32_input_backend_clear_pairings();
process_rumble_timer(&g_rumble_timer);
require(!scanning_enabled && !classic_scanning_enabled && !incoming_connections,
"failed trust deletion must explicitly stop a direct background LE scan");
platform_on_device_disconnected(&right);
platform_on_device_disconnected(&left);
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(!scanning_enabled && !classic_scanning_enabled && !incoming_connections &&
!switch_pico_switch2_pairing_allowed() &&
platform_on_device_discovered(right.conn.btaddr, nullptr, 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"late disconnects and pairing requests must not clear the failed-closed latch");
}
void test_switch2_mate_pairing_window() {
start_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
ready_switch2(left);
require(scanning_enabled && background_scan_parameters && !classic_scanning_enabled,
"solo half must enter background discovery before opening pairing");
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(scanning_enabled && !background_scan_parameters && classic_scanning_enabled &&
bondable && accepted_stk_methods == kAllBlePairingMethods,
"explicit pairing must restore full discovery timing and the existing authentication window");
now_ms = g_pairing_window_deadline_ms;
process_rumble_timer(&g_rumble_timer);
require(scanning_enabled && background_scan_parameters && !classic_scanning_enabled &&
!bondable && accepted_stk_methods == 0 &&
!switch_pico_switch2_pairing_allowed(),
"pairing expiry must restore low-duty mate discovery without extending authentication");
platform_on_device_disconnected(&left);
require(scanning_enabled && !background_scan_parameters && classic_scanning_enabled &&
!bondable && accepted_stk_methods == 0,
"last controller loss must restore idle discovery defaults without opening pairing");
}
void test_switch2_pairing_inventory() {
start_pairing_backend();
switch2_pairing_count = 2;
switch2_pairing_types[0] = BD_ADDR_TYPE_LE_PUBLIC;
switch2_pairing_types[1] = BD_ADDR_TYPE_LE_RANDOM;
switch2_pairings[0][5] = 1;
switch2_pairings[1][0] = 0xc1;
switch2_pairings[1][5] = 2;
bluepad32_input_backend_request_pairing_snapshot();
process_rumble_timer(&g_rumble_timer);
Bluepad32PairingSnapshot snapshot{};
bluepad32_input_backend_pairing_snapshot(&snapshot);
require(snapshot.record_count == 2 && !snapshot.overflow &&
snapshot.records[0].transport == Bluepad32PairingTransport::kBle &&
snapshot.records[0].address_type == BD_ADDR_TYPE_LE_PUBLIC &&
snapshot.records[0].address[5] == 1 &&
snapshot.records[1].address_type == BD_ADDR_TYPE_LE_RANDOM &&
snapshot.records[1].address[0] == 0xc1,
"pairing inventory must include proprietary trust records with real BLE address types");
switch2_pairing_count = UNI_SWITCH2_PAIRING_CAPACITY;
classic_bond_count = 1;
bluepad32_input_backend_request_pairing_snapshot();
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_pairing_snapshot(&snapshot);
require(snapshot.record_count == BLUEPAD32_PAIRING_RECORD_CAPACITY &&
snapshot.overflow,
"combined ordinary and proprietary inventory must preserve bounded overflow reporting");
const uint32_t successful_token = bluepad32_input_backend_clear_pairings();
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_pairing_snapshot(&snapshot);
require(snapshot.record_count == 0 && !snapshot.overflow &&
switch2_pairing_count == 0 &&
bluepad32_input_backend_clear_pairings_completed(snapshot, successful_token),
"clear completion must follow deletion of proprietary and ordinary pairing records");
switch2_pairing_count = 1;
switch2_clear_succeeds = false;
const uint32_t failed_token = bluepad32_input_backend_clear_pairings();
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_pairing_snapshot(&snapshot);
require(snapshot.status == Bluepad32PairingSnapshotStatus::kFailed &&
snapshot.completed_clear_pairings_token == successful_token &&
!bluepad32_input_backend_clear_pairings_completed(snapshot, failed_token) &&
!incoming_connections && !scanning_enabled &&
!switch_pico_switch2_pairing_allowed(),
"failed trust deletion must never acknowledge clear or admit reconnects");
bluepad32_input_backend_open_pairing_window();
bluepad32_input_backend_request_pairing_snapshot();
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_pairing_snapshot(&snapshot);
require(snapshot.status == Bluepad32PairingSnapshotStatus::kFailed &&
snapshot.completed_clear_pairings_token == successful_token &&
!switch_pico_switch2_pairing_allowed() &&
g_connection_policy_state == ConnectionPolicyState::FailedClosed,
"inventory refresh and pairing requests must not erase failed-clear state");
switch2_clear_succeeds = true;
const uint32_t retry_token = bluepad32_input_backend_clear_pairings();
require(retry_token != failed_token, "explicit retry must receive a fresh clear token");
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_pairing_snapshot(&snapshot);
require(snapshot.status == Bluepad32PairingSnapshotStatus::kReady &&
snapshot.record_count == 0 &&
bluepad32_input_backend_clear_pairings_completed(snapshot, retry_token) &&
incoming_connections && scanning_enabled &&
!switch_pico_switch2_pairing_allowed(),
"successful explicit retry must restore normal reconnect policy, not fresh pairing");
}
void test_ready_order(bool reverse) {
start_pairing_backend();
uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)};
uni_hid_device_t replacements[kSlotCount] = {
device(0), device(1), device(2), device(3)};
const int forward[kSlotCount] = {0, 1, 2, 3};
const int backward[kSlotCount] = {3, 2, 1, 0};
const int* order = reverse ? backward : forward;
platform_on_device_connected(&devices[order[0]]);
for (int position = 0; position < kSlotCount; ++position) {
const int slot = order[position];
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS,
"ready device must bind to its Bluepad index");
for (int candidate = 0; candidate < kSlotCount; ++candidate) {
ControllerState snapshot{};
bool expected_active = false;
for (int ready = 0; ready <= position; ++ready) {
expected_active = expected_active || order[ready] == candidate;
}
require(read_controller_state(candidate, &snapshot) ==
expected_active,
"only ready indexed slots may become active");
}
if (position + 1 < kSlotCount) {
require(scanning_enabled && classic_scanning_enabled,
"pairing-window discovery must continue while a physical slot remains free");
require(incoming_connections,
"incoming connections must remain enabled before all slots are ready");
}
}
require(!scanning_enabled && !classic_scanning_enabled,
"discovery must stop when all four physical slots are full");
require(!incoming_connections,
"incoming connections must be disabled only when all slots are full");
for (int slot = 0; slot < kSlotCount; ++slot) {
platform_on_device_disconnected(&devices[slot]);
require(scanning_enabled && classic_scanning_enabled && incoming_connections,
"disconnecting any slot must resume connection policy");
for (int candidate = 0; candidate < kSlotCount; ++candidate) {
ControllerState snapshot{};
require(read_controller_state(candidate, &snapshot) ==
(candidate != slot),
"disconnect must preserve every surviving slot");
}
require(platform_on_device_ready(&replacements[slot]) ==
UNI_ERROR_SUCCESS,
"replacement must bind to each freed indexed slot");
require(!scanning_enabled && !incoming_connections,
"restoring four ready slots must stop connection policy");
devices[slot] = replacements[slot];
}
bd_addr_t address{};
require(platform_on_device_discovered(address, "extra", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"discovery must reject devices while all four slots are occupied");
}
void test_rejections() {
start_backend();
uni_hid_device_t non_gamepad = device(0, false);
uni_hid_device_t out_of_range = device(4);
uni_hid_device_t slot_zero = device(0);
uni_hid_device_t collision = device(0);
require(platform_on_device_ready(&non_gamepad) ==
UNI_ERROR_INVALID_CONTROLLER,
"non-gamepad must be rejected");
require(platform_on_device_ready(&out_of_range) == UNI_ERROR_NO_SLOTS,
"Bluepad index 4 must be rejected");
require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS,
"valid device must occupy its indexed slot");
require(platform_on_device_ready(&collision) == UNI_ERROR_NO_SLOTS,
"different device cannot replace an occupied slot");
g_slots[0].identity.stable = true;
g_slots[0].identity.transport = ControllerTransport::kClassic;
g_slots[0].identity.address[5] = 1;
Bluepad32SlotSnapshot identity_snapshot{};
bluepad32_input_backend_snapshot(0, &identity_snapshot);
require(bluepad32_input_backend_identify(
identity_snapshot.identity) &&
g_slots[0].pending_profile_feedback_count == 1 &&
!bluepad32_input_backend_identify(
controller_identity_global()),
"Identify did not target only the selected live controller");
g_slots[0].pending_profile_feedback_count = 0;
g_slots[0].pending_profile_feedback[0] = {};
uni_controller_t collision_data{};
collision_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
collision_data.gamepad.buttons = BUTTON_B;
platform_on_controller_data(&collision, &collision_data);
ControllerState snapshot{};
require(read_controller_state(0, &snapshot),
"occupied slot must stay active");
require(!snapshot.button_east,
"mismatched device input must not enter the occupied slot");
uni_controller_t slot_zero_data{};
slot_zero_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
slot_zero_data.gamepad.accel[0] = 8192;
platform_on_controller_data(&slot_zero, &slot_zero_data);
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count == 3,
"valid slot input must remain observable");
slot_zero.controller.battery = 201;
g_last_snapshot_generation[0] = 0;
Bluepad32PlaytestSnapshot playtest{};
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.active && playtest.state_generation != 0 &&
playtest.state.motion_sample_count == 3 &&
playtest.battery == 201 &&
(playtest.capabilities & 0x08u) != 0,
"playtest snapshot did not expose input capabilities");
struct LayoutCase {
uni_controller_subtype_t subtype;
Bluepad32ControllerLayout layout;
};
const LayoutCase layouts[] = {
{CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL, Bluepad32ControllerLayout::kWiiHorizontal},
{CONTROLLER_SUBTYPE_WIIMOTE_VERTICAL, Bluepad32ControllerLayout::kWiiVertical},
{CONTROLLER_SUBTYPE_WIIMOTE_ACCEL, Bluepad32ControllerLayout::kWiiHorizontal},
{CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK, Bluepad32ControllerLayout::kWiiNunchuk},
{CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL, Bluepad32ControllerLayout::kWiiNunchuk},
{CONTROLLER_SUBTYPE_WII_CLASSIC, Bluepad32ControllerLayout::kUnspecified},
{CONTROLLER_SUBTYPE_WIIUPRO, Bluepad32ControllerLayout::kUnspecified},
{CONTROLLER_SUBTYPE_WII_BALANCE_BOARD, Bluepad32ControllerLayout::kUnspecified},
{CONTROLLER_SUBTYPE_WIIMOTE_UDRAW_TABLET, Bluepad32ControllerLayout::kUnspecified},
{CONTROLLER_SUBTYPE_NONE, Bluepad32ControllerLayout::kUnspecified},
};
for (const LayoutCase& expected : layouts) {
slot_zero.controller_subtype = expected.subtype;
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.controller_layout == expected.layout &&
playtest.state.motion_sample_count == 3,
"Wii layout must follow the live subtype, not motion or previous extension");
}
bluepad32_input_backend_report_sent(0);
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count == 3,
"playtest snapshot consumed report-path motion");
bluepad32_input_backend_playtest_snapshot(4, &playtest);
require(!playtest.active && playtest.state_generation == 0,
"playtest snapshot accepted slot 4");
require(!read_controller_state(4, &snapshot),
"public snapshot must reject slot 4");
bluepad32_input_backend_report_sent(4);
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count == 3,
"slot 4 acknowledgement must not consume slot 0 IMU");
bluepad32_input_backend_queue_rumble(4, ControllerRumbleOutput{1, 2});
process_rumble_timer(&g_rumble_timer);
require(slot_zero.rumble_calls == 0,
"slot 4 rumble must not reach a valid controller");
}
void test_independent_lifecycle() {
test_identity_encoding_contract();
start_pairing_backend();
uni_hid_device_t invalid_transport =
device(6, true, UNI_BT_CONN_PROTOCOL_BLE);
invalid_transport.conn.handle = 0xffff;
require(controller_identity_is_global(
identity_for_device(&invalid_transport)),
"invalid GAP handles must remain on the global identity");
uni_hid_device_t sco_transport =
device(7, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
gap_connection_types[sco_transport.conn.handle] = GAP_CONNECTION_SCO;
require(controller_identity_is_global(
identity_for_device(&sco_transport)),
"SCO links must remain on the global identity");
uni_hid_device_t aborted = device(0);
const uint32_t aborted_generation = g_slots[0].connection_generation;
platform_on_device_connected(&aborted);
require(g_slots[0].device == &aborted && !g_slots[0].active,
"connected device must remain identifiable while becoming ready");
platform_on_device_disconnected(&aborted);
require(g_slots[0].device == nullptr && !g_slots[0].active,
"pre-ready disconnect must clear its pending slot identity");
require(g_slots[0].connection_generation == aborted_generation + 1,
"pre-ready disconnect must invalidate its connection generation");
require(g_connection_status == ConnectionStatus::Scanning &&
scanning_enabled && classic_scanning_enabled &&
incoming_connections,
"pre-ready disconnect must restart Classic and BLE scans");
uni_hid_device_t devices[kSlotCount] = {
device(0, true, UNI_BT_CONN_PROTOCOL_NONE),
device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR),
device(2, true, UNI_BT_CONN_PROTOCOL_NONE),
device(3, true, UNI_BT_CONN_PROTOCOL_BR_EDR)};
gap_connection_types[devices[0].conn.handle] = GAP_CONNECTION_ACL;
gap_connection_types[devices[1].conn.handle] = GAP_CONNECTION_LE;
gap_connection_types[devices[2].conn.handle] = GAP_CONNECTION_LE;
gap_connection_types[devices[3].conn.handle] = GAP_CONNECTION_LE;
const bd_addr_t classic_address =
{0x10, 0x11, 0x12, 0x13, 0x14, 0x15};
const bd_addr_t resolved_connection_address =
{0x41, 0x21, 0x22, 0x23, 0x24, 0x25};
const bd_addr_t created_connection_address =
{0x42, 0x31, 0x32, 0x33, 0x34, 0x35};
const bd_addr_t reencrypted_connection_address =
{0x43, 0x41, 0x42, 0x43, 0x44, 0x45};
memcpy(devices[0].conn.btaddr, classic_address,
sizeof(classic_address));
memcpy(devices[1].conn.btaddr, resolved_connection_address,
sizeof(resolved_connection_address));
memcpy(devices[2].conn.btaddr, created_connection_address,
sizeof(created_connection_address));
memcpy(devices[3].conn.btaddr, reencrypted_connection_address,
sizeof(reencrypted_connection_address));
require(
devices[0].conn.protocol == UNI_BT_CONN_PROTOCOL_NONE &&
gap_get_connection_type(devices[0].conn.handle) ==
GAP_CONNECTION_ACL &&
devices[1].conn.protocol == UNI_BT_CONN_PROTOCOL_BR_EDR &&
gap_get_connection_type(devices[1].conn.handle) ==
GAP_CONNECTION_LE &&
devices[2].conn.protocol == UNI_BT_CONN_PROTOCOL_NONE &&
gap_get_connection_type(devices[2].conn.handle) ==
GAP_CONNECTION_LE,
"identity fixtures must expose authoritative GAP transports over "
"missing or stale cached protocols");
const bd_addr_t resolved_address =
{0x20, 0x21, 0x22, 0x23, 0x24, 0x25};
const bd_addr_t reencrypted_address =
{0x30, 0x31, 0x32, 0x33, 0x34, 0x35};
dispatch_identity_event(
SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, devices[1],
BD_ADDR_TYPE_LE_PUBLIC, resolved_address);
register_lookup_device(&devices[3]);
dispatch_identity_event(
SM_EVENT_REENCRYPTION_STARTED, devices[3],
BD_ADDR_TYPE_LE_RANDOM, reencrypted_address);
dispatch_identity_event(
SM_EVENT_REENCRYPTION_COMPLETE, devices[3],
BD_ADDR_TYPE_LE_RANDOM, reencrypted_address);
for (int slot = 0; slot < kSlotCount; ++slot) {
platform_on_device_connected(&devices[slot]);
require(g_slots[slot].device == &devices[slot] &&
!g_slots[slot].active,
"each pending device must retain its indexed identity");
}
const uint32_t first_pending_generation =
g_slots[0].connection_generation;
platform_on_device_disconnected(&devices[0]);
require(g_slots[0].device == nullptr && !g_slots[0].active,
"pre-ready disconnect must clear only its own pending identity");
for (int slot = 1; slot < kSlotCount; ++slot) {
require(g_slots[slot].device == &devices[slot] &&
!g_slots[slot].active,
"pre-ready disconnect must preserve all pending survivors");
}
require(g_slots[0].connection_generation ==
first_pending_generation + 1,
"pending disconnect beside peers must invalidate its generation");
require(g_connection_status == ConnectionStatus::Connecting &&
scanning_enabled && classic_scanning_enabled &&
incoming_connections,
"open pairing slot must preserve pending peers and resume scanning");
for (int slot = 1; slot < kSlotCount; ++slot) {
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS,
"each surviving pending device must still become ready");
}
platform_on_device_connected(&devices[0]);
require(platform_on_device_ready(&devices[0]) == UNI_ERROR_SUCCESS,
"reconnected slot 0 device must complete all four slots");
require(g_connection_status == ConnectionStatus::Ready &&
!scanning_enabled && !incoming_connections,
"four ready lifecycle devices must stop connection policy");
Bluepad32SlotSnapshot lifecycle_snapshots[kSlotCount]{};
uint32_t baseline_connection_generations[kSlotCount]{};
for (int slot = 0; slot < kSlotCount; ++slot) {
bluepad32_input_backend_snapshot(
static_cast<uint8_t>(slot), &lifecycle_snapshots[slot]);
require(lifecycle_snapshots[slot].active,
"ready slot snapshot must publish active state");
baseline_connection_generations[slot] =
lifecycle_snapshots[slot].connection_generation;
}
require_identity(
lifecycle_snapshots[0].identity, true,
ControllerTransport::kClassic, BD_ADDR_TYPE_UNKNOWN,
devices[0].conn.btaddr, devices[0].vendor_id,
devices[0].product_id,
"Classic snapshot identity must use the connected device address");
require_identity(
lifecycle_snapshots[1].identity, true,
ControllerTransport::kBle, BD_ADDR_TYPE_LE_PUBLIC,
resolved_address, devices[1].vendor_id, devices[1].product_id,
"resolved BLE snapshot must use the stable identity address");
require(controller_identity_is_global(
lifecycle_snapshots[2].identity),
"unresolved BLE snapshot must use the global unstable identity");
require_identity(
lifecycle_snapshots[3].identity, true,
ControllerTransport::kBle, BD_ADDR_TYPE_LE_RANDOM,
reencrypted_address, devices[3].vendor_id,
devices[3].product_id,
"reencrypted BLE snapshot must use the bonded identity address");
require(observed_profile_identity_count == 3 &&
controller_identity_equal(
observed_profile_identities[0],
lifecycle_snapshots[1].identity) &&
controller_identity_equal(
observed_profile_identities[1],
lifecycle_snapshots[3].identity) &&
controller_identity_equal(
observed_profile_identities[2],
lifecycle_snapshots[0].identity),
"only stable ready identities must be enrolled for profiles");
size_t classic_identity_observations = 0;
for (size_t index = 0; index < observed_profile_identity_count;
++index) {
if (controller_identity_equal(
observed_profile_identities[index],
lifecycle_snapshots[0].identity)) {
++classic_identity_observations;
}
}
require(classic_identity_observations == 1,
"active GAP ACL with no cached protocol must enroll its stable "
"Classic identity exactly once");
require(baseline_connection_generations[0] ==
first_pending_generation + 1 &&
baseline_connection_generations[1] ==
baseline_connection_generations[2] &&
baseline_connection_generations[2] ==
baseline_connection_generations[3],
"connection generations must isolate each slot lifecycle");
const bd_addr_t created_address =
{0x40, 0x41, 0x42, 0x43, 0x44, 0x45};
register_lookup_device(&devices[2]);
dispatch_identity_event(
SM_EVENT_IDENTITY_CREATED, devices[2],
BD_ADDR_TYPE_LE_RANDOM, created_address);
bluepad32_input_backend_snapshot(2, &lifecycle_snapshots[2]);
require_identity(
lifecycle_snapshots[2].identity, true,
ControllerTransport::kBle, BD_ADDR_TYPE_LE_RANDOM,
created_address, devices[2].vendor_id, devices[2].product_id,
"new BLE identity event must update an active slot snapshot");
require(observed_profile_identity_count == 4 &&
controller_identity_equal(
observed_profile_identities[3],
lifecycle_snapshots[2].identity),
"late BLE identity creation must enroll the stable identity");
const uint32_t buttons[kSlotCount] = {
BUTTON_B, BUTTON_A, BUTTON_X, BUTTON_Y};
uni_controller_t data[kSlotCount]{};
for (int slot = 0; slot < kSlotCount; ++slot) {
data[slot].klass = UNI_CONTROLLER_CLASS_GAMEPAD;
data[slot].gamepad.buttons = buttons[slot];
data[slot].gamepad.accel[slot % 3] = 8192 + slot;
data[slot].gamepad.gyro[(slot + 1) % 3] = 1024 + slot;
platform_on_controller_data(&devices[slot], &data[slot]);
}
ControllerState states[kSlotCount]{};
for (int slot = 0; slot < kSlotCount; ++slot) {
require(read_controller_state(slot, &states[slot]) &&
states[slot].motion_sample_count == 3,
"every slot must expose independent input and IMU");
}
require(states[0].button_east && !states[0].button_south &&
!states[0].button_west && !states[0].button_north,
"slot 0 must contain only slot 0 input");
require(states[1].button_south && !states[1].button_east &&
!states[1].button_west && !states[1].button_north,
"slot 1 must contain only slot 1 input");
require(states[2].button_west && !states[2].button_east &&
!states[2].button_south && !states[2].button_north,
"slot 2 must contain only slot 2 input");
require(states[3].button_north && !states[3].button_east &&
!states[3].button_south && !states[3].button_west,
"slot 3 must contain only slot 3 input");
bluepad32_input_backend_report_sent(3);
for (int slot = 0; slot < kSlotCount; ++slot) {
require(read_controller_state(slot, &states[slot]) &&
states[slot].motion_sample_count == (slot == 3 ? 0 : 3),
"slot 3 acknowledgement must not consume slots 0-2 IMU");
}
for (int slot = 0; slot < 3; ++slot) {
bluepad32_input_backend_report_sent(slot);
require(read_controller_state(slot, &states[slot]) &&
states[slot].motion_sample_count == 0,
"each slot acknowledgement must consume only its own IMU");
}
const ControllerRumbleOutput initial_rumble[kSlotCount] = {
{11, 21}, {12, 22}, {13, 23}, {14, 24}};
for (int slot = 0; slot < kSlotCount; ++slot) {
bluepad32_input_backend_queue_rumble(slot, initial_rumble[slot]);
}
process_rumble_timer(&g_rumble_timer);
for (int slot = 0; slot < kSlotCount; ++slot) {
require(devices[slot].rumble_calls == 1 &&
devices[slot].last_low == 11 + slot &&
devices[slot].last_high == 21 + slot &&
devices[slot].last_rumble_duration_ms ==
host_rumble_duration_ms(),
"each slot rumble must reach only its indexed controller");
}
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 0});
process_rumble_timer(&g_rumble_timer);
require(devices[0].rumble_calls == 2 &&
devices[0].last_rumble_duration_ms == 0,
"zero XInput magnitude must stop rumble immediately");
bluepad32_input_backend_queue_rumble(3, ControllerRumbleOutput{55, 66});
const uint32_t disconnected_generation =
baseline_connection_generations[3];
platform_on_device_disconnected(&devices[3]);
require(scanning_enabled && classic_scanning_enabled && incoming_connections,
"slot 3 disconnect must resume scanning and incoming connections");
require(!read_controller_state(3, &states[3]) &&
!states[3].button_north && states[3].left_stick_x == 0,
"slot 3 disconnect must publish protocol-neutral state");
bluepad32_input_backend_snapshot(3, &lifecycle_snapshots[3]);
require(!lifecycle_snapshots[3].active &&
lifecycle_snapshots[3].connection_generation ==
disconnected_generation + 1 &&
controller_identity_is_global(
lifecycle_snapshots[3].identity) &&
!lifecycle_snapshots[3].state.button_north &&
lifecycle_snapshots[3].state.left_stick_x == 0,
"disconnect snapshot must atomically publish neutral state, "
"cleared identity, and a new connection generation");
for (int survivor = 0; survivor < 3; ++survivor) {
bluepad32_input_backend_snapshot(
static_cast<uint8_t>(survivor),
&lifecycle_snapshots[survivor]);
require(lifecycle_snapshots[survivor].active &&
lifecycle_snapshots[survivor].connection_generation ==
baseline_connection_generations[survivor],
"disconnect generation must not leak into surviving slots");
}
require(read_controller_state(0, &states[0]) &&
states[0].button_east &&
read_controller_state(1, &states[1]) &&
states[1].button_south &&
read_controller_state(2, &states[2]) &&
states[2].button_west,
"slot 3 disconnect must preserve slots 0-2");
platform_on_controller_data(&devices[0], &data[0]);
require(read_controller_state(0, &states[0]) &&
states[0].button_east,
"slot 0 input must continue while slot 3 is disconnected");
const int slot_zero_calls_while_scanning = devices[0].rumble_calls;
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{115, 116});
tick_backend_timer(99);
require(devices[0].rumble_calls == slot_zero_calls_while_scanning + 1 &&
devices[0].last_low == 115 &&
devices[0].last_high == 116,
"slot 0 rumble must continue while slot 3 is disconnected");
uni_hid_device_t slot_three_replacement =
device(3, true, UNI_BT_CONN_PROTOCOL_BLE);
memcpy(slot_three_replacement.conn.btaddr,
devices[3].conn.btaddr, sizeof(devices[3].conn.btaddr));
require(
slot_three_replacement.conn.handle == devices[3].conn.handle &&
gap_get_connection_type(slot_three_replacement.conn.handle) ==
GAP_CONNECTION_LE &&
memcmp(slot_three_replacement.conn.btaddr,
devices[3].conn.btaddr, sizeof(devices[3].conn.btaddr)) ==
0,
"replacement isolation fixture must reuse the active BLE handle "
"and address");
require(platform_on_device_ready(&slot_three_replacement) ==
UNI_ERROR_SUCCESS,
"slot 3 replacement must bind to the freed indexed slot");
process_rumble_timer(&g_rumble_timer);
require(slot_three_replacement.rumble_calls == 0,
"slot 3 replacement must not receive disconnected device rumble");
bluepad32_input_backend_snapshot(3, &lifecycle_snapshots[3]);
require(lifecycle_snapshots[3].active &&
lifecycle_snapshots[3].connection_generation ==
disconnected_generation + 1 &&
controller_identity_is_global(
lifecycle_snapshots[3].identity),
"replacement must keep the new generation and cannot inherit "
"the disconnected BLE identity");
require(observed_profile_identity_count == 4,
"unstable replacement must not be enrolled for profiles");
g_slots[3].pending_rumble = {
3, disconnected_generation, ControllerRumbleOutput{77, 88},
kXInputHostRumbleDurationMs};
g_slots[3].rumble_pending = true;
process_rumble_timer(&g_rumble_timer);
require(slot_three_replacement.rumble_calls == 0,
"stale slot 3 connection generation must be rejected");
uni_controller_t replacement_data{};
replacement_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
replacement_data.gamepad.buttons = BUTTON_Y;
replacement_data.gamepad.accel[0] = 9000;
platform_on_controller_data(&slot_three_replacement, &replacement_data);
require(read_controller_state(3, &states[3]) &&
states[3].button_north && states[3].motion_sample_count == 3,
"replacement input and IMU must populate only slot 3");
require(read_controller_state(0, &states[0]) &&
states[0].button_east &&
read_controller_state(1, &states[1]) &&
states[1].button_south &&
read_controller_state(2, &states[2]) &&
states[2].button_west,
"slot 3 replacement must not disturb slots 0-2");
const int survivor_calls[kSlotCount - 1] = {
devices[0].rumble_calls,
devices[1].rumble_calls,
devices[2].rumble_calls};
bluepad32_input_backend_queue_rumble(3, ControllerRumbleOutput{90, 91});
process_rumble_timer(&g_rumble_timer);
require(slot_three_replacement.rumble_calls == 1 &&
slot_three_replacement.last_low == 90 &&
slot_three_replacement.last_high == 91,
"new-generation slot 3 rumble must reach its replacement");
for (int slot = 0; slot < 3; ++slot) {
require(devices[slot].rumble_calls == survivor_calls[slot],
"slot 3 rumble must not affect slots 0-2");
}
const int all_slot_calls[kSlotCount] = {
devices[0].rumble_calls,
devices[1].rumble_calls,
devices[2].rumble_calls,
slot_three_replacement.rumble_calls};
for (int slot = 0; slot < kSlotCount; ++slot) {
bluepad32_input_backend_queue_rumble(
slot, ControllerRumbleOutput{static_cast<uint8_t>(100 + slot),
static_cast<uint8_t>(110 + slot)});
}
process_rumble_timer(&g_rumble_timer);
for (int slot = 0; slot < kSlotCount; ++slot) {
const uni_hid_device_t& target =
slot == 3 ? slot_three_replacement : devices[slot];
require(target.rumble_calls == all_slot_calls[slot] + 1 &&
target.last_low == 100 + slot &&
target.last_high == 110 + slot,
"survivor rumble must continue after slot 3 replacement");
}
uni_hid_device_t replacements[kSlotCount] = {
device(0), device(1), device(2), device(3)};
for (int slot = 0; slot < 3; ++slot) {
platform_on_device_disconnected(&devices[slot]);
require(scanning_enabled && classic_scanning_enabled && incoming_connections,
"disconnecting slots 0-2 must resume connection policy");
require(!read_controller_state(slot, &states[slot]) &&
states[slot].left_stick_x == 0,
"disconnect must publish protocol-neutral state");
for (int survivor = 0; survivor < kSlotCount; ++survivor) {
if (survivor == slot) {
continue;
}
require(read_controller_state(survivor, &states[survivor]),
"disconnect must preserve all three survivors");
}
require(platform_on_device_ready(&replacements[slot]) ==
UNI_ERROR_SUCCESS,
"replacement must bind to each freed slot");
require(g_connection_status == ConnectionStatus::Ready &&
!scanning_enabled && !incoming_connections,
"replacement must restore the full four-slot policy");
}
const int slot_zero_calls_before_mailboxes = replacements[0].rumble_calls;
const int slot_three_calls_before_mailboxes =
slot_three_replacement.rumble_calls;
bluepad32_input_backend_queue_rumble(3, ControllerRumbleOutput{119, 120});
bluepad32_input_backend_queue_rumble(3, ControllerRumbleOutput{121, 122});
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{123, 124});
process_rumble_timer(&g_rumble_timer);
require(slot_three_replacement.rumble_calls ==
slot_three_calls_before_mailboxes + 1 &&
slot_three_replacement.last_low == 121 &&
slot_three_replacement.last_high == 122,
"slot 3 mailbox must dispatch only its latest queued value");
require(replacements[0].rumble_calls ==
slot_zero_calls_before_mailboxes + 1 &&
replacements[0].last_low == 123 &&
replacements[0].last_high == 124,
"slot 0 activity must not evict the slot 3 mailbox");
}
void test_pairing_window_policy() {
bd_addr_t address = {1, 2, 3, 4, 5, 6};
bluepad32_input_backend_init();
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"discovery must remain closed before backend initialization");
start_backend();
require(g_connection_policy_state == ConnectionPolicyState::Open &&
classic_scanning_enabled && scanning_enabled &&
incoming_connections,
"boot must allow normal Bluepad32 autoconnect");
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_SUCCESS,
"boot policy must accept a discovered controller");
dispatch_pairing_event(HCI_EVENT_USER_CONFIRMATION_REQUEST);
dispatch_pairing_event(HCI_EVENT_USER_PASSKEY_REQUEST);
require(confirmation_rejections == 1 && passkey_rejections == 1 &&
confirmation_accepts == 0 && passkey_accepts == 0,
"closed BOOTSEL window must reject new SSP authentication");
uni_hid_device_t reconnecting = device(0);
platform_on_device_connected(&reconnecting);
require(device_disconnect_calls == 0 &&
g_slots[0].device == &reconnecting,
"boot policy must retain a reconnecting controller");
platform_on_device_disconnected(&reconnecting);
bluepad32_input_backend_open_pairing_window();
require(!g_pairing_window_open,
"Core0 request must wait for Core1 consumption");
process_rumble_timer(&g_rumble_timer);
require(g_pairing_window_open && bondable &&
accepted_stk_methods == kAllBlePairingMethods &&
g_pairing_window_deadline_ms == 60000 &&
g_connection_policy_state == ConnectionPolicyState::Open &&
classic_scanning_enabled && scanning_enabled &&
incoming_connections,
"BOOTSEL must enable Classic and BLE pairing without interrupting autoconnect");
dispatch_pairing_event(HCI_EVENT_USER_CONFIRMATION_REQUEST);
dispatch_pairing_event(HCI_EVENT_USER_PASSKEY_REQUEST);
require(confirmation_accepts == 1 && passkey_accepts == 1,
"open BOOTSEL window must accept new SSP authentication");
tick_backend_timer(20);
require(!observed_status_led_on,
"pairing double blink must finish its first pulse");
tick_backend_timer(20);
require(observed_status_led_on,
"pairing double blink must start its second pulse");
tick_backend_timer(20);
require(!observed_status_led_on,
"pairing double blink must finish its second pulse");
now_ms = 30000;
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(g_pairing_window_deadline_ms == 90000,
"pairing request must extend deadline from current Core1 time");
uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)};
for (int slot = 0; slot < kSlotCount; ++slot) {
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS,
"policy test devices must fill all slots");
}
require(g_connection_policy_state == ConnectionPolicyState::Paused &&
g_pairing_window_open && !scanning_enabled &&
!classic_scanning_enabled && !incoming_connections,
"full slots must pause scanning without closing the deadline");
now_ms = 90000;
process_rumble_timer(&g_rumble_timer);
require(!g_pairing_window_open && !bondable &&
accepted_stk_methods == 0 &&
g_connection_policy_state == ConnectionPolicyState::Paused,
"Classic and BLE pairing authentication must close at the deadline");
platform_on_device_disconnected(&devices[3]);
require(g_connection_policy_state == ConnectionPolicyState::Passive &&
!classic_scanning_enabled && !scanning_enabled &&
incoming_connections,
"a freed slot with active controllers must remain passive");
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"passive policy must reject inquiry discoveries");
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(g_connection_policy_state == ConnectionPolicyState::Open &&
classic_scanning_enabled && scanning_enabled &&
incoming_connections,
"explicit BOOTSEL window must resume active discovery");
require(platform_on_device_discovered(address, "controller", 0, 0) ==
UNI_ERROR_SUCCESS,
"pairing window discovery must accept a controller");
}
void test_slot_lighting() {
start_pairing_backend();
uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)};
for (uint8_t slot = 0; slot < kSlotCount; ++slot) {
devices[slot].report_parser.set_lightbar_color = set_lightbar;
devices[slot].report_parser.set_player_leds = set_player_leds;
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS,
"color-capable controller did not become ready");
const SwitchRgbColor expected =
switch_pro_get_slot_light_color(slot);
require(devices[slot].lightbar_calls == 1 &&
devices[slot].lightbar_red == expected.red &&
devices[slot].lightbar_green == expected.green &&
devices[slot].lightbar_blue == expected.blue &&
devices[slot].player_led_calls == 0,
"slot color did not reach the controller lightbar");
require(platform_on_device_ready(&devices[slot]) == UNI_ERROR_SUCCESS &&
devices[slot].lightbar_calls == 1,
"duplicate ready event rewrote controller lighting");
}
platform_on_device_disconnected(&devices[2]);
uni_hid_device_t fallback = device(2);
fallback.report_parser.set_player_leds = set_player_leds;
require(platform_on_device_ready(&fallback) == UNI_ERROR_SUCCESS &&
fallback.lightbar_calls == 0 &&
fallback.player_led_calls == 1 &&
fallback.player_leds == (1u << 2u),
"controller without RGB support did not receive its slot LED");
}
void test_profile_chord_remains_raw() {
start_pairing_backend();
uni_hid_device_t controller = device(0);
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
"raw profile chord controller did not become ready");
uni_controller_t input{};
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
input.gamepad.buttons =
BUTTON_A | BUTTON_SHOULDER_L | BUTTON_SHOULDER_R;
input.gamepad.misc_buttons =
MISC_BUTTON_SELECT | MISC_BUTTON_START;
platform_on_controller_data(&controller, &input);
ControllerState snapshot{};
require(read_controller_state(0, &snapshot) &&
snapshot.button_south && !snapshot.button_east &&
snapshot.button_left_shoulder &&
snapshot.button_right_shoulder &&
snapshot.button_select && snapshot.button_start,
"Core 1 suppressed the profile chord or mutated ABXY mapping");
process_rumble_timer(&g_rumble_timer);
require(controller.rumble_calls == 0 &&
!g_slots[0].feedback_pending,
"legacy ABXY chord still produced local feedback");
}
void test_profile_feedback_scheduler() {
start_pairing_backend();
uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)};
devices[0].report_parser.set_lightbar_color = set_lightbar;
devices[1].report_parser.set_player_leds = set_player_leds;
devices[2].report_parser.set_lightbar_color = set_lightbar;
devices[3].report_parser.set_player_leds = set_player_leds;
uint32_t generations[kSlotCount]{};
for (uint8_t slot = 0; slot < kSlotCount; ++slot) {
require(platform_on_device_ready(&devices[slot]) ==
UNI_ERROR_SUCCESS,
"profile feedback controller did not become ready");
Bluepad32SlotSnapshot snapshot{};
bluepad32_input_backend_snapshot(slot, &snapshot);
generations[slot] = snapshot.connection_generation;
require(devices[slot].rumble_calls == 0 &&
!g_slots[slot].profile_feedback.active &&
g_slots[slot].pending_profile_feedback_count == 0,
"initial controller/profile load scheduled confirmation feedback");
bluepad32_input_backend_queue_profile_feedback(
slot, generations[slot], static_cast<uint8_t>(slot + 1u),
ControllerProfileConfirmationPolicy::kRumble);
bluepad32_input_backend_queue_rumble(
slot, ControllerRumbleOutput{
static_cast<uint8_t>(0x10u + slot),
static_cast<uint8_t>(0x20u + slot)});
}
now_ms = 0;
process_rumble_timer(&g_rumble_timer);
for (uint8_t slot = 0; slot < kSlotCount; ++slot) {
require(devices[slot].rumble_calls == 1 &&
devices[slot].last_rumble_duration_ms ==
kProfileFeedbackPhaseDurationMs &&
devices[slot].last_high == UINT8_MAX &&
devices[slot].last_low == UINT8_MAX &&
g_slots[slot].rumble_pending &&
g_slots[slot].profile_feedback.active,
"profile pulse sequence did not start at full strength");
}
now_ms = 74;
process_rumble_timer(&g_rumble_timer);
now_ms = 75;
process_rumble_timer(&g_rumble_timer);
for (const uni_hid_device_t& controller : devices) {
require(controller.rumble_calls == 1,
"profile pulse did not retain a 75 ms on phase");
}
now_ms = 149;
process_rumble_timer(&g_rumble_timer);
now_ms = 150;
process_rumble_timer(&g_rumble_timer);
require(devices[0].rumble_calls == 2 &&
devices[0].last_high == 0x20 &&
devices[0].last_low == 0x10 &&
!g_slots[0].rumble_pending,
"one-pulse confirmation did not defer host rumble through its off phase");
for (uint8_t slot = 1; slot < kSlotCount; ++slot) {
require(devices[slot].rumble_calls == 2 &&
devices[slot].last_high == UINT8_MAX &&
g_slots[slot].rumble_pending,
"second profile pulse did not start after 75 ms off");
}
now_ms = 225;
process_rumble_timer(&g_rumble_timer);
now_ms = 300;
process_rumble_timer(&g_rumble_timer);
require(devices[1].rumble_calls == 3 &&
devices[1].last_high == 0x21 &&
!g_slots[1].rumble_pending &&
devices[2].rumble_calls == 3 &&
devices[3].rumble_calls == 3,
"two/three/four-pulse sequences diverged at 300 ms");
now_ms = 375;
process_rumble_timer(&g_rumble_timer);
now_ms = 450;
process_rumble_timer(&g_rumble_timer);
require(devices[2].rumble_calls == 4 &&
devices[2].last_high == 0x22 &&
!g_slots[2].rumble_pending &&
devices[3].rumble_calls == 4 &&
devices[3].last_high == UINT8_MAX,
"three/four-pulse sequences diverged at 450 ms");
now_ms = 525;
process_rumble_timer(&g_rumble_timer);
now_ms = 600;
process_rumble_timer(&g_rumble_timer);
require(devices[3].rumble_calls == 5 &&
devices[3].last_high == 0x23 &&
!g_slots[3].rumble_pending &&
devices[0].lightbar_calls == 1 &&
devices[1].player_led_calls == 1 &&
devices[2].lightbar_calls == 1 &&
devices[3].player_led_calls == 1,
"four-pulse confirmation did not release host rumble at 600 ms");
const int slot_zero_lightbar_calls = devices[0].lightbar_calls;
const int slot_one_player_led_calls_before_slot_zero =
devices[1].player_led_calls;
const int slot_two_lightbar_calls_before_slot_zero =
devices[2].lightbar_calls;
const int slot_three_player_led_calls_before_slot_zero =
devices[3].player_led_calls;
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 2,
ControllerProfileConfirmationPolicy::kNone);
bluepad32_input_backend_queue_rumble(
0, ControllerRumbleOutput{0x31, 0x41});
now_ms = 700;
process_rumble_timer(&g_rumble_timer);
require(devices[0].rumble_calls == 3 &&
devices[0].last_high == 0x41 &&
devices[0].lightbar_calls ==
slot_zero_lightbar_calls &&
!g_slots[0].profile_feedback.active,
"none policy scheduled profile rumble or lighting");
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 2,
ControllerProfileConfirmationPolicy::kLed);
bluepad32_input_backend_queue_rumble(
0, ControllerRumbleOutput{0x32, 0x42});
now_ms = 800;
process_rumble_timer(&g_rumble_timer);
require(devices[0].rumble_calls == 3 &&
devices[0].lightbar_calls ==
slot_zero_lightbar_calls + 1 &&
devices[0].lightbar_red ==
kProfileLightbarPalette[1].red &&
devices[0].lightbar_green ==
kProfileLightbarPalette[1].green &&
devices[0].lightbar_blue ==
kProfileLightbarPalette[1].blue &&
observed_status_led_on &&
g_slots[0].rumble_pending,
"LED policy did not set transient profile color and first "
"onboard blink");
now_ms = 875;
process_rumble_timer(&g_rumble_timer);
require(!observed_status_led_on &&
devices[0].rumble_calls == 3,
"onboard profile blink did not enter its 75 ms off phase");
now_ms = 950;
process_rumble_timer(&g_rumble_timer);
require(observed_status_led_on,
"second onboard profile blink did not start");
now_ms = 1025;
process_rumble_timer(&g_rumble_timer);
require(!observed_status_led_on &&
g_slots[0].rumble_pending,
"host rumble interrupted the final onboard off phase");
now_ms = 1100;
process_rumble_timer(&g_rumble_timer);
const SwitchRgbColor slot_zero_color =
switch_pro_get_slot_light_color(0);
require(devices[0].rumble_calls == 4 &&
devices[0].last_high == 0x42 &&
!g_slots[0].rumble_pending &&
devices[0].lightbar_calls ==
slot_zero_lightbar_calls + 2 &&
devices[0].lightbar_red == slot_zero_color.red &&
devices[0].lightbar_green == slot_zero_color.green &&
devices[0].lightbar_blue == slot_zero_color.blue &&
devices[1].player_led_calls ==
slot_one_player_led_calls_before_slot_zero &&
devices[2].lightbar_calls ==
slot_two_lightbar_calls_before_slot_zero &&
devices[3].player_led_calls ==
slot_three_player_led_calls_before_slot_zero,
"LED-only sequence did not restore its slot color in "
"isolation after the final gap");
const int slot_one_player_led_calls =
devices[1].player_led_calls;
bluepad32_input_backend_queue_profile_feedback(
1, generations[1], 3,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
now_ms = 1200;
process_rumble_timer(&g_rumble_timer);
require(devices[1].rumble_calls == 4 &&
devices[1].last_high == UINT8_MAX &&
devices[1].player_led_calls ==
slot_one_player_led_calls + 1 &&
devices[1].player_leds == 0x07 &&
observed_status_led_on,
"combined policy did not drive rumble, onboard LED, and player LEDs");
for (uint32_t deadline = 1275; deadline <= 1650;
deadline += 75) {
now_ms = deadline;
process_rumble_timer(&g_rumble_timer);
}
require(devices[1].rumble_calls ==
(host_rumble_duration_ms() ==
kXInputHostRumbleDurationMs
? 7
: 6) &&
!g_slots[1].profile_feedback.active &&
devices[1].player_led_calls ==
slot_one_player_led_calls + 2 &&
devices[1].player_leds == (1u << 1u),
"combined three-pulse sequence did not terminate and "
"restore slot player lighting");
const int old_rumble_calls = devices[2].rumble_calls;
bluepad32_input_backend_queue_profile_feedback(
2, generations[2], 4,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
bluepad32_input_backend_queue_profile_feedback(
2, generations[2], 1,
ControllerProfileConfirmationPolicy::kLed);
require(g_slots[2].pending_profile_feedback_count == 2,
"two queued profile events did not fill the bounded FIFO");
platform_on_device_disconnected(&devices[2]);
uni_hid_device_t replacement = device(2);
replacement.report_parser.set_lightbar_color = set_lightbar;
require(platform_on_device_ready(&replacement) ==
UNI_ERROR_SUCCESS,
"replacement feedback controller did not become ready");
now_ms = 1700;
process_rumble_timer(&g_rumble_timer);
require(devices[2].rumble_calls == old_rumble_calls &&
replacement.rumble_calls == 0 &&
replacement.lightbar_calls == 1 &&
!g_slots[2].profile_feedback.active &&
g_slots[2].pending_profile_feedback_count == 0,
"slot replacement accepted stale queued profile feedback");
bluepad32_input_backend_queue_profile_feedback(
2, generations[2], 4,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
now_ms = 1705;
process_rumble_timer(&g_rumble_timer);
require(replacement.rumble_calls == 0 &&
replacement.lightbar_calls == 1,
"stale generation feedback reached a replacement controller");
const int slot_three_rumble_calls = devices[3].rumble_calls;
const int slot_three_player_led_calls =
devices[3].player_led_calls;
bluepad32_input_backend_queue_profile_feedback(
3, generations[3], 1,
ControllerProfileConfirmationPolicy::kLed);
now_ms = 1800;
process_rumble_timer(&g_rumble_timer);
require(observed_status_led_on &&
g_slots[3].profile_feedback.pulses_started == 1 &&
devices[3].player_led_calls ==
slot_three_player_led_calls + 1 &&
devices[3].player_leds == 0x01,
"one-blink LED profile indication did not start");
now_ms = 1875;
process_rumble_timer(&g_rumble_timer);
require(!observed_status_led_on,
"one-blink LED profile indication did not turn off");
now_ms = 1950;
process_rumble_timer(&g_rumble_timer);
require(!g_slots[3].profile_feedback.active &&
devices[3].rumble_calls ==
slot_three_rumble_calls &&
devices[3].player_led_calls ==
slot_three_player_led_calls + 2 &&
devices[3].player_leds == (1u << 3u),
"one-blink LED-only profile indication did not restore "
"slot lighting cleanly");
bluepad32_input_backend_queue_profile_feedback(
3, generations[3], 4,
ControllerProfileConfirmationPolicy::kLed);
now_ms = 2000;
process_rumble_timer(&g_rumble_timer);
require(devices[3].player_leds == 0x0f,
"profile 4 player count was not shown during its sequence");
for (uint8_t pulse = 1; pulse <= 4; ++pulse) {
require(observed_status_led_on &&
g_slots[3].profile_feedback.on &&
g_slots[3].profile_feedback.pulses_started ==
pulse,
"four-blink LED sequence missed an on phase");
now_ms = static_cast<uint32_t>(
2075u + static_cast<uint32_t>(pulse - 1u) * 150u);
process_rumble_timer(&g_rumble_timer);
require(!observed_status_led_on &&
!g_slots[3].profile_feedback.on,
"four-blink LED sequence missed an off phase");
if (pulse != 4) {
now_ms += 75;
process_rumble_timer(&g_rumble_timer);
}
}
now_ms = 2600;
process_rumble_timer(&g_rumble_timer);
require(!g_slots[3].profile_feedback.active &&
devices[3].rumble_calls ==
slot_three_rumble_calls &&
devices[3].player_led_calls ==
slot_three_player_led_calls + 4 &&
devices[3].player_leds == (1u << 3u),
"four-blink LED-only profile indication did not restore "
"slot lighting");
Bluepad32SlotSnapshot replacement_snapshot{};
bluepad32_input_backend_snapshot(2, &replacement_snapshot);
const int replacement_lightbar_calls = replacement.lightbar_calls;
bluepad32_input_backend_queue_profile_feedback(
2, replacement_snapshot.connection_generation, 1,
ControllerProfileConfirmationPolicy::kLed);
now_ms = 2700;
process_rumble_timer(&g_rumble_timer);
require(replacement.lightbar_calls ==
replacement_lightbar_calls + 1,
"current-generation profile lighting was not applied");
now_ms = 2775;
process_rumble_timer(&g_rumble_timer);
platform_on_device_disconnected(&replacement);
uni_hid_device_t second_replacement = device(2);
second_replacement.report_parser.set_lightbar_color =
set_lightbar;
require(platform_on_device_ready(&second_replacement) ==
UNI_ERROR_SUCCESS &&
second_replacement.lightbar_calls == 1,
"second replacement did not receive steady slot lighting");
now_ms = 2850;
process_rumble_timer(&g_rumble_timer);
require(second_replacement.lightbar_calls == 1 &&
replacement.lightbar_calls ==
replacement_lightbar_calls + 1 &&
!g_slots[2].profile_feedback.active,
"stale final-gap restore touched a replacement connection");
const int fifo_lightbar_calls = devices[0].lightbar_calls;
const int fifo_rumble_calls = devices[0].rumble_calls;
const int isolated_slot_one_lighting =
devices[1].player_led_calls;
const int isolated_slot_two_lighting =
second_replacement.lightbar_calls;
const int isolated_slot_three_lighting =
devices[3].player_led_calls;
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 1,
ControllerProfileConfirmationPolicy::kLed);
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 2,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
require(g_slots[0].pending_profile_feedback_count == 2,
"initial and switched profile events were not queued");
now_ms = 3000;
process_rumble_timer(&g_rumble_timer);
require(g_slots[0].profile_feedback.active &&
g_slots[0].profile_feedback.pulse_count == 1 &&
!g_slots[0].profile_feedback.rumble_enabled &&
g_slots[0].pending_profile_feedback_count == 1 &&
devices[0].rumble_calls == fifo_rumble_calls &&
devices[0].lightbar_calls ==
fifo_lightbar_calls + 1 &&
devices[0].lightbar_red ==
kProfileLightbarPalette[0].red,
"LED-only initial event did not run first from the FIFO");
now_ms = 3075;
process_rumble_timer(&g_rumble_timer);
now_ms = 3150;
process_rumble_timer(&g_rumble_timer);
require(g_slots[0].profile_feedback.active &&
g_slots[0].profile_feedback.pulse_count == 2 &&
g_slots[0].profile_feedback.rumble_enabled &&
g_slots[0].pending_profile_feedback_count == 0 &&
devices[0].rumble_calls == fifo_rumble_calls + 1 &&
devices[0].lightbar_calls ==
fifo_lightbar_calls + 3 &&
devices[0].lightbar_red ==
kProfileLightbarPalette[1].red,
"switched profile event did not follow initial indication "
"after its final gap");
now_ms = 3225;
process_rumble_timer(&g_rumble_timer);
now_ms = 3300;
process_rumble_timer(&g_rumble_timer);
now_ms = 3375;
process_rumble_timer(&g_rumble_timer);
now_ms = 3450;
process_rumble_timer(&g_rumble_timer);
const SwitchRgbColor final_slot_zero_color =
switch_pro_get_slot_light_color(0);
const bool stateful_host_rumble =
host_rumble_duration_ms() == kXInputHostRumbleDurationMs;
require(!g_slots[0].profile_feedback.active &&
devices[0].rumble_calls ==
fifo_rumble_calls +
(stateful_host_rumble ? 3 : 2) &&
(!stateful_host_rumble ||
(devices[0].last_high == 0x42 &&
devices[0].last_low == 0x32)) &&
devices[0].lightbar_calls ==
fifo_lightbar_calls + 4 &&
devices[0].lightbar_red ==
final_slot_zero_color.red &&
devices[0].lightbar_green ==
final_slot_zero_color.green &&
devices[0].lightbar_blue ==
final_slot_zero_color.blue &&
devices[1].player_led_calls ==
isolated_slot_one_lighting &&
second_replacement.lightbar_calls ==
isolated_slot_two_lighting &&
devices[3].player_led_calls ==
isolated_slot_three_lighting,
"ordered profile FIFO did not restore or remain slot-local");
}
void test_stateful_host_rumble_restore() {
start_pairing_backend();
uni_hid_device_t devices[kSlotCount] = {
device(0), device(1), device(2), device(3)};
uint32_t generations[kSlotCount]{};
for (uint8_t slot = 0; slot < kSlotCount; ++slot) {
require(platform_on_device_ready(&devices[slot]) ==
UNI_ERROR_SUCCESS,
"rumble restore controller did not become ready");
Bluepad32SlotSnapshot snapshot{};
bluepad32_input_backend_snapshot(slot, &snapshot);
generations[slot] = snapshot.connection_generation;
}
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
test_adapter_mode = AdapterUsbMode::kXInput;
const ControllerRumbleOutput desired[kSlotCount] = {
{0x11, 0x21}, {0x12, 0x22}, {0x13, 0x23}, {0x14, 0x24}};
for (uint8_t slot = 0; slot < kSlotCount; ++slot) {
bluepad32_input_backend_queue_rumble(slot, desired[slot]);
}
now_ms = 0;
process_rumble_timer(&g_rumble_timer);
for (uint8_t slot = 0; slot < kSlotCount; ++slot) {
require(devices[slot].rumble_calls == 1 &&
devices[slot].last_rumble_duration_ms ==
kXInputHostRumbleDurationMs &&
g_slots[slot].retained_host_rumble_valid,
"initial XInput rumble was not dispatched and retained");
bluepad32_input_backend_queue_profile_feedback(
slot, generations[slot], static_cast<uint8_t>(slot + 1u),
ControllerProfileConfirmationPolicy::kRumble);
}
now_ms = 10;
process_rumble_timer(&g_rumble_timer);
for (now_ms = 85; now_ms <= 610; now_ms += 75) {
process_rumble_timer(&g_rumble_timer);
}
for (uint8_t slot = 0; slot < kSlotCount; ++slot) {
require(devices[slot].rumble_calls ==
static_cast<int>(slot + 3u) &&
devices[slot].last_low ==
desired[slot].low_frequency_magnitude &&
devices[slot].last_high ==
desired[slot].high_frequency_magnitude &&
devices[slot].last_rumble_duration_ms ==
kXInputHostRumbleDurationMs &&
!g_slots[slot].profile_feedback.active,
"XInput rumble did not resume after its profile pulse count");
}
const int stop_calls_before = devices[0].rumble_calls;
bluepad32_input_backend_queue_rumble(
0, ControllerRumbleOutput{0x51, 0x61});
now_ms = 700;
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 2,
ControllerProfileConfirmationPolicy::kRumble);
now_ms = 705;
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_queue_rumble(
0, ControllerRumbleOutput{0, 0});
for (now_ms = 780; now_ms <= 1005; now_ms += 75) {
process_rumble_timer(&g_rumble_timer);
}
require(devices[0].rumble_calls == stop_calls_before + 4 &&
devices[0].last_rumble_duration_ms == 0 &&
devices[0].last_low == 0 &&
devices[0].last_high == 0 &&
g_slots[0].retained_host_rumble_valid,
"newest XInput stop did not win during profile pulses");
test_adapter_mode = AdapterUsbMode::kSwitchProbe;
const int switch_calls_before = devices[1].rumble_calls;
bluepad32_input_backend_queue_rumble(
1, ControllerRumbleOutput{0x31, 0x41});
now_ms = 1100;
process_rumble_timer(&g_rumble_timer);
require(!g_slots[1].retained_host_rumble_valid,
"Switch rumble retained stale XInput desired state");
bluepad32_input_backend_queue_profile_feedback(
1, generations[1], 1,
ControllerProfileConfirmationPolicy::kRumble);
now_ms = 1105;
process_rumble_timer(&g_rumble_timer);
now_ms = 1180;
process_rumble_timer(&g_rumble_timer);
now_ms = 1255;
process_rumble_timer(&g_rumble_timer);
require(devices[1].rumble_calls == switch_calls_before + 2 &&
devices[1].last_rumble_duration_ms ==
kProfileFeedbackPhaseDurationMs,
"finite Switch rumble resumed after profile feedback");
test_adapter_mode = AdapterUsbMode::kXInput;
bluepad32_input_backend_queue_rumble(
2, ControllerRumbleOutput{0x71, 0x81});
now_ms = 1300;
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_queue_profile_feedback(
2, generations[2], 3,
ControllerProfileConfirmationPolicy::kRumble);
now_ms = 1305;
process_rumble_timer(&g_rumble_timer);
const int old_device_calls = devices[2].rumble_calls;
platform_on_device_disconnected(&devices[2]);
uni_hid_device_t replacement = device(2);
require(platform_on_device_ready(&replacement) ==
UNI_ERROR_SUCCESS,
"rumble restore replacement did not become ready");
now_ms = 2000;
process_rumble_timer(&g_rumble_timer);
require(devices[2].rumble_calls == old_device_calls &&
replacement.rumble_calls == 0 &&
!g_slots[2].retained_host_rumble_valid &&
!g_slots[2].profile_feedback.active,
"stale XInput rumble resumed on a replacement connection");
#else
bluepad32_input_backend_queue_rumble(
0, ControllerRumbleOutput{0x31, 0x41});
now_ms = 0;
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_queue_profile_feedback(
0, generations[0], 1,
ControllerProfileConfirmationPolicy::kRumble);
now_ms = 5;
process_rumble_timer(&g_rumble_timer);
now_ms = 80;
process_rumble_timer(&g_rumble_timer);
now_ms = 155;
process_rumble_timer(&g_rumble_timer);
require(devices[0].rumble_calls == 2 &&
!g_slots[0].retained_host_rumble_valid,
"bounded Switch rumble resumed after profile feedback");
#endif
}
void test_motion_toggle_action() {
start_pairing_backend();
uni_hid_device_t slot_zero = device(0);
uni_hid_device_t slot_one = device(1);
require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&slot_one) == UNI_ERROR_SUCCESS,
"motion action test controllers did not become ready");
Bluepad32SlotSnapshot backend_snapshot{};
bluepad32_input_backend_snapshot(0, &backend_snapshot);
require(!bluepad32_input_backend_toggle_motion(
0, backend_snapshot.connection_generation + 1u),
"stale connection toggled motion");
require(bluepad32_input_backend_toggle_motion(
0, backend_snapshot.connection_generation),
"live connection did not toggle motion");
uni_controller_t input{};
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
input.gamepad.accel[0] = 8192;
platform_on_controller_data(&slot_zero, &input);
ControllerState snapshot{};
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 0 : 3),
"motion toggle did not change slot motion publication");
process_rumble_timer(&g_rumble_timer);
require(slot_zero.rumble_calls == 1 &&
slot_zero.last_rumble_duration_ms ==
(kDefaultMotionEnabled
? kMotionDisabledFeedbackDurationMs
: kMotionEnabledFeedbackDurationMs) &&
slot_zero.last_high ==
(kDefaultMotionEnabled
? kMotionDisabledFeedbackWeakMagnitude
: kMotionEnabledFeedbackWeakMagnitude) &&
slot_zero.last_low ==
(kDefaultMotionEnabled
? kMotionDisabledFeedbackStrongMagnitude
: kMotionEnabledFeedbackStrongMagnitude),
"motion toggle did not send distinct state feedback");
uni_controller_t peer_input{};
peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
peer_input.gamepad.accel[0] = 8192;
platform_on_controller_data(&slot_one, &peer_input);
require(read_controller_state(1, &snapshot) &&
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 3 : 0),
"slot 0 motion action changed slot 1 motion state");
require(bluepad32_input_backend_toggle_motion(
0, backend_snapshot.connection_generation),
"second live motion toggle failed");
platform_on_controller_data(&slot_zero, &input);
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 3 : 0),
"second motion toggle did not restore configured state");
platform_on_device_disconnected(&slot_zero);
require(!bluepad32_input_backend_toggle_motion(
0, backend_snapshot.connection_generation),
"disconnected generation toggled motion");
uni_hid_device_t replacement = device(0);
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS &&
g_slots[0].motion_enabled == kDefaultMotionEnabled &&
g_slots[0].pre_hotkey_button_mask == 0 &&
!g_slots[0].feedback_pending,
"disconnect did not reset slot 0 motion action state");
}
void test_protocol_neutral_analog_state() {
start_pairing_backend();
uni_hid_device_t controller = device(0);
uni_hid_device_t peer = device(1);
platform_on_device_connected(&controller);
platform_on_device_connected(&peer);
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&peer) == UNI_ERROR_SUCCESS,
"analog-state controllers did not become ready");
uni_controller_t peer_input{};
peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
peer_input.gamepad.brake = 256;
peer_input.gamepad.throttle = 768;
peer_input.gamepad.buttons =
BUTTON_TRIGGER_L | BUTTON_TRIGGER_R;
platform_on_controller_data(&peer, &peer_input);
ControllerState peer_state{};
require(read_controller_state(1, &peer_state) &&
peer_state.left_trigger == 16399 &&
peer_state.right_trigger == 49199,
"peer analog trigger state was not published exactly");
uni_controller_t input{};
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
input.gamepad.axis_x = -512;
input.gamepad.axis_y = 0;
input.gamepad.axis_rx = 511;
input.gamepad.axis_ry = -256;
input.gamepad.buttons =
BUTTON_TRIGGER_L | BUTTON_TRIGGER_R;
struct TriggerCase {
int32_t brake;
int32_t throttle;
uint16_t expected_left;
uint16_t expected_right;
};
constexpr TriggerCase trigger_cases[] = {
{4, 512, 256, 32799},
{512, 1020, 32799, UINT16_MAX},
{1020, 1016, UINT16_MAX, 65086},
{1016, 1023, 65086, UINT16_MAX},
{1023, 4, UINT16_MAX, 256},
};
ControllerState state{};
for (const TriggerCase& trigger_case : trigger_cases) {
input.gamepad.brake = trigger_case.brake;
input.gamepad.throttle = trigger_case.throttle;
platform_on_controller_data(&controller, &input);
require(read_controller_state(0, &state) &&
state.left_trigger == trigger_case.expected_left &&
state.right_trigger == trigger_case.expected_right,
"digital trigger bits flattened analog trigger precision");
require(read_controller_state(1, &peer_state) &&
peer_state.left_trigger == 16399 &&
peer_state.right_trigger == 49199,
"slot 0 trigger update changed slot 1");
}
require(state.left_stick_x == INT16_MIN &&
state.left_stick_y == 0 &&
state.right_stick_x == INT16_MAX &&
state.right_stick_y == -16384,
"stick axes were not normalized to signed full range");
input.gamepad.brake = 0;
input.gamepad.throttle = 0;
input.gamepad.buttons = BUTTON_TRIGGER_L;
platform_on_controller_data(&controller, &input);
require(read_controller_state(0, &state) &&
state.left_trigger == UINT16_MAX &&
state.right_trigger == 0,
"left digital-only trigger did not map independently");
input.gamepad.buttons = BUTTON_TRIGGER_R;
platform_on_controller_data(&controller, &input);
require(read_controller_state(0, &state) &&
state.left_trigger == 0 &&
state.right_trigger == UINT16_MAX,
"right digital-only trigger did not map independently");
input.gamepad.buttons = 0;
platform_on_controller_data(&controller, &input);
require(read_controller_state(0, &state) &&
state.left_trigger == 0 &&
state.right_trigger == 0,
"released triggers did not return to zero");
require(read_controller_state(1, &peer_state) &&
peer_state.left_trigger == 16399 &&
peer_state.right_trigger == 49199,
"slot 0 digital trigger fallback changed slot 1");
input.gamepad.misc_buttons = MISC_BUTTON_CAPTURE;
platform_on_controller_data(&controller, &input);
require(read_controller_state(0, &state) &&
state.button_capture,
"touchpad/capture input did not reach the logical capture button");
input.gamepad.misc_buttons = 0;
platform_on_controller_data(&controller, &input);
require(read_controller_state(0, &state) &&
!state.button_capture,
"released touchpad/capture input remained pressed");
}
void test_host_rumble_mode_duration() {
start_pairing_backend();
uni_hid_device_t controller = device(0);
platform_on_device_connected(&controller);
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
"rumble-mode controller did not become ready");
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
test_adapter_mode = AdapterUsbMode::kSwitchProbe;
#endif
bluepad32_input_backend_queue_rumble(
0, ControllerRumbleOutput{100, 101});
process_rumble_timer(&g_rumble_timer);
require(controller.last_rumble_duration_ms ==
kSwitchHostRumbleDurationMs,
"Switch mode did not use bounded host rumble");
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
test_adapter_mode = AdapterUsbMode::kXInput;
bluepad32_input_backend_queue_rumble(
0, ControllerRumbleOutput{102, 103});
process_rumble_timer(&g_rumble_timer);
require(controller.last_rumble_duration_ms ==
kXInputHostRumbleDurationMs,
"XInput mode did not retain stateful host rumble");
#endif
}
void test_xbox_trigger_rumble() {
start_pairing_backend();
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
test_adapter_mode = AdapterUsbMode::kSwitchProbe;
#endif
auto controller = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
controller.vendor_id = 0x045e;
controller.product_id = 0x02e0;
controller.report_parser.play_dual_rumble =
uni_hid_parser_xboxone_play_dual_rumble;
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
"Xbox did not become ready");
ControllerRumbleOutput hd{80, 100};
hd.hd.actuators[0].sample_count = 3;
hd.hd.actuators[1].sample_count = 1;
hd.hd.actuators[0].samples[1].high_amplitude_q15 = 16384;
bluepad32_input_backend_queue_rumble(0, hd);
process_rumble_timer(&g_rumble_timer);
require(xbox_left_trigger == 63 && xbox_right_trigger == 0 &&
controller.last_low == 80 && controller.last_high == 100 &&
controller.last_rumble_duration_ms == 50,
"left high-band substep must drive only left trigger, preserving grips");
hd = {};
hd.hd.actuators[0].sample_count = 1;
hd.hd.actuators[1].sample_count = 1;
hd.hd.actuators[0].samples[0].low_amplitude_q15 = 32767;
hd.hd.actuators[1].samples[0].high_amplitude_q15 = 65535;
bluepad32_input_backend_queue_rumble(0, hd);
process_rumble_timer(&g_rumble_timer);
require(xbox_left_trigger == 0 && xbox_right_trigger == 127 &&
controller.last_rumble_duration_ms == 50,
"right trigger-only effect must not stop or overflow after amplification");
dispatch_rumble(&controller, 75, 100, 100);
require(xbox_left_trigger == 0 && xbox_right_trigger == 0,
"local feedback must clear trigger vibration");
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
test_adapter_mode = AdapterUsbMode::kXInput;
#endif
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{200, 180});
process_rumble_timer(&g_rumble_timer);
require(xbox_left_trigger == 90 && xbox_right_trigger == 90 &&
controller.last_rumble_duration_ms == host_rumble_duration_ms(),
"conventional high-frequency rumble must feed both triggers");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{});
process_rumble_timer(&g_rumble_timer);
require(xbox_left_trigger == 0 && xbox_right_trigger == 0 &&
controller.last_high == 0 && controller.last_low == 0 &&
controller.last_rumble_duration_ms == 0,
"explicit stop must stop all four motors");
const unsigned calls = xbox_quad_calls;
controller.report_parser.play_dual_rumble = play_rumble;
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{45, 67});
process_rumble_timer(&g_rumble_timer);
require(xbox_quad_calls == calls && controller.last_low == 45 &&
controller.last_high == 67,
"Microsoft VID alone must not route an unrelated parser to Xbox output");
controller.report_parser.play_dual_rumble =
uni_hid_parser_xboxone_play_dual_rumble;
bluepad32_input_backend_queue_rumble(0, hd);
platform_on_device_disconnected(&controller);
process_rumble_timer(&g_rumble_timer);
require(xbox_quad_calls == calls,
"pending Xbox output must not reach a disconnected controller");
}
void test_clear_pairings() {
classic_bond_count = 1;
classic_bonds[0][0] = 0x10;
ble_bond_count = 1;
ble_bond_types[0] = BD_ADDR_TYPE_LE_PUBLIC;
ble_bonds[0][0] = 0x20;
start_pairing_backend();
require(g_pairing_snapshot.status ==
Bluepad32PairingSnapshotStatus::kReady &&
g_pairing_snapshot.record_count == 2 &&
g_pairing_snapshot.records[0].transport ==
Bluepad32PairingTransport::kClassic &&
g_pairing_snapshot.records[1].transport ==
Bluepad32PairingTransport::kBle,
"initial pairing snapshot must enumerate Classic and BLE bonds");
require(!bluepad32_input_backend_clear_pairings_completed(
g_pairing_snapshot, 0),
"zero pairing-clear token must never be a valid completion query");
const uint32_t snapshot_generation =
g_pairing_snapshot.generation;
g_next_clear_pairings_request_token = UINT32_MAX;
uni_hid_device_t devices[2] = {device(0), device(1)};
for (uni_hid_device_t& controller : devices) {
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
"pairing reset controller did not become ready");
}
bluepad32_input_backend_queue_rumble(
0, ControllerRumbleOutput{100, 101});
const uint32_t clear_token =
bluepad32_input_backend_clear_pairings();
const uint32_t repeated_token =
bluepad32_input_backend_clear_pairings();
expected_pending_clear_token = clear_token;
repeat_clear_during_disconnect = true;
g_connection_policy_state =
ConnectionPolicyState::Uninitialized;
require(clear_token == UINT32_MAX &&
repeated_token == clear_token &&
g_clear_pairings_requested_token == clear_token &&
g_pairing_snapshot.completed_clear_pairings_token == 0 &&
!bluepad32_input_backend_clear_pairings_completed(
g_pairing_snapshot, clear_token) &&
delete_key_calls == 0 && device_disconnect_calls == 0,
"Core0 repeated pending clear calls must share one nonzero token, "
"remain incomplete, and defer the operation to Core1");
process_rumble_timer(&g_rumble_timer);
require(repeated_in_progress_clear_token == clear_token,
"clear repeated during Core1 work did not share its token");
require(delete_key_calls == 1 && device_disconnect_calls == 2,
"pairing reset must delete bonds and disconnect every session");
require(g_pairing_snapshot.status ==
Bluepad32PairingSnapshotStatus::kReady &&
g_pairing_snapshot.record_count == 0 &&
g_pairing_snapshot.generation ==
snapshot_generation + 1 &&
g_pairing_snapshot.completed_clear_pairings_token ==
clear_token &&
bluepad32_input_backend_clear_pairings_completed(
g_pairing_snapshot, clear_token),
"pairing reset must publish its completion token with an empty "
"refreshed snapshot after policy update");
expected_pending_clear_token = 0;
for (const BackendSlot& slot : g_slots) {
require(slot.device == nullptr && !slot.active &&
!slot.rumble_pending && !slot.feedback_pending &&
slot.state.left_stick_x == 0 &&
slot.state.left_stick_y == 0 &&
slot.state.right_stick_x == 0 &&
slot.state.right_stick_y == 0 &&
slot.state.motion_sample_count == 0,
"pairing reset must publish neutral empty slots");
}
require(!g_pairing_window_open && !bondable &&
accepted_stk_methods == 0 &&
g_connection_policy_state == ConnectionPolicyState::Open &&
scanning_enabled && classic_scanning_enabled &&
incoming_connections && observed_status_led_on,
"pairing reset must close authentication and resume autoconnect");
tick_backend_timer(9);
require(!observed_status_led_on,
"pairing reset confirmation must use the rapid blink pattern");
process_rumble_timer(&g_rumble_timer);
require(delete_key_calls == 1 && device_disconnect_calls == 2 &&
g_pairing_snapshot.completed_clear_pairings_token ==
clear_token,
"pairing reset request must execute only once");
const uint32_t completed_generation =
g_pairing_snapshot.generation;
bluepad32_input_backend_request_pairing_snapshot();
require(g_pairing_snapshot.status ==
Bluepad32PairingSnapshotStatus::kPending &&
bluepad32_input_backend_clear_pairings_completed(
g_pairing_snapshot, clear_token),
"unrelated refresh revoked an already completed clear");
process_rumble_timer(&g_rumble_timer);
require(g_pairing_snapshot.generation ==
completed_generation + 1 &&
g_pairing_snapshot.completed_clear_pairings_token ==
clear_token &&
bluepad32_input_backend_clear_pairings_completed(
g_pairing_snapshot, clear_token),
"ordinary pairing refresh fabricated or revoked clear completion");
const uint32_t refreshed_generation =
g_pairing_snapshot.generation;
const uint32_t wrapped_token =
bluepad32_input_backend_clear_pairings();
require(wrapped_token == 1 &&
g_pairing_snapshot.status ==
Bluepad32PairingSnapshotStatus::kPending &&
g_pairing_snapshot.completed_clear_pairings_token ==
clear_token &&
!bluepad32_input_backend_clear_pairings_completed(
g_pairing_snapshot, wrapped_token) &&
bluepad32_input_backend_clear_pairings_completed(
g_pairing_snapshot, clear_token),
"UINT32_MAX-to-1 wrap reused the prior completion or revoked it");
expected_pending_clear_token = wrapped_token;
process_rumble_timer(&g_rumble_timer);
expected_pending_clear_token = 0;
require(delete_key_calls == 2 && device_disconnect_calls == 2 &&
g_pairing_snapshot.generation ==
refreshed_generation + 1 &&
g_pairing_snapshot.completed_clear_pairings_token ==
wrapped_token &&
bluepad32_input_backend_clear_pairings_completed(
g_pairing_snapshot, wrapped_token) &&
bluepad32_input_backend_clear_pairings_completed(
g_pairing_snapshot, clear_token),
"wrapped clear completion did not acknowledge both itself and "
"the earlier pre-wrap request exactly once");
}
void test_configuration_timer_rearms_before_storage_work() {
const uint32_t adds_before = g_configuration_timer.add_count;
expected_configuration_timer_add_count = adds_before + 1;
expect_configuration_timer_prearmed = true;
process_configuration_timer(&g_configuration_timer);
expect_configuration_timer_prearmed = false;
require(g_configuration_timer.add_count ==
expected_configuration_timer_add_count &&
g_configuration_timer.timeout_ms ==
kConfigurationPollIntervalMs,
"configuration timer did not remain recurring");
}
void test_flash_core_start_contract() {
bluepad32_input_backend_init();
flash_core_init_result = false;
bluepad32_input_backend_start();
require(flash_core_init_calls == 1 && core1_launch_calls == 0 &&
g_connection_policy_state ==
ConnectionPolicyState::FailedClosed,
"Core0 flash-safe init failure must prevent Core1 launch");
flash_core_init_result = true;
bluepad32_input_backend_start();
require(flash_core_init_calls == 2 && core1_launch_calls == 1,
"Core0 must register as a flash-safe victim before Core1 launch");
bluepad32_input_backend_start();
require(flash_core_init_calls == 2 && core1_launch_calls == 1,
"backend start must remain idempotent");
}
void test_wake_identity_gates_connections() {
switch2_connections_ready = false;
bluepad32_input_backend_init();
platform_on_init_complete();
require(switch2_wake_initializations == 1 &&
g_connection_policy_state ==
ConnectionPolicyState::Uninitialized &&
!scanning_enabled && !classic_scanning_enabled &&
!incoming_connections,
"controller discovery started before wake identity was ready");
switch2_connections_ready = true;
process_rumble_timer(&g_rumble_timer);
require(g_connection_policy_state == ConnectionPolicyState::Open &&
scanning_enabled && classic_scanning_enabled &&
incoming_connections,
"controller discovery did not start after wake identity setup");
}
void test_system_button_wake_trigger() {
start_pairing_backend();
uni_hid_device_t controller = device(0);
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
"wake trigger controller did not become ready");
uni_controller_t input{};
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
platform_on_controller_data(&controller, &input);
require(switch2_wake_requests == 0,
"neutral input requested a wake burst");
input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM;
platform_on_controller_data(&controller, &input);
require(switch2_wake_requests == 0,
"plain system button requested a wake burst");
input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R;
platform_on_controller_data(&controller, &input);
platform_on_controller_data(&controller, &input);
require(switch2_wake_requests == 1,
"held L+R+System chord did not produce exactly one wake request");
input.gamepad.buttons = 0;
input.gamepad.misc_buttons = 0;
platform_on_controller_data(&controller, &input);
input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R;
platform_on_controller_data(&controller, &input);
require(switch2_wake_requests == 1,
"L+R without System requested wake");
input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM;
platform_on_controller_data(&controller, &input);
require(switch2_wake_requests == 2,
"second L+R+System chord edge did not request wake");
input.gamepad.buttons = 0;
input.gamepad.misc_buttons = MISC_BUTTON_CAPTURE;
platform_on_controller_data(&controller, &input);
require(switch2_wake_requests == 2,
"non-system misc button requested wake");
}
void test_flash_core_init_fatal() {
bluepad32_input_backend_init();
flash_core_init_result = false;
bool stopped = false;
try {
core1_main();
} catch (const CoreStopped&) {
stopped = true;
}
require(stopped && flash_core_init_calls == 1 &&
cyw43_init_calls == 0 && uni_init_calls == 0 &&
g_connection_policy_state ==
ConnectionPolicyState::FailedClosed,
"flash-safe Core1 init failure must halt before CYW43 init");
}
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) && defined(SWITCH_PICO_USB_OUTPUT_MODES)
void advance_native_backend(uint32_t duration_ms) {
const uint32_t end_ms = now_ms + duration_ms;
while (now_ms < end_ms) {
++now_ms;
for (;;) {
const auto due = std::find_if(
native_timers.begin(), native_timers.end(),
[](const auto* timer) {
return timer != &g_rumble_timer &&
timer != &g_configuration_timer &&
timer->due_ms <= now_ms;
});
if (due == native_timers.end()) break;
auto* timer = *due;
native_timers.erase(due);
timer->handler(timer);
}
if (now_ms % kRumblePollIntervalMs == 0)
process_rumble_timer(&g_rumble_timer);
}
}
void require_native_channels(bool left, bool right) {
HapticsExperimentDiagnostics status;
haptics_experiment_snapshot(&status);
require(status.state == HapticsExperimentState::kRunning &&
status.mode == 1,
"stateful host rumble lost native gameplay ownership");
require(status.packet_frames == SWITCH_PICO_HD_PACKET_FRAMES,
"native gameplay ignored the configured packet frame count");
const unsigned sample_offset = status.packet_frames == 32 ? 14 : 10;
require(last_native_packet[3] == 0x91 &&
last_native_packet[sample_offset - 2] ==
(status.packet_frames == 32 ? 0x92 : 0xd2) &&
last_native_packet[sample_offset - 1] == 64,
"stateful channel inspection requires a native PCM block");
unsigned active[2]{};
for (unsigned frame = 0; frame < status.packet_frames; ++frame) {
active[0] += last_native_packet[sample_offset + frame * 2] != 0;
active[1] += last_native_packet[sample_offset + frame * 2 + 1] != 0;
}
require((left ? active[0] > status.packet_frames / 2u : active[0] == 0) &&
(right ? active[1] > status.packet_frames / 2u : active[1] == 0),
"native PCM did not preserve the requested stateful channels");
}
void test_native_stateful_routing() {
start_pairing_backend();
test_adapter_mode = AdapterUsbMode::kXInput;
auto selected = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
selected.vendor_id = 0x054c;
selected.product_id = 0x0ce6;
selected.conn.connected = true;
selected.conn.interrupt_cid = 0x80;
selected.outgoing_buffer.queued = 1;
require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS,
"selected DualSense was rejected");
process_rumble_timer(&g_rumble_timer);
HapticsExperimentDiagnostics status;
haptics_experiment_snapshot(&status);
require(status.mode == 1 && status.state == HapticsExperimentState::kPending,
"XInput DualSense did not auto-arm into native Prepare");
const uint32_t generation = g_slots[0].connection_generation;
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{180, 0});
advance_native_backend(70);
const int prepare_calls = selected.rumble_calls;
selected.outgoing_buffer.queued = 0;
advance_native_backend(350);
require_native_channels(true, false);
require(selected.rumble_calls == prepare_calls &&
last_native_cid == selected.conn.interrupt_cid,
"XInput interleaved compatibility rumble into native PCM");
auto unselected = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
unselected.vendor_id = selected.vendor_id;
unselected.product_id = selected.product_id;
unselected.conn.connected = true;
unselected.conn.interrupt_cid = 0x82;
require(platform_on_device_ready(&unselected) == UNI_ERROR_SUCCESS,
"unselected DualSense was rejected");
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{22, 33});
advance_native_backend(10);
require(unselected.rumble_calls == 1 && unselected.last_low == 22 &&
unselected.last_high == 33,
"unselected controller lost compatibility fallback");
require(!haptics_experiment_submit_rumble(1, generation, time_us_64(), 255, 255),
"native stream accepted an unselected slot");
bluepad32_input_backend_queue_profile_feedback(
0, generation, 1, ControllerProfileConfirmationPolicy::kRumble);
advance_native_backend(40);
require_native_channels(true, true);
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 170});
advance_native_backend(220);
require_native_channels(false, true);
require(selected.rumble_calls == prepare_calls,
"profile feedback escaped the native overlay");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 0});
advance_native_backend(80);
require_native_channels(false, false);
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{150, 0});
advance_native_backend(80);
require(haptics_experiment_request(0, 0), "native stop was rejected");
advance_native_backend(20);
require(!haptics_experiment_owns(&selected) && selected.last_low == 150 &&
selected.last_high == 0 &&
selected.last_rumble_duration_ms == kXInputHostRumbleDurationMs,
"explicit native stop lost the latest compatibility fallback");
require(haptics_experiment_request(2, 0), "manual gameplay rearm failed");
advance_native_backend(300);
require_native_channels(true, false);
const int rearm_calls = selected.rumble_calls;
advance_native_backend(300);
require(selected.rumble_calls == rearm_calls,
"held XInput state required periodic compatibility refresh");
require(haptics_experiment_request(0, 0), "second native stop failed");
advance_native_backend(20);
require(selected.last_low == 150 && selected.last_high == 0 &&
selected.last_rumble_duration_ms == kXInputHostRumbleDurationMs,
"manual rearm discarded held state needed by the next Stop");
require(haptics_experiment_request(1, 0), "fixture start failed");
advance_native_backend(10);
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 160});
advance_native_backend(6200);
require(haptics_experiment_request(2, 0), "post-fixture gameplay arm failed");
advance_native_backend(300);
require_native_channels(false, true);
// A real HD frame replaces stateful XInput semantics and keeps its watchdog.
test_adapter_mode = AdapterUsbMode::kSwitchProbe;
ControllerRumbleOutput hd{};
hd.hd.actuators[0].sample_count = 1;
hd.hd.actuators[1].sample_count = 1;
hd.hd.actuators[0].samples[0].low_amplitude_q15 = 20000;
hd.hd.actuators[0].samples[0].low_frequency_index = 64;
bluepad32_input_backend_queue_rumble(0, hd);
advance_native_backend(40);
require_native_channels(true, false);
advance_native_backend(100);
require_native_channels(false, false);
test_adapter_mode = AdapterUsbMode::kXInput;
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{255, 0});
platform_on_device_disconnected(&selected);
require(!haptics_experiment_submit_rumble(0, generation, time_us_64(), 255, 255),
"disconnected generation remained eligible for native output");
require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS,
"replacement DualSense was rejected");
advance_native_backend(300);
require_native_channels(false, false);
require(!haptics_experiment_submit_rumble(0, generation, time_us_64(), 255, 255),
"old generation reached a replacement native stream");
require(haptics_experiment_request(0, 0), "replacement stop failed");
advance_native_backend(20);
platform_on_device_disconnected(&selected);
platform_on_device_disconnected(&unselected);
}
void test_native_second_slot_selection() {
start_pairing_backend();
test_adapter_mode = AdapterUsbMode::kXInput;
auto pro = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
pro.vendor_id = 0x057e;
pro.product_id = 0x2009;
pro.conn.connected = true;
pro.conn.interrupt_cid = 0x80;
require(platform_on_device_ready(&pro) == UNI_ERROR_SUCCESS,
"first-slot Switch Pro was rejected");
auto dualsense = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
dualsense.vendor_id = 0x054c;
dualsense.product_id = 0x0ce6;
dualsense.conn.connected = true;
dualsense.conn.interrupt_cid = 0x82;
require(platform_on_device_ready(&dualsense) == UNI_ERROR_SUCCESS,
"second-slot DualSense was rejected");
advance_native_backend(30);
HapticsExperimentDiagnostics status;
haptics_experiment_snapshot(&status);
require(status.state == HapticsExperimentState::kRunning && status.slot == 1,
"native auto-arm ignored the first eligible DualSense outside slot zero");
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{90, 0});
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{20, 0});
advance_native_backend(200);
require_native_channels(true, false);
require(pro.rumble_calls == 1 && last_native_cid == 0x82,
"mixed controller slots lost their independent rumble paths");
auto later = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
later.vendor_id = dualsense.vendor_id;
later.product_id = dualsense.product_id;
later.conn.connected = true;
later.conn.interrupt_cid = 0x84;
require(platform_on_device_ready(&later) == UNI_ERROR_SUCCESS,
"later DualSense was rejected");
advance_native_backend(30);
haptics_experiment_snapshot(&status);
require(status.slot == 1 && last_native_cid == 0x82,
"a later DualSense stole the selected native stream");
require(haptics_experiment_request(0, 1), "selected stream did not stop");
advance_native_backend(20);
platform_on_device_disconnected(&dualsense);
platform_on_device_disconnected(&pro);
platform_on_device_disconnected(&later);
}
#endif
uni_hid_device_t wii_device(int index) {
auto result = device(index, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
result.vendor_id = 0x057e;
result.product_id = 0x0330;
result.controller_type = CONTROLLER_TYPE_WiiController;
result.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL;
return result;
}
void test_wii_accelerometer_streams() {
start_pairing_backend();
auto remote = wii_device(0);
remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL;
require(platform_on_device_ready(&remote) == UNI_ERROR_SUCCESS, "Wii must become ready");
const auto generation = slot_snapshot(0).connection_generation;
if (kDefaultMotionEnabled) {
require(bluepad32_input_backend_toggle_motion(0, generation),
"Wii console motion must be disabled for independent gesture streams");
}
remote_accel_fixture = {&remote, {8192, 0, 0}, 7, true};
nunchuk_accel_fixture = {&remote, {0, 8192, 0}, 9, false};
uni_controller_t input{};
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
input.gamepad.accel[0] = 8192;
input.gamepad.gyro[2] = 1024;
now_ms = 100;
platform_on_controller_data(&remote, &input);
auto snapshot = slot_snapshot(0);
require(snapshot.state.motion_sample_count == 0 &&
snapshot.accelerometer.valid && !snapshot.nunchuk_accelerometer.valid &&
snapshot.accelerometer.y == -4096 && snapshot.accelerometer.timestamp_ms == 100,
"Remote gesture acceleration must remain calibrated with console motion off");
now_ms = 125;
nunchuk_accel_fixture.valid = true;
platform_on_controller_data(&remote, &input);
snapshot = slot_snapshot(0);
require(snapshot.state.motion_sample_count == 0 &&
snapshot.nunchuk_accelerometer.valid && snapshot.nunchuk_accelerometer.z == 4096 &&
snapshot.nunchuk_accelerometer.timestamp_ms == 125 &&
snapshot.accelerometer.timestamp_ms == 100,
"Nunchuk interleave must publish its own sample without refreshing the Remote");
now_ms = 150;
++remote_accel_fixture.sequence; // Same physical values, genuinely new report.
platform_on_controller_data(&remote, &input);
now_ms = 200; // Status/ack callback exposes the same parser snapshots.
platform_on_controller_data(&remote, &input);
snapshot = slot_snapshot(0);
bluepad32_input_backend_report_sent(0);
snapshot = slot_snapshot(0);
require(snapshot.accelerometer.timestamp_ms == 150 &&
snapshot.nunchuk_accelerometer.timestamp_ms == 125 &&
snapshot.accelerometer.valid && snapshot.nunchuk_accelerometer.valid,
"cached parser callbacks and USB consumption must not freshen either sensor");
now_ms = 225;
nunchuk_accel_fixture.valid = false;
platform_on_controller_data(&remote, &input);
snapshot = slot_snapshot(0);
require(!snapshot.nunchuk_accelerometer.valid &&
snapshot.accelerometer.valid && snapshot.accelerometer.timestamp_ms == 150,
"Nunchuk detach/calibration failure must immediately invalidate only its own sensor");
now_ms = 250;
nunchuk_accel_fixture.valid = true;
++nunchuk_accel_fixture.sequence;
platform_on_controller_data(&remote, &input);
snapshot = slot_snapshot(0);
require(snapshot.nunchuk_accelerometer.valid &&
snapshot.nunchuk_accelerometer.timestamp_ms == 250 &&
snapshot.accelerometer.timestamp_ms == 150,
"replacement Nunchuk must resume with its own fresh timestamp");
platform_on_device_disconnected(&remote);
snapshot = slot_snapshot(0);
require(!snapshot.accelerometer.valid && !snapshot.nunchuk_accelerometer.valid,
"Remote disconnect must invalidate both accelerometer streams");
auto replacement = wii_device(0);
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS, "replacement Wii must become ready");
snapshot = slot_snapshot(0);
require(!snapshot.accelerometer.valid && !snapshot.nunchuk_accelerometer.valid,
"reused backend slot must not inherit either sensor sample");
}
void dispatch_wii_requests() {
wii_dispatch_active = true;
now_ms += g_rumble_timer.timeout_ms;
g_rumble_timer.handler(&g_rumble_timer);
wii_dispatch_active = false;
}
#ifdef SWITCH2_BRIDGE_WII_INPUT
void test_wii_bridge_sensors() {
auto selected = wii_device(1);
auto other = wii_device(0);
bluepad32_input_backend_init();
bluepad32_input_backend_select_wii_source(selected.conn.btaddr);
start_pairing_backend();
require(platform_on_device_ready(&other) == UNI_ERROR_SUCCESS, "unselected Wii must remain available normally");
uni_controller_t input{};
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
input.gamepad.buttons = BUTTON_A;
remote_accel_fixture = {&other, {8193, -42, 91}, 7, true};
gyro_fixture = {&other, {123456, -45678, 91011}, 11, true};
platform_on_controller_data(&other, &input);
Bluepad32WiiBridgeSnapshot snapshot{};
bluepad32_input_backend_wii_snapshot(&snapshot);
require(!snapshot.controller.active && snapshot.slot == 0xff,
"an unrelated Wii cannot claim the selected native source");
selected.controller_type = CONTROLLER_TYPE_UnknownController;
require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS, "non-Wii address match must connect normally");
platform_on_controller_data(&selected, &input);
bluepad32_input_backend_wii_snapshot(&snapshot);
require(!snapshot.controller.active, "matching address alone must not identify a Wii");
platform_on_device_disconnected(&selected);
auto replacement = wii_device(1);
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS, "selected physical Wii must become ready");
const uint32_t generation = slot_snapshot(1).connection_generation;
if (kDefaultMotionEnabled) {
require(bluepad32_input_backend_toggle_motion(1, generation), "legacy motion toggle must be independent");
}
remote_accel_fixture = {&replacement, {8193, -42, 91}, 7, true};
gyro_fixture = {&replacement, {123456, -45678, 91011}, 11, true};
now_ms = 100;
platform_on_controller_data(&replacement, &input);
bluepad32_input_backend_wii_snapshot(&snapshot);
require(snapshot.controller.active && snapshot.slot == 1 &&
snapshot.controller.state.button_south &&
snapshot.controller.state.motion_sample_count == 0 &&
snapshot.accel_valid && snapshot.gyro_valid &&
snapshot.accel_q13[0] == 8193 && snapshot.gyro_q10[0] == 123456,
"native raw precision and ordinary buttons must survive legacy motion disable");
require(snapshot.accel_received_us == 100000 && snapshot.gyro_received_us == 100000,
"both first genuine sensor samples need ingress timestamps");
now_ms = 250;
++remote_accel_fixture.sequence;
platform_on_controller_data(&replacement, &input);
now_ms = 300; // Status/ACK callback has no new sensor sequence.
platform_on_controller_data(&replacement, &input);
bluepad32_input_backend_report_sent(1);
bluepad32_input_backend_wii_snapshot(&snapshot);
require(snapshot.received_us == 300000 && snapshot.accel_received_us == 250000 &&
snapshot.gyro_received_us == 100000 && snapshot.gyro_sequence == 11,
"accel interleaves, status and USB consumption cannot rejuvenate gyro");
require(bluepad32_input_backend_set_wii_orientation(
snapshot.controller.identity, generation, true), "orientation change must queue");
dispatch_wii_requests();
platform_on_controller_data(&replacement, &input);
bluepad32_input_backend_wii_snapshot(&snapshot);
require(snapshot.controller.connection_generation != generation &&
!snapshot.accel_valid && !snapshot.gyro_valid &&
!snapshot.controller.accelerometer.valid,
"a logical epoch must not republish cached parser samples as fresh");
++remote_accel_fixture.sequence;
++gyro_fixture.sequence;
now_ms = 320;
platform_on_controller_data(&replacement, &input);
bluepad32_input_backend_wii_snapshot(&snapshot);
require(snapshot.accel_valid && snapshot.gyro_valid && snapshot.gyro_received_us == 320000,
"distinct samples must recover the new logical epoch");
platform_on_device_disconnected(&replacement);
bluepad32_input_backend_wii_snapshot(&snapshot);
require(!snapshot.controller.active && !snapshot.accel_valid && !snapshot.gyro_valid &&
snapshot.slot == 0xff, "disconnect must retire source identity and samples together");
}
void (*during_wii_rumble)() = nullptr;
void observe_wii_rumble(uni_hid_device_t* target, uint16_t delay,
uint16_t duration, uint8_t weak, uint8_t strong) {
require(state_lock_depth == 0, "Wii transport must run without the backend lock");
play_rumble(target, delay, duration, weak, strong);
if (during_wii_rumble != nullptr) during_wii_rumble();
}
void test_wii_bridge_cues() {
auto selected = wii_device(1);
selected.report_parser.play_dual_rumble = observe_wii_rumble;
auto other = wii_device(0);
bluepad32_input_backend_init();
bluepad32_input_backend_select_wii_source(selected.conn.btaddr);
start_pairing_backend();
require(platform_on_device_ready(&other) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS, "cue fixtures must connect");
uint64_t token = 0;
require(bluepad32_input_backend_wii_sample_request(3, &token) &&
bluepad32_input_backend_wii_sample_result(token) == 0 &&
selected.rumble_calls == 0, "Core 0 queue acceptance is not cue completion");
process_rumble_timer(&g_rumble_timer);
require(bluepad32_input_backend_wii_sample_result(token) == 1 &&
bluepad32_input_backend_wii_sample_result(token) == -1 &&
selected.rumble_calls == 1 && selected.last_rumble_duration_ms == 25,
"double-click completion must be consumable once after its first driver dispatch");
now_ms = 60;
process_rumble_timer(&g_rumble_timer);
require(selected.rumble_calls == 1, "double-click gap must not vibrate");
now_ms = 115;
process_rumble_timer(&g_rumble_timer);
require(selected.rumble_calls == 2 && selected.last_rumble_duration_ms == 25 &&
other.rumble_calls == 0, "double-click must dispatch exactly its second selected-Wii pulse");
now_ms = 2000;
process_rumble_timer(&g_rumble_timer);
uint64_t newer;
require(bluepad32_input_backend_wii_sample_request(1, &newer) && newer != token,
"finite patterns must release the cue ledger without reusing tokens");
process_rumble_timer(&g_rumble_timer);
require(selected.last_rumble_duration_ms == 1000, "buzz must have a finite one-second driver timer");
require(bluepad32_input_backend_wii_sample_request(0, &token), "stop must replace a running pattern");
process_rumble_timer(&g_rumble_timer);
require(selected.last_rumble_duration_ms == 0 &&
bluepad32_input_backend_wii_sample_result(token) == 1 &&
bluepad32_input_backend_wii_sample_result(newer) == -1,
"stop must retire the old ledger and complete only after dispatch");
const int stopped_calls = selected.rumble_calls;
now_ms += 3000;
process_rumble_timer(&g_rumble_timer);
require(selected.rumble_calls == stopped_calls, "stopped patterns must never replay");
}
void test_wii_bridge_cue_races() {
auto selected = wii_device(0);
selected.report_parser.play_dual_rumble = observe_wii_rumble;
bluepad32_input_backend_init();
bluepad32_input_backend_select_wii_source(selected.conn.btaddr);
start_pairing_backend();
require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS, "selected Wii must connect");
const auto connection = slot_snapshot(0);
bluepad32_input_backend_queue_profile_feedback(
0, connection.connection_generation, 1, ControllerProfileConfirmationPolicy::kRumble);
uint64_t token;
require(bluepad32_input_backend_wii_sample_request(3, &token), "cue must wait behind profile feedback");
process_rumble_timer(&g_rumble_timer);
require(selected.last_rumble_duration_ms == 75 &&
bluepad32_input_backend_wii_sample_result(token) == 0,
"profile confirmation has priority and cannot count as cue dispatch");
now_ms = 150;
during_wii_rumble = [] { bluepad32_input_backend_wii_sample_cancel(); };
process_rumble_timer(&g_rumble_timer);
during_wii_rumble = nullptr;
require(bluepad32_input_backend_wii_sample_result(token) == -1,
"cancellation during driver dispatch must defeat its late completion");
process_rumble_timer(&g_rumble_timer);
require(selected.last_rumble_duration_ms == 0, "canceled in-flight cue must receive a finite stop");
wii_rumble_ready = false;
require(bluepad32_input_backend_wii_sample_request(1, &token), "topology setup may defer cue dispatch");
const int calls = selected.rumble_calls;
process_rumble_timer(&g_rumble_timer);
require(bluepad32_input_backend_wii_sample_result(token) == 0 && selected.rumble_calls == calls,
"a Wii void-hook early return must not masquerade as dispatch");
now_ms += 2000;
require(bluepad32_input_backend_wii_sample_result(token) == -1, "undispatched cue deadline must expire");
wii_rumble_ready = true;
process_rumble_timer(&g_rumble_timer);
require(selected.rumble_calls == calls, "expired cue must not dispatch when setup finally completes");
uint64_t replacement_token;
require(bluepad32_input_backend_wii_sample_request(2, &replacement_token) && replacement_token != token,
"expiration must permit a uniquely identified new request");
during_wii_rumble = [] { platform_on_device_disconnected(g_slots[0].device); };
process_rumble_timer(&g_rumble_timer);
during_wii_rumble = nullptr;
auto replacement = wii_device(0);
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS, "replacement must connect");
process_rumble_timer(&g_rumble_timer);
require(bluepad32_input_backend_wii_sample_result(replacement_token) == -1 &&
replacement.rumble_calls == 0,
"late driver completion must never cross a physical generation");
replacement.report_parser.play_dual_rumble = nullptr;
require(!bluepad32_input_backend_wii_sample_request(1, &token),
"a missing Wii rumble driver cannot accept a cue");
}
#endif
void test_wii_orientation() {
start_pairing_backend();
initialize_runtime_profile_storage();
auto remote = wii_device(0);
auto ordinary = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
ready_switch2(remote);
ready_switch2(ordinary);
const auto identity = slot_snapshot(0).identity;
const auto ordinary_identity = slot_snapshot(1).identity;
configure_gesture_profile(identity, 2, 37);
configure_gesture_profile(ordinary_identity, 5, 63);
controller_profile_runtime_reset();
for (uint8_t index = 0; index < 2; ++index) {
controller_profile_runtime_transform(
index, slot_snapshot(index), now_ms, AdapterUsbMode::kXInput);
}
uni_controller_t report{};
report.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
report.gamepad.buttons = BUTTON_A;
++now_ms;
platform_on_controller_data(&remote, &report);
platform_on_controller_data(&ordinary, &report);
for (uint8_t index = 0; index < 2; ++index) {
require(controller_profile_runtime_transform(
index, slot_snapshot(index), now_ms, AdapterUsbMode::kXInput)
.state.button_system,
"both controllers need active toggle macros before changing only Wii orientation");
}
report.gamepad.buttons = BUTTON_Y;
report.gamepad.dpad = DPAD_UP;
report.gamepad.axis_x = 511;
report.gamepad.brake = 1023;
platform_on_controller_data(&remote, &report);
const auto before = slot_snapshot(0);
const auto ordinary_before = slot_snapshot(1);
require(bluepad32_input_backend_capture_start(
0, before.connection_generation, CaptureOptions{}),
"orientation transition must have an active recorder to retire");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101});
bluepad32_input_backend_queue_profile_feedback(
0, before.connection_generation, 8, ControllerProfileConfirmationPolicy::kRumbleAndLed);
const unsigned writes_before = profile_write_attempts;
const size_t observations_before = observed_profile_identity_count;
require(bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation, true),
"live standalone Wii must accept a vertical request");
Bluepad32PlaytestSnapshot playtest{};
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(wii_mode_events.empty() &&
playtest.controller_layout == Bluepad32ControllerLayout::kWiiHorizontal &&
playtest.connection_generation == before.connection_generation &&
playtest.state.button_north && playtest.state.dpad_up,
"enqueue must not call the parser or claim a new mapping on the management core");
dispatch_wii_requests();
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(wii_mode_events.size() == 1 && wii_mode_events[0].device == &remote &&
wii_mode_events[0].mode == WII_MODE_VERTICAL &&
playtest.active &&
playtest.controller_layout == Bluepad32ControllerLayout::kWiiVertical &&
playtest.connection_generation == before.connection_generation + 1u &&
controller_identity_equal(playtest.identity, identity) &&
playtest.physical_button_mask == 0 && !playtest.state.button_north &&
!playtest.state.dpad_up && playtest.state.left_stick_x == 0 &&
playtest.state.left_trigger == 0 && playtest.state.motion_sample_count == 0,
"timer dispatch must atomically retire old input and publish the new live mapping epoch");
Bluepad32CaptureSnapshot capture{};
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
capture.state == CaptureState::kDisconnected &&
!controller_profile_runtime_transform(
0, slot_snapshot(0), now_ms, AdapterUsbMode::kXInput).state.button_system &&
controller_profile_runtime_transform(
1, slot_snapshot(1), now_ms, AdapterUsbMode::kXInput).state.button_system &&
slot_snapshot(1).connection_generation == ordinary_before.connection_generation &&
remote.last_high == 0 && remote.last_low == 0 &&
!bluepad32_input_backend_toggle_motion(0, before.connection_generation),
"mapping changes must cancel old macro/capture/hotkey/feedback epochs without disturbing peers");
report.gamepad = {};
report.gamepad.buttons = BUTTON_B;
platform_on_controller_data(&remote, &report);
require(slot_snapshot(0).state.button_east && !slot_snapshot(0).state.button_north,
"fresh input must use the new mapping without reviving old controls");
const uint32_t vertical_generation = playtest.connection_generation;
require(bluepad32_input_backend_set_wii_orientation(
identity, vertical_generation, false),
"current vertical mapping must be reversible without reconnecting");
dispatch_wii_requests();
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.controller_layout == Bluepad32ControllerLayout::kWiiHorizontal &&
playtest.connection_generation == vertical_generation + 1u &&
wii_mode_events.size() == 2 && wii_mode_events[1].mode == WII_MODE_HORIZONTAL,
"horizontal selection must return the real parser and metadata to horizontal");
const uint32_t horizontal_generation = playtest.connection_generation;
require(bluepad32_input_backend_set_wii_orientation(
identity, horizontal_generation, false),
"reselection must still reach a parser with a potentially deferred opposite choice");
dispatch_wii_requests();
require(wii_mode_events.size() == 3 && wii_mode_events[2].mode == WII_MODE_HORIZONTAL &&
slot_snapshot(0).connection_generation == horizontal_generation + 1u &&
profile_write_attempts == writes_before &&
observed_profile_identity_count == observations_before &&
device_disconnect_calls == 0,
"reselection and orientation roundtrip must preserve profiles, flash and physical connections");
require_active_profile(identity, 2, 37);
require_active_profile(ordinary_identity, 5, 63);
}
void test_wii_orientation_races() {
auto remote = wii_device(0);
const auto identity = identity_for_device(&remote);
require(!bluepad32_input_backend_set_wii_orientation(identity, 0, true),
"orientation requests must reject an uninitialized backend");
start_pairing_backend();
platform_on_device_connected(&remote);
require(!bluepad32_input_backend_set_wii_orientation(
identity, slot_snapshot(0).connection_generation, true),
"orientation requests must reject connections that are not ready");
require(platform_on_device_ready(&remote) == UNI_ERROR_SUCCESS,
"Wii race fixture must become ready");
const auto before = slot_snapshot(0);
auto ordinary = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
ordinary.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL;
ready_switch2(ordinary);
auto unstable = wii_device(2);
unstable.conn.protocol = UNI_BT_CONN_PROTOCOL_NONE;
gap_connection_types[unstable.conn.handle] = GAP_CONNECTION_INVALID;
ready_switch2(unstable);
auto wrong_identity = identity;
wrong_identity.product_id ^= 1u;
require(!bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation - 1u, true) &&
!bluepad32_input_backend_set_wii_orientation(
wrong_identity, before.connection_generation, true) &&
!bluepad32_input_backend_set_wii_orientation(
slot_snapshot(1).identity, slot_snapshot(1).connection_generation, true) &&
!bluepad32_input_backend_set_wii_orientation(
slot_snapshot(2).identity, slot_snapshot(2).connection_generation, true),
"stale generation, wrong owner, non-Wii and global fallback must not target a parser");
const uni_controller_subtype_t extensions[] = {
CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK,
CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK_ACCEL,
CONTROLLER_SUBTYPE_WII_CLASSIC,
};
for (auto subtype : extensions) {
remote.controller_subtype = subtype;
require(!bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation, false),
"Nunchuk and other extension mappings cannot be changed by standalone orientation");
}
remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL;
require(bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation, true),
"race fixture must enqueue while still standalone");
remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_NUNCHUK;
dispatch_wii_requests();
Bluepad32PlaytestSnapshot playtest{};
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(wii_mode_events.empty() &&
playtest.controller_layout == Bluepad32ControllerLayout::kWiiNunchuk &&
playtest.connection_generation == before.connection_generation,
"extension attachment before dispatch must reject the queued request without changing topology");
remote.controller_subtype = CONTROLLER_SUBTYPE_WIIMOTE_HORIZONTAL;
dispatch_wii_requests();
require(wii_mode_events.empty(),
"a request rejected during attachment must not revive when the Nunchuk detaches");
require(bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation, true),
"standalone connection must accept another request after detach");
const WiiOrientationRequest stale = g_slots[0].pending_wii_orientation;
platform_on_device_disconnected(&remote);
remote = wii_device(0);
ready_switch2(remote);
dispatch_wii_requests();
require(wii_mode_events.empty() &&
!bluepad32_input_backend_set_wii_orientation(
identity, before.connection_generation, true),
"physical object reuse with the same identity must discard old pending requests and reject its old epoch");
// Exercise the dispatch guard even if an already accepted old envelope
// survives a future mailbox refactor.
g_slots[0].pending_wii_orientation = stale;
g_slots[0].wii_orientation_pending = true;
dispatch_wii_requests();
g_slots[0].pending_wii_orientation = {
wrong_identity, slot_snapshot(0).connection_generation, true};
g_slots[0].wii_orientation_pending = true;
dispatch_wii_requests();
require(wii_mode_events.empty() &&
slot_snapshot(0).connection_generation == before.connection_generation + 1u,
"dispatch must independently revalidate both connection generation and identity");
const auto replacement = slot_snapshot(0);
require(bluepad32_input_backend_set_wii_orientation(
replacement.identity, replacement.connection_generation, true) &&
bluepad32_input_backend_set_wii_orientation(
replacement.identity, replacement.connection_generation, false),
"rapid standalone choices must coalesce before dispatch");
dispatch_wii_requests();
require(wii_mode_events.size() == 1 && wii_mode_events[0].mode == WII_MODE_HORIZONTAL &&
slot_snapshot(0).connection_generation == replacement.connection_generation + 1u,
"coalescing back to the current mapping must not apply an obsolete intermediate choice");
}
void require_selected_radio(bool active_scan, bool free_slot) {
require(scanning_enabled == (active_scan && SWITCH_PICO_ENABLE_BLE) &&
classic_scanning_enabled ==
(active_scan && SWITCH_PICO_ENABLE_CLASSIC) &&
incoming_connections ==
(free_slot && SWITCH_PICO_ENABLE_CLASSIC),
"radio policy activated a disabled transport or lost an enabled one");
}
void test_transport_mode_policy() {
classic_bond_count = 1;
classic_bonds[0][5] = 0xa1;
ble_bond_count = 1;
ble_bond_types[0] = BD_ADDR_TYPE_LE_RANDOM;
ble_bonds[0][5] = 0xb1;
auto remembered_switch2 = switch2_device(3, UNI_SW2_PRO_PID);
remember_switch2(remembered_switch2);
initialize_runtime_profile_storage();
ControllerIdentity saved_identities[2]{};
for (unsigned index = 0; index < 2; ++index) {
auto& identity = saved_identities[index];
identity.stable = true;
identity.transport = index == 0 ? ControllerTransport::kClassic
: ControllerTransport::kBle;
identity.address_type = index == 0 ? BD_ADDR_TYPE_UNKNOWN
: BD_ADDR_TYPE_LE_RANDOM;
memcpy(identity.address, index == 0 ? classic_bonds[0] : ble_bonds[0],
sizeof(bd_addr_t));
identity.vendor_id = 0x1234;
identity.product_id = 0x5678;
auto profile = controller_profile_default(identity, 2 + index);
profile.weak_rumble_scale = 37 + index;
require(runtime_profile_storage.set(identity, 2 + index, profile) ==
ProfileStorageResult::kOk &&
runtime_profile_storage.activate(identity, 2 + index) ==
ProfileStorageResult::kOk,
"mode fixture must persist distinct Classic and BLE profiles");
}
if (!SWITCH_PICO_ENABLE_BLE) switch2_connections_ready = false;
start_backend();
Bluepad32PairingSnapshot bonds{};
bluepad32_input_backend_pairing_snapshot(&bonds);
require(bonds.record_count == 3 &&
bonds.records[0].transport == Bluepad32PairingTransport::kClassic &&
bonds.records[1].transport == Bluepad32PairingTransport::kBle &&
bonds.records[2].transport == Bluepad32PairingTransport::kBle &&
memcmp(bonds.records[0].address, classic_bonds[0], sizeof(bd_addr_t)) == 0 &&
memcmp(bonds.records[1].address, ble_bonds[0], sizeof(bd_addr_t)) == 0 &&
memcmp(bonds.records[2].address, remembered_switch2.conn.btaddr,
sizeof(bd_addr_t)) == 0 && delete_key_calls == 0,
"boot must expose remembered keys from both transports without deleting them");
for (bool ble : {false, true}) {
const bool enabled = ble ? SWITCH_PICO_ENABLE_BLE
: SWITCH_PICO_ENABLE_CLASSIC;
auto candidate = device(0, true, ble ? UNI_BT_CONN_PROTOCOL_BLE
: UNI_BT_CONN_PROTOCOL_BR_EDR);
lookup_devices[0] = &candidate;
lookup_device_count = 1;
// A discovery candidate need not have a live GAP handle yet.
const auto handle = candidate.conn.handle;
candidate.conn.handle = HCI_CON_HANDLE_INVALID;
require(platform_on_device_discovered(candidate.conn.btaddr, "candidate", 0, 0) ==
(enabled ? UNI_ERROR_SUCCESS : UNI_ERROR_IGNORE_DEVICE),
"discovery admitted a known disabled-transport candidate");
candidate.conn.handle = handle;
// A stale protocol hint must never overrule the actual live GAP link.
candidate.conn.protocol = ble ? UNI_BT_CONN_PROTOCOL_BR_EDR
: UNI_BT_CONN_PROTOCOL_BLE;
const int disconnected_before = device_disconnect_calls;
platform_on_device_connected(&candidate);
require(device_disconnect_calls == disconnected_before + !enabled,
"connected admission failed to tear down a disabled live transport");
require(platform_on_device_ready(&candidate) ==
(enabled ? UNI_ERROR_SUCCESS : UNI_ERROR_INVALID_CONTROLLER) &&
slot_snapshot(0).active == enabled,
"ready callback bypassed transport admission");
uni_controller_t report{};
report.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
report.gamepad.buttons = BUTTON_A;
platform_on_controller_data(&candidate, &report);
require(slot_snapshot(0).state.button_south == enabled,
"rejected transport published controller input");
if (enabled) platform_on_device_disconnected(&candidate);
require_selected_radio(true, true);
}
auto ordinary_ble = device(0, true, UNI_BT_CONN_PROTOCOL_BLE);
lookup_devices[0] = &ordinary_ble;
__wrap_sm_request_pairing(ordinary_ble.conn.handle);
__wrap_sm_request_pairing(HCI_CON_HANDLE_INVALID);
require(ordinary_smp_requests == (SWITCH_PICO_ENABLE_BLE ? 2u : 0u),
"Classic-only mode forwarded an SMP authentication request");
lookup_device_count = 0;
if (!SWITCH_PICO_ENABLE_BLE || !SWITCH_PICO_ENABLE_CLASSIC) {
auto unknown = device(0);
const int disconnected_before = device_disconnect_calls;
platform_on_device_connected(&unknown);
require(device_disconnect_calls == disconnected_before + 1 &&
platform_on_device_ready(&unknown) == UNI_ERROR_INVALID_CONTROLLER &&
!slot_snapshot(0).active,
"single-transport mode admitted an unidentified connection");
}
uint8_t confirmation[] = {HCI_EVENT_USER_CONFIRMATION_REQUEST, 6, 1, 2, 3, 4, 5, 6};
uint8_t passkey[] = {HCI_EVENT_USER_PASSKEY_REQUEST, 6, 1, 2, 3, 4, 5, 6};
handle_btstack_event(HCI_EVENT_PACKET, 0, confirmation, sizeof(confirmation));
handle_btstack_event(HCI_EVENT_PACKET, 0, passkey, sizeof(passkey));
require(confirmation_accepts == 0 && passkey_accepts == 0 &&
confirmation_rejections == SWITCH_PICO_ENABLE_CLASSIC &&
passkey_rejections == SWITCH_PICO_ENABLE_CLASSIC,
"closed pairing policy must reject only the enabled Classic authentication");
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require(bondable &&
switch_pico_switch2_pairing_allowed() == (SWITCH_PICO_ENABLE_BLE != 0) &&
(!SWITCH_PICO_ENABLE_BLE || accepted_stk_methods == kAllBlePairingMethods),
"pairing window did not enable only the selected authentication policy");
handle_btstack_event(HCI_EVENT_PACKET, 0, confirmation, sizeof(confirmation));
handle_btstack_event(HCI_EVENT_PACKET, 0, passkey, sizeof(passkey));
require(confirmation_accepts == SWITCH_PICO_ENABLE_CLASSIC &&
passkey_accepts == SWITCH_PICO_ENABLE_CLASSIC,
"pairing window accepted disabled Classic authentication");
uni_hid_device_t controllers[kSlotCount]{};
for (uint8_t index = 0; index < kSlotCount; ++index) {
const bool ble = SWITCH_PICO_ENABLE_BLE &&
(!SWITCH_PICO_ENABLE_CLASSIC || (index & 1u));
controllers[index] = device(index, true, ble ? UNI_BT_CONN_PROTOCOL_BLE
: UNI_BT_CONN_PROTOCOL_BR_EDR);
platform_on_device_connected(&controllers[index]);
require(platform_on_device_ready(&controllers[index]) == UNI_ERROR_SUCCESS,
"enabled transport failed to fill the available slots");
}
require_selected_radio(false, false);
require(platform_on_device_discovered(controllers[0].conn.btaddr, "full", 0, 0) ==
UNI_ERROR_IGNORE_DEVICE,
"full slots admitted another discovery");
now_ms = g_pairing_window_deadline_ms;
process_rumble_timer(&g_rumble_timer);
require(!bondable && !switch_pico_switch2_pairing_allowed() &&
(!SWITCH_PICO_ENABLE_BLE || accepted_stk_methods == 0),
"pairing authentication remained enabled after the deadline");
platform_on_device_disconnected(&controllers[3]);
require_selected_radio(false, true);
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require_selected_radio(true, true);
for (uint8_t index = 0; index < 3; ++index) {
platform_on_device_disconnected(&controllers[index]);
}
require_selected_radio(true, true);
require(classic_bond_count == 1 && ble_bond_count == 1 &&
switch2_pairing_count == 1 && delete_key_calls == 0,
"pairing windows or disconnects erased inactive-mode bonds");
for (unsigned index = 0; index < 2; ++index) {
require_active_profile(saved_identities[index], 2 + index, 37 + index);
}
const uint32_t token = bluepad32_input_backend_clear_pairings();
process_rumble_timer(&g_rumble_timer);
bluepad32_input_backend_pairing_snapshot(&bonds);
require(bluepad32_input_backend_clear_pairings_completed(bonds, token) &&
bonds.record_count == 0 && classic_bond_count == 0 &&
ble_bond_count == 0 && switch2_pairing_count == 0,
"explicit clearing must still cover all stored transports");
for (unsigned index = 0; index < 2; ++index) {
require_active_profile(saved_identities[index], 2 + index, 37 + index);
}
require_selected_radio(true, true);
}
void test_transport_background_scan() {
start_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
remember_switch2(right);
register_lookup_device(&right);
negotiated_intervals[left.conn.handle] = 24;
ready_switch2(left);
require(scanning_enabled && background_scan_parameters &&
!classic_scanning_enabled &&
incoming_connections == (SWITCH_PICO_ENABLE_CLASSIC != 0) &&
negotiated_intervals[left.conn.handle] == 6 &&
platform_on_device_discovered(right.conn.btaddr, "mate", 0, 0) ==
UNI_ERROR_SUCCESS,
"ready solo must find its remembered mate using only low-duty BLE");
platform_on_device_connected(&right);
require(!scanning_enabled && !classic_scanning_enabled,
"pending mate setup must stop discovery");
require(platform_on_device_ready(&right) == UNI_ERROR_SUCCESS &&
!scanning_enabled && !classic_scanning_enabled,
"a complete pair must not continue background discovery");
platform_on_device_disconnected(&right);
require(scanning_enabled && background_scan_parameters &&
!classic_scanning_enabled,
"departed mate must restore low-duty BLE discovery");
bluepad32_input_backend_open_pairing_window();
process_rumble_timer(&g_rumble_timer);
require_selected_radio(true, true);
require(!background_scan_parameters,
"explicit pairing must replace background scan timing");
now_ms = g_pairing_window_deadline_ms;
process_rumble_timer(&g_rumble_timer);
require(scanning_enabled && background_scan_parameters &&
!classic_scanning_enabled,
"pairing expiry must restore only remembered-mate BLE discovery");
platform_on_device_disconnected(&left);
require_selected_radio(true, true);
require(!background_scan_parameters,
"last disconnect must restore foreground scan timing");
}
} // namespace
int main(int argc, char** argv) {
require(argc == 2, "scenario argument required");
const std::string scenario = argv[1];
#ifdef SWITCH2_BRIDGE_WII_INPUT
if (scenario == "wii-bridge-sensors") {
test_wii_bridge_sensors();
return 0;
}
if (scenario == "wii-bridge-cues") {
test_wii_bridge_cues();
return 0;
}
if (scenario == "wii-bridge-cue-races") {
test_wii_bridge_cue_races();
return 0;
}
#endif
if (scenario == "transport-policy") {
test_transport_mode_policy();
return 0;
}
if (scenario == "transport-background") {
test_transport_background_scan();
return 0;
}
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) && defined(SWITCH_PICO_USB_OUTPUT_MODES)
if (scenario == "native-stateful") {
test_native_stateful_routing();
return 0;
}
if (scenario == "native-second-slot") {
test_native_second_slot_selection();
return 0;
}
#endif
if (scenario == "wii-orientation") {
test_wii_orientation();
return 0;
}
if (scenario == "wii-orientation-races") {
test_wii_orientation_races();
return 0;
}
if (scenario == "wii-accelerometers") {
test_wii_accelerometer_streams();
return 0;
}
if (scenario == "switch2-hd-pro") {
test_switch2_hd_pro();
} else if (scenario == "switch2-gesture-timing") {
test_switch2_gesture_timing();
} else if (scenario == "switch2-gesture-slot-left-first") {
test_switch2_gesture_slot_order(true, 0);
} else if (scenario == "switch2-gesture-slot-right-first") {
test_switch2_gesture_slot_order(false, 0);
} else if (scenario == "switch2-gesture-slot-occupied") {
test_switch2_gesture_slot_order(true, 1);
} else if (scenario == "switch2-gesture-stale") {
test_switch2_gesture_stale();
} else if (scenario == "switch2-gesture-clock-wrap") {
test_switch2_gesture_clock_wrap();
} else if (scenario == "switch2-gesture-masking-epochs") {
test_switch2_gesture_masking_epochs();
} else if (scenario == "switch2-gesture-override-lifetime") {
test_switch2_gesture_override_lifetime();
} else if (scenario == "switch2-gesture-two-pairs") {
test_switch2_gesture_two_pairs();
} else if (scenario == "switch2-gesture-ambiguous") {
test_switch2_gesture_ambiguous();
} else if (scenario == "switch2-gesture-seed-failure") {
test_switch2_gesture_seed_failure();
} else if (scenario == "switch2-gesture-device-scope") {
test_switch2_gesture_device_scope();
} else if (scenario == "switch2-hd-solo-left") {
test_switch2_hd_solo(false);
} else if (scenario == "switch2-hd-solo-right") {
test_switch2_hd_solo(true);
} else if (scenario == "switch2-hd-pair") {
test_switch2_hd_pair();
} else if (scenario == "switch2-hd-overflow") {
test_switch2_hd_overflow();
} else if (scenario == "switch2-hd-epochs") {
test_switch2_hd_epochs();
} else if (scenario == "switch2-hd-feedback") {
test_switch2_hd_feedback();
} else if (scenario == "switch2-individual-core-start") {
test_switch2_individual_core_start();
} else if (scenario == "switch2-individual-forward") {
test_switch2_individual_boot(false);
} else if (scenario == "switch2-individual-reverse") {
test_switch2_individual_boot(true);
} else if (scenario == "switch2-mode-forward") {
test_switch2_mode_roundtrip(false);
} else if (scenario == "switch2-mode-reverse") {
test_switch2_mode_roundtrip(true);
} else if (scenario == "switch2-mode-two-pairs") {
test_switch2_mode_two_pairs();
} else if (scenario == "switch2-mode-seed-failure") {
test_switch2_live_pair_failure(false);
} else if (scenario == "switch2-mode-identity-failure") {
test_switch2_live_pair_failure(true);
} else if (scenario == "switch2-forward") {
test_switch2_pair_lifecycle(false);
} else if (scenario == "switch2-reverse") {
test_switch2_pair_lifecycle(true);
} else if (scenario == "switch2-multiple-pairs") {
test_switch2_multiple_pairs();
} else if (scenario == "switch2-pair-failure-left") {
test_switch2_pair_admission_failure(false);
} else if (scenario == "switch2-pair-failure-right") {
test_switch2_pair_admission_failure(true);
} else if (scenario == "switch2-pair-replacement") {
test_switch2_pair_member_replacement();
} else if (scenario == "switch2-admission") {
test_switch2_admission();
} else if (scenario == "switch2-radio-policy") {
test_switch2_radio_policy(false);
} else if (scenario == "switch2-radio-individual") {
test_switch2_radio_policy(true);
} else if (scenario == "switch2-radio-settling") {
test_switch2_radio_settling();
} else if (scenario == "switch2-mate-reconnect") {
test_switch2_mate_reconnect();
} else if (scenario == "switch2-mate-pending") {
test_switch2_mate_pending();
} else if (scenario == "switch2-mate-pairing-window") {
test_switch2_mate_pairing_window();
} else if (scenario == "switch2-pairing-inventory") {
test_switch2_pairing_inventory();
} else if (scenario == "ready-forward") {
test_ready_order(false);
} else if (scenario == "ready-reverse") {
test_ready_order(true);
} else if (scenario == "rejections") {
test_rejections();
} else if (scenario == "lifecycle") {
test_independent_lifecycle();
} else if (scenario == "pairing-policy") {
test_pairing_window_policy();
} else if (scenario == "slot-lighting") {
test_slot_lighting();
} else if (scenario == "stateful-rumble") {
test_stateful_host_rumble_restore();
} else if (scenario == "profile-chord-raw") {
test_profile_chord_remains_raw();
} else if (scenario == "profile-feedback") {
test_profile_feedback_scheduler();
} else if (scenario == "motion-hotkey") {
test_motion_toggle_action();
} else if (scenario == "analog-state") {
test_protocol_neutral_analog_state();
} else if (scenario == "rumble-mode") {
test_host_rumble_mode_duration();
} else if (scenario == "xbox-rumble") {
test_xbox_trigger_rumble();
} else if (scenario == "clear-pairings") {
test_clear_pairings();
} else if (scenario == "configuration-timer") {
test_configuration_timer_rearms_before_storage_work();
} else if (scenario == "flash-core-start") {
test_flash_core_start_contract();
} else if (scenario == "wake-identity-gate") {
test_wake_identity_gates_connections();
} else if (scenario == "system-wake") {
test_system_button_wake_trigger();
} else if (scenario == "flash-core-failure") {
test_flash_core_init_fatal();
} else {
require(false, "unknown scenario");
}
return 0;
}