fix: retain the lower player slot when joining Joy-Cons

This commit is contained in:
Joey Yakimowich-Payne 2026-09-07 18:07:31 -06:00
commit b0a896997b
9 changed files with 61 additions and 1 deletions

View file

@ -678,6 +678,13 @@ all chord buttons must release before retry. Confirmation is bounded to the
participating halves. Disconnect/reuse or a changed saved default clears the
override, without writing a per-pair preference.
Explicit gesture joins retain the lower participating player slot rather than
always retaining the left half's slot. A regression reproduced the old player-2
result with the left half ready first but occupying the higher transport/player
index. Both ready orders and an earlier unrelated player are covered. Hardware
with right on player 1 and left on player 2 now joins on player 1; all profile
banks remain unchanged.
All 349 tests and all five builds passed. Real hardware exercised both live
mode transitions, Individual boot persistence, physical join/split pulses,
unchanged configuration generations during gestures, and automatic default

View file

@ -237,6 +237,9 @@ uv run switch-pico-config joycon-mode paired --json
pulse, then **release all four buttons**.
Two solo halves join; an existing pair splits. Other controllers are untouched.
An explicit join keeps the lower of the two participating player slots, not
whichever slot belongs to the left half. Both LEDs follow that retained slot;
the other slot is neutralized. It never takes a slot from an unrelated player.
The shortcut does not change the saved default: its connection-only override
ends when either participating half disconnects, or when the adapter default
changes. Existing solo/pair banks and active selections are reused.

Binary file not shown.

Binary file not shown.

View file

@ -2665,8 +2665,14 @@ void process_joycon_gestures(uint32_t now_ms) {
if (!mature) continue;
// Latch success AND failure before any enrollment I/O. A failed seed
// must not retry at the timer cadence or undo either participant.
// Player-slot ownership is independent of physical handedness.
// Keep the lower occupied slot, even when the left half owns the higher one.
const bool keep_left_slot = owner_slot < right_slot;
const bool changed = joining
? merge_joycon_slots(owner_slot, right_slot, right, true)
? merge_joycon_slots(
keep_left_slot ? owner_slot : right_slot,
keep_left_slot ? right_slot : owner_slot,
keep_left_slot ? right : gesture.device, true)
: split_joycon_slot(static_cast<uint8_t>(owner_slot), true);
if (changed) recompute_connection_status();
}

View file

@ -1679,6 +1679,41 @@ void test_switch2_gesture_timing() {
"connection gestures must never write the saved adapter preference");
}
void test_switch2_gesture_slot_order(bool left_first, uint8_t lower_slot) {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
start_backend();
auto ordinary = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto left = switch2_device(lower_slot + 1, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(lower_slot, UNI_SW2_JOYCON_R_PID);
if (lower_slot != 0) ready_switch2(ordinary);
// Transport indices can put the left half in the higher player slot,
// even when its ready callback arrives first.
ready_switch2(left_first ? left : right);
ready_switch2(left_first ? right : left);
const auto lower_before = slot_snapshot(lower_slot);
const auto higher_before = slot_snapshot(lower_slot + 1);
const auto unrelated_before = slot_snapshot(0);
hold_gesture(left, right, 2000);
require(live_pair_slot(left, right) == lower_slot &&
!slot_snapshot(lower_slot + 1).active,
"gesture join must retain the lower participating player slot regardless of side or ready order");
const auto joined = slot_snapshot(lower_slot);
require_pair_owner(joined.identity, left, right);
require(joined.connection_generation != lower_before.connection_generation &&
slot_snapshot(lower_slot + 1).connection_generation != higher_before.connection_generation &&
slot_snapshot(lower_slot + 1).state.left_stick_x == 0 &&
slot_snapshot(lower_slot + 1).state.right_stick_x == 0 &&
left.player_leds == (1u << lower_slot) &&
right.player_leds == (1u << lower_slot),
"join must invalidate old player epochs, neutralize the retired slot and light both halves for the retained player");
if (lower_slot != 0) {
require(slot_snapshot(0).active &&
slot_snapshot(0).connection_generation == unrelated_before.connection_generation &&
controller_identity_equal(slot_snapshot(0).identity, unrelated_before.identity),
"gesture join must not take an earlier slot occupied by an unrelated controller");
}
}
void test_switch2_gesture_stale() {
runtime_configuration.joycon_mode = JoyConMode::kIndividual;
start_backend();
@ -5377,6 +5412,12 @@ int main(int argc, char** argv) {
test_switch2_hd_pro();
} else if (scenario == "switch2-gesture-timing") {
test_switch2_gesture_timing();
} else if (scenario == "switch2-gesture-slot-left-first") {
test_switch2_gesture_slot_order(true, 0);
} else if (scenario == "switch2-gesture-slot-right-first") {
test_switch2_gesture_slot_order(false, 0);
} else if (scenario == "switch2-gesture-slot-occupied") {
test_switch2_gesture_slot_order(true, 1);
} else if (scenario == "switch2-gesture-stale") {
test_switch2_gesture_stale();
} else if (scenario == "switch2-gesture-clock-wrap") {

View file

@ -108,6 +108,9 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"switch2-mode-seed-failure",
"switch2-mode-identity-failure",
"switch2-gesture-timing",
"switch2-gesture-slot-left-first",
"switch2-gesture-slot-right-first",
"switch2-gesture-slot-occupied",
"switch2-gesture-stale",
"switch2-gesture-clock-wrap",
"switch2-gesture-masking-epochs",