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

@ -24,11 +24,9 @@ constexpr int32_t kAxisMinimum = -512;
constexpr int32_t kAxisMaximum = 511;
constexpr int32_t kTriggerMaximum = 1023;
constexpr uint16_t kSwitchHostRumbleDurationMs = 50;
#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY
// XInput vibration is stateful and remains active until XInputSetState sends
// a new magnitude.
constexpr uint16_t kXInputHostRumbleDurationMs = UINT16_MAX;
#endif
constexpr uint32_t kRumblePollIntervalMs = 5;
constexpr uint32_t kConfigurationPollIntervalMs = 50;
constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT;
@ -42,16 +40,15 @@ constexpr uint8_t kAllBlePairingMethods =
SM_STK_GENERATION_METHOD_OOB |
SM_STK_GENERATION_METHOD_PASSKEY |
SM_STK_GENERATION_METHOD_NUMERIC_COMPARISON;
constexpr uint32_t kAbxyHotkeyButtonMask =
SWITCH_ABXY_HOTKEY_BUTTON_MASK;
constexpr uint32_t kAbxyHotkeyMiscMask = SWITCH_ABXY_HOTKEY_MISC_MASK;
constexpr bool kDefaultSwapAbxy = SWITCH_ABXY_DEFAULT_SWAPPED != 0;
constexpr uint32_t kAbxyFeedbackDurationMs =
SWITCH_ABXY_FEEDBACK_DURATION_MS;
constexpr uint8_t kAbxyFeedbackWeakMagnitude =
SWITCH_ABXY_FEEDBACK_WEAK_MAGNITUDE;
constexpr uint8_t kAbxyFeedbackStrongMagnitude =
SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE;
constexpr uint16_t kProfileFeedbackPhaseDurationMs = 75;
constexpr uint8_t kProfileFeedbackWeakMagnitude = UINT8_MAX;
constexpr uint8_t kProfileFeedbackStrongMagnitude = UINT8_MAX;
constexpr SwitchRgbColor kProfileLightbarPalette[CONTROLLER_PROFILE_COUNT] = {
{0x00, 0x55, 0xff},
{0x00, 0xcc, 0x66},
{0xff, 0xaa, 0x00},
{0xcc, 0x33, 0xff},
};
constexpr uint32_t kMotionHotkeyDpadMask =
SWITCH_MOTION_HOTKEY_DPAD_MASK;
constexpr uint32_t kMotionHotkeyButtonMask =
@ -73,9 +70,8 @@ constexpr uint8_t kMotionEnabledFeedbackWeakMagnitude =
constexpr uint8_t kMotionEnabledFeedbackStrongMagnitude =
SWITCH_MOTION_ENABLED_FEEDBACK_STRONG_MAGNITUDE;
static_assert(kAbxyHotkeyButtonMask != 0);
static_assert(kAbxyHotkeyMiscMask != 0);
static_assert(kAbxyFeedbackDurationMs > 0);
static_assert(kProfileFeedbackPhaseDurationMs == 75);
static_assert(CONTROLLER_PROFILE_COUNT == 4);
static_assert(kMotionHotkeyDpadMask != 0);
static_assert(kMotionHotkeyButtonMask != 0);
static_assert(kMotionHotkeyMiscMask != 0);
@ -104,6 +100,7 @@ struct RumbleEnvelope {
uint8_t slot;
uint32_t connection_generation;
ControllerRumbleOutput rumble;
uint16_t duration_ms;
};
struct FeedbackEnvelope {
uint32_t connection_generation;
@ -111,6 +108,23 @@ struct FeedbackEnvelope {
uint8_t weak_magnitude;
uint8_t strong_magnitude;
};
struct ProfileFeedbackEnvelope {
uint32_t connection_generation;
uint8_t active_profile_number;
ControllerProfileConfirmationPolicy policy;
};
struct ProfileFeedbackSequence {
uint32_t connection_generation;
uint32_t phase_deadline_ms;
uint8_t pulse_count;
uint8_t pulses_started;
bool active;
bool on;
bool rumble_enabled;
bool led_enabled;
};
// Security Manager identity events arrive before Bluepad32 publishes a ready
// device. Retain only the four live handle/address associations so a BLE RPA
@ -126,6 +140,7 @@ struct BleIdentityMapping {
struct BackendSlot {
ControllerState state;
uint16_t pre_hotkey_button_mask;
ControllerIdentity identity;
// Non-null with active=false is a connected device still becoming ready.
uni_hid_device_t* device;
@ -133,14 +148,17 @@ struct BackendSlot {
uint32_t connection_generation;
bool active;
bool rumble_pending;
bool swap_abxy;
bool abxy_hotkey_latched;
bool motion_enabled;
bool motion_hotkey_latched;
bool feedback_pending;
uint32_t feedback_until_ms;
bool profile_feedback_pending;
RumbleEnvelope pending_rumble;
bool retained_host_rumble_valid;
RumbleEnvelope retained_host_rumble;
FeedbackEnvelope pending_feedback;
ProfileFeedbackEnvelope pending_profile_feedback;
ProfileFeedbackSequence profile_feedback;
};
critical_section_t g_state_lock;
@ -409,6 +427,31 @@ void apply_slot_lighting(uint8_t slot_index, uni_hid_device_t* device) {
device, static_cast<uint8_t>(1u << slot_index));
}
}
bool valid_confirmation_policy(
ControllerProfileConfirmationPolicy policy) {
return static_cast<uint8_t>(policy) <=
static_cast<uint8_t>(
ControllerProfileConfirmationPolicy::kRumbleAndLed);
}
void apply_profile_lighting(
uint8_t active_profile_number, uni_hid_device_t* device) {
if (device == nullptr || active_profile_number == 0 ||
active_profile_number > CONTROLLER_PROFILE_COUNT) {
return;
}
if (device->report_parser.set_lightbar_color != nullptr) {
const SwitchRgbColor color =
kProfileLightbarPalette[active_profile_number - 1u];
device->report_parser.set_lightbar_color(
device, color.red, color.green, color.blue);
} else if (device->report_parser.set_player_leds != nullptr) {
device->report_parser.set_player_leds(
device, static_cast<uint8_t>(
(1u << active_profile_number) - 1u));
}
}
ConnectionStatus compute_connection_status() {
@ -430,11 +473,13 @@ ConnectionStatus compute_connection_status() {
}
void publish_device_state(uint8_t slot, uni_hid_device_t* device,
uint16_t pre_hotkey_button_mask,
const ControllerState& state) {
critical_section_enter_blocking(&g_state_lock);
BackendSlot& target = g_slots[slot];
if (target.active && target.device == device) {
target.state = state;
target.pre_hotkey_button_mask = pre_hotkey_button_mask;
++target.state_generation;
}
critical_section_exit(&g_state_lock);
@ -444,10 +489,18 @@ void publish_all_neutral() {
critical_section_enter_blocking(&g_state_lock);
for (BackendSlot& slot : g_slots) {
slot.state = make_neutral_state();
slot.pre_hotkey_button_mask = 0;
slot.identity = controller_identity_global();
slot.device = nullptr;
slot.active = false;
slot.rumble_pending = false;
slot.retained_host_rumble_valid = false;
slot.retained_host_rumble = {};
slot.feedback_pending = false;
slot.feedback_until_ms = 0;
slot.profile_feedback_pending = false;
slot.pending_profile_feedback = {};
slot.profile_feedback = {};
++slot.state_generation;
++slot.connection_generation;
}
@ -541,32 +594,129 @@ bool has_motion(const uni_gamepad_t& gamepad) {
}
return false;
}
constexpr uint16_t logical_button_bit(
ControllerProfileLogicalButton button) {
return static_cast<uint16_t>(
1u << static_cast<uint8_t>(button));
}
constexpr uint16_t logical_button_mask(
uint32_t dpad, uint32_t buttons, uint32_t misc_buttons) {
return static_cast<uint16_t>(
((buttons & BUTTON_A) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kSouth)
: 0u) |
((buttons & BUTTON_B) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kEast)
: 0u) |
((buttons & BUTTON_X) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kWest)
: 0u) |
((buttons & BUTTON_Y) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kNorth)
: 0u) |
((buttons & BUTTON_SHOULDER_L) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kLeftShoulder)
: 0u) |
((buttons & BUTTON_SHOULDER_R) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kRightShoulder)
: 0u) |
((misc_buttons & MISC_BUTTON_SELECT) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kSelect)
: 0u) |
((misc_buttons & MISC_BUTTON_START) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kStart)
: 0u) |
((misc_buttons & MISC_BUTTON_SYSTEM) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kSystem)
: 0u) |
((misc_buttons & MISC_BUTTON_CAPTURE) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kCapture)
: 0u) |
((buttons & BUTTON_THUMB_L) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kLeftStick)
: 0u) |
((buttons & BUTTON_THUMB_R) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kRightStick)
: 0u) |
((dpad & DPAD_UP) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kDpadUp)
: 0u) |
((dpad & DPAD_DOWN) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kDpadDown)
: 0u) |
((dpad & DPAD_LEFT) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kDpadLeft)
: 0u) |
((dpad & DPAD_RIGHT) != 0
? logical_button_bit(
ControllerProfileLogicalButton::kDpadRight)
: 0u));
}
constexpr uint16_t kMotionHotkeyLogicalButtonMask =
logical_button_mask(
kMotionHotkeyDpadMask, kMotionHotkeyButtonMask,
kMotionHotkeyMiscMask);
uint16_t logical_button_mask(const uni_gamepad_t& gamepad) {
return logical_button_mask(
gamepad.dpad, gamepad.buttons, gamepad.misc_buttons);
}
ControllerState map_gamepad(const uni_gamepad_t& gamepad,
bool swap_abxy,
bool motion_enabled) {
bool motion_enabled,
uint16_t button_mask) {
ControllerState state = make_neutral_state();
state.dpad_up = (gamepad.dpad & DPAD_UP) != 0;
state.dpad_down = (gamepad.dpad & DPAD_DOWN) != 0;
state.dpad_left = (gamepad.dpad & DPAD_LEFT) != 0;
state.dpad_right = (gamepad.dpad & DPAD_RIGHT) != 0;
state.dpad_up =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kDpadUp)) != 0;
state.dpad_down =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kDpadDown)) != 0;
state.dpad_left =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kDpadLeft)) != 0;
state.dpad_right =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kDpadRight)) != 0;
// Bluepad32's A/B/X/Y are positional: south/east/west/north.
state.button_south = (gamepad.buttons & BUTTON_A) != 0;
state.button_east = (gamepad.buttons & BUTTON_B) != 0;
state.button_west = (gamepad.buttons & BUTTON_X) != 0;
state.button_north = (gamepad.buttons & BUTTON_Y) != 0;
if (swap_abxy) {
bool temporary = state.button_east;
state.button_east = state.button_south;
state.button_south = temporary;
temporary = state.button_north;
state.button_north = state.button_west;
state.button_west = temporary;
}
state.button_left_shoulder = (gamepad.buttons & BUTTON_SHOULDER_L) != 0;
state.button_right_shoulder = (gamepad.buttons & BUTTON_SHOULDER_R) != 0;
// Bluepad32's A/B/X/Y are positional: south/east/west/north. Persistent
// profile mappings are the only button remapping layer.
state.button_south =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kSouth)) != 0;
state.button_east =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kEast)) != 0;
state.button_west =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kWest)) != 0;
state.button_north =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kNorth)) != 0;
state.button_left_shoulder =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kLeftShoulder)) != 0;
state.button_right_shoulder =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kRightShoulder)) != 0;
state.left_trigger =
(gamepad.buttons & BUTTON_TRIGGER_L) != 0
? UINT16_MAX
@ -575,13 +725,25 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad,
(gamepad.buttons & BUTTON_TRIGGER_R) != 0
? UINT16_MAX
: scale_trigger(gamepad.throttle);
state.button_left_stick = (gamepad.buttons & BUTTON_THUMB_L) != 0;
state.button_right_stick = (gamepad.buttons & BUTTON_THUMB_R) != 0;
state.button_left_stick =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kLeftStick)) != 0;
state.button_right_stick =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kRightStick)) != 0;
state.button_select = (gamepad.misc_buttons & MISC_BUTTON_SELECT) != 0;
state.button_start = (gamepad.misc_buttons & MISC_BUTTON_START) != 0;
state.button_system = (gamepad.misc_buttons & MISC_BUTTON_SYSTEM) != 0;
state.button_capture = (gamepad.misc_buttons & MISC_BUTTON_CAPTURE) != 0;
state.button_select =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kSelect)) != 0;
state.button_start =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kStart)) != 0;
state.button_system =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kSystem)) != 0;
state.button_capture =
(button_mask & logical_button_bit(
ControllerProfileLogicalButton::kCapture)) != 0;
state.left_stick_x = scale_axis(gamepad.axis_x);
state.left_stick_y = scale_axis(gamepad.axis_y);
@ -606,11 +768,11 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad,
return state;
}
struct HotkeyDecision {
bool swap_abxy;
bool motion_enabled;
uint32_t suppress_dpad;
uint32_t suppress_buttons;
uint32_t suppress_misc_buttons;
uint16_t suppress_logical_buttons;
};
void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms,
@ -622,24 +784,23 @@ void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms,
strong_magnitude};
}
void reset_slot_hotkeys(BackendSlot& slot) {
slot.swap_abxy = kDefaultSwapAbxy;
slot.abxy_hotkey_latched = false;
slot.motion_enabled = kDefaultMotionEnabled;
slot.motion_hotkey_latched = false;
slot.pre_hotkey_button_mask = 0;
slot.feedback_pending = false;
slot.feedback_until_ms = 0;
slot.pending_feedback = {};
slot.profile_feedback_pending = false;
slot.pending_profile_feedback = {};
slot.profile_feedback = {};
slot.retained_host_rumble_valid = false;
slot.retained_host_rumble = {};
}
HotkeyDecision update_controller_hotkeys(
uint8_t slot_index, uni_hid_device_t* device,
const uni_gamepad_t& gamepad) {
const bool abxy_pressed =
(gamepad.buttons & kAbxyHotkeyButtonMask) ==
kAbxyHotkeyButtonMask &&
(gamepad.misc_buttons & kAbxyHotkeyMiscMask) ==
kAbxyHotkeyMiscMask;
const bool motion_pressed =
(gamepad.dpad & kMotionHotkeyDpadMask) ==
kMotionHotkeyDpadMask &&
@ -648,18 +809,12 @@ HotkeyDecision update_controller_hotkeys(
(gamepad.misc_buttons & kMotionHotkeyMiscMask) ==
kMotionHotkeyMiscMask;
HotkeyDecision decision{
kDefaultSwapAbxy, kDefaultMotionEnabled, 0, 0, 0};
kDefaultMotionEnabled, 0, 0, 0, 0};
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (slot.active && slot.device == device) {
if (abxy_pressed && !slot.abxy_hotkey_latched) {
slot.swap_abxy = !slot.swap_abxy;
queue_local_feedback(
slot, static_cast<uint16_t>(kAbxyFeedbackDurationMs),
kAbxyFeedbackWeakMagnitude,
kAbxyFeedbackStrongMagnitude);
} else if (motion_pressed && !slot.motion_hotkey_latched) {
if (motion_pressed && !slot.motion_hotkey_latched) {
slot.motion_enabled = !slot.motion_enabled;
if (slot.motion_enabled) {
queue_local_feedback(
@ -673,18 +828,14 @@ HotkeyDecision update_controller_hotkeys(
kMotionDisabledFeedbackStrongMagnitude);
}
}
slot.abxy_hotkey_latched = abxy_pressed;
slot.motion_hotkey_latched = motion_pressed;
decision.swap_abxy = slot.swap_abxy;
decision.motion_enabled = slot.motion_enabled;
if (abxy_pressed) {
decision.suppress_buttons |= kAbxyHotkeyButtonMask;
decision.suppress_misc_buttons |= kAbxyHotkeyMiscMask;
}
if (motion_pressed) {
decision.suppress_dpad |= kMotionHotkeyDpadMask;
decision.suppress_buttons |= kMotionHotkeyButtonMask;
decision.suppress_misc_buttons |= kMotionHotkeyMiscMask;
decision.suppress_logical_buttons |=
kMotionHotkeyLogicalButtonMask;
}
}
critical_section_exit(&g_state_lock);
@ -995,13 +1146,56 @@ void apply_connection_policy() {
}
}
bool deadline_reached(uint32_t now_ms, uint32_t deadline_ms) {
return static_cast<int32_t>(now_ms - deadline_ms) >= 0;
}
bool advance_profile_feedback(ProfileFeedbackSequence* sequence,
uint32_t now_ms) {
bool rumble_dispatch = false;
for (uint8_t transition = 0;
transition < CONTROLLER_PROFILE_COUNT * 2u &&
sequence->active &&
deadline_reached(now_ms, sequence->phase_deadline_ms);
++transition) {
sequence->phase_deadline_ms +=
kProfileFeedbackPhaseDurationMs;
if (sequence->on) {
sequence->on = false;
rumble_dispatch = false;
} else if (sequence->pulses_started >=
sequence->pulse_count) {
sequence->active = false;
} else {
sequence->on = true;
++sequence->pulses_started;
rumble_dispatch = sequence->rumble_enabled;
}
}
return rumble_dispatch;
}
void update_status_led() {
++g_status_led_tick;
const uint32_t now_ms = btstack_run_loop_get_time_ms();
bool led_on = false;
bool profile_led_override = false;
bool profile_led_on = false;
critical_section_enter_blocking(&g_state_lock);
for (const BackendSlot& slot : g_slots) {
if (slot.profile_feedback.active &&
slot.profile_feedback.led_enabled) {
profile_led_override = true;
profile_led_on =
profile_led_on || slot.profile_feedback.on;
}
}
critical_section_exit(&g_state_lock);
if (static_cast<int32_t>(
now_ms - g_pairing_reset_feedback_deadline_ms) < 0) {
bool led_on = false;
if (profile_led_override) {
led_on = profile_led_on;
} else if (static_cast<int32_t>(
now_ms - g_pairing_reset_feedback_deadline_ms) < 0) {
led_on = (g_status_led_tick % 20) < 10;
} else if (pairing_window_active_at(now_ms)) {
const uint16_t phase = g_status_led_tick % 200;
@ -1037,17 +1231,95 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
if (update_pairing_window(now_ms)) {
apply_connection_policy();
}
const bool xinput_host_mode =
host_rumble_duration_ms() == kXInputHostRumbleDurationMs;
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
RumbleEnvelope envelope{};
FeedbackEnvelope feedback{};
ProfileFeedbackEnvelope profile_feedback{};
uni_hid_device_t* device = nullptr;
bool profile_lighting_dispatch = false;
bool profile_rumble_dispatch = false;
bool feedback_dispatch = false;
bool host_dispatch = false;
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (slot.feedback_pending) {
if (slot.retained_host_rumble_valid &&
(!xinput_host_mode ||
slot.retained_host_rumble.duration_ms !=
kXInputHostRumbleDurationMs ||
slot.retained_host_rumble.slot != slot_index ||
slot.retained_host_rumble.connection_generation !=
slot.connection_generation ||
!slot.active || slot.device == nullptr)) {
slot.retained_host_rumble_valid = false;
slot.retained_host_rumble = {};
}
if (slot.profile_feedback.active &&
slot.profile_feedback.connection_generation !=
slot.connection_generation) {
slot.profile_feedback = {};
}
const bool profile_feedback_was_active =
slot.profile_feedback.active;
const bool completed_feedback_had_rumble =
slot.profile_feedback.rumble_enabled;
profile_rumble_dispatch =
advance_profile_feedback(&slot.profile_feedback, now_ms);
if (profile_feedback_was_active &&
!slot.profile_feedback.active &&
completed_feedback_had_rumble &&
slot.retained_host_rumble_valid) {
slot.pending_rumble = slot.retained_host_rumble;
slot.rumble_pending = true;
}
if (profile_rumble_dispatch) {
device = slot.device;
}
const bool feedback_active =
static_cast<int32_t>(now_ms - slot.feedback_until_ms) < 0;
if (!slot.profile_feedback.active && !feedback_active &&
slot.profile_feedback_pending) {
profile_feedback = slot.pending_profile_feedback;
slot.profile_feedback_pending = false;
const uint8_t policy =
static_cast<uint8_t>(profile_feedback.policy);
if (slot.active && slot.device != nullptr &&
profile_feedback.connection_generation ==
slot.connection_generation &&
profile_feedback.active_profile_number != 0 &&
profile_feedback.active_profile_number <=
CONTROLLER_PROFILE_COUNT &&
valid_confirmation_policy(profile_feedback.policy) &&
profile_feedback.policy !=
ControllerProfileConfirmationPolicy::kNone) {
slot.profile_feedback = {
slot.connection_generation,
now_ms + kProfileFeedbackPhaseDurationMs,
profile_feedback.active_profile_number,
1,
true,
true,
(policy & static_cast<uint8_t>(
ControllerProfileConfirmationPolicy::
kRumble)) != 0,
(policy & static_cast<uint8_t>(
ControllerProfileConfirmationPolicy::
kLed)) != 0,
};
device = slot.device;
profile_lighting_dispatch =
slot.profile_feedback.led_enabled;
profile_rumble_dispatch =
slot.profile_feedback.rumble_enabled;
}
}
if (!slot.profile_feedback.active &&
slot.feedback_pending) {
feedback = slot.pending_feedback;
feedback_dispatch =
slot.active && slot.device != nullptr &&
@ -1062,10 +1334,12 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
}
}
const bool feedback_active =
static_cast<int32_t>(now_ms - slot.feedback_until_ms) < 0;
if (!feedback_dispatch && !feedback_active &&
slot.rumble_pending) {
const bool local_feedback_active =
slot.profile_feedback.active ||
static_cast<int32_t>(
now_ms - slot.feedback_until_ms) < 0;
if (!profile_rumble_dispatch && !feedback_dispatch &&
!local_feedback_active && slot.rumble_pending) {
envelope = slot.pending_rumble;
slot.rumble_pending = false;
host_dispatch =
@ -1079,7 +1353,17 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
}
critical_section_exit(&g_state_lock);
if (feedback_dispatch) {
if (profile_lighting_dispatch) {
apply_profile_lighting(
profile_feedback.active_profile_number, device);
}
if (profile_rumble_dispatch && device != nullptr &&
device->report_parser.play_dual_rumble != nullptr) {
device->report_parser.play_dual_rumble(
device, 0, kProfileFeedbackPhaseDurationMs,
kProfileFeedbackWeakMagnitude,
kProfileFeedbackStrongMagnitude);
} else if (feedback_dispatch) {
device->report_parser.play_dual_rumble(
device, 0, feedback.duration_ms,
feedback.weak_magnitude, feedback.strong_magnitude);
@ -1089,7 +1373,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
envelope.rumble.low_frequency_magnitude == 0 &&
envelope.rumble.high_frequency_magnitude == 0;
device->report_parser.play_dual_rumble(
device, 0, stop ? 0 : host_rumble_duration_ms(),
device, 0, stop ? 0 : envelope.duration_ms,
envelope.rumble.high_frequency_magnitude,
envelope.rumble.low_frequency_magnitude);
}
@ -1273,15 +1557,20 @@ void platform_on_controller_data(uni_hid_device_t* device,
}
uni_gamepad_t gamepad = controller->gamepad;
const uint16_t pre_hotkey_button_mask =
logical_button_mask(gamepad);
const HotkeyDecision hotkeys = update_controller_hotkeys(
static_cast<uint8_t>(slot_index), device, gamepad);
gamepad.dpad &= ~hotkeys.suppress_dpad;
gamepad.buttons &= ~hotkeys.suppress_buttons;
gamepad.misc_buttons &= ~hotkeys.suppress_misc_buttons;
const uint16_t output_button_mask = static_cast<uint16_t>(
pre_hotkey_button_mask & ~hotkeys.suppress_logical_buttons);
publish_device_state(
static_cast<uint8_t>(slot_index), device,
map_gamepad(gamepad, hotkeys.swap_abxy,
hotkeys.motion_enabled));
pre_hotkey_button_mask,
map_gamepad(
gamepad, hotkeys.motion_enabled, output_button_mask));
}
const uni_property_t* platform_get_property(uni_property_idx_t index) {
@ -1462,6 +1751,8 @@ void bluepad32_input_backend_snapshot(uint8_t slot_index,
out->active = slot.active;
out->connection_generation = slot.connection_generation;
out->identity = slot.identity;
out->pre_hotkey_button_mask =
slot.pre_hotkey_button_mask;
out->state = slot.state;
const uint32_t state_generation = slot.state_generation;
critical_section_exit(&g_state_lock);
@ -1485,11 +1776,45 @@ void bluepad32_input_backend_queue_rumble(
return;
}
const uint16_t duration_ms = host_rumble_duration_ms();
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (slot.active && slot.device != nullptr) {
slot.pending_rumble = {slot_index, slot.connection_generation, rumble};
const RumbleEnvelope envelope{
slot_index, slot.connection_generation, rumble,
duration_ms};
slot.pending_rumble = envelope;
slot.rumble_pending = true;
if (duration_ms == kXInputHostRumbleDurationMs) {
slot.retained_host_rumble = envelope;
slot.retained_host_rumble_valid = true;
} else {
slot.retained_host_rumble = {};
slot.retained_host_rumble_valid = false;
}
}
critical_section_exit(&g_state_lock);
}
void bluepad32_input_backend_queue_profile_feedback(
uint8_t slot_index, uint32_t connection_generation,
uint8_t active_profile_number,
ControllerProfileConfirmationPolicy policy) {
if (!g_initialized || !valid_slot(slot_index) ||
active_profile_number == 0 ||
active_profile_number > CONTROLLER_PROFILE_COUNT ||
!valid_confirmation_policy(policy) ||
policy == ControllerProfileConfirmationPolicy::kNone) {
return;
}
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (slot.active && slot.device != nullptr &&
slot.connection_generation == connection_generation) {
slot.pending_profile_feedback = {
connection_generation, active_profile_number, policy};
slot.profile_feedback_pending = true;
}
critical_section_exit(&g_state_lock);
}

View file

@ -4,6 +4,7 @@
#include "controller_color.h"
#include "controller_identity.h"
#include "controller_profile.h"
#include "controller_state.h"
#include "switch_haptics.h"
@ -37,6 +38,9 @@ struct Bluepad32SlotSnapshot {
bool active;
uint32_t connection_generation;
ControllerIdentity identity;
// Physical logical-button state before backend hotkey consumption.
// Valid only for this snapshot's connection generation.
uint16_t pre_hotkey_button_mask;
ControllerState state;
};
@ -54,3 +58,9 @@ void bluepad32_input_backend_pairing_snapshot(
void bluepad32_input_backend_report_sent(uint8_t slot);
void bluepad32_input_backend_queue_rumble(
uint8_t slot, const ControllerRumbleOutput& rumble);
// Schedule bounded local profile confirmation only for the matching live
// connection generation. Profile numbers are one-based (1..4).
void bluepad32_input_backend_queue_profile_feedback(
uint8_t slot, uint32_t connection_generation,
uint8_t active_profile_number,
ControllerProfileConfirmationPolicy policy);

View file

@ -2,26 +2,12 @@
#pragma once
// Bluepad32 button masks. Default chord: L + R + SELECT + START.
#define SWITCH_ABXY_HOTKEY_BUTTON_MASK \
(BUTTON_SHOULDER_L | BUTTON_SHOULDER_R)
#define SWITCH_ABXY_HOTKEY_MISC_MASK \
(MISC_BUTTON_SELECT | MISC_BUTTON_START)
// Motion toggle chord: D-pad Up + R + START / Options.
#define SWITCH_MOTION_HOTKEY_DPAD_MASK DPAD_UP
#define SWITCH_MOTION_HOTKEY_BUTTON_MASK BUTTON_SHOULDER_R
#define SWITCH_MOTION_HOTKEY_MISC_MASK MISC_BUTTON_START
#define SWITCH_MOTION_DEFAULT_ENABLED 1
// 0 starts each new connection in Nintendo positional layout; 1 starts swapped.
#define SWITCH_ABXY_DEFAULT_SWAPPED 0
// Local confirmation pulse sent only to the controller that toggled.
#define SWITCH_ABXY_FEEDBACK_DURATION_MS 120
#define SWITCH_ABXY_FEEDBACK_WEAK_MAGNITUDE 0xFF
#define SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE 0xFF
// A longer pulse confirms disabled; a shorter pulse confirms enabled.
#define SWITCH_MOTION_DISABLED_FEEDBACK_DURATION_MS 180
#define SWITCH_MOTION_DISABLED_FEEDBACK_WEAK_MAGNITUDE 0xA0

View file

@ -13,11 +13,20 @@ struct ControllerProfileRuntimeContext {
ControllerIdentity identity{};
uint32_t database_generation = 0;
uint8_t active_profile_index = 0;
bool profile_snapshot_valid = false;
ControllerProfile profile{};
ControllerSyntheticInputContext synthetic{};
bool runtime_generations_initialized = false;
AdapterUsbMode output_mode = AdapterUsbMode::kSwitchProbe;
uint32_t configuration_reset_generation = 0;
bool switching_chord_held = false;
bool switching_chord_armed = true;
bool switching_activation_requested = false;
uint16_t held_switching_chord = 0;
uint8_t switching_target_profile_index = 0;
uint32_t switching_transaction_id = 0;
bool profile_change_pending = false;
ControllerProfileRuntimeProfileChangeEvent pending_profile_change{};
};
ControllerProfileRuntimeContext
@ -25,6 +34,23 @@ ControllerProfileRuntimeContext
ControllerProfile g_default_profile{};
ControllerProfileTransformResult g_neutral_output{};
bool g_initialized = false;
uint32_t g_next_activation_sequence = 1;
uint32_t next_activation_transaction_id() {
const uint32_t transaction_id =
0x80000000u | g_next_activation_sequence;
g_next_activation_sequence =
g_next_activation_sequence == 0x7fffffffu
? 1u
: g_next_activation_sequence + 1u;
return transaction_id;
}
uint16_t effective_switching_chord(const ControllerProfile& profile) {
return profile.switching_chord == 0
? CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD
: profile.switching_chord;
}
void initialize_defaults() {
if (g_initialized) {
@ -52,6 +78,30 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
ProfileServiceActiveProfileSnapshot snapshot{};
profile_service_active_profile_snapshot(identity, &snapshot);
const bool same_connection =
context->active &&
context->connection_generation == connection_generation;
const bool same_identity =
same_connection &&
controller_identity_equal(context->identity, identity);
const uint8_t previous_profile_index =
context->active_profile_index;
const bool previous_profile_valid =
context->profile_snapshot_valid;
if (!same_connection) {
context->runtime_generations_initialized = false;
context->switching_chord_held = false;
context->switching_chord_armed = true;
context->switching_activation_requested = false;
context->held_switching_chord = 0;
context->switching_target_profile_index = 0;
context->switching_transaction_id = 0;
context->profile_change_pending = false;
context->pending_profile_change = {};
}
controller_synthetic_input_cancel(&context->synthetic,
current_input_button_mask);
context->active = true;
context->connection_generation = connection_generation;
context->identity = identity;
@ -59,9 +109,25 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
? snapshot.metadata.generation
: observed_database_generation;
context->active_profile_index = snapshot.valid ? snapshot.profile_index : 0;
context->profile_snapshot_valid = snapshot.valid;
context->profile = snapshot.valid ? snapshot.profile : g_default_profile;
controller_synthetic_input_cancel(&context->synthetic,
current_input_button_mask);
if (context->switching_chord_held &&
!context->switching_activation_requested) {
context->switching_target_profile_index =
static_cast<uint8_t>(
(context->active_profile_index + 1u) %
CONTROLLER_PROFILE_COUNT);
}
if (same_identity && previous_profile_valid && snapshot.valid &&
previous_profile_index != context->active_profile_index) {
context->pending_profile_change = {
connection_generation,
context->database_generation,
static_cast<uint8_t>(context->active_profile_index + 1u),
controller_profile_confirmation_policy(context->profile),
};
context->profile_change_pending = true;
}
}
ControllerProfileRuntimeContext* update_context(
@ -91,7 +157,75 @@ ControllerProfileRuntimeContext* update_context(
}
return &context;
}
void process_profile_switching(
ControllerProfileRuntimeContext* context,
const ControllerIdentity& identity,
uint16_t switching_input_button_mask,
uint16_t current_input_button_mask,
ControllerState* consumed_input) {
if (context == nullptr || consumed_input == nullptr) {
return;
}
if (context->switching_chord_held) {
if ((switching_input_button_mask &
context->held_switching_chord) ==
context->held_switching_chord) {
controller_profile_apply_button_mask(
static_cast<uint16_t>(
current_input_button_mask &
~context->held_switching_chord),
consumed_input);
if (!context->switching_activation_requested) {
const ConfigurationTransactionStatus status =
profile_service_activate_internal(
context->switching_transaction_id, identity,
context->switching_target_profile_index);
if (status != ConfigurationTransactionStatus::kBusy) {
context->switching_activation_requested = true;
}
}
return;
}
context->switching_chord_held = false;
context->switching_activation_requested = false;
context->held_switching_chord = 0;
context->switching_transaction_id = 0;
}
const uint16_t chord =
effective_switching_chord(context->profile);
const bool chord_fully_held =
(switching_input_button_mask & chord) == chord;
if (!chord_fully_held) {
context->switching_chord_armed = true;
return;
}
if (!context->switching_chord_armed) {
return;
}
context->switching_chord_armed = false;
context->switching_chord_held = true;
context->held_switching_chord = chord;
context->switching_target_profile_index =
static_cast<uint8_t>(
(context->active_profile_index + 1u) %
CONTROLLER_PROFILE_COUNT);
context->switching_transaction_id =
next_activation_transaction_id();
controller_profile_apply_button_mask(
static_cast<uint16_t>(
current_input_button_mask & ~context->held_switching_chord),
consumed_input);
const ConfigurationTransactionStatus status =
profile_service_activate_internal(
context->switching_transaction_id, identity,
context->switching_target_profile_index);
if (status != ConfigurationTransactionStatus::kBusy) {
context->switching_activation_requested = true;
}
}
} // namespace
@ -101,6 +235,7 @@ void controller_profile_runtime_reset() {
for (ControllerProfileRuntimeContext& context : g_contexts) {
clear_context(&context);
}
g_next_activation_sequence = 1;
}
ControllerProfileTransformResult controller_profile_runtime_transform(
@ -112,6 +247,12 @@ ControllerProfileTransformResult controller_profile_runtime_transform(
if (context == nullptr) {
return g_neutral_output;
}
ControllerState consumed_input = snapshot.state;
const uint16_t current_input_button_mask =
controller_profile_extract_button_mask(snapshot.state);
process_profile_switching(
context, snapshot.identity, snapshot.pre_hotkey_button_mask,
current_input_button_mask, &consumed_input);
const uint32_t reset_generation =
configuration_service_reset_generation();
@ -124,12 +265,32 @@ ControllerProfileTransformResult controller_profile_runtime_transform(
reset_generation) {
controller_synthetic_input_cancel(
&context->synthetic,
controller_profile_extract_button_mask(snapshot.state));
controller_profile_extract_button_mask(consumed_input));
context->output_mode = output_mode;
context->configuration_reset_generation = reset_generation;
}
return controller_synthetic_input_apply(
&context->synthetic, snapshot.state, context->profile, now_ms);
&context->synthetic, consumed_input, context->profile, now_ms);
}
bool controller_profile_runtime_take_profile_change(
uint8_t slot, ControllerProfileRuntimeProfileChangeEvent* output) {
if (output == nullptr) {
return false;
}
*output = {};
if (slot >= CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT) {
return false;
}
ControllerProfileRuntimeContext& context = g_contexts[slot];
if (!context.profile_change_pending) {
return false;
}
*output = context.pending_profile_change;
context.profile_change_pending = false;
context.pending_profile_change = {};
return true;
}
ControllerRumbleOutput controller_profile_runtime_scale_host_rumble(

View file

@ -8,6 +8,24 @@
#include "controller_profile_transform.h"
constexpr uint8_t CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT = 4;
constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD =
static_cast<uint16_t>(
(1u << static_cast<uint8_t>(
ControllerProfileLogicalButton::kLeftShoulder)) |
(1u << static_cast<uint8_t>(
ControllerProfileLogicalButton::kRightShoulder)) |
(1u << static_cast<uint8_t>(
ControllerProfileLogicalButton::kSelect)) |
(1u << static_cast<uint8_t>(
ControllerProfileLogicalButton::kStart)));
struct ControllerProfileRuntimeProfileChangeEvent {
uint32_t connection_generation = 0;
uint32_t database_generation = 0;
uint8_t active_profile_number = 0;
ControllerProfileConfirmationPolicy policy =
ControllerProfileConfirmationPolicy::kNone;
};
struct ControllerProfileRuntimeLocalConfirmation {
ControllerRumbleOutput rumble{};
@ -18,14 +36,21 @@ struct ControllerProfileRuntimeLocalConfirmation {
// Reset all four fixed slot caches to the default profile.
void controller_profile_runtime_reset();
// Refresh a slot only when its connection key or database generation changes,
// cancel synthetic state on every runtime invalidation, then apply the shared
// transform and synthetic pipeline. Inactive snapshots return neutral output
// and invalidate the slot immediately.
// Refresh a slot when its identity, connection generation, or database
// generation changes; identity-only promotion preserves a held switching
// transaction. Consume pre-hotkey switching chords while transforming the
// backend-suppressed state through the shared synthetic pipeline. Inactive
// snapshots return neutral output and invalidate the slot.
ControllerProfileTransformResult controller_profile_runtime_transform(
uint8_t slot, const Bluepad32SlotSnapshot& snapshot, uint32_t now_ms,
AdapterUsbMode output_mode);
// Take the single committed profile-index change observed by the slot. Initial
// profile loads, identity promotions, and connection replacements do not
// publish an event.
bool controller_profile_runtime_take_profile_change(
uint8_t slot, ControllerProfileRuntimeProfileChangeEvent* output);
// Refresh from the current slot snapshot and scale host-originated rumble.
ControllerRumbleOutput controller_profile_runtime_scale_host_rumble(
uint8_t slot, const Bluepad32SlotSnapshot& snapshot,

View file

@ -18,6 +18,7 @@ enum class PendingCommandType : uint8_t {
struct PendingCommand {
PendingCommandType type = PendingCommandType::kNone;
uint32_t transaction_id = 0;
ControllerIdentity identity{};
uint8_t profile_index = 0;
};
@ -48,6 +49,7 @@ PublishedActiveProfile
uint8_t g_active_profile_count = 0;
ProfileTransaction g_transaction;
PendingCommand g_command;
PendingCommand g_internal_activation;
bool g_identity_dirty = false;
bool g_has_committed = false;
uint32_t g_last_commit_ms = 0;
@ -159,6 +161,17 @@ void finish_mutation(ConfigurationTransactionStatus status,
critical_section_exit(&g_lock);
}
void finish_internal_activation(
ConfigurationTransactionStatus status) {
critical_section_enter_blocking(&g_lock);
g_internal_activation = {};
refresh_metadata_locked(
status == ConfigurationTransactionStatus::kStorageError
? ProfileServiceState::kStorageError
: ProfileServiceState::kReady);
critical_section_exit(&g_lock);
}
} // namespace
void profile_service_prepare() {
@ -176,6 +189,7 @@ void profile_service_prepare() {
g_selected.profile_index = 0;
g_transaction = {};
g_command = {};
g_internal_activation = {};
g_identity_dirty = false;
g_has_committed = false;
g_last_commit_ms = 0;
@ -226,6 +240,7 @@ bool profile_service_observe_identity_on_storage_core(
void profile_service_task_on_storage_core(uint32_t now_ms) {
PendingCommand command{};
bool process_write = false;
bool process_internal_activation = false;
bool process_identity = false;
ControllerIdentity write_identity{};
uint8_t write_profile_index = 0;
@ -242,6 +257,10 @@ void profile_service_task_on_storage_core(uint32_t now_ms) {
write_profile_index = g_transaction.profile_index;
memcpy(write_payload, g_transaction.payload,
sizeof(write_payload));
} else if (g_internal_activation.type !=
PendingCommandType::kNone) {
command = g_internal_activation;
process_internal_activation = true;
} else if (g_identity_dirty) {
process_identity = true;
}
@ -249,6 +268,7 @@ void profile_service_task_on_storage_core(uint32_t now_ms) {
critical_section_exit(&g_lock);
if (!process_write && !process_identity &&
!process_internal_activation &&
command.type == PendingCommandType::kNone) {
return;
}
@ -279,8 +299,13 @@ void profile_service_task_on_storage_core(uint32_t now_ms) {
}
if (database_result != ControllerProfileDatabaseResult::kOk) {
finish_mutation(database_result_status(database_result),
!process_write);
const ConfigurationTransactionStatus error_status =
database_result_status(database_result);
if (process_internal_activation) {
finish_internal_activation(error_status);
} else {
finish_mutation(error_status, !process_write);
}
return;
}
@ -315,7 +340,11 @@ void profile_service_task_on_storage_core(uint32_t now_ms) {
critical_section_enter_blocking(&g_lock);
g_identity_dirty = false;
critical_section_exit(&g_lock);
finish_mutation(status, !process_write);
if (process_internal_activation) {
finish_internal_activation(status);
} else {
finish_mutation(status, !process_write);
}
}
ConfigurationTransactionStatus profile_service_select(
@ -362,6 +391,7 @@ ConfigurationTransactionStatus profile_service_begin(
}
critical_section_enter_blocking(&g_lock);
if (g_command.type != PendingCommandType::kNone ||
g_internal_activation.type != PendingCommandType::kNone ||
g_transaction.snapshot.status ==
ConfigurationTransactionStatus::kReceiving ||
g_transaction.snapshot.status ==
@ -465,6 +495,7 @@ ConfigurationTransactionStatus profile_service_reset(
}
critical_section_enter_blocking(&g_lock);
if (g_command.type != PendingCommandType::kNone ||
g_internal_activation.type != PendingCommandType::kNone ||
g_transaction.snapshot.status ==
ConfigurationTransactionStatus::kReceiving ||
g_transaction.snapshot.status ==
@ -485,6 +516,7 @@ ConfigurationTransactionStatus profile_service_reset(
return ConfigurationTransactionStatus::kMalformed;
}
g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending;
g_command.transaction_id = transaction_id;
g_command.type = PendingCommandType::kReset;
g_command.identity = identity;
g_command.profile_index = profile_index;
@ -500,6 +532,7 @@ ConfigurationTransactionStatus profile_service_activate(
}
critical_section_enter_blocking(&g_lock);
if (g_command.type != PendingCommandType::kNone ||
g_internal_activation.type != PendingCommandType::kNone ||
g_transaction.snapshot.status ==
ConfigurationTransactionStatus::kReceiving ||
g_transaction.snapshot.status ==
@ -519,6 +552,7 @@ ConfigurationTransactionStatus profile_service_activate(
return ConfigurationTransactionStatus::kMalformed;
}
g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending;
g_command.transaction_id = transaction_id;
g_command.type = PendingCommandType::kActivate;
g_command.identity = identity;
g_command.profile_index = profile_index;
@ -526,6 +560,36 @@ ConfigurationTransactionStatus profile_service_activate(
return ConfigurationTransactionStatus::kPending;
}
ConfigurationTransactionStatus profile_service_activate_internal(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t profile_index) {
if (!g_prepared) {
profile_service_prepare();
}
critical_section_enter_blocking(&g_lock);
if (g_command.type != PendingCommandType::kNone ||
g_internal_activation.type != PendingCommandType::kNone ||
g_transaction.snapshot.status ==
ConfigurationTransactionStatus::kReceiving ||
g_transaction.snapshot.status ==
ConfigurationTransactionStatus::kPending) {
critical_section_exit(&g_lock);
return ConfigurationTransactionStatus::kBusy;
}
if ((transaction_id & 0x80000000u) == 0 ||
!valid_identity(identity) ||
profile_index >= CONTROLLER_PROFILE_COUNT) {
critical_section_exit(&g_lock);
return ConfigurationTransactionStatus::kMalformed;
}
g_internal_activation.type = PendingCommandType::kActivate;
g_internal_activation.transaction_id = transaction_id;
g_internal_activation.identity = identity;
g_internal_activation.profile_index = profile_index;
critical_section_exit(&g_lock);
return ConfigurationTransactionStatus::kPending;
}
void profile_service_list_snapshot(ProfileServiceListSnapshot* output) {
if (output == nullptr) {
return;

View file

@ -79,6 +79,11 @@ ConfigurationTransactionStatus profile_service_reset(
ConfigurationTransactionStatus profile_service_activate(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t profile_index);
// Queue a controller-originated activation without replacing the host-visible
// transaction snapshot. transaction_id must be nonzero with its high bit set.
ConfigurationTransactionStatus profile_service_activate_internal(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t profile_index);
void profile_service_list_snapshot(ProfileServiceListSnapshot* output);
void profile_service_selected_snapshot(

View file

@ -320,6 +320,14 @@ int main() {
const ControllerProfileTransformResult transformed =
controller_profile_runtime_transform(
instance, snapshot, now_ms, output_mode);
ControllerProfileRuntimeProfileChangeEvent profile_change{};
if (controller_profile_runtime_take_profile_change(
instance, &profile_change)) {
bluepad32_input_backend_queue_profile_feedback(
instance, profile_change.connection_generation,
profile_change.active_profile_number,
profile_change.policy);
}
g_user_states[instance] = transformed.state;
#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY
bool sent = false;

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",