Add persistent profile switching feedback

This commit is contained in:
Joey Yakimowich-Payne 2026-09-02 18:45:28 -06:00
commit 045edc984e
12 changed files with 1573 additions and 198 deletions

View file

@ -1039,7 +1039,8 @@ void test_independent_lifecycle() {
"unstable replacement must not be enrolled for profiles");
g_slots[3].pending_rumble = {
3, disconnected_generation, ControllerRumbleOutput{77, 88}};
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,
@ -1273,102 +1274,437 @@ void test_slot_lighting() {
"controller without RGB support did not receive its slot LED");
}
void require_south_button_mapping(const ControllerState& state,
bool swapped,
const char* message) {
require(state.button_east == swapped && state.button_south == !swapped &&
!state.button_north && !state.button_west,
message);
}
void test_abxy_hotkey() {
void test_profile_chord_remains_raw() {
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,
"ABXY test controllers did not become ready");
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;
platform_on_controller_data(&slot_zero, &input);
ControllerState snapshot{};
require(read_controller_state(0, &snapshot),
"slot 0 ABXY state was not published");
require_south_button_mapping(
snapshot, kDefaultSwapAbxy,
"slot 0 did not start in the configured ABXY layout");
input.gamepad.buttons =
kAbxyHotkeyButtonMask | BUTTON_A;
input.gamepad.misc_buttons = kAbxyHotkeyMiscMask;
platform_on_controller_data(&slot_zero, &input);
require(read_controller_state(0, &snapshot),
"toggled slot 0 state was not published");
require_south_button_mapping(
snapshot, !kDefaultSwapAbxy,
"hotkey did not toggle slot 0 ABXY mapping");
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, ControllerRumbleOutput{0x11, 0x22});
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(slot_zero.rumble_calls == 1 &&
slot_zero.last_high == kAbxyFeedbackWeakMagnitude &&
slot_zero.last_low == kAbxyFeedbackStrongMagnitude &&
slot_zero.last_high == UINT8_MAX &&
slot_zero.last_low == UINT8_MAX &&
g_slots[0].rumble_pending,
"ABXY confirmation was not full-strength or did not take priority");
platform_on_controller_data(&slot_zero, &input);
process_rumble_timer(&g_rumble_timer);
require(g_slots[0].swap_abxy == !kDefaultSwapAbxy &&
slot_zero.rumble_calls == 1,
"held hotkey toggled or rumbled more than once");
input.gamepad = {};
platform_on_controller_data(&slot_zero, &input);
input.gamepad.buttons = kAbxyHotkeyButtonMask | BUTTON_A;
input.gamepad.misc_buttons = kAbxyHotkeyMiscMask;
platform_on_controller_data(&slot_zero, &input);
process_rumble_timer(&g_rumble_timer);
require(g_slots[0].swap_abxy == kDefaultSwapAbxy &&
slot_zero.rumble_calls == 2,
"released hotkey did not re-arm for a second toggle");
now_ms = kAbxyFeedbackDurationMs - 1;
process_rumble_timer(&g_rumble_timer);
require(slot_zero.rumble_calls == 2 && g_slots[0].rumble_pending,
"host rumble interrupted ABXY confirmation");
now_ms = kAbxyFeedbackDurationMs;
process_rumble_timer(&g_rumble_timer);
require(slot_zero.rumble_calls == 3 &&
slot_zero.last_high == 0x22 &&
slot_zero.last_low == 0x11 &&
!g_slots[0].rumble_pending,
"deferred host rumble did not resume after confirmation");
uni_controller_t peer_input{};
peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
peer_input.gamepad.buttons = BUTTON_A;
platform_on_controller_data(&slot_one, &peer_input);
require(read_controller_state(1, &snapshot),
"slot 1 ABXY state was not published");
require_south_button_mapping(
snapshot, kDefaultSwapAbxy,
"slot 0 hotkey changed slot 1 layout");
platform_on_device_disconnected(&slot_zero);
uni_hid_device_t replacement = device(0);
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS &&
g_slots[0].swap_abxy == kDefaultSwapAbxy &&
!g_slots[0].abxy_hotkey_latched &&
require(controller.rumble_calls == 0 &&
!g_slots[0].feedback_pending,
"disconnect did not reset slot 0 hotkey state");
"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].profile_feedback_pending,
"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;
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 persistent 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);
require(devices[0].rumble_calls == 4 &&
devices[0].last_high == 0x42 &&
!g_slots[0].rumble_pending,
"LED-only sequence did not release deferred host rumble");
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,
"combined three-pulse sequence did not terminate");
const int old_rumble_calls = devices[2].rumble_calls;
bluepad32_input_backend_queue_profile_feedback(
2, generations[2], 4,
ControllerProfileConfirmationPolicy::kRumbleAndLed);
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].profile_feedback_pending,
"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,
"one-blink LED-only profile indication did not terminate 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 did not persist four player LEDs");
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,
"four-blink LED-only profile indication did not terminate");
}
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_ADAPTER_FEASIBILITY
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_hotkey() {
@ -1393,12 +1729,17 @@ void test_motion_hotkey() {
input.gamepad.buttons = kMotionHotkeyButtonMask;
input.gamepad.misc_buttons = kMotionHotkeyMiscMask;
platform_on_controller_data(&slot_zero, &input);
Bluepad32SlotSnapshot backend_snapshot{};
bluepad32_input_backend_snapshot(0, &backend_snapshot);
require(read_controller_state(0, &snapshot) &&
backend_snapshot.pre_hotkey_button_mask ==
kMotionHotkeyLogicalButtonMask &&
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 0 : 3) &&
!snapshot.dpad_up && !snapshot.button_right_shoulder &&
!snapshot.button_start,
"motion chord did not toggle motion or suppress its inputs");
"motion chord was not published pre-hotkey or suppressed "
"from normal output");
process_rumble_timer(&g_rumble_timer);
require(slot_zero.rumble_calls == 1 &&
@ -1452,6 +1793,7 @@ void test_motion_hotkey() {
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS &&
g_slots[0].motion_enabled == kDefaultMotionEnabled &&
!g_slots[0].motion_hotkey_latched &&
g_slots[0].pre_hotkey_button_mask == 0 &&
!g_slots[0].feedback_pending,
"disconnect did not reset slot 0 motion hotkey state");
}
@ -1641,8 +1983,12 @@ int main(int argc, char** argv) {
test_pairing_window_policy();
} else if (scenario == "slot-lighting") {
test_slot_lighting();
} else if (scenario == "abxy-hotkey") {
test_abxy_hotkey();
} 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_hotkey();
} else if (scenario == "analog-state") {

View file

@ -16,11 +16,20 @@ struct FakeProfileRow {
uint8_t active_profile = 0;
ControllerProfile profiles[CONTROLLER_PROFILE_COUNT]{};
};
struct ActivationAttempt {
uint32_t transaction_id = 0;
ControllerIdentity identity{};
uint8_t profile_index = 0;
};
std::array<FakeProfileRow, CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT> rows{};
uint32_t database_generation = 7;
uint32_t configuration_reset_generation = 3;
unsigned active_snapshot_count = 0;
std::array<ActivationAttempt, 32> activation_attempts{};
size_t activation_attempt_count = 0;
unsigned activation_busy_attempts = 0;
void require(bool condition, const char* message) {
if (!condition) {
@ -44,6 +53,9 @@ void prepare_profiles() {
database_generation = 7;
configuration_reset_generation = 3;
active_snapshot_count = 0;
activation_attempts = {};
activation_attempt_count = 0;
activation_busy_attempts = 0;
for (uint8_t slot = 0;
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
FakeProfileRow& row = rows[slot];
@ -78,6 +90,31 @@ Bluepad32SlotSnapshot make_snapshot(uint8_t slot,
snapshot.state = controller_neutral_state();
return snapshot;
}
void apply_button_masks(uint16_t pre_hotkey_mask,
uint16_t output_mask,
Bluepad32SlotSnapshot* snapshot) {
snapshot->pre_hotkey_button_mask = pre_hotkey_mask;
snapshot->state = controller_neutral_state();
controller_profile_apply_button_mask(output_mask, &snapshot->state);
}
void apply_button_mask(uint16_t mask, Bluepad32SlotSnapshot* snapshot) {
apply_button_masks(mask, mask, snapshot);
}
ControllerProfileRuntimeProfileChangeEvent take_profile_change(
uint8_t slot, bool* available) {
ControllerProfileRuntimeProfileChangeEvent event{};
*available =
controller_profile_runtime_take_profile_change(slot, &event);
return event;
}
constexpr uint16_t logical_button_bit(
ControllerProfileLogicalButton button) {
return static_cast<uint16_t>(
1u << static_cast<uint8_t>(button));
}
ControllerProfileTransformResult runtime_transform(
uint8_t slot, const Bluepad32SlotSnapshot& snapshot,
@ -248,6 +285,292 @@ void test_analog_thresholds_rumble_and_local_confirmation() {
"local confirmation was scaled or lost its profile policy");
}
void test_default_switching_retry_commit_and_feedback() {
prepare_profiles();
ControllerProfile& initial_profile = rows[0].profiles[0];
initial_profile.macro_trigger = static_cast<uint8_t>(
ControllerProfileLogicalButton::kLeftShoulder);
initial_profile.macro_step_count = 2;
initial_profile.macro_steps[0].type =
ControllerProfileMacroStepType::kState;
initial_profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons;
initial_profile.macro_steps[0].duration_ms = 1000;
initial_profile.macro_steps[0].output_button_mask =
logical_button_bit(ControllerProfileLogicalButton::kNorth);
initial_profile.macro_steps[1].type =
ControllerProfileMacroStepType::kEnd;
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 0);
bool event_available = true;
(void)take_profile_change(0, &event_available);
require(!event_available,
"initial profile load published confirmation feedback");
activation_busy_attempts = 2;
const uint16_t held_mask = static_cast<uint16_t>(
CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD |
logical_button_bit(ControllerProfileLogicalButton::kSouth));
apply_button_mask(held_mask, &snapshot);
ControllerProfileTransformResult output =
runtime_transform(0, snapshot, 1);
require(activation_attempt_count == 1 &&
rows[0].active_profile == 0 &&
output.state.button_south &&
!output.state.button_left_shoulder &&
!output.state.button_right_shoulder &&
!output.state.button_select &&
!output.state.button_start &&
!output.state.button_north,
"default chord was not consumed before a busy activation and synthetic trigger");
output = runtime_transform(0, snapshot, 2);
output = runtime_transform(0, snapshot, 3);
require(activation_attempt_count == 3 &&
activation_attempts[0].transaction_id ==
activation_attempts[1].transaction_id &&
activation_attempts[1].transaction_id ==
activation_attempts[2].transaction_id &&
(activation_attempts[0].transaction_id & 0x80000000u) != 0 &&
activation_attempts[0].transaction_id != 0 &&
activation_attempts[0].profile_index == 1 &&
output.state.button_south && !output.state.button_north,
"held busy chord did not retry one internal activation transaction");
(void)runtime_transform(0, snapshot, 4);
require(activation_attempt_count == 3,
"accepted activation repeated while the chord remained held");
(void)take_profile_change(0, &event_available);
require(!event_available,
"activation feedback was published before storage commit");
rows[0].active_profile = 1;
rows[0].profiles[1].button_map[
static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth)] =
static_cast<uint8_t>(ControllerProfileLogicalButton::kNorth);
rows[0].profiles[1].confirmation_policy =
ControllerProfileConfirmationPolicy::kLed;
++database_generation;
output = runtime_transform(0, snapshot, 5);
const ControllerProfileRuntimeProfileChangeEvent event =
take_profile_change(0, &event_available);
require(event_available && event.connection_generation == 1 &&
event.database_generation == database_generation &&
event.active_profile_number == 2 &&
event.policy == ControllerProfileConfirmationPolicy::kLed &&
output.state.button_north &&
!output.state.button_south &&
!output.state.button_left_shoulder &&
activation_attempt_count == 3,
"committed activation did not cancel, transform, and publish exactly one event");
(void)take_profile_change(0, &event_available);
require(!event_available,
"committed profile change feedback was published twice");
apply_button_mask(0, &snapshot);
(void)runtime_transform(0, snapshot, 6);
apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD,
&snapshot);
(void)runtime_transform(0, snapshot, 7);
require(activation_attempt_count == 4 &&
activation_attempts[3].profile_index == 2 &&
activation_attempts[3].transaction_id !=
activation_attempts[0].transaction_id &&
(activation_attempts[3].transaction_id & 0x80000000u) != 0,
"release did not re-arm one activation for the next profile");
}
void test_identity_promotion_preserves_held_switching() {
prepare_profiles();
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
snapshot.identity = controller_identity_global();
apply_button_mask(
CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, &snapshot);
const ControllerProfileTransformResult unresolved =
runtime_transform(0, snapshot, 0);
require(activation_attempt_count == 1 &&
controller_identity_is_global(
activation_attempts[0].identity) &&
unresolved.left_trigger_digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD,
"unresolved BLE identity did not begin one activation");
snapshot.identity = rows[0].identity;
const ControllerProfileTransformResult promoted =
runtime_transform(0, snapshot, 1);
bool event_available = true;
(void)take_profile_change(0, &event_available);
require(activation_attempt_count == 1 &&
promoted.left_trigger_digital_threshold == 1000 &&
!event_available,
"identity-only BLE promotion re-armed a held activation or "
"failed to refresh its profile");
(void)runtime_transform(0, snapshot, 2);
require(activation_attempt_count == 1,
"promoted BLE identity repeated an accepted held activation");
apply_button_mask(0, &snapshot);
(void)runtime_transform(0, snapshot, 3);
apply_button_mask(
CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD, &snapshot);
(void)runtime_transform(0, snapshot, 4);
require(activation_attempt_count == 2 &&
controller_identity_equal(
activation_attempts[1].identity,
rows[0].identity),
"physical release did not re-arm the promoted identity");
}
void configure_motion_suppression_probe(ControllerProfile* profile) {
profile->macro_trigger = static_cast<uint8_t>(
ControllerProfileLogicalButton::kDpadUp);
profile->macro_step_count = 2;
profile->macro_steps[0].type =
ControllerProfileMacroStepType::kState;
profile->macro_steps[0].override_flags =
kControllerProfileOverrideButtons;
profile->macro_steps[0].duration_ms = 1000;
profile->macro_steps[0].output_button_mask =
logical_button_bit(ControllerProfileLogicalButton::kNorth);
profile->macro_steps[1].type =
ControllerProfileMacroStepType::kEnd;
}
void test_switching_uses_pre_hotkey_buttons_only() {
constexpr uint16_t kMotionHotkeyLogicalMask =
static_cast<uint16_t>(
logical_button_bit(
ControllerProfileLogicalButton::kDpadUp) |
logical_button_bit(
ControllerProfileLogicalButton::kRightShoulder) |
logical_button_bit(
ControllerProfileLogicalButton::kStart));
constexpr uint16_t kCustomChord =
static_cast<uint16_t>(
kMotionHotkeyLogicalMask |
logical_button_bit(
ControllerProfileLogicalButton::kSouth));
for (uint8_t custom = 0; custom < 2; ++custom) {
prepare_profiles();
ControllerProfile& profile = rows[0].profiles[0];
configure_motion_suppression_probe(&profile);
const uint16_t switching_chord =
custom != 0 ? kCustomChord
: CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD;
profile.switching_chord = custom != 0 ? kCustomChord : 0;
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 0);
const uint16_t pre_hotkey_mask = static_cast<uint16_t>(
switching_chord | kMotionHotkeyLogicalMask);
const uint16_t output_mask = static_cast<uint16_t>(
pre_hotkey_mask & ~kMotionHotkeyLogicalMask);
apply_button_masks(
pre_hotkey_mask, output_mask, &snapshot);
ControllerProfileTransformResult output =
runtime_transform(0, snapshot, 1);
require(activation_attempt_count == 1 &&
!output.state.dpad_up &&
!output.state.button_right_shoulder &&
!output.state.button_start &&
!output.state.button_left_shoulder &&
!output.state.button_select &&
!output.state.button_south &&
!output.state.button_north,
custom != 0
? "custom chord hidden by motion suppression"
: "default chord hidden by motion suppression");
output = runtime_transform(0, snapshot, 2);
require(activation_attempt_count == 1 &&
!output.state.button_north,
"held pre-hotkey chord repeated activation or leaked "
"into synthetic input");
}
}
void test_custom_switching_chord_and_wrap() {
prepare_profiles();
constexpr uint16_t kCustomChord =
static_cast<uint16_t>(
logical_button_bit(ControllerProfileLogicalButton::kSouth) |
logical_button_bit(ControllerProfileLogicalButton::kCapture));
rows[0].profiles[0].switching_chord = kCustomChord;
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 0);
apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD,
&snapshot);
ControllerProfileTransformResult output =
runtime_transform(0, snapshot, 1);
require(activation_attempt_count == 0 &&
output.state.button_left_shoulder &&
output.state.button_right_shoulder &&
output.state.button_select && output.state.button_start,
"nonzero switching chord did not replace the default");
apply_button_mask(kCustomChord, &snapshot);
output = runtime_transform(0, snapshot, 2);
require(activation_attempt_count == 1 &&
activation_attempts[0].profile_index == 1 &&
!output.state.button_south &&
!output.state.button_capture,
"custom switching chord was not consumed or activated");
prepare_profiles();
rows[0].active_profile = 3;
rows[0].profiles[3].switching_chord = kCustomChord;
snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 10);
bool event_available = true;
(void)take_profile_change(0, &event_available);
require(!event_available,
"initial profile 4 load published a change event");
apply_button_mask(kCustomChord, &snapshot);
(void)runtime_transform(0, snapshot, 11);
require(activation_attempt_count == 1 &&
activation_attempts[0].profile_index == 0,
"profile switching did not wrap profile 4 to profile 1");
}
void test_switching_slot_isolation() {
prepare_profiles();
std::array<Bluepad32SlotSnapshot,
CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT>
snapshots{};
for (uint8_t slot = 0;
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
snapshots[slot] = make_snapshot(slot);
(void)runtime_transform(slot, snapshots[slot], 0);
}
apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD,
&snapshots[2]);
(void)runtime_transform(2, snapshots[2], 1);
snapshots[0].state.button_south = true;
const ControllerProfileTransformResult untouched =
runtime_transform(0, snapshots[0], 1);
require(activation_attempt_count == 1 &&
controller_identity_equal(
activation_attempts[0].identity,
rows[2].identity) &&
untouched.state.button_south,
"one slot's switching chord affected another slot");
apply_button_mask(CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD,
&snapshots[3]);
(void)runtime_transform(2, snapshots[2], 2);
(void)runtime_transform(3, snapshots[3], 2);
require(activation_attempt_count == 2 &&
controller_identity_equal(
activation_attempts[1].identity,
rows[3].identity),
"held switching state was shared between controller slots");
}
void configure_synthetic_profile(uint8_t slot) {
ControllerProfile& profile = rows[slot].profiles[0];
profile = controller_profile_default(rows[slot].identity, 0);
@ -347,6 +670,12 @@ void test_all_runtime_cancellation_causes() {
output = runtime_transform(0, snapshot, 33);
require_no_synthetic_output(
output, "profile/database generation change did not cancel");
bool event_available = false;
const ControllerProfileRuntimeProfileChangeEvent cancellation_event =
take_profile_change(0, &event_available);
require(event_available &&
cancellation_event.active_profile_number == 2,
"profile commit feedback was not observed with cancellation");
prepare_profiles();
configure_synthetic_profile(0);
@ -413,6 +742,20 @@ uint32_t profile_service_database_generation() {
uint32_t configuration_service_reset_generation() {
return configuration_reset_generation;
}
ConfigurationTransactionStatus profile_service_activate_internal(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t profile_index) {
require(activation_attempt_count < activation_attempts.size(),
"activation attempt fixture overflow");
activation_attempts[activation_attempt_count++] = {
transaction_id, identity, profile_index};
if (activation_busy_attempts != 0) {
--activation_busy_attempts;
return ConfigurationTransactionStatus::kBusy;
}
return ConfigurationTransactionStatus::kPending;
}
void profile_service_active_profile_snapshot(
const ControllerIdentity& identity,
@ -439,6 +782,11 @@ int main() {
test_four_slot_cache_and_unchanged_generation();
test_activation_disconnect_and_default_preservation();
test_analog_thresholds_rumble_and_local_confirmation();
test_default_switching_retry_commit_and_feedback();
test_identity_promotion_preserves_held_switching();
test_switching_uses_pre_hotkey_buttons_only();
test_custom_switching_chord_and_wrap();
test_switching_slot_isolation();
test_all_runtime_cancellation_causes();
test_runtime_slot_synthetic_isolation();
return 0;

View file

@ -211,6 +211,100 @@ void test_pending_commands_are_not_decoded_as_profile_writes() {
"activation did not publish profile, index, and generation together");
}
void test_host_and_controller_mutations_are_serialized() {
const ControllerIdentity identity = controller_identity_global();
ControllerProfile profile =
controller_profile_default(identity, 1);
profile.weak_rumble_scale = 23;
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
"serialization fixture profile did not encode");
constexpr uint32_t kHostTransactionId = 0x11223344;
constexpr uint32_t kInternalTransactionId = 0x80000019;
require(profile_service_begin(
kHostTransactionId, identity, 1,
CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
profile_storage_crc32(encoded, sizeof(encoded))) ==
ConfigurationTransactionStatus::kReceiving,
"host write did not acquire the profile mutation boundary");
require(profile_service_activate_internal(
kInternalTransactionId, identity, 2) ==
ConfigurationTransactionStatus::kBusy,
"controller activation raced a receiving host write");
ProfileServiceTransactionSnapshot snapshot =
transaction_snapshot();
require(snapshot.transaction.transaction_id ==
kHostTransactionId &&
snapshot.transaction.status ==
ConfigurationTransactionStatus::kReceiving,
"busy controller activation replaced the host transaction");
require(profile_service_append(
kHostTransactionId, 0, encoded, sizeof(encoded)) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_commit(kHostTransactionId) ==
ConfigurationTransactionStatus::kPending,
"host write did not reach pending after controller contention");
profile_service_task_on_storage_core(3000);
require(transaction_snapshot().transaction.status ==
ConfigurationTransactionStatus::kCommitted,
"serialized host write did not commit");
constexpr uint32_t kHostActivationTransactionId = 0x22334455;
require(profile_service_activate(
kHostActivationTransactionId, identity, 0) ==
ConfigurationTransactionStatus::kPending,
"host activation did not acquire the released boundary");
require(profile_service_activate_internal(
kInternalTransactionId, identity, 2) ==
ConfigurationTransactionStatus::kBusy,
"controller activation raced a pending host activation");
snapshot = transaction_snapshot();
require(snapshot.transaction.transaction_id ==
kHostActivationTransactionId &&
snapshot.transaction.status ==
ConfigurationTransactionStatus::kPending &&
active_profile_snapshot(identity).profile_index == 3,
"pending host activation was replaced or leaked before commit");
profile_service_task_on_storage_core(4000);
require(active_profile_snapshot(identity).profile_index == 0,
"serialized host activation did not commit");
require(profile_service_activate_internal(
0x19, identity, 2) ==
ConfigurationTransactionStatus::kMalformed,
"internal activation admitted a transaction without the high bit");
require(profile_service_activate_internal(
kInternalTransactionId, identity, 2) ==
ConfigurationTransactionStatus::kPending,
"controller activation did not acquire the released boundary");
require(profile_service_begin(
0x55667788, identity, 0,
CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded),
profile_storage_crc32(encoded, sizeof(encoded))) ==
ConfigurationTransactionStatus::kBusy,
"host write raced a pending controller activation");
snapshot = transaction_snapshot();
require(snapshot.transaction.transaction_id ==
kHostActivationTransactionId &&
snapshot.transaction.status ==
ConfigurationTransactionStatus::kCommitted &&
active_profile_snapshot(identity).profile_index == 0,
"pending controller activation replaced host-visible status or leaked before commit");
profile_service_task_on_storage_core(5000);
const ProfileServiceActiveProfileSnapshot active =
active_profile_snapshot(identity);
snapshot = transaction_snapshot();
require(active.valid && active.profile_index == 2 &&
snapshot.transaction.transaction_id ==
kHostActivationTransactionId &&
snapshot.transaction.status ==
ConfigurationTransactionStatus::kCommitted,
"controller activation did not publish while preserving host-visible status");
}
} // namespace
ProfileStorageIo pico_profile_storage_io() {
@ -219,5 +313,6 @@ ProfileStorageIo pico_profile_storage_io() {
int main() {
test_pending_commands_are_not_decoded_as_profile_writes();
test_host_and_controller_mutations_are_serialized();
return 0;
}

View file

@ -42,7 +42,9 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"lifecycle",
"pairing-policy",
"slot-lighting",
"abxy-hotkey",
"profile-chord-raw",
"profile-feedback",
"stateful-rumble",
"motion-hotkey",
"analog-state",
"rumble-mode",