feat(native-usb): route gameplay rumble and attach only after startup readiness

This commit is contained in:
Joey Yakimowich-Payne 2026-09-19 08:20:47 -06:00
commit ae1ca51b9a
18 changed files with 1158 additions and 43 deletions

View file

@ -1,8 +1,29 @@
// Reuse the backend's transport/storage fixture; these scenarios exercise only
// the native gamepad contract, not a second implementation of its scheduler.
#include <uni.h>
extern "C" bool uni_hid_parser_wii_rumble_ready(uni_hid_device_t*);
#define profile_service_active_profile_snapshot fixture_active_profile_snapshot
#define main backend_fixture_main
#include "bluepad32_backend_lifecycle_test.cpp"
#undef main
#undef profile_service_active_profile_snapshot
namespace {
void (*during_profile_resolution)() = nullptr;
bool native_wii_ready = true;
}
void profile_service_active_profile_snapshot(
const ControllerIdentity& identity, ProfileServiceActiveProfileSnapshot* output) {
require(state_lock_depth == 0, "profile service callbacks must not hold the backend lock");
if (during_profile_resolution) during_profile_resolution();
fixture_active_profile_snapshot(identity, output);
}
extern "C" bool uni_hid_parser_wii_rumble_ready(uni_hid_device_t*) {
require(state_lock_depth == 0, "Wii readiness must not hold the backend lock");
return native_wii_ready;
}
namespace {
struct SensorFixture {
@ -600,6 +621,316 @@ void mono_rumble() {
"retired mono work must never enter a replacement connection");
}
void gameplay_timeline() {
start_pairing_backend();
auto pad = dualsense(0);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "gameplay source must connect");
uint8_t right[] = {40, 80, 120};
const uint8_t left[] = {60, 180};
const uint8_t stop = 0;
require(bluepad32_input_backend_native_rumble_submit(0, right, 3) &&
bluepad32_input_backend_native_rumble_submit(1, left, 2),
"mixed sample counts must be accepted independently");
right[0] = 255;
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 40 && pad.last_low == 60 && pad.last_rumble_duration_ms == 4,
"copied right/weak and left/strong samples share the earliest finite boundary");
now_ms = 5;
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 80 && pad.last_low == 60 && pad.last_rumble_duration_ms == 1,
"5 ms polling skips elapsed time rather than replaying the first sample");
now_ms = 10;
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 120 && pad.last_low == 180 && pad.last_rumble_duration_ms == 40,
"last samples hold only to their original receipt watchdog");
require(bluepad32_input_backend_native_rumble_submit(0, &stop, 1),
"explicit zero magnitude must be a valid side stop");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 0 && pad.last_low == 180 && pad.last_rumble_duration_ms == 40,
"right stop must preserve the left contribution and deadline");
now_ms = 49;
const int calls = pad.rumble_calls;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == calls, "unchanged finite holds must not produce duplicate writes");
now_ms = 50;
process_rumble_timer(&g_rumble_timer);
require(pad.last_rumble_duration_ms == 0, "watchdog expiry must stop without further host packets");
now_ms = 500;
process_rumble_timer(&g_rumble_timer);
const int stopped = pad.rumble_calls;
const uint8_t delayed[] = {21, 42, 84};
require(bluepad32_input_backend_native_rumble_submit(0, delayed, 3), "delayed block must queue");
now_ms = 520;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == stopped + 1 && pad.last_high == 84 &&
pad.last_rumble_duration_ms == 30,
"a stalled timer dispatches only the current sample with its remaining lifetime");
bluepad32_input_backend_native_rumble_cancel(0);
process_rumble_timer(&g_rumble_timer);
require(pad.last_rumble_duration_ms == 0, "explicit cancellation must stop its live hold");
now_ms = UINT32_MAX - 9u;
const uint8_t pulse = 99;
require(bluepad32_input_backend_native_rumble_submit(1, &pulse, 1), "pre-wrap request must queue");
process_rumble_timer(&g_rumble_timer);
require(pad.last_low == 99 && pad.last_rumble_duration_ms == 50,
"finite host lifetime must remain valid before clock wrap");
now_ms = 39;
process_rumble_timer(&g_rumble_timer);
require(pad.last_low == 99, "wrap must not cause early watchdog expiry");
now_ms = 40;
process_rumble_timer(&g_rumble_timer);
require(pad.last_rumble_duration_ms == 0, "watchdog must expire exactly across clock wrap");
}
void gameplay_availability() {
start_pairing_backend();
auto pad = dualsense(0);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "busy gameplay source must connect");
const uint8_t old[] = {10, 20, 30};
const uint8_t newest[] = {70, 140};
require(bluepad32_input_backend_native_rumble_submit(0, old, 3), "old block must queue");
dualsense_transport_available = false;
process_rumble_timer(&g_rumble_timer);
now_ms = 5;
require(bluepad32_input_backend_native_rumble_submit(0, newest, 2), "busy source must retain a new block");
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == 0, "driver rejection cannot count as a successful output");
now_ms = 15;
dualsense_transport_available = true;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == 1 && pad.last_high == 140 && pad.last_rumble_duration_ms == 40,
"driver recovery must dispatch only the newest current phase, not old samples");
const uint8_t next = 210;
during_dualsense_dispatch = [] {
const uint8_t replacement = 33;
require(bluepad32_input_backend_native_rumble_submit(0, &replacement, 1),
"gameplay must replace work while an earlier driver call is in flight");
};
require(bluepad32_input_backend_native_rumble_submit(0, &next, 1), "dispatch race must queue");
process_rumble_timer(&g_rumble_timer);
during_dualsense_dispatch = nullptr;
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 33 && pad.last_rumble_duration_ms == 50,
"older dispatch completion cannot consume a newly accepted revision");
bluepad32_input_backend_native_rumble_cancel(0);
process_rumble_timer(&g_rumble_timer);
const int stopped = pad.rumble_calls;
dualsense_transport_available = false;
require(bluepad32_input_backend_native_rumble_submit(0, old, 3), "stale block must queue");
now_ms += 50;
process_rumble_timer(&g_rumble_timer);
dualsense_transport_available = true;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == stopped, "expired rejected work must never replay after driver recovery");
}
void gameplay_priority() {
start_pairing_backend();
auto pad = dualsense(0);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "priority source must connect");
report_dualsense(pad);
const uint8_t game = 45;
uint64_t old_cue, new_cue;
require(bluepad32_input_backend_native_sample_request(0, 1, &old_cue) &&
bluepad32_input_backend_native_rumble_submit(0, &game, 1),
"gameplay must replace a pending cue on the same side");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 45 &&
bluepad32_input_backend_native_sample_result(0, old_cue) == -1,
"replaced cue cannot dispatch or complete after gameplay");
require(bluepad32_input_backend_native_sample_request(0, 6, &new_cue),
"a new cue must replace live gameplay");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 96 && pad.last_rumble_duration_ms == 60 &&
bluepad32_input_backend_native_sample_result(0, new_cue) == 1,
"built-in cue completion must retain its driver-dispatch semantics");
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1) &&
bluepad32_input_backend_native_rumble_submit(1, &game, 1),
"fresh gameplay must replace the playing cue");
process_rumble_timer(&g_rumble_timer);
const auto source = bridge_snapshot();
bluepad32_input_backend_queue_profile_feedback(source.slot,
source.controller.connection_generation, 1, ControllerProfileConfirmationPolicy::kRumble);
require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
"queued higher-priority feedback must not admit gameplay for later replay");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == UINT8_MAX && pad.last_low == UINT8_MAX &&
pad.last_rumble_duration_ms == 75, "profile feedback must own the only motor writer");
now_ms = 150;
process_rumble_timer(&g_rumble_timer);
const int after_feedback = pad.rumble_calls;
now_ms = 155;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == after_feedback, "profile completion must never resurrect interrupted gameplay");
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "fresh post-feedback gameplay must resume");
process_rumble_timer(&g_rumble_timer);
require(bluepad32_input_backend_toggle_motion(source.slot, source.controller.connection_generation),
"motion feedback must queue through the existing local-feedback path");
process_rumble_timer(&g_rumble_timer);
const int local_calls = pad.rumble_calls;
now_ms += 3000;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == local_calls, "local feedback must cancel rather than retain interrupted gameplay");
}
void gameplay_source_epochs() {
start_pairing_backend();
auto pad = dualsense(0);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "epoch source must connect");
const uint8_t game = 170;
require(!bluepad32_input_backend_native_rumble_submit(0, nullptr, 1) &&
!bluepad32_input_backend_native_rumble_submit(0, &game, 0) &&
!bluepad32_input_backend_native_rumble_submit(0, &game, 4) &&
!bluepad32_input_backend_native_rumble_submit(PROBE_CONTROLLER_COUNT, &game, 1),
"invalid gameplay frames must fail before dispatch");
pad.report_parser.play_dual_rumble = nullptr;
require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
"source without a rumble driver must reject gameplay");
pad.report_parser.play_dual_rumble = observe_dualsense_rumble;
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "epoch block must queue");
during_dualsense_dispatch = [] { bluepad32_input_backend_select_native_source(0, nullptr); };
process_rumble_timer(&g_rumble_timer);
during_dualsense_dispatch = nullptr;
process_rumble_timer(&g_rumble_timer);
require(pad.last_rumble_duration_ms == 0, "same-source reselection must stop an in-flight retired epoch");
require(bluepad32_input_backend_native_rumble_submit(1, &game, 1), "disconnect block must queue");
process_rumble_timer(&g_rumble_timer);
platform_on_device_disconnected(&pad);
require(pad.last_rumble_duration_ms == 0 &&
!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
"disconnect must retire the driver's finite timer and refuse new work");
pad = dualsense(0);
platform_on_device_connected(&pad);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "same-address slot replacement must connect");
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == 0, "slot memory reuse cannot inherit old samples or stop obligations");
controller_profile_runtime_reset();
during_profile_resolution = [] { bluepad32_input_backend_select_native_source(0, nullptr); };
require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
"source generation must be rechecked after unlocked profile callbacks");
during_profile_resolution = nullptr;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == 0, "a profile-resolution race cannot reach the replacement epoch");
}
void gameplay_profile_gain() {
start_pairing_backend();
initialize_runtime_profile_storage();
auto pad = dualsense(0);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "profile source must connect");
const auto identity = identity_for_device(&pad);
auto profile = controller_profile_default(identity, 0);
profile.weak_rumble_scale = 128;
profile.strong_rumble_scale = 64;
require(runtime_profile_storage.set(identity, 0, profile) == ProfileStorageResult::kOk,
"profile must configure independent host motor gains");
const uint8_t maximum = 255;
require(bluepad32_input_backend_native_rumble_submit(0, &maximum, 1) &&
bluepad32_input_backend_native_rumble_submit(1, &maximum, 1), "scaled gameplay must queue");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 128 && pad.last_low == 64,
"right/weak and left/strong magnitudes must use their matching persisted profile gains");
profile.weak_rumble_scale = 0;
profile.strong_rumble_scale = 0;
require(runtime_profile_storage.set(identity, 0, profile) == ProfileStorageResult::kOk,
"profile mute must update its generation");
require(bluepad32_input_backend_native_rumble_submit(0, &maximum, 1) &&
bluepad32_input_backend_native_rumble_submit(1, &maximum, 1), "muted gameplay must queue");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 0 && pad.last_low == 0 && pad.last_rumble_duration_ms == 0,
"muting a profile must stop live host output rather than retaining unscaled samples");
uint64_t cue;
require(bluepad32_input_backend_native_sample_request(0, 7, &cue), "muted profile still permits local cues");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 220 && bluepad32_input_backend_native_sample_result(0, cue) == 1,
"host gain must not scale built-in local confirmation cues");
}
void gameplay_two_pairs() {
start_pairing_backend();
auto first = dualsense(0);
auto second = dualsense(1);
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "both gameplay pairs must connect");
const uint8_t values[] = {31, 62, 93, 124};
for (uint8_t instance = 0; instance < 4; ++instance)
require(bluepad32_input_backend_native_rumble_submit(instance, values + instance, 1),
"each gameplay child must bind its own pair");
process_rumble_timer(&g_rumble_timer);
require(first.last_high == 31 && first.last_low == 62 &&
second.last_high == 93 && second.last_low == 124,
"four logical contributions must route to two independent physical sources");
bluepad32_input_backend_native_rumble_cancel(0);
now_ms = 10;
process_rumble_timer(&g_rumble_timer);
require(first.last_high == 0 && first.last_low == 62 && first.last_rumble_duration_ms == 40 &&
second.rumble_calls == 1, "side cancellation cannot dispatch into another pair");
bluepad32_input_backend_select_native_source(0, nullptr);
process_rumble_timer(&g_rumble_timer);
require(first.last_rumble_duration_ms == 0 && second.rumble_calls == 1,
"source reselection must retire only its own pair's output");
platform_on_device_disconnected(&first);
now_ms = 50;
process_rumble_timer(&g_rumble_timer);
require(second.last_rumble_duration_ms == 0 && second.rumble_calls == 2,
"the surviving pair must expire on its original independent watchdog");
}
void gameplay_paired_revision() {
start_pairing_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
ready_switch2(left);
ready_switch2(right);
right.report_parser.play_dual_rumble = [](
uni_hid_device_t* pad, uint16_t delay, uint16_t duration, uint8_t weak, uint8_t strong) {
play_rumble(pad, delay, duration, weak, strong);
const uint8_t fresh = 150;
require(bluepad32_input_backend_native_rumble_submit(1, &fresh, 1),
"right dispatch may accept a newer left revision");
};
const uint8_t game = 75;
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "paired gameplay must queue");
const int previous_left = left.rumble_calls;
process_rumble_timer(&g_rumble_timer);
require(right.last_high == 75 && right.last_low == 75 && left.rumble_calls == previous_left,
"new left work must invalidate even a prepared zero-output command before left dispatch");
right.report_parser.play_dual_rumble = play_rumble;
process_rumble_timer(&g_rumble_timer);
require(left.last_high == 150 && left.last_low == 150,
"the newer paired-side revision must remain pending until its real driver dispatch");
const uint8_t stop = 0;
require(bluepad32_input_backend_native_rumble_submit(0, &stop, 1), "paired right stop must queue");
process_rumble_timer(&g_rumble_timer);
require(right.last_rumble_duration_ms == 0 && left.last_high == 150 &&
left.last_rumble_duration_ms == 50, "paired stop must preserve only the live sibling contribution");
}
void gameplay_wii() {
start_pairing_backend();
auto remote = wii_device(0);
remote.report_parser.play_dual_rumble = observe_mono_rumble;
require(platform_on_device_ready(&remote) == UNI_ERROR_SUCCESS, "Wii GAMEPAD source must connect");
const uint8_t right = 70, left = 140;
require(bluepad32_input_backend_native_rumble_submit(0, &right, 1) &&
bluepad32_input_backend_native_rumble_submit(1, &left, 1), "Wii contributions must queue");
native_wii_ready = false;
process_rumble_timer(&g_rumble_timer);
require(remote.rumble_calls == 0, "Wii topology setup must not consume pending output");
native_wii_ready = true;
now_ms = 10;
process_rumble_timer(&g_rumble_timer);
require(mono_magnitude == 140 && remote.last_rumble_duration_ms == 40,
"Wii compatibility combines both contributions on its finite mono motor");
bluepad32_input_backend_native_rumble_cancel(1);
process_rumble_timer(&g_rumble_timer);
require(mono_magnitude == 70 && remote.last_rumble_duration_ms == 40,
"Wii side cancellation must not stop its sibling's mono contribution");
now_ms = 50;
process_rumble_timer(&g_rumble_timer);
require(mono_magnitude == 0, "Wii gameplay must stop at its original watchdog");
}
} // namespace
extern "C" void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t*, const uint8_t*, uint16_t) {}
@ -993,6 +1324,14 @@ int main(int argc, char** argv) {
if (scenario == "source-isolation") source_isolation();
else if (scenario == "cue-lifetime") cue_lifetime();
else if (scenario == "cue-races") cue_races();
else if (scenario == "gameplay-timeline") gameplay_timeline();
else if (scenario == "gameplay-availability") gameplay_availability();
else if (scenario == "gameplay-priority") gameplay_priority();
else if (scenario == "gameplay-source-epochs") gameplay_source_epochs();
else if (scenario == "gameplay-profile-gain") gameplay_profile_gain();
else if (scenario == "gameplay-two-pairs") gameplay_two_pairs();
else if (scenario == "gameplay-paired-revision") gameplay_paired_revision();
else if (scenario == "gameplay-wii") gameplay_wii();
else if (scenario == "stable-logical-slot") stable_logical_slot();
else if (scenario == "sensorless-admission") sensorless_admission();
else if (scenario == "independent-motion") independent_motion();

View file

@ -0,0 +1,270 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "hardware_stub.h"
static void startup_set_bits(volatile uint32_t* address, uint32_t bits);
static void startup_clear_bits(volatile uint32_t* address, uint32_t bits);
static void startup_reset(uint32_t mask);
static void startup_launch(void (*entry)(void));
static void startup_irq_handler(unsigned irq, void (*handler)(void));
static void startup_irq_enable(unsigned irq, bool enabled);
static void startup_wait(void);
#define hw_set_bits(address, bits) startup_set_bits(address, bits)
#define hw_clear_bits(address, bits) startup_clear_bits(address, bits)
#define reset_block(mask) startup_reset(mask)
#define multicore_launch_core1(entry) startup_launch(entry)
#define irq_set_exclusive_handler(irq, handler) startup_irq_handler(irq, handler)
#define irq_set_enabled(irq, enabled) startup_irq_enable(irq, enabled)
#define tight_loop_contents() startup_wait()
#define NATIVE_TEST_EXTERNAL_ROUTER 1
#define NATIVE_TEST_EXTERNAL_IRQ 1
#include "native_hub_transport_fixture.c"
// This is a register/IRQ model, not a physical USB bus or Core1 timing model.
// The physical pull-up follows DIRECT when its override is enabled; otherwise
// it follows SIE_CTRL. In particular, a disabled SIE pull-up cannot mask a
// forced physical pull-up. Register bit writes and startup hardware/router
// boundaries sample this signal. Each scenario starts in fresh process BSS.
static bool attached, observer_initialized, observer_launched, observer_ready;
static bool routing_enabled, addresses_published, irq_enabled, enumeration_done;
static uint8_t routed_addresses[PROBE_ROUTER_SLOTS], routed_default;
static void (*installed_irq)(void);
static uint32_t ready_delay_us, observer_start_us, wait_us, attach_time_us;
static unsigned attach_edges, detach_edges, controller_resets, setup_irqs;
static bool observer_never_ready;
static bool physical_pullup(void) {
if (usb_hw->phy_direct_override & USB_USBPHY_DIRECT_OVERRIDE_DP_PULLUP_EN_OVERRIDE_EN_BITS)
return (usb_hw->phy_direct & USB_USBPHY_DIRECT_DP_PULLUP_EN_BITS) != 0;
return (usb_hw->sie_ctrl & USB_SIE_CTRL_PULLUP_EN_BITS) != 0;
}
static uint8_t route_address(uint8_t address) {
assert(routing_enabled && addresses_published);
if (!address) return routed_default;
for (uint8_t slot = 0; slot < PROBE_ROUTER_SLOTS; ++slot)
if (routed_addresses[slot] == address) return slot;
return NONE;
}
void native_test_service_interrupt(void) {
if (native_test_interrupt_mask || servicing_interrupt || !irq_enabled ||
!installed_irq || !(usb_hw->ints & usb_hw->inte)) return;
servicing_interrupt = true;
if (usb_hw->ints & USB_INTS_SETUP_REQ_BITS) ++setup_irqs;
installed_irq();
usb_hw->ints = 0;
servicing_interrupt = false;
}
static void host_setup(uint8_t address, const tusb_control_request_t* request) {
assert(attached && physical_pullup());
uint8_t slot = route_address(address);
assert(slot == 0 && "root request must route through the published address table");
assert(native_hub_select_device(address,slot,UINT32_MAX / 2u));
memcpy(usb_dpram->setup_packet,request,sizeof(*request));
usb_hw->sie_status = USB_SIE_STATUS_SETUP_REC_BITS;
usb_hw->ints = USB_INTS_SETUP_REQ_BITS;
unsigned previous = setup_irqs;
native_test_service_interrupt();
assert(setup_irqs == previous+1u && "first host SETUP must reach the installed IRQ");
native_hub_task();
assert(!failed);
}
static void receive_descriptor(uint8_t type, uint16_t expected_length) {
uint8_t response[PACKET];
uint16_t length = 0;
assert(native_test_in(0,response,&length,true));
assert(length == expected_length && response[1] == type);
if (type == TUSB_DESC_DEVICE) {
assert(response[0] == 18 && response[4] == 9 && response[7] == PACKET);
} else {
assert(response[0] == 9 && response[2] == expected_length && response[3] == 0);
assert(response[4] == 1 && response[5] == 1);
}
assert(native_test_out(0,NULL,0,true));
assert(devices[0].control.stage == IDLE && !failed);
}
static void enumerate_at_attach(void) {
// Do not reset/reinitialize the transport here: that could repair the very
// startup state being tested. Deliver the first SETUP inside the physical
// attach write, before native_hub_init has even returned to its caller.
const tusb_control_request_t descriptor = {
.bmRequestType = 0x80, .bRequest = TUSB_REQ_GET_DESCRIPTOR,
.wValue = TUSB_DESC_DEVICE << 8, .wLength = 18,
};
host_setup(0,&descriptor);
receive_descriptor(TUSB_DESC_DEVICE,18);
const tusb_control_request_t set_address = {
.bmRequestType = 0, .bRequest = TUSB_REQ_SET_ADDRESS, .wValue = 9,
};
host_setup(0,&set_address);
assert(route_address(0) == 0 && route_address(9) == NONE);
uint8_t response[PACKET];
uint16_t length = 1;
assert(native_test_in(0,response,&length,true) && length == 0);
assert(route_address(9) == 0 && route_address(0) == NONE);
assert(usb_hw->dev_addr_ctrl == 9);
const tusb_control_request_t configuration = {
.bmRequestType = 0x80, .bRequest = TUSB_REQ_GET_DESCRIPTOR,
.wValue = TUSB_DESC_CONFIGURATION << 8, .wLength = 25,
};
host_setup(9,&configuration);
receive_descriptor(TUSB_DESC_CONFIGURATION,25);
enumeration_done = true;
}
static void observe_pullup(void) {
bool visible = physical_pullup();
if (visible == attached) return;
attached = visible;
if (!visible) {
++detach_edges;
return;
}
++attach_edges;
attach_time_us = native_test_time_us;
assert((usb_hw->main_ctrl & USB_MAIN_CTRL_CONTROLLER_EN_BITS) &&
"physical attach preceded controller readiness");
assert((usb_hw->sie_ctrl & USB_SIE_CTRL_EP0_INT_1BUF_BITS) &&
"physical attach preceded EP0 readiness");
assert(observer_ready && routing_enabled && addresses_published &&
"physical attach preceded observer/address routing readiness");
assert(routed_addresses[0] == 0 && routed_default == 0);
for (unsigned slot = 1; slot < PROBE_ROUTER_SLOTS; ++slot)
assert(routed_addresses[slot] == NONE);
assert(irq_enabled && installed_irq && !native_test_interrupt_mask &&
"physical attach preceded IRQ readiness");
assert((usb_hw->inte & (USB_INTS_SETUP_REQ_BITS | USB_INTS_BUFF_STATUS_BITS |
USB_INTS_BUS_RESET_BITS)) ==
(USB_INTS_SETUP_REQ_BITS | USB_INTS_BUFF_STATUS_BITS | USB_INTS_BUS_RESET_BITS));
assert(started && "physical attach preceded foreground/pending-IRQ readiness");
enumerate_at_attach();
}
static void startup_set_bits(volatile uint32_t* address, uint32_t bits) {
(hw_set_bits)(address,bits);
observe_pullup();
}
static void startup_clear_bits(volatile uint32_t* address, uint32_t bits) {
(hw_clear_bits)(address,bits);
observe_pullup();
}
static void startup_reset(uint32_t mask) {
assert(mask == RESETS_RESET_USBCTRL_BITS);
++controller_resets;
memset(usb_hw,0,sizeof(*usb_hw));
observe_pullup();
}
static void startup_launch(void (*entry)(void)) {
observe_pullup();
assert(observer_initialized && entry == probe_router_core1);
observer_launched = true;
}
static void startup_irq_handler(unsigned irq, void (*handler)(void)) {
observe_pullup();
assert(irq == USBCTRL_IRQ);
installed_irq = handler;
}
static void startup_irq_enable(unsigned irq, bool enabled) {
observe_pullup();
assert(irq == USBCTRL_IRQ);
irq_enabled = enabled;
native_test_service_interrupt();
}
static void startup_wait(void) {
observe_pullup();
assert(!attached && "host must remain detached throughout observer startup");
native_test_time_us += 1000u;
wait_us += 1000u;
assert(wait_us <= 100000u && "observer startup must have a bounded timeout");
}
void probe_router_init(uint32_t hz) {
observe_pullup();
assert(hz == 240000000u);
observer_initialized = true;
observer_start_us = native_test_time_us;
memset(routed_addresses,NONE,sizeof(routed_addresses));
routed_default = NONE;
}
void probe_router_core1(void) {}
void probe_router_publish(const uint8_t values[PROBE_ROUTER_SLOTS], uint8_t slot) {
observe_pullup();
memcpy(routed_addresses,values,sizeof(routed_addresses));
routed_default = slot;
addresses_published = true;
}
void probe_router_enable(bool enabled) {
observe_pullup();
routing_enabled = enabled;
}
bool probe_router_set_phase(uint32_t phase) {
(void)phase;
observe_pullup();
return true;
}
void probe_router_snapshot(probe_router_stats* snapshot) {
observe_pullup();
assert(observer_initialized && observer_launched);
if (!observer_never_ready && native_test_time_us-observer_start_us >= ready_delay_us)
observer_ready = true;
memset(snapshot,0,sizeof(*snapshot));
snapshot->ready = observer_ready;
}
bool tud_vendor_control_xfer_cb(uint8_t slot, uint8_t stage, const tusb_control_request_t* request) {
(void)slot; (void)stage; (void)request;
assert(false && "root standard enumeration must not invoke vendor handling");
return false;
}
void reset_usb_boot(uint32_t gpio_mask, uint32_t disable_mask) {
(void)gpio_mask; (void)disable_mask;
assert(false && "cold startup must not enter BOOTSEL");
abort();
}
int main(int argc, char** argv) {
assert(argc == 2);
if (strcmp(argv[1],"delayed") == 0) ready_delay_us = 75000u;
else if (strcmp(argv[1],"timeout") == 0) observer_never_ready = true;
else assert(strcmp(argv[1],"ready") == 0);
// No native_test_initialize/startup helper: call the actual initializer
// from cold BSS, rather than inheriting the transport fixture's ready state.
assert(!physical_pullup());
bool initialized = native_hub_init();
observe_pullup();
assert(controller_resets == 1 && detach_edges == 0);
if (observer_never_ready) {
assert(!initialized && !started && !physical_pullup());
assert(!attach_edges && !enumeration_done && !setup_irqs);
assert(wait_us == 100000u);
} else {
assert(initialized && attached && attach_edges == 1);
assert(enumeration_done && setup_irqs == 3);
assert(wait_us == ready_delay_us && attach_time_us-observer_start_us == ready_delay_us);
assert(startup_time == attach_time_us && !failed);
}
printf("native cold startup %s passed for %u children\n",argv[1],CHILDREN);
return 0;
}

View file

@ -15,6 +15,7 @@ uint16_t native_test_received_length[CHILDREN][2];
uint8_t native_test_received_data[CHILDREN][2][PACKET];
static bool servicing_interrupt;
#ifndef NATIVE_TEST_EXTERNAL_IRQ
void native_test_service_interrupt(void) {
if (native_test_interrupt_mask || servicing_interrupt || !usb_hw->ints) return;
servicing_interrupt = true;
@ -22,13 +23,16 @@ void native_test_service_interrupt(void) {
usb_hw->ints = 0;
servicing_interrupt = false;
}
#endif
#ifndef NATIVE_TEST_EXTERNAL_ROUTER
void probe_router_init(uint32_t hz) { (void)hz; }
void probe_router_core1(void) {}
void probe_router_publish(const uint8_t values[PROBE_ROUTER_SLOTS], uint8_t slot) { (void)values; (void)slot; }
void probe_router_enable(bool enabled) { (void)enabled; }
bool probe_router_set_phase(uint32_t phase) { (void)phase; return true; }
void probe_router_snapshot(probe_router_stats* snapshot) { memset(snapshot,0,sizeof(*snapshot)); snapshot->ready = 1; }
#endif
#ifndef NATIVE_TEST_EXTERNAL_LOG
int probe_debug_printf(const char* format, ...) { (void)format; return 0; }
#endif

View file

@ -76,6 +76,13 @@ void bluepad32_input_backend_native_sample_cancel(uint8_t instance) {
assert(instance < PROBE_CONTROLLER_COUNT);
cue_tokens[instance] = 0;
}
bool bluepad32_input_backend_native_rumble_submit(uint8_t, const uint8_t*, uint8_t) {
assert(false && "gameplay motor dispatch belongs to the native backend fixture");
return false;
}
void bluepad32_input_backend_native_rumble_cancel(uint8_t) {
assert(false && "gameplay motor cancellation belongs to the native backend fixture");
}
void bluepad32_input_backend_queue_profile_feedback(uint8_t, uint32_t, uint8_t, ControllerProfileConfirmationPolicy) {}
void controller_profile_runtime_reset() {
for (ControllerProfile& value : profiles)

View file

@ -680,7 +680,122 @@ static void test_indexed_memory(void) {
}
}
static void expect_invalid_rumble(uint8_t report_id, const uint8_t* data, size_t length) {
probe_rumble_frame output = {.count = 3, .magnitude = {17, 93, 241}};
assert(!probe_protocol_decode_rumble(report_id, data, length, &output));
assert(output.count == 3);
assert(output.magnitude[0] == 17 && output.magnitude[1] == 93 && output.magnitude[2] == 241);
}
static void test_native_rumble(void) {
// Independent block bytes from public rumble-procon-gccon.pcapng.gz,
// packets 9970 and 257200. These are Pro Controller report 02 LRA blocks;
// the report 01 wrapper below is synthetic, not a captured Joy-Con packet.
static const uint8_t captured_blocks[][16] = {
{0x50, 0x81, 0x01, 0x10, 0x1e, 0x00},
{0x52, 0x9f, 0x19, 0xe0, 0x9d, 0x00},
};
probe_rumble_frame output;
uint8_t wire[65];
for (unsigned i = 0; i < 2; ++i) {
memset(wire, 0xa5, sizeof(wire));
wire[0] = 0x01;
memcpy(wire + 1, captured_blocks[i], sizeof(captured_blocks[i]));
assert(probe_protocol_decode_rumble(0, wire, 64, &output));
assert(output.count == 1 && output.magnitude[0] == i);
assert(probe_protocol_decode_rumble(1, wire + 1, 63, &output));
assert(output.count == 1 && output.magnitude[0] == i);
}
// Manually specified byte boundaries, not an encoder/decoder roundtrip.
// Frequencies are both 1023: they must not leak into either amplitude,
// nor be rejected merely because this compatibility decoder ignores them.
static const struct {
uint8_t sample[5];
uint8_t expected;
} boundaries[] = {
{{0xff, 0x03, 0xf0, 0x3f, 0x00}, 0}, // amplitudes 0, 0
{{0xff, 0x0b, 0xf0, 0x3f, 0x00}, 0}, // 2, 0 rounds down
{{0xff, 0x03, 0xf0, 0xff, 0x00}, 1}, // 0, 3 rounds up
{{0xff, 0xff, 0xf0, 0x3f, 0x00}, 16}, // 63, 0
{{0xff, 0x03, 0xf1, 0x3f, 0x00}, 16}, // 64, 0
{{0xff, 0xff, 0xf7, 0x3f, 0x80}, 128}, // 511, 512
{{0xff, 0x03, 0xf8, 0xff, 0x7f}, 128}, // 512, 511
{{0xff, 0xff, 0xff, 0x3f, 0x00}, 255}, // 1023, 0
{{0xff, 0x03, 0xf0, 0xff, 0xff}, 255}, // 0, 1023
};
wire[1] = 0x5f;
for (unsigned i = 0; i < sizeof(boundaries) / sizeof(boundaries[0]); ++i) {
memcpy(wire + 2, boundaries[i].sample, 5);
assert(probe_protocol_decode_rumble(0, wire, 17, &output));
assert(output.count == 1 && output.magnitude[0] == boundaries[i].expected);
}
// Three distinguishable samples retain wire order; a shorter count ignores
// stale later samples. Both callback envelopes accept minimal/compact/USB sizes.
static const uint8_t ordered[16] = {
0x70,
0x00, 0xfc, 0x0f, 0x00, 0x00, // amplitudes 1023, 0 -> 255
0x00, 0x00, 0x00, 0xc0, 0x3f, // amplitudes 0, 255 -> 64
0xff, 0xff, 0xf7, 0x3f, 0x80, // amplitudes 511, 512 -> 128
};
static const size_t lengths[] = {17, 42, 64};
memcpy(wire + 1, ordered, sizeof(ordered));
for (unsigned count = 1; count <= 3; ++count) {
for (unsigned sequence = 0; sequence < 16; ++sequence) {
wire[1] = (uint8_t)(0x40u | (count << 4) | sequence);
for (unsigned i = 0; i < sizeof(lengths) / sizeof(lengths[0]); ++i) {
for (unsigned form = 0; form < 2; ++form) {
assert(probe_protocol_decode_rumble((uint8_t)form, wire + form,
lengths[i] - form, &output));
assert(output.count == count && output.magnitude[0] == 255);
if (count >= 2) assert(output.magnitude[1] == 64);
if (count == 3) assert(output.magnitude[2] == 128);
}
}
}
}
wire[1] = 0x4f; // HOLD: nonzero stale samples must not become a stop/update.
assert(probe_protocol_decode_rumble(0, wire, 17, &output));
assert(output.count == 0);
assert(probe_protocol_decode_rumble(1, wire + 1, 16, &output));
assert(output.count == 0);
// Complete 16-byte block required even for HOLD or a one-sample update.
for (unsigned count = 0; count <= 3; ++count) {
wire[1] = (uint8_t)(0x40u | (count << 4));
for (size_t length = 0; length < 17; ++length)
expect_invalid_rumble(0, wire, length);
for (size_t length = 0; length < 16; ++length)
expect_invalid_rumble(1, wire + 1, length);
}
expect_invalid_rumble(0, wire, 65);
expect_invalid_rumble(1, wire + 1, 64);
expect_invalid_rumble(0, wire, SIZE_MAX);
expect_invalid_rumble(1, wire + 1, SIZE_MAX);
expect_invalid_rumble(0, NULL, 64);
expect_invalid_rumble(1, NULL, 63);
assert(!probe_protocol_decode_rumble(0, wire, 64, NULL));
assert(!probe_protocol_decode_rumble(1, wire + 1, 63, NULL));
for (unsigned id = 0; id <= UINT8_MAX; ++id) {
if (id != 1) {
wire[0] = (uint8_t)id;
expect_invalid_rumble(0, wire, 64);
}
if (id > 1) expect_invalid_rumble((uint8_t)id, wire + 1, 63);
}
wire[0] = 1;
for (unsigned header = 0; header <= UINT8_MAX; ++header) {
if ((header & 0xc0u) == 0x40u) continue;
wire[1] = (uint8_t)header;
expect_invalid_rumble(0, wire, 64);
expect_invalid_rumble(1, wire + 1, 63);
}
}
int main(void) {
test_native_rumble();
test_indexed_memory();
test_descriptors();
for (unsigned side = 0; side < 2; ++side) {

View file

@ -53,13 +53,37 @@ def test_native_gamepad_backend_native(
check=True,
cwd=root,
)
scenarios = ["stable-logical-slot", "cue-lifetime", "cue-races"]
scenarios = [
"stable-logical-slot",
"cue-lifetime",
"cue-races",
"gameplay-timeline",
"gameplay-availability",
"gameplay-priority",
"gameplay-source-epochs",
"gameplay-profile-gain",
]
if controller_count == 2:
scenarios.append("source-isolation")
else:
scenarios.extend(("two-pair-sources", "two-pair-cues", "explicit-precedence"))
scenarios.extend(
(
"two-pair-sources",
"two-pair-cues",
"explicit-precedence",
"gameplay-two-pairs",
)
)
if source == "GAMEPAD":
scenarios.extend(("paired-source", "pair-cue-races", "mono-rumble"))
scenarios.extend(
(
"paired-source",
"pair-cue-races",
"mono-rumble",
"gameplay-paired-revision",
"gameplay-wii",
)
)
if controller_count == 2:
scenarios.extend(("sensorless-admission", "independent-motion"))
else:

View file

@ -93,3 +93,43 @@ def test_native_hub_management_native(
cwd=root,
)
subprocess.run([str(router_executable)], check=True, cwd=root)
@pytest.mark.parametrize("controller_count", [2, 4], ids=["one-pair", "two-pair"])
@pytest.mark.parametrize("trace_enabled", [False, True], ids=["plain", "trace"])
def test_native_hub_cold_startup(
tmp_path: Path, controller_count: int, trace_enabled: bool
) -> None:
root = Path(__file__).resolve().parents[1]
cc = shutil.which("cc") or shutil.which("gcc")
assert cc is not None, "a host C compiler is required"
executable = tmp_path / "native_hub_startup_test"
flags = [f"-DPROBE_CONTROLLER_COUNT={controller_count}"]
if trace_enabled:
flags.append("-DSWITCH2_PROBE_TRACE_NATIVE_INPUT=1")
subprocess.run(
[
cc,
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
"-ffunction-sections",
"-fdata-sections",
"-DSWITCH2_PROBE_HUB=1",
*flags,
f"-I{root / 'tests' / 'native_hub_stubs'}",
f"-I{root / 'src' / 'firmware'}",
f"-I{root / 'tools' / 'pico_usb_address_probe'}",
f"-I{root / 'tools' / 'switch2_usb_probe'}",
str(root / "tests" / "native_hub_startup_test.c"),
"-Wl,--gc-sections",
"-o",
str(executable),
],
check=True,
cwd=root,
)
for scenario in ("ready", "delayed", "timeout"):
subprocess.run([str(executable), scenario], check=True, cwd=root)