Verify and publish dual-controller AIO firmware

This commit is contained in:
Joey Yakimowich-Payne 2026-08-30 22:40:03 -06:00
commit 18c27b06ae
5 changed files with 297 additions and 48 deletions

View file

@ -1,12 +1,12 @@
# Switch Pico Controller Bridge
Raspberry Pi Pico firmware that emulates a Switch Pro controller over USB. Input can come from the SDL3-to-UART computer bridge or, on Pico 2 W, directly from a Bluetooth controller through Bluepad32.
Raspberry Pi Pico firmware that emulates one or more Switch Pro controllers over USB. Input can come from the SDL3-to-UART computer bridge or, on Pico 2 W, directly from Bluetooth controllers through Bluepad32.
## What you get
- **Firmware** (`switch-pico.cpp` + `switch_pro_driver.*`): acts as a wired Switch Pro, accepting either UART bridge reports or the optional Pico 2 W Bluepad32 backend.
- **Firmware** (`switch-pico.cpp` + `switch_pro_driver.*`): acts as a Switch Pro controller (one on standard Pico, two on Pico 2 W AIO), accepting either UART bridge reports or the optional Pico 2 W Bluepad32 backend.
- **Python bridge** (`switch_pico_bridge.controller_uart_bridge` / CLI `controller-uart-bridge`): reads SDL3 controllers on the host, sends reports over UART, and applies rumble locally. Hot‑plug friendly and cross‑platform (macOS/Windows/Linux).
- **Colour override** (`controller_color_config.h`): compile‑time RGB overrides for body/buttons/grips as seen by the Switch.
- **Pico 2 W AIO firmware** (`firmware/switch-pico-aio.uf2`): hosts one Bluetooth controller and sends its controls, calibrated motion, and rumble through the same Switch Pro USB device without a computer.
- **Pico 2 W AIO firmware** (`firmware/switch-pico-aio.uf2`): hosts two concurrent Bluetooth controllers and sends their controls, calibrated motion, and rumble through two separate Switch Pro USB interfaces without a computer.
## Quick start
1. Flash the Pico with `firmware/switch-pico.uf2` (or build your own) using BOOTSEL drag-and-drop (see “Manual UF2 flashing” below).
@ -17,7 +17,11 @@ Raspberry Pi Pico firmware that emulates a Switch Pro controller over USB. Input
## Pico 2 W all-in-one Bluetooth option
The AIO build runs TinyUSB and Switch report generation on Core 0 while Bluepad32, BTstack, and the CYW43439 radio run on Core 1. A fixed state snapshot and bounded rumble queue are the only cross-core interfaces.
### Architecture
The AIO build accepts two concurrent Bluetooth controllers on a single Pico 2 W. The device runs TinyUSB and Switch report generation on Core 0, while Bluepad32, BTstack, and the CYW43439 radio run on Core 1. Core 0 maintains two USB Pro HID interfaces (slots 0 and 1), and each Bluetooth connection is isolated in the Bluepad32 slot assigned when that connection becomes active. A fixed state snapshot and per-slot bounded rumble queue are the only cross-core synchronization points.
Both USB interfaces are always present to the Switch. The Switch enumerates them as two separate Pro Controllers on the same physical device. Inputs and rumble are independent per controller.
### Build and flash
@ -42,21 +46,40 @@ The default `python3 build.py` command and `firmware/switch-pico.*` artifacts re
Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad32-sdl3-imu.patch` idempotently before compiling Bluepad32. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. It intentionally leaves the dependency worktree dirty; the committed submodule revision remains Bluepad32 4.2.0.
### Pair a controller
### Pairing two controllers
1. Flash and connect the Pico 2 W to the Switch.
2. Enable `System Settings → Controllers and Sensors → Pro Controller Wired Communication`.
3. Put one controller into Bluetooth pairing mode:
3. Put the first controller into Bluetooth pairing mode:
- DualSense: hold Create + PS.
- DualShock 4: hold Share + PS.
- Switch Pro: press its sync button.
- Xbox Bluetooth controller: hold its pair button.
- 8BitDo: use a Bluetooth mode supported by Bluepad32; use Switch/S mode when motion is required.
4. Wait for the controller to connect. Pairing keys persist across Pico reboots.
4. Wait for the first controller to connect and become ready. The LED continues slow-blinking because the second slot remains open. Pairing keys persist across Pico reboots.
5. Put the second controller into pairing mode and wait for it to connect and become ready. The LED turns solid only after both controllers are active.
The Pico 2 W onboard LED reports Bluetooth state: a slow 0.5-second blink means scanning, a fast 0.1-second blink means a controller connected but is not ready, and solid means the controller is ready. A solid LED immediately after boot that never starts blinking indicates Bluepad32 initialization did not complete.
During initial setup, pairing order determines the initial slot assignment: the first controller paired occupies slot 0, and the second occupies slot 1. Pairing keys persist, so both controllers can reconnect after a Pico reboot without re-pairing. Slot numbers are not permanently bound to physical controllers: while one controller remains connected, a returning controller fills the other open slot; after a reboot or whenever both slots are empty, whichever persisted controller reconnects first receives slot 0, so the physical controllers can swap USB interfaces.
Only one wireless controller owns the emulated Pro Controller. Turn off or disconnect it before pairing another; scanning resumes automatically after disconnect. A disconnect immediately publishes neutral buttons, sticks, and motion.
### LED meanings and device state
The Pico 2 W onboard LED reports the overall Bluetooth state:
- **Slow blink (0.5 s period)**: at least one slot is open and scanning for a Bluetooth controller.
- **Fast blink (0.1 s period)**: at least one controller is connected but not yet ready (handshake in progress).
- **Solid**: both slots are filled and both controllers are ready for input.
- **Solid immediately after boot that never starts blinking**: Bluepad32 initialization did not complete; check firmware flashing and UART logs.
The LED transitions to slow blink as soon as any slot becomes empty (e.g., a controller is turned off or unpaired). Scanning resumes automatically.
### Managing controller disconnect and reconnect
Controllers can disconnect and reconnect independently:
- **Disconnect one controller**: that controller's slot becomes empty. The LED transitions to slow blink if both slots are no longer filled. The other controller continues sending input.
- **Reconnect while the other controller remains connected**: the returning controller fills the only open slot, preserving the current assignment. The LED transitions through fast blink and back to solid.
- **Reconnect after both slots become empty or after reboot**: reconnection/autoconnect order determines the assignments. The physical controllers can swap USB interfaces if their order changes.
- **Turn off or unpair a controller**: delete it from Bluetooth settings on the Pico or reset pairing entirely using Bluepad32 commands. It will no longer auto-reconnect; the slot remains open for a new controller.
When a controller disconnects, the Pico immediately publishes neutral buttons, sticks, and motion for that slot. The other controller is unaffected.
### Controller capabilities
@ -69,6 +92,26 @@ Only one wireless controller owns the emulated Pro Controller. Turn off or disco
Motion is normalized to 1024 units per degree/second and 8192 units per g in SDL3 axes, then converted to Nintendo axes and raw counts. The latest normalized sample is duplicated across the report's three nominal 5 ms slots; it remains pending until a regular `0x30` USB report successfully consumes it.
### Rumble per controller
Rumble effects are per-slot and independent. The Switch sends rumble commands to a specific USB interface, and the Pico routes each command to the Bluetooth controller in the matching slot. Each slot has a critical-section-protected latest-value mailbox tagged with its connection generation; a newer pending command replaces the older one, and disconnect invalidates commands from the prior controller.
### Hardware validation
The dual-interface AIO build has been verified on a real Switch with two DualSense controllers: the Switch assigned two controller slots; buttons, sticks, calibrated motion, and rumble remained independent; disconnecting either controller left the other working; scanning resumed and the disconnected controller reconnected to the open slot.
To reproduce the validation:
1. **Verify USB enumeration**: Connect the Pico 2 W to a USB host (PC, Mac, or USB analyzer). Confirm that two HID devices are present (e.g., `lsusb -v` on Linux shows interface 0 and interface 1, both with Product ID 0x2009).
2. **Verify Bluetooth pairing**: Pair two controllers via Bluepad32. Confirm the LED transitions from scanning → fast blink → solid.
3. **Verify input on one controller**: Move sticks, press buttons, and check that the controller paired first during initial setup appears in slot 0.
4. **Verify input on two controllers**: Move sticks on the controller paired second during initial setup, and confirm its inputs appear in slot 1 while the first controller is unaffected.
5. **Verify disconnect and reconnect**: Turn off one controller while leaving the other connected. The LED reverts to slow blink. Turn the disconnected controller back on; it reconnects to the only open slot. Verify the occupied slot continues reporting the other controller's input.
6. **Verify rumble per slot**: Send rumble to interface 0 and confirm only the slot 0 controller vibrates. Send rumble to interface 1 and confirm only the slot 1 controller vibrates.
7. **Verify motion**: Enable gyro/accel on both controllers. Rotate each controller independently and confirm that motion is per-slot (rotating controller 0 does not affect controller 1's IMU output).
On the tested Linux host, both HID interfaces enumerated (`lsusb -t` showed interface 0 and 1), but `hid-nintendo` probes timed out (`-110`) while requesting controller information from this composite device and removed their transient hidraw nodes. This is an observed, undiagnosed composite interoperability limitation; its root cause has not been established. The timeout was not observed on the Switch, so successful `hid-nintendo` binding is not the release criterion for dual-interface AIO firmware.
Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry Pi's BTstack license.
## Planned features

View file

@ -39,6 +39,7 @@ struct RumbleEnvelope {
struct BackendSlot {
SwitchInputState state;
// Non-null with active=false is a connected device still becoming ready.
uni_hid_device_t* device;
uint32_t state_generation;
uint32_t connection_generation;
@ -83,14 +84,22 @@ int slot_for_device(const uni_hid_device_t* device) {
return slot >= 0 && slot < kSlotCount ? slot : -1;
}
bool all_slots_ready() {
ConnectionStatus compute_connection_status() {
critical_section_enter_blocking(&g_state_lock);
bool ready = true;
bool all_ready = true;
bool any_connecting = false;
for (const BackendSlot& slot : g_slots) {
ready = ready && slot.active && slot.device != nullptr;
const bool has_device = slot.device != nullptr;
all_ready = all_ready && slot.active && has_device;
any_connecting = any_connecting || (!slot.active && has_device);
}
critical_section_exit(&g_state_lock);
return ready;
if (all_ready) {
return ConnectionStatus::Ready;
}
return any_connecting ? ConnectionStatus::Connecting
: ConnectionStatus::Scanning;
}
void publish_device_state(uint8_t slot, uni_hid_device_t* device,
@ -115,6 +124,8 @@ void publish_all_neutral() {
++slot.connection_generation;
}
critical_section_exit(&g_state_lock);
g_connection_status = ConnectionStatus::Initializing;
g_status_led_tick = 0;
}
constexpr int32_t clamp_axis(int32_t value) {
@ -287,6 +298,16 @@ void resume_connections() {
uni_bt_allow_incoming_connections(true);
uni_bt_start_scanning_and_autoconnect_unsafe();
}
void recompute_connection_status() {
g_connection_status = compute_connection_status();
g_status_led_tick = 0;
if (g_connection_status == ConnectionStatus::Ready) {
uni_bt_stop_scanning_unsafe();
uni_bt_allow_incoming_connections(false);
} else {
resume_connections();
}
}
void platform_init(int argc, const char** argv) {
(void)argc;
@ -297,9 +318,7 @@ void platform_on_init_complete() {
btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer);
btstack_run_loop_set_timer(&g_rumble_timer, kRumblePollIntervalMs);
btstack_run_loop_add_timer(&g_rumble_timer);
g_connection_status = ConnectionStatus::Scanning;
g_status_led_tick = 0;
resume_connections();
recompute_connection_status();
}
uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, uint16_t cod, uint8_t rssi) {
@ -307,14 +326,31 @@ uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, uint
(void)name;
(void)cod;
(void)rssi;
return all_slots_ready() ? UNI_ERROR_IGNORE_DEVICE : UNI_ERROR_SUCCESS;
return compute_connection_status() == ConnectionStatus::Ready
? UNI_ERROR_IGNORE_DEVICE
: UNI_ERROR_SUCCESS;
}
void platform_on_device_connected(uni_hid_device_t* device) {
(void)device;
if (!all_slots_ready()) {
g_connection_status = ConnectionStatus::Connecting;
g_status_led_tick = 0;
const int slot_index = slot_for_device(device);
if (slot_index < 0) {
return;
}
bool tracked_connection = false;
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (!slot.active && slot.device == nullptr) {
slot.device = device;
slot.rumble_pending = false;
tracked_connection = true;
} else {
tracked_connection = slot.device == device;
}
critical_section_exit(&g_state_lock);
if (tracked_connection) {
recompute_connection_status();
}
}
@ -324,24 +360,24 @@ void platform_on_device_disconnected(uni_hid_device_t* device) {
return;
}
bool disconnected_active_slot = false;
bool disconnected_tracked_device = false;
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
if (slot.active && slot.device == device) {
slot.state = make_neutral_state();
if (slot.device == device) {
if (slot.active) {
slot.state = make_neutral_state();
++slot.state_generation;
}
slot.device = nullptr;
slot.active = false;
slot.rumble_pending = false;
++slot.state_generation;
++slot.connection_generation;
disconnected_active_slot = true;
disconnected_tracked_device = true;
}
critical_section_exit(&g_state_lock);
if (disconnected_active_slot) {
g_connection_status = ConnectionStatus::Scanning;
g_status_led_tick = 0;
resume_connections();
if (disconnected_tracked_device) {
recompute_connection_status();
}
}
@ -358,13 +394,15 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
bool occupied_mismatch = false;
critical_section_enter_blocking(&g_state_lock);
BackendSlot& slot = g_slots[slot_index];
occupied_mismatch = slot.active && slot.device != device;
if (!occupied_mismatch && !slot.active) {
slot.state = make_neutral_state();
occupied_mismatch = slot.device != nullptr && slot.device != device;
if (!occupied_mismatch) {
slot.device = device;
slot.active = true;
slot.rumble_pending = false;
++slot.state_generation;
if (!slot.active) {
slot.state = make_neutral_state();
slot.active = true;
slot.rumble_pending = false;
++slot.state_generation;
}
}
critical_section_exit(&g_state_lock);
@ -372,15 +410,7 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
return UNI_ERROR_NO_SLOTS;
}
g_status_led_tick = 0;
if (all_slots_ready()) {
g_connection_status = ConnectionStatus::Ready;
uni_bt_stop_scanning_unsafe();
uni_bt_allow_incoming_connections(false);
} else {
g_connection_status = ConnectionStatus::Scanning;
resume_connections();
}
recompute_connection_status();
return UNI_ERROR_SUCCESS;
}

Binary file not shown.

Binary file not shown.

View file

@ -10,7 +10,10 @@ namespace {
bool incoming_connections = false;
int scan_starts = 0;
int scan_stops = 0;
bool scanning_enabled = false;
uni_platform* installed_platform = nullptr;
bool observed_status_led_on = false;
int observed_status_led_writes = 0;
void require(bool condition, const char* message) {
if (!condition) {
@ -50,10 +53,12 @@ void uni_bt_allow_incoming_connections(bool enabled) {
void uni_bt_start_scanning_and_autoconnect_unsafe() {
++scan_starts;
scanning_enabled = true;
}
void uni_bt_stop_scanning_unsafe() {
++scan_stops;
scanning_enabled = false;
}
void uni_platform_set_custom(uni_platform* platform) {
@ -68,7 +73,11 @@ int cyw43_arch_init() {
return 0;
}
void cyw43_arch_gpio_put(int, bool) {}
void cyw43_arch_gpio_put(int, bool enabled) {
observed_status_led_on = enabled;
++observed_status_led_writes;
}
void multicore_launch_core1(void (*)()) {}
#include "../bluepad32_input_backend.cpp"
@ -82,10 +91,33 @@ void start_backend() {
require(scan_starts == 1, "initialization must start scanning");
}
void tick_backend_timer(int ticks) {
for (int tick = 0; tick < ticks; ++tick) {
process_rumble_timer(&g_rumble_timer);
}
}
void test_ready_order(int first_slot) {
start_backend();
uni_hid_device_t devices[2] = {device(0), device(1)};
const int second_slot = 1 - first_slot;
tick_backend_timer(99);
require(observed_status_led_on,
"scanning LED must stay on for the first slow-blink half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"scanning LED must turn off at the slow-blink half-cycle");
platform_on_device_connected(&devices[first_slot]);
tick_backend_timer(19);
require(observed_status_led_on,
"connecting LED must stay on for the first fast-blink half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"connecting LED must turn off at the fast-blink half-cycle");
tick_backend_timer(20);
require(observed_status_led_on,
"connecting LED must turn on for the next fast-blink cycle");
require(platform_on_device_ready(&devices[first_slot]) ==
UNI_ERROR_SUCCESS,
@ -94,6 +126,12 @@ void test_ready_order(int first_slot) {
"scanning must continue while one slot remains free");
require(incoming_connections,
"incoming connections must remain enabled with one ready slot");
tick_backend_timer(99);
require(observed_status_led_on,
"one ready slot must leave the LED in the slow scanning cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"one open slot must produce the scanning LED off transition");
SwitchInputState first{};
SwitchInputState second{};
@ -109,6 +147,14 @@ void test_ready_order(int first_slot) {
"scanning must stop exactly when both slots are ready");
require(!incoming_connections,
"incoming connections must be disabled only when full");
tick_backend_timer(1);
require(observed_status_led_on,
"both ready slots must turn the status LED on");
const int ready_led_writes = observed_status_led_writes;
tick_backend_timer(200);
require(observed_status_led_on &&
observed_status_led_writes == ready_led_writes,
"both ready slots must keep the status LED solid");
bd_addr_t address{};
require(platform_on_device_discovered(address, "extra", 0, 0) ==
@ -146,12 +192,76 @@ void test_rejections() {
void test_independent_lifecycle() {
start_backend();
uni_hid_device_t aborted = device(0);
const uint32_t aborted_generation = g_slots[0].connection_generation;
platform_on_device_connected(&aborted);
require(g_slots[0].device == &aborted && !g_slots[0].active,
"connected device must remain identifiable while becoming ready");
tick_backend_timer(19);
require(observed_status_led_on,
"a lone pending connection must use the fast LED on half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"a lone pending connection must use the fast LED off half-cycle");
const int starts_before_aborted_disconnect = scan_starts;
platform_on_device_disconnected(&aborted);
require(g_slots[0].device == nullptr && !g_slots[0].active,
"pre-ready disconnect must clear its pending slot identity");
require(g_slots[0].connection_generation == aborted_generation + 1,
"pre-ready disconnect must invalidate its connection generation");
require(g_connection_status == ConnectionStatus::Scanning &&
scanning_enabled && incoming_connections &&
scan_starts == starts_before_aborted_disconnect + 1,
"pre-ready disconnect with no peer must resume scanning");
tick_backend_timer(99);
require(observed_status_led_on,
"pre-ready disconnect must restore the slow LED on half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"pre-ready disconnect must restore the slow LED off half-cycle");
uni_hid_device_t first = device(0);
uni_hid_device_t survivor = device(1);
platform_on_device_connected(&first);
platform_on_device_connected(&survivor);
require(g_slots[0].device == &first && !g_slots[0].active &&
g_slots[1].device == &survivor && !g_slots[1].active,
"concurrent pending devices must retain independent identities");
tick_backend_timer(19);
require(observed_status_led_on,
"concurrent pending devices must use the fast LED on half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"concurrent pending devices must use the fast LED off half-cycle");
const uint32_t first_pending_generation =
g_slots[0].connection_generation;
const int starts_before_first_pending_disconnect = scan_starts;
platform_on_device_disconnected(&first);
require(g_slots[0].device == nullptr && !g_slots[0].active &&
g_slots[1].device == &survivor && !g_slots[1].active,
"pre-ready disconnect must preserve the other pending identity");
require(g_slots[0].connection_generation ==
first_pending_generation + 1,
"pending disconnect beside a peer must invalidate its generation");
require(g_connection_status == ConnectionStatus::Connecting &&
scanning_enabled && incoming_connections &&
scan_starts == starts_before_first_pending_disconnect + 1,
"open slot must scan while another slot remains connecting");
tick_backend_timer(19);
require(observed_status_led_on,
"surviving pending device must retain the fast LED on half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"surviving pending device must retain the fast LED off half-cycle");
require(platform_on_device_ready(&survivor) == UNI_ERROR_SUCCESS,
"slot 1 must be accepted before slot 0");
"surviving pending device must still become ready");
platform_on_device_connected(&first);
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS,
"slot 0 must complete the pair");
"reconnected slot 0 device must complete the pair");
uni_controller_t data0{};
data0.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
@ -239,6 +349,72 @@ void test_independent_lifecycle() {
require(survivor.rumble_calls == 2 && survivor.last_low == 92 &&
survivor.last_high == 93,
"survivor rumble must continue after peer replacement");
const int starts_before_slot_one_disconnect = scan_starts;
platform_on_device_disconnected(&survivor);
require(scan_starts == starts_before_slot_one_disconnect + 1 &&
incoming_connections,
"slot 1 disconnect must resume scanning for its open slot");
require(bluepad32_input_backend_snapshot(0, &state0) && state0.button_x,
"slot 1 disconnect must preserve slot 0 state and activity");
require(!bluepad32_input_backend_snapshot(1, &state1) &&
!state1.button_b && state1.lx == 32768,
"slot 1 disconnect must neutralize only slot 1");
uni_controller_t continuing_slot_zero_data{};
continuing_slot_zero_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
continuing_slot_zero_data.gamepad.buttons = BUTTON_B;
platform_on_controller_data(&replacement, &continuing_slot_zero_data);
require(bluepad32_input_backend_snapshot(0, &state0) && state0.button_a,
"slot 0 input must continue while slot 1 is disconnected");
const int slot_zero_calls_while_scanning = replacement.rumble_calls;
bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{115, 116});
tick_backend_timer(99);
require(replacement.rumble_calls == slot_zero_calls_while_scanning + 1 &&
replacement.last_low == 115 && replacement.last_high == 116,
"slot 0 rumble must continue while slot 1 is disconnected");
require(observed_status_led_on,
"disconnect scanning must use the slow LED on half-cycle");
tick_backend_timer(1);
require(!observed_status_led_on,
"disconnect scanning must reach the slow LED off half-cycle");
uni_hid_device_t first_slot_one_replacement = device(1);
require(platform_on_device_ready(&first_slot_one_replacement) ==
UNI_ERROR_SUCCESS,
"slot 1 replacement must bind without disturbing slot 0");
tick_backend_timer(1);
require(observed_status_led_on,
"replacing the open slot must return the LED to solid ready");
const int replacement_ready_led_writes = observed_status_led_writes;
tick_backend_timer(100);
require(observed_status_led_on &&
observed_status_led_writes == replacement_ready_led_writes,
"replacement pair must keep the ready LED solid");
bluepad32_input_backend_queue_rumble(1, SwitchRumbleOutput{117, 118});
platform_on_device_disconnected(&first_slot_one_replacement);
uni_hid_device_t second_slot_one_replacement = device(1);
require(platform_on_device_ready(&second_slot_one_replacement) ==
UNI_ERROR_SUCCESS,
"a subsequent slot 1 replacement must bind to the freed slot");
process_rumble_timer(&g_rumble_timer);
require(second_slot_one_replacement.rumble_calls == 0,
"slot 1 replacement must not receive prior-generation rumble");
const int slot_zero_calls_before_mailboxes = replacement.rumble_calls;
bluepad32_input_backend_queue_rumble(1, SwitchRumbleOutput{119, 120});
bluepad32_input_backend_queue_rumble(1, SwitchRumbleOutput{121, 122});
bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{123, 124});
process_rumble_timer(&g_rumble_timer);
require(second_slot_one_replacement.rumble_calls == 1 &&
second_slot_one_replacement.last_low == 121 &&
second_slot_one_replacement.last_high == 122,
"slot 1 mailbox must dispatch only its latest queued value");
require(replacement.rumble_calls == slot_zero_calls_before_mailboxes + 1 &&
replacement.last_low == 123 && replacement.last_high == 124,
"slot 0 activity must not evict the slot 1 mailbox");
}
} // namespace