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();