Implement per-controller ABXY toggle

This commit is contained in:
Joey Yakimowich-Payne 2026-08-31 22:17:37 -06:00
commit 94ccfc7220
8 changed files with 247 additions and 15 deletions

View file

@ -79,6 +79,18 @@ The Pico 2 W onboard LED reports the overall Bluetooth state:
- **Pair a new controller**: hold BOOTSEL until the LED double-blinks, then put the controller into its explicit Bluetooth pairing mode.
- **Pairing window expires**: scanning and incoming connections stop; already connected controllers remain connected.
### Per-controller ABXY layout
Each connected AIO controller can toggle its own ABXY layout by pressing **L + R + Select + Start** together. On DualSense, use **L1 + R1 + Create + Options**. The controller gives one short rumble when the toggle is accepted; release the chord before toggling again.
- **Standard**: south→B, east→A, west→Y, north→X.
- **Swapped**: south→A, east→B, west→X, north→Y.
- The chord is consumed locally and is not forwarded to the Switch.
- Other controller slots are unaffected.
- Layout returns to the configured default after disconnect or reboot.
Edit `controller_hotkey_config.h` to change the chord, default layout, or confirmation pulse.
### Per-slot controller colors
Each AIO slot has one color shared by its emulated Switch Pro grips and its physical Bluetooth controller:

View file

@ -1,4 +1,5 @@
#include "bluepad32_input_backend.h"
#include "controller_hotkey_config.h"
#include <limits.h>
#include <stddef.h>
@ -23,6 +24,20 @@ constexpr uint16_t kRumbleDurationMs = 50;
constexpr uint32_t kRumblePollIntervalMs = 5;
constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT;
constexpr uint32_t kPairingWindowDurationMs = 60000;
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;
static_assert(kAbxyHotkeyButtonMask != 0);
static_assert(kAbxyHotkeyMiscMask != 0);
static_assert(kAbxyFeedbackDurationMs > 0);
static_assert(kSlotCount == 4);
static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == kSlotCount);
@ -56,6 +71,11 @@ struct BackendSlot {
uint32_t connection_generation;
bool active;
bool rumble_pending;
bool swap_abxy;
bool hotkey_latched;
bool feedback_pending;
uint32_t feedback_generation;
uint32_t feedback_until_ms;
RumbleEnvelope pending_rumble;
};
@ -243,7 +263,8 @@ bool has_motion(const uni_gamepad_t& gamepad) {
return false;
}
SwitchInputState map_gamepad(const uni_gamepad_t& gamepad) {
SwitchInputState map_gamepad(const uni_gamepad_t& gamepad,
bool swap_abxy) {
SwitchInputState state = make_neutral_state();
state.dpad_up = (gamepad.dpad & DPAD_UP) != 0;
@ -256,6 +277,14 @@ SwitchInputState map_gamepad(const uni_gamepad_t& gamepad) {
state.button_a = (gamepad.buttons & BUTTON_B) != 0;
state.button_y = (gamepad.buttons & BUTTON_X) != 0;
state.button_x = (gamepad.buttons & BUTTON_Y) != 0;
if (swap_abxy) {
bool temporary = state.button_a;
state.button_a = state.button_b;
state.button_b = temporary;
temporary = state.button_x;
state.button_x = state.button_y;
state.button_y = temporary;
}
state.button_l = (gamepad.buttons & BUTTON_SHOULDER_L) != 0;
state.button_r = (gamepad.buttons & BUTTON_SHOULDER_R) != 0;
state.button_zl = (gamepad.buttons & BUTTON_TRIGGER_L) != 0 || gamepad.brake >= kTriggerThreshold;
@ -290,6 +319,36 @@ SwitchInputState map_gamepad(const uni_gamepad_t& gamepad) {
return state;
}
struct LayoutDecision {
bool swap_abxy;
bool suppress_hotkey;
};
LayoutDecision update_layout_hotkey(uint8_t slot_index,
uni_hid_device_t* device,
const uni_gamepad_t& gamepad) {
const bool chord_pressed =
(gamepad.buttons & kAbxyHotkeyButtonMask) ==
kAbxyHotkeyButtonMask &&
(gamepad.misc_buttons & kAbxyHotkeyMiscMask) ==
kAbxyHotkeyMiscMask;
LayoutDecision decision{kDefaultSwapAbxy, false};
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (slot.active && slot.device == device) {
if (chord_pressed && !slot.hotkey_latched) {
slot.swap_abxy = !slot.swap_abxy;
slot.feedback_pending = true;
slot.feedback_generation = slot.connection_generation;
}
slot.hotkey_latched = chord_pressed;
decision = {slot.swap_abxy, chord_pressed};
}
critical_section_exit(&g_state_lock);
return decision;
}
bool pairing_window_active_at(uint32_t now_ms) {
return g_pairing_window_open &&
@ -379,25 +438,48 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
RumbleEnvelope envelope{};
uni_hid_device_t* device = nullptr;
bool 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.rumble_pending) {
if (slot.feedback_pending) {
feedback_dispatch =
slot.active && slot.device != nullptr &&
slot.feedback_generation == slot.connection_generation &&
slot.device->report_parser.play_dual_rumble != nullptr;
slot.feedback_pending = false;
if (feedback_dispatch) {
device = slot.device;
slot.feedback_until_ms =
now_ms + kAbxyFeedbackDurationMs;
}
}
const bool feedback_active =
static_cast<int32_t>(now_ms - slot.feedback_until_ms) < 0;
if (!feedback_dispatch && !feedback_active &&
slot.rumble_pending) {
envelope = slot.pending_rumble;
slot.rumble_pending = false;
dispatch = envelope.slot == slot_index && slot.active &&
slot.device != nullptr &&
envelope.connection_generation ==
slot.connection_generation;
if (dispatch) {
host_dispatch =
envelope.slot == slot_index && slot.active &&
slot.device != nullptr &&
envelope.connection_generation ==
slot.connection_generation;
if (host_dispatch) {
device = slot.device;
}
}
critical_section_exit(&g_state_lock);
if (dispatch &&
device->report_parser.play_dual_rumble != nullptr) {
if (feedback_dispatch) {
device->report_parser.play_dual_rumble(
device, 0, kAbxyFeedbackDurationMs,
kAbxyFeedbackWeakMagnitude,
kAbxyFeedbackStrongMagnitude);
} else if (host_dispatch &&
device->report_parser.play_dual_rumble != nullptr) {
device->report_parser.play_dual_rumble(
device, 0, kRumbleDurationMs,
envelope.rumble.high_frequency_magnitude,
@ -464,6 +546,10 @@ void platform_on_device_connected(uni_hid_device_t* device) {
if (!slot.active && slot.device == nullptr) {
slot.device = device;
slot.rumble_pending = false;
slot.swap_abxy = kDefaultSwapAbxy;
slot.hotkey_latched = false;
slot.feedback_pending = false;
slot.feedback_until_ms = 0;
tracked_connection = true;
} else {
tracked_connection = slot.device == device;
@ -492,6 +578,10 @@ void platform_on_device_disconnected(uni_hid_device_t* device) {
slot.device = nullptr;
slot.active = false;
slot.rumble_pending = false;
slot.swap_abxy = kDefaultSwapAbxy;
slot.hotkey_latched = false;
slot.feedback_pending = false;
slot.feedback_until_ms = 0;
++slot.connection_generation;
disconnected_tracked_device = true;
}
@ -523,6 +613,10 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
slot.state = make_neutral_state();
slot.active = true;
slot.rumble_pending = false;
slot.swap_abxy = kDefaultSwapAbxy;
slot.hotkey_latched = false;
slot.feedback_pending = false;
slot.feedback_until_ms = 0;
++slot.state_generation;
became_active = true;
}
@ -541,14 +635,23 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
return UNI_ERROR_SUCCESS;
}
void platform_on_controller_data(uni_hid_device_t* device, uni_controller_t* controller) {
void platform_on_controller_data(uni_hid_device_t* device,
uni_controller_t* controller) {
const int slot_index = slot_for_device(device);
if (slot_index < 0 || controller == nullptr ||
controller->klass != UNI_CONTROLLER_CLASS_GAMEPAD) {
return;
}
uni_gamepad_t gamepad = controller->gamepad;
const LayoutDecision layout = update_layout_hotkey(
static_cast<uint8_t>(slot_index), device, gamepad);
if (layout.suppress_hotkey) {
gamepad.buttons &= ~kAbxyHotkeyButtonMask;
gamepad.misc_buttons &= ~kAbxyHotkeyMiscMask;
}
publish_device_state(static_cast<uint8_t>(slot_index), device,
map_gamepad(controller->gamepad));
map_gamepad(gamepad, layout.swap_abxy));
}
const uni_property_t* platform_get_property(uni_property_idx_t index) {
@ -621,6 +724,7 @@ void bluepad32_input_backend_init() {
slot = {};
slot.state = make_neutral_state();
slot.pending_rumble.slot = slot_index;
slot.swap_abxy = kDefaultSwapAbxy;
g_consumed_generation[slot_index] = 0;
g_last_snapshot_generation[slot_index] = 0;
}

View file

@ -0,0 +1,17 @@
// Compile-time AIO controller hotkey configuration.
#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)
// 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 0x80
#define SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE 0x80

Binary file not shown.

Binary file not shown.

View file

@ -702,6 +702,102 @@ void test_slot_lighting() {
"controller without RGB support did not receive its slot LED");
}
void require_south_button_mapping(const SwitchInputState& state,
bool swapped,
const char* message) {
require(state.button_a == swapped && state.button_b == !swapped &&
!state.button_x && !state.button_y,
message);
}
void test_abxy_hotkey() {
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_controller_t input{};
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
input.gamepad.buttons = BUTTON_A;
platform_on_controller_data(&slot_zero, &input);
SwitchInputState snapshot{};
require(bluepad32_input_backend_snapshot(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(bluepad32_input_backend_snapshot(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_l && !snapshot.button_r &&
!snapshot.button_minus && !snapshot.button_plus,
"hotkey chord leaked into the Switch report");
bluepad32_input_backend_queue_rumble(
0, SwitchRumbleOutput{0x11, 0x22});
process_rumble_timer(&g_rumble_timer);
require(slot_zero.rumble_calls == 1 &&
slot_zero.last_high == kAbxyFeedbackWeakMagnitude &&
slot_zero.last_low == kAbxyFeedbackStrongMagnitude &&
g_slots[0].rumble_pending,
"ABXY confirmation did not take priority over host rumble");
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(bluepad32_input_backend_snapshot(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].hotkey_latched &&
!g_slots[0].feedback_pending,
"disconnect did not reset slot 0 hotkey state");
}
void test_flash_core_start_contract() {
bluepad32_input_backend_init();
flash_core_init_result = false;
@ -753,6 +849,8 @@ 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 == "flash-core-start") {
test_flash_core_start_contract();
} else if (scenario == "flash-core-failure") {

View file

@ -54,9 +54,9 @@ enum {
BUTTON_TRIGGER_R = 1 << 7,
BUTTON_THUMB_L = 1 << 8,
BUTTON_THUMB_R = 1 << 9,
MISC_BUTTON_SELECT = 1 << 0,
MISC_BUTTON_START = 1 << 1,
MISC_BUTTON_SYSTEM = 1 << 2,
MISC_BUTTON_SYSTEM = 1 << 0,
MISC_BUTTON_SELECT = 1 << 1,
MISC_BUTTON_START = 1 << 2,
MISC_BUTTON_CAPTURE = 1 << 3,
};

View file

@ -37,6 +37,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"lifecycle",
"pairing-policy",
"slot-lighting",
"abxy-hotkey",
"flash-core-start",
"flash-core-failure",
):