Introduce protocol-neutral controller state

This commit is contained in:
Joey Yakimowich-Payne 2026-09-02 14:53:56 -06:00
commit b11ae076a8
28 changed files with 833 additions and 501 deletions

View file

@ -285,6 +285,13 @@ uint32_t btstack_run_loop_get_time_ms() {
#include "../bluepad32_input_backend.cpp"
#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY
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,
@ -355,7 +362,7 @@ void test_ready_order(bool reverse) {
"ready device must bind to its Bluepad index");
for (int candidate = 0; candidate < kSlotCount; ++candidate) {
SwitchInputState snapshot{};
ControllerState snapshot{};
bool expected_active = false;
for (int ready = 0; ready <= position; ++ready) {
expected_active = expected_active || order[ready] == candidate;
@ -386,7 +393,7 @@ void test_ready_order(bool reverse) {
"disconnecting any slot must resume connection policy");
for (int candidate = 0; candidate < kSlotCount; ++candidate) {
SwitchInputState snapshot{};
ControllerState snapshot{};
require(bluepad32_input_backend_snapshot(candidate, &snapshot) ==
(candidate != slot),
"disconnect must preserve every surviving slot");
@ -427,10 +434,10 @@ void test_rejections() {
collision_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
collision_data.gamepad.buttons = BUTTON_B;
platform_on_controller_data(&collision, &collision_data);
SwitchInputState snapshot{};
ControllerState snapshot{};
require(bluepad32_input_backend_snapshot(0, &snapshot),
"occupied slot must stay active");
require(!snapshot.button_a,
require(!snapshot.button_east,
"mismatched device input must not enter the occupied slot");
uni_controller_t slot_zero_data{};
@ -438,15 +445,15 @@ void test_rejections() {
slot_zero_data.gamepad.accel[0] = 8192;
platform_on_controller_data(&slot_zero, &slot_zero_data);
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
snapshot.imu_sample_count == 3,
snapshot.motion_sample_count == 3,
"valid slot input must remain observable");
require(!bluepad32_input_backend_snapshot(4, &snapshot),
"public snapshot must reject slot 4");
bluepad32_input_backend_report_sent(4);
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
snapshot.imu_sample_count == 3,
snapshot.motion_sample_count == 3,
"slot 4 acknowledgement must not consume slot 0 IMU");
bluepad32_input_backend_queue_rumble(4, SwitchRumbleOutput{1, 2});
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");
@ -534,39 +541,39 @@ void test_independent_lifecycle() {
platform_on_controller_data(&devices[slot], &data[slot]);
}
SwitchInputState states[kSlotCount]{};
ControllerState states[kSlotCount]{};
for (int slot = 0; slot < kSlotCount; ++slot) {
require(bluepad32_input_backend_snapshot(slot, &states[slot]) &&
states[slot].imu_sample_count == 3,
states[slot].motion_sample_count == 3,
"every slot must expose independent input and IMU");
}
require(states[0].button_a && !states[0].button_b &&
!states[0].button_y && !states[0].button_x,
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_b && !states[1].button_a &&
!states[1].button_y && !states[1].button_x,
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_y && !states[2].button_a &&
!states[2].button_b && !states[2].button_x,
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_x && !states[3].button_a &&
!states[3].button_b && !states[3].button_y,
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(bluepad32_input_backend_snapshot(slot, &states[slot]) &&
states[slot].imu_sample_count == (slot == 3 ? 0 : 3),
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(bluepad32_input_backend_snapshot(slot, &states[slot]) &&
states[slot].imu_sample_count == 0,
states[slot].motion_sample_count == 0,
"each slot acknowledgement must consume only its own IMU");
}
const SwitchRumbleOutput initial_rumble[kSlotCount] = {
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]);
@ -577,17 +584,17 @@ void test_independent_lifecycle() {
devices[slot].last_low == 11 + slot &&
devices[slot].last_high == 21 + slot &&
devices[slot].last_rumble_duration_ms ==
kRumbleDurationMs,
host_rumble_duration_ms(),
"each slot rumble must reach only its indexed controller");
}
bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{0, 0});
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, SwitchRumbleOutput{55, 66});
bluepad32_input_backend_queue_rumble(3, ControllerRumbleOutput{55, 66});
const uint32_t disconnected_generation =
g_slots[3].connection_generation;
const int starts_before_slot_three_disconnect = scan_starts;
@ -596,21 +603,21 @@ void test_independent_lifecycle() {
scanning_enabled && incoming_connections,
"slot 3 disconnect must resume scanning and incoming connections");
require(!bluepad32_input_backend_snapshot(3, &states[3]) &&
!states[3].button_x && states[3].lx == 32768,
"slot 3 disconnect must neutralize only slot 3");
!states[3].button_north && states[3].left_stick_x == 0,
"slot 3 disconnect must publish protocol-neutral state");
require(bluepad32_input_backend_snapshot(0, &states[0]) &&
states[0].button_a &&
states[0].button_east &&
bluepad32_input_backend_snapshot(1, &states[1]) &&
states[1].button_b &&
states[1].button_south &&
bluepad32_input_backend_snapshot(2, &states[2]) &&
states[2].button_y,
states[2].button_west,
"slot 3 disconnect must preserve slots 0-2");
platform_on_controller_data(&devices[0], &data[0]);
require(bluepad32_input_backend_snapshot(0, &states[0]) &&
states[0].button_a,
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, SwitchRumbleOutput{115, 116});
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 &&
@ -626,7 +633,7 @@ void test_independent_lifecycle() {
"slot 3 replacement must not receive disconnected device rumble");
g_slots[3].pending_rumble = {
3, disconnected_generation, SwitchRumbleOutput{77, 88}};
3, disconnected_generation, ControllerRumbleOutput{77, 88}};
g_slots[3].rumble_pending = true;
process_rumble_timer(&g_rumble_timer);
require(slot_three_replacement.rumble_calls == 0,
@ -638,21 +645,21 @@ void test_independent_lifecycle() {
replacement_data.gamepad.accel[0] = 9000;
platform_on_controller_data(&slot_three_replacement, &replacement_data);
require(bluepad32_input_backend_snapshot(3, &states[3]) &&
states[3].button_x && states[3].imu_sample_count == 3,
states[3].button_north && states[3].motion_sample_count == 3,
"replacement input and IMU must populate only slot 3");
require(bluepad32_input_backend_snapshot(0, &states[0]) &&
states[0].button_a &&
states[0].button_east &&
bluepad32_input_backend_snapshot(1, &states[1]) &&
states[1].button_b &&
states[1].button_south &&
bluepad32_input_backend_snapshot(2, &states[2]) &&
states[2].button_y,
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, SwitchRumbleOutput{90, 91});
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 &&
@ -669,8 +676,8 @@ void test_independent_lifecycle() {
slot_three_replacement.rumble_calls};
for (int slot = 0; slot < kSlotCount; ++slot) {
bluepad32_input_backend_queue_rumble(
slot, SwitchRumbleOutput{static_cast<uint8_t>(100 + slot),
static_cast<uint8_t>(110 + slot)});
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) {
@ -691,8 +698,8 @@ void test_independent_lifecycle() {
scanning_enabled && incoming_connections,
"disconnecting slots 0-2 must resume connection policy");
require(!bluepad32_input_backend_snapshot(slot, &states[slot]) &&
states[slot].lx == 32768,
"disconnect must neutralize its indexed slot");
states[slot].left_stick_x == 0,
"disconnect must publish protocol-neutral state");
for (int survivor = 0; survivor < kSlotCount; ++survivor) {
if (survivor == slot) {
continue;
@ -712,9 +719,9 @@ void test_independent_lifecycle() {
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, SwitchRumbleOutput{119, 120});
bluepad32_input_backend_queue_rumble(3, SwitchRumbleOutput{121, 122});
bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{123, 124});
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 &&
@ -861,11 +868,11 @@ void test_slot_lighting() {
"controller without RGB support did not receive its slot LED");
}
void require_south_button_mapping(const SwitchInputState& state,
void require_south_button_mapping(const ControllerState& state,
bool swapped,
const char* message) {
require(state.button_a == swapped && state.button_b == !swapped &&
!state.button_x && !state.button_y,
require(state.button_east == swapped && state.button_south == !swapped &&
!state.button_north && !state.button_west,
message);
}
@ -881,7 +888,7 @@ void test_abxy_hotkey() {
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
input.gamepad.buttons = BUTTON_A;
platform_on_controller_data(&slot_zero, &input);
SwitchInputState snapshot{};
ControllerState snapshot{};
require(bluepad32_input_backend_snapshot(0, &snapshot),
"slot 0 ABXY state was not published");
require_south_button_mapping(
@ -897,12 +904,12 @@ void test_abxy_hotkey() {
require_south_button_mapping(
snapshot, !kDefaultSwapAbxy,
"hotkey did not toggle slot 0 ABXY mapping");
require(!snapshot.button_l && !snapshot.button_r &&
!snapshot.button_minus && !snapshot.button_plus,
require(!snapshot.button_left_shoulder && !snapshot.button_right_shoulder &&
!snapshot.button_select && !snapshot.button_start,
"hotkey chord leaked into the Switch report");
bluepad32_input_backend_queue_rumble(
0, SwitchRumbleOutput{0x11, 0x22});
0, ControllerRumbleOutput{0x11, 0x22});
process_rumble_timer(&g_rumble_timer);
require(slot_zero.rumble_calls == 1 &&
slot_zero.last_high == kAbxyFeedbackWeakMagnitude &&
@ -971,9 +978,9 @@ void test_motion_hotkey() {
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
input.gamepad.accel[0] = 8192;
platform_on_controller_data(&slot_zero, &input);
SwitchInputState snapshot{};
ControllerState snapshot{};
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
snapshot.imu_sample_count ==
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 3 : 0),
"slot 0 did not start with configured motion state");
@ -982,10 +989,10 @@ void test_motion_hotkey() {
input.gamepad.misc_buttons = kMotionHotkeyMiscMask;
platform_on_controller_data(&slot_zero, &input);
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
snapshot.imu_sample_count ==
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 0 : 3) &&
!snapshot.dpad_up && !snapshot.button_r &&
!snapshot.button_plus,
!snapshot.dpad_up && !snapshot.button_right_shoulder &&
!snapshot.button_start,
"motion chord did not toggle motion or suppress its inputs");
process_rumble_timer(&g_rumble_timer);
@ -1015,7 +1022,7 @@ void test_motion_hotkey() {
peer_input.gamepad.accel[0] = 8192;
platform_on_controller_data(&slot_one, &peer_input);
require(bluepad32_input_backend_snapshot(1, &snapshot) &&
snapshot.imu_sample_count ==
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 3 : 0),
"slot 0 motion chord changed slot 1 motion state");
@ -1027,7 +1034,7 @@ void test_motion_hotkey() {
input.gamepad.misc_buttons = kMotionHotkeyMiscMask;
platform_on_controller_data(&slot_zero, &input);
require(bluepad32_input_backend_snapshot(0, &snapshot) &&
snapshot.imu_sample_count ==
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 3 : 0),
"released motion chord did not re-arm or restore motion");
process_rumble_timer(&g_rumble_timer);
@ -1044,6 +1051,76 @@ void test_motion_hotkey() {
"disconnect did not reset slot 0 motion hotkey state");
}
void test_protocol_neutral_analog_state() {
start_pairing_backend();
uni_hid_device_t controller = device(0);
platform_on_device_connected(&controller);
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
"analog-state controller did not become ready");
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.brake = 1;
input.gamepad.throttle = 512;
platform_on_controller_data(&controller, &input);
ControllerState state{};
require(bluepad32_input_backend_snapshot(0, &state),
"analog state was not published");
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");
require(state.left_trigger == scale_trigger(1) &&
state.left_trigger > 0 &&
state.right_trigger == scale_trigger(512) &&
state.right_trigger < UINT16_MAX,
"analog trigger precision was discarded");
input.gamepad.brake = 0;
input.gamepad.throttle = 0;
input.gamepad.buttons =
BUTTON_TRIGGER_L | BUTTON_TRIGGER_R;
platform_on_controller_data(&controller, &input);
require(bluepad32_input_backend_snapshot(0, &state) &&
state.left_trigger == UINT16_MAX &&
state.right_trigger == UINT16_MAX,
"digital trigger buttons did not map to full analog range");
}
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_ADAPTER_FEASIBILITY
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_ADAPTER_FEASIBILITY
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_clear_pairings() {
classic_bond_count = 1;
classic_bonds[0][0] = 0x10;
@ -1067,7 +1144,7 @@ void test_clear_pairings() {
"pairing reset controller did not become ready");
}
bluepad32_input_backend_queue_rumble(
0, SwitchRumbleOutput{100, 101});
0, ControllerRumbleOutput{100, 101});
bluepad32_input_backend_clear_pairings();
require(g_clear_pairings_requested && delete_key_calls == 0 &&
@ -1086,11 +1163,11 @@ void test_clear_pairings() {
for (const BackendSlot& slot : g_slots) {
require(slot.device == nullptr && !slot.active &&
!slot.rumble_pending && !slot.feedback_pending &&
slot.state.lx == kStickMidpoint &&
slot.state.ly == kStickMidpoint &&
slot.state.rx == kStickMidpoint &&
slot.state.ry == kStickMidpoint &&
slot.state.imu_sample_count == 0,
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 &&
@ -1163,6 +1240,10 @@ int main(int argc, char** argv) {
test_abxy_hotkey();
} else if (scenario == "motion-hotkey") {
test_motion_hotkey();
} else if (scenario == "analog-state") {
test_protocol_neutral_analog_state();
} else if (scenario == "rumble-mode") {
test_host_rumble_mode_duration();
} else if (scenario == "clear-pairings") {
test_clear_pairings();
} else if (scenario == "flash-core-start") {

View file

@ -8,7 +8,7 @@ namespace {
int failures = 0;
void expect_output(const char* scenario, SwitchRumbleOutput actual,
void expect_output(const char* scenario, ControllerRumbleOutput actual,
uint8_t expected_low, uint8_t expected_high) {
if (actual.low_frequency_magnitude == expected_low &&
actual.high_frequency_magnitude == expected_high) {

View file

@ -26,7 +26,7 @@ struct SentReport {
struct RumbleEvent {
unsigned count = 0;
uint8_t instance = 0xff;
SwitchRumbleOutput output{};
ControllerRumbleOutput output{};
};
uint64_t now_ms = 0;
@ -95,7 +95,8 @@ SwitchProReport get_current_report(uint8_t instance,
void expect_neutral_sticks(SwitchProReport& report,
const char* state_failure) {
constexpr uint16_t packed_mid = SWITCH_PRO_JOYSTICK_MID >> 4u;
constexpr uint16_t packed_mid =
CONTROLLER_AXIS_UNSIGNED_CENTER >> 4u;
constexpr uint16_t packed_inverted_mid =
static_cast<uint16_t>(-static_cast<int32_t>(packed_mid)) & 0x0fffu;
expect(report.inputs.leftStick.getX() == packed_mid &&
@ -195,7 +196,7 @@ std::array<uint8_t, 10> complete_rumble_report(
return report;
}
void rumble_callback(uint8_t instance, const SwitchRumbleOutput& output) {
void rumble_callback(uint8_t instance, const ControllerRumbleOutput& output) {
expect(instance < rumble_events.size(),
"rumble callback received an invalid instance");
if (instance >= rumble_events.size()) {
@ -270,18 +271,22 @@ void test_failed_startup_identify_retries_preserve_counter() {
void test_input_reports_and_timers_are_isolated() {
initialize_contexts();
std::array<SwitchInputState, kInstanceCount> states{};
std::array<ControllerState, kInstanceCount> states{};
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
SwitchInputState& state = states[instance];
state.lx = static_cast<uint16_t>(0x1111u * (instance + 1u));
state.ly = static_cast<uint16_t>(0x2222u + 0x1111u * instance);
state.rx = static_cast<uint16_t>(0x5555u + 0x1111u * instance);
state.ry = static_cast<uint16_t>(0x8888u + 0x1111u * instance);
ControllerState& state = states[instance];
state.left_stick_x = controller_axis_from_unsigned(
static_cast<uint16_t>(0x1111u * (instance + 1u)));
state.left_stick_y = controller_axis_from_unsigned(
static_cast<uint16_t>(0x2222u + 0x1111u * instance));
state.right_stick_x = controller_axis_from_unsigned(
static_cast<uint16_t>(0x5555u + 0x1111u * instance));
state.right_stick_y = controller_axis_from_unsigned(
static_cast<uint16_t>(0x8888u + 0x1111u * instance));
}
states[0].button_a = true;
states[1].button_b = true;
states[2].button_x = true;
states[3].button_y = true;
states[0].button_east = true;
states[1].button_south = true;
states[2].button_north = true;
states[3].button_west = true;
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
switch_pro_set_input(instance, states[instance]);
@ -320,9 +325,9 @@ void test_input_reports_and_timers_are_isolated() {
}
}
SwitchInputState changed_zero = states[0];
changed_zero.button_a = false;
changed_zero.button_home = true;
ControllerState changed_zero = states[0];
changed_zero.button_east = false;
changed_zero.button_system = true;
switch_pro_set_input(0, changed_zero);
now_ms = 30;
expect(switch_pro_task(0),
@ -333,8 +338,8 @@ void test_input_reports_and_timers_are_isolated() {
!unchanged_three.inputs.buttonHome,
"instance 0 input change leaked into instance 3");
SwitchInputState changed_three = states[3];
changed_three.button_y = false;
ControllerState changed_three = states[3];
changed_three.button_west = false;
changed_three.button_capture = true;
switch_pro_set_input(3, changed_three);
now_ms = 45;
@ -357,13 +362,14 @@ void test_callback_send_and_imu_modes_are_isolated() {
expect(reports_for_instance(1) == 0,
"feature callback queued a reply on instance 1");
SwitchInputState zero{};
zero.lx = zero.ly = zero.rx = zero.ry = SWITCH_PRO_JOYSTICK_MID;
zero.imu_sample_count = 1;
zero.imu_samples[0] = {101, 202, 303, 404, 505, 606};
SwitchInputState one = zero;
one.button_x = true;
one.imu_samples[0] = {1001, 2002, 3003, 4004, 5005, 6006};
ControllerState zero{};
zero.left_stick_x = zero.left_stick_y =
zero.right_stick_x = zero.right_stick_y = 0;
zero.motion_sample_count = 1;
zero.motion_samples[0] = {101, 202, 303, 404, 505, 606};
ControllerState one = zero;
one.button_north = true;
one.motion_samples[0] = {1001, 2002, 3003, 4004, 5005, 6006};
switch_pro_set_input(0, zero);
switch_pro_set_input(1, one);
now_ms = 21;
@ -384,15 +390,16 @@ void test_callback_send_and_imu_modes_are_isolated() {
now_ms = 6;
switch_pro_task(0);
switch_pro_task(1);
SwitchInputState moving{};
moving.lx = moving.ly = moving.rx = moving.ry = SWITCH_PRO_JOYSTICK_MID;
moving.imu_sample_count = 1;
moving.imu_samples[0] = {100, 200, 300, 20000, 0, 0};
SwitchInputState stationary{};
stationary.lx = stationary.ly = stationary.rx = stationary.ry =
SWITCH_PRO_JOYSTICK_MID;
stationary.imu_sample_count = 1;
stationary.imu_samples[0] = {1000, 2000, 3000, 0, 0, 0};
ControllerState moving{};
moving.left_stick_x = moving.left_stick_y =
moving.right_stick_x = moving.right_stick_y = 0;
moving.motion_sample_count = 1;
moving.motion_samples[0] = {100, 200, 300, 20000, 0, 0};
ControllerState stationary{};
stationary.left_stick_x = stationary.left_stick_y =
stationary.right_stick_x = stationary.right_stick_y = 0;
stationary.motion_sample_count = 1;
stationary.motion_samples[0] = {1000, 2000, 3000, 0, 0, 0};
switch_pro_set_input(0, moving);
switch_pro_set_input(1, stationary);
now_ms = 21;
@ -568,8 +575,8 @@ void test_lifecycle_and_invalid_instances() {
"unmount did not reset every configured context");
}
SwitchInputState ignored{};
ignored.button_home = true;
ControllerState ignored{};
ignored.button_system = true;
switch_pro_init(kInvalidInstance);
switch_pro_set_input(kInvalidInstance, ignored);
switch_pro_set_rumble_callback(kInvalidInstance, rumble_callback);
@ -579,18 +586,41 @@ void test_lifecycle_and_invalid_instances() {
"invalid instance reported ready");
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> buffer{};
expect(tud_hid_get_report_cb(kInvalidInstance, 0, HID_REPORT_TYPE_INPUT,
buffer.data(), buffer.size()) == 0,
"invalid instance served GET_REPORT data");
expect(tud_hid_descriptor_report_cb(kInvalidInstance) == nullptr,
"invalid instance served a report descriptor");
}
void test_protocol_neutral_trigger_threshold() {
initialize_contexts();
ControllerState state{};
state.left_trigger =
static_cast<uint16_t>(SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD - 1u);
state.right_trigger = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD;
switch_pro_set_input(0, state);
now_ms = 15;
expect(switch_pro_task(0), "trigger threshold report was not sent");
SwitchProReport report = copy_switch_report(latest_regular_report(0));
expect(!report.inputs.buttonZL && report.inputs.buttonZR,
"Switch trigger threshold changed at the lower boundary");
state.left_trigger = SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD;
state.right_trigger = CONTROLLER_TRIGGER_MIN;
switch_pro_set_input(0, state);
now_ms = 30;
expect(switch_pro_task(0), "second trigger threshold report was not sent");
report = copy_switch_report(latest_regular_report(0));
expect(report.inputs.buttonZL && !report.inputs.buttonZR,
"Switch trigger threshold changed at the upper boundary");
}
void test_uart_parser_is_pure() {
initialize_contexts();
SwitchInputState driver_state{};
driver_state.lx = driver_state.ly = driver_state.rx = driver_state.ry =
SWITCH_PRO_JOYSTICK_MID;
driver_state.button_x = true;
ControllerState driver_state{};
driver_state.left_stick_x = driver_state.left_stick_y =
driver_state.right_stick_x = driver_state.right_stick_y = 0;
driver_state.button_north = true;
switch_pro_set_input(0, driver_state);
now_ms = 15;
switch_pro_task(0);
@ -610,14 +640,20 @@ void test_uart_parser_is_pure() {
for (unsigned i = 0; i < packet.size() - 1; ++i) {
packet.back() = static_cast<uint8_t>(packet.back() + packet[i]);
}
SwitchInputState parsed{};
ControllerState parsed{};
expect(switch_pro_apply_uart_packet(packet.data(), packet.size(), parsed),
"valid UART packet was rejected");
expect(parsed.button_a && parsed.button_l && parsed.dpad_down &&
expect(parsed.button_east && parsed.button_left_shoulder && parsed.dpad_down &&
parsed.dpad_left,
"UART buttons or hat were parsed incorrectly");
expect(parsed.lx == 0x1212 && parsed.ly == 0x3434 &&
parsed.rx == 0x5656 && parsed.ry == 0x7878,
expect(parsed.left_stick_x ==
controller_axis_from_unsigned(0x1212) &&
parsed.left_stick_y ==
controller_axis_from_unsigned(0x3434) &&
parsed.right_stick_x ==
controller_axis_from_unsigned(0x5656) &&
parsed.right_stick_y ==
controller_axis_from_unsigned(0x7878),
"UART axes were parsed incorrectly");
std::array<uint8_t, SWITCH_PRO_ENDPOINT_SIZE> current{};
@ -628,14 +664,14 @@ void test_uart_parser_is_pure() {
expect(current_report.inputs.buttonX && !current_report.inputs.buttonA,
"UART parsing mutated driver context state");
SwitchInputState unchanged{};
unchanged.button_home = true;
unchanged.lx = 123;
ControllerState unchanged{};
unchanged.button_system = true;
unchanged.left_stick_x = 123;
packet.back() ^= 0xffu;
expect(!switch_pro_apply_uart_packet(packet.data(), packet.size(),
unchanged),
"invalid UART checksum was accepted");
expect(unchanged.button_home && unchanged.lx == 123,
expect(unchanged.button_system && unchanged.left_stick_x == 123,
"failed UART parse modified its output reference");
}
@ -687,6 +723,7 @@ int main() {
test_rumble_callbacks_and_decoders_are_isolated();
test_grip_colors_are_isolated();
test_lifecycle_and_invalid_instances();
test_protocol_neutral_trigger_threshold();
test_uart_parser_is_pure();
if (failures != 0) {
std::cerr << failures << " driver context test(s) failed\n";

View file

@ -44,6 +44,8 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"slot-lighting",
"abxy-hotkey",
"motion-hotkey",
"analog-state",
"rumble-mode",
"clear-pairings",
"flash-core-start",
"flash-core-failure",

View file

@ -99,8 +99,8 @@ void test_microsoft_compatible_id_descriptor() {
"Microsoft descriptor index mismatch");
expect(kMsCompatIdDescriptor[8] == SWITCH_PICO_HID_INSTANCE_COUNT,
"Microsoft function count mismatch");
for (uint8_t instance = 0; instance < SWITCH_PICO_HID_INSTANCE_COUNT;
++instance) {
for (uint8_t instance = 0;
instance < SWITCH_PICO_HID_INSTANCE_COUNT; ++instance) {
const uint8_t *function = &kMsCompatIdDescriptor[16 + instance * 24];
expect(function[0] == instance,
"Microsoft descriptor interface mismatch");
@ -113,8 +113,7 @@ void test_microsoft_compatible_id_descriptor() {
}
void test_input_report_mapping() {
SwitchInputState state{};
state.lx = state.ly = state.rx = state.ry = 32768;
ControllerState state{};
auto report = XInputFeasibility::build_input_report(state);
expect(report.report_id == 0 && report.report_size == 20,
"neutral report header mismatch");
@ -126,19 +125,19 @@ void test_input_report_mapping() {
"neutral axes mismatch");
state.dpad_up = true;
state.button_b = true;
state.button_a = true;
state.button_y = true;
state.button_x = true;
state.button_plus = true;
state.button_minus = true;
state.button_home = true;
state.button_zl = true;
state.button_zr = true;
state.lx = 0;
state.ly = 0;
state.rx = UINT16_MAX;
state.ry = UINT16_MAX;
state.button_south = true;
state.button_east = true;
state.button_west = true;
state.button_north = true;
state.button_start = true;
state.button_select = true;
state.button_system = true;
state.left_trigger = UINT16_MAX;
state.right_trigger = UINT16_MAX;
state.left_stick_x = INT16_MIN;
state.left_stick_y = INT16_MIN;
state.right_stick_x = INT16_MAX;
state.right_stick_y = INT16_MAX;
report = XInputFeasibility::build_input_report(state);
expect((report.buttons & XInputFeasibility::kDpadUp) != 0,
"D-pad mapping missing");
@ -148,15 +147,22 @@ void test_input_report_mapping() {
(report.buttons & XInputFeasibility::kButtonY) != 0,
"positional face-button mapping mismatch");
expect(report.left_trigger == 0xff && report.right_trigger == 0xff,
"digital trigger mapping mismatch");
"full analog trigger mapping mismatch");
expect(report.left_x == INT16_MIN && report.left_y == INT16_MAX &&
report.right_x == INT16_MAX && report.right_y == -INT16_MAX,
report.right_x == INT16_MAX &&
report.right_y == -INT16_MAX,
"axis endpoint mapping mismatch");
state.left_trigger = 0x8000;
state.right_trigger = 0x7fff;
report = XInputFeasibility::build_input_report(state);
expect(report.left_trigger == 0x80 && report.right_trigger == 0x7f,
"analog trigger precision was discarded");
}
void test_rumble_report() {
const uint8_t packet[8] = {0x00, 0x08, 0x00, 0xa5, 0x5a, 0x00, 0x00, 0x00};
SwitchRumbleOutput output{};
ControllerRumbleOutput output{};
expect(
XInputFeasibility::parse_rumble_report(packet, sizeof(packet), &output),
"valid rumble report rejected");