feat(native-usb): add 32-frame HD gameplay rumble and USB flight diagnostics

Preserve native frequency/amplitude timelines for DualSense PCM output,
remove the unsupported 64-frame path, and retain compatibility rumble for
other controllers. Use the validated 300 MHz sampling phase and restrict
non-bondable Classic discovery autoconnect to previously paired peers.

Buffer native-hub UART stdout and release USB IRQs around port-reset
callbacks. Add observer liveness and pre-SETUP root-response observations
without changing recovery behavior. Cover transport and haptics boundaries.

Record the user-accepted 0.108 trial: controls remained responsive and
rumble felt fine. Instrumentation changes timing; the disconnect root
cause and long-term reliability remain unqualified.

Validation: 624 tests, 11 affected firmware/probe builds, and on-device
concurrent USB and HD-auto-start checks. Private captures, generated
images, and unrelated working-tree files are intentionally excluded.
This commit is contained in:
Joey Yakimowich-Payne 2026-09-19 17:03:46 -06:00
commit 80b788099b
46 changed files with 2873 additions and 412 deletions

View file

@ -32,6 +32,7 @@ uni_platform* installed_platform = nullptr;
bool observed_status_led_on = false;
int observed_status_led_writes = 0;
uint32_t now_ms = 0;
uint32_t now_sub_ms_us = 0;
struct WiiAccelFixture {
uni_hid_device_t* device = nullptr;
int32_t acceleration[3]{};
@ -689,6 +690,8 @@ uint32_t time_us_32() {
return now_ms * 1000u;
}
uint64_t time_us_64() { return uint64_t{now_ms} * 1000 + now_sub_ms_us; }
void switch2_wake_initialize() {
++switch2_wake_initializations;
@ -711,6 +714,7 @@ void switch2_wake_diagnostics(Switch2WakeDiagnostics*) {
#include "core/controller_identity.cpp"
namespace {
unsigned state_lock_depth = 0;
void (*after_backend_state_unlock)() = nullptr;
void tracked_state_lock_enter(critical_section_t* lock) {
critical_section_enter_blocking(lock);
@ -721,6 +725,8 @@ void tracked_state_lock_exit(critical_section_t* lock) {
require(state_lock_depth != 0, "state lock exit must match an enter");
--state_lock_depth;
critical_section_exit(lock);
if (state_lock_depth == 0 && after_backend_state_unlock)
after_backend_state_unlock();
}
} // namespace
@ -769,26 +775,34 @@ namespace {
std::vector<btstack_timer_source_t*> native_timers;
std::array<uint8_t, 143> last_native_packet{};
uint16_t last_native_cid = 0;
struct NativePacketObservation {
uint64_t sent_us;
uint16_t cid;
std::array<uint8_t, 143> data;
};
std::vector<NativePacketObservation> native_packets;
}
void native_test_add_timer(btstack_timer_source_t* timer) {
require(state_lock_depth == 0, "HD timer scheduling must not hold the backend lock");
btstack_run_loop_remove_timer(timer);
timer->due_ms = uint64_t{now_ms} + timer->timeout_ms + 1;
native_timers.push_back(timer);
}
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) {
require(state_lock_depth == 0, "HD timer cancellation must not hold the backend lock");
const auto found = std::find(native_timers.begin(), native_timers.end(), timer);
if (found == native_timers.end()) return 0;
native_timers.erase(found);
return 1;
}
uint64_t time_us_64() { return uint64_t{now_ms} * 1000; }
uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t) { return 143; }
bool l2cap_can_send_packet_now(uint16_t) { return true; }
int hci_number_free_acl_slots_for_handle(uint16_t) { return 8; }
uint8_t l2cap_request_can_send_now_event(uint16_t cid) {
require(state_lock_depth == 0, "HD permission callbacks must not hold the backend lock");
for (const auto& slot : g_slots) {
if (slot.device != nullptr && slot.device->conn.interrupt_cid == cid) {
(void)uni_platform_on_l2cap_can_send_now(slot.device, cid);
@ -800,9 +814,11 @@ uint8_t l2cap_request_can_send_now_event(uint16_t cid) {
}
uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) {
require(state_lock_depth == 0, "HD packet writes must not hold the backend lock");
require(size == last_native_packet.size(), "native report size changed");
std::copy(data, data + size, last_native_packet.begin());
last_native_cid = cid;
native_packets.push_back({time_us_64(), cid, last_native_packet});
return ERROR_CODE_SUCCESS;
}
@ -5351,12 +5367,11 @@ void require_native_channels(bool left, bool right) {
require(status.state == HapticsExperimentState::kRunning &&
status.mode == 1,
"stateful host rumble lost native gameplay ownership");
require(status.packet_frames == SWITCH_PICO_HD_PACKET_FRAMES,
"native gameplay ignored the configured packet frame count");
const unsigned sample_offset = status.packet_frames == 32 ? 14 : 10;
require(status.packet_frames == 32,
"native gameplay requires the supported 32-frame transport");
constexpr unsigned sample_offset = 14;
require(last_native_packet[3] == 0x91 &&
last_native_packet[sample_offset - 2] ==
(status.packet_frames == 32 ? 0x92 : 0xd2) &&
last_native_packet[sample_offset - 2] == 0x92 &&
last_native_packet[sample_offset - 1] == 64,
"stateful channel inspection requires a native PCM block");
unsigned active[2]{};

View file

@ -0,0 +1,114 @@
#include <assert.h>
#include <stdbool.h>
#include <string.h>
#include <btstack.h>
#include "bt/uni_bt.h"
#include "bt/uni_bt_bredr.h"
#include "bt/uni_bt_sdp.h"
#include "uni_hid_device.h"
static const bd_addr_t paired = {1, 2, 3, 4, 5, 6};
static const bd_addr_t unknown = {7, 8, 9, 10, 11, 12};
static bool bondable;
static bool allocated;
static unsigned connection_attempts;
static bd_addr_t attempted_address;
static uni_hid_device_t device;
int gap_get_bondable_mode(void) { return bondable; }
bool gap_get_link_key_for_bd_addr(bd_addr_t address, link_key_t key, link_key_type_t* type) {
if (memcmp(address, paired, sizeof(bd_addr_t)) != 0) return false;
memset(key, 0x55, sizeof(link_key_t));
*type = COMBINATION_KEY;
return true;
}
void uni_log(const char* format, ...) { (void)format; }
void btstack_assert_failed(const char* file, uint16_t line) {
(void)file;
(void)line;
assert(false);
}
uni_error_t uni_hid_device_on_device_discovered(bd_addr_t address, const char* name,
uint16_t cod, uint8_t rssi) {
(void)address; (void)name; (void)cod; (void)rssi;
return UNI_ERROR_SUCCESS; // The platform has room and permits discovery.
}
uni_hid_device_t* uni_hid_device_get_instance_for_address(bd_addr_t address) {
return allocated && memcmp(address, device.conn.btaddr, sizeof(bd_addr_t)) == 0 ? &device : NULL;
}
uni_hid_device_t* uni_hid_device_create(bd_addr_t address) {
assert(!allocated);
allocated = true;
memset(&device, 0, sizeof(device));
memcpy(device.conn.btaddr, address, sizeof(bd_addr_t));
return &device;
}
void uni_hid_device_set_cod(uni_hid_device_t* d, uint32_t cod) { d->cod = cod; }
void uni_hid_device_set_name(uni_hid_device_t* d, const char* name) {
assert(strlen(name) < sizeof(d->name));
strcpy(d->name, name);
}
bool uni_hid_device_has_name(const uni_hid_device_t* d) { return d->name[0] != 0; }
bool uni_hid_device_is_incoming(const uni_hid_device_t* d) { return d->conn.incoming; }
bool uni_hid_device_guess_controller_type_from_name(uni_hid_device_t* d, const char* name) {
(void)d; (void)name;
return false;
}
void uni_hid_device_set_ready(uni_hid_device_t* d) { (void)d; assert(false); }
void uni_bt_sdp_query_start(uni_hid_device_t* d) { (void)d; assert(false); }
void uni_bt_sdp_query_start_hid_descriptor(uni_hid_device_t* d) { (void)d; assert(false); }
int gap_remote_name_request(const bd_addr_t address, uint8_t mode, uint16_t offset) {
(void)address; (void)mode; (void)offset;
assert(false); // The real inquiry event below already contains a name.
return ERROR_CODE_COMMAND_DISALLOWED;
}
void uni_bt_packet_handler(uint8_t type, uint16_t channel, uint8_t* packet, uint16_t size) {
(void)type; (void)channel; (void)packet; (void)size;
assert(false);
}
uint8_t l2cap_create_channel(btstack_packet_handler_t handler, bd_addr_t address,
uint16_t psm, uint16_t mtu, uint16_t* cid) {
(void)handler; (void)mtu;
assert(psm == BLUETOOTH_PSM_HID_CONTROL);
memcpy(attempted_address, address, sizeof(bd_addr_t));
++connection_attempts;
*cid = 0x40;
return ERROR_CODE_SUCCESS;
}
static void discover(const bd_addr_t address) {
// Real GAP_EVENT_INQUIRY_RESULT layout, decoded by BTstack accessors.
static const char name[] = "Test gamepad";
uint8_t event[27 + sizeof(name) - 1] = {GAP_EVENT_INQUIRY_RESULT, sizeof(event) - 2};
for (unsigned i = 0; i < sizeof(bd_addr_t); ++i) event[2 + i] = address[5 - i];
event[8] = 1;
event[9] = 0x08; event[10] = 0x25; // Peripheral/gamepad class.
event[14] = 1; event[15] = 220;
event[25] = 1; event[26] = sizeof(name) - 1;
memcpy(event + 27, name, sizeof(name) - 1);
uni_bt_bredr_on_gap_inquiry_result(0, event, sizeof(event));
}
int main(void) {
bondable = false;
discover(unknown);
assert(connection_attempts == 0 && !allocated);
discover(paired);
assert(connection_attempts == 1 && memcmp(attempted_address, paired, sizeof(bd_addr_t)) == 0);
allocated = false;
bondable = true;
discover(unknown);
assert(connection_attempts == 2 && memcmp(attempted_address, unknown, sizeof(bd_addr_t)) == 0);
// Closing the pairing window must reject the same still-unpaired peer.
allocated = false;
bondable = false;
discover(unknown);
assert(connection_attempts == 2 && !allocated);
return 0;
}

View file

@ -22,12 +22,12 @@ enum class Delivery { kImmediate, kDeferred, kNever };
enum class GenericKind { kCompatibility, kLed };
constexpr uint32_t kPacketFrames = SWITCH_PICO_HD_PACKET_FRAMES;
static_assert(kPacketFrames == 32 || kPacketFrames == 64);
static_assert(kPacketFrames == 32);
constexpr uint32_t kPackets = 18432 / kPacketFrames;
constexpr uint32_t kPrimingPackets = 3072 / kPacketFrames;
constexpr uint32_t kToneEndPacket = 15360 / kPacketFrames;
constexpr uint32_t kPhasePackets = 768 / kPacketFrames;
constexpr unsigned kSampleOffset = kPacketFrames == 32 ? 14 : 10;
constexpr unsigned kSampleOffset = 14;
struct Pcm {
uint64_t at_us;
@ -242,7 +242,7 @@ uint64_t due(uint64_t started, uint32_t packet) {
const uint8_t* samples(const Pcm& packet) {
const auto& b = packet.bytes;
assert(b[3] == 0x91);
assert(b[kSampleOffset - 2] == (kPacketFrames == 32 ? 0x92 : 0xd2));
assert(b[kSampleOffset - 2] == 0x92);
assert(b[kSampleOffset - 1] == 64);
return b.data() + kSampleOffset;
}
@ -258,14 +258,9 @@ void verify_report(const Pcm& packet, uint32_t sent_index) {
return;
}
assert(b[2] == 0 && b[3] == 0x91);
if (kPacketFrames == 32) {
assert(b[4] == 7 && b[5] == 0xfe);
for (unsigned i = 6; i < 10; ++i) assert(b[i] == 0);
assert(b[10] == 0xff && b[11] == static_cast<uint8_t>(sent_index - 1));
} else {
assert(b[4] == 3 && b[5] == 0x62 && b[6] == 16);
assert(b[7] == static_cast<uint8_t>(sent_index * 2));
}
assert(b[4] == 7 && b[5] == 0xfe);
for (unsigned i = 6; i < 10; ++i) assert(b[i] == 0);
assert(b[10] == 0xff && b[11] == static_cast<uint8_t>(sent_index - 1));
samples(packet);
for (unsigned i = kSampleOffset + kPacketFrames * 2; i < 139; ++i) {
assert(b[i] == 0);
@ -857,6 +852,169 @@ void stateful_rumble_generation_and_overflow() {
assert(generic_sent.empty());
}
NativeHapticsFrame native_frame(bool left, bool right) {
NativeHapticsFrame frame{};
frame.actuators[0] = {1, {{385, 481, static_cast<uint16_t>(left ? 1023 : 0), 0}}};
frame.actuators[1] = {1, {{385, 481, 0, static_cast<uint16_t>(right ? 1023 : 0)}}};
return frame;
}
void verify_channels(const Pcm& packet, bool left, bool right) {
const auto* block = samples(packet);
unsigned active[2]{};
for (unsigned frame = 0; frame < kPacketFrames; ++frame) {
active[0] += block[2 * frame] != 0;
active[1] += block[2 * frame + 1] != 0;
}
assert(left ? active[0] > kPacketFrames / 2 : active[0] == 0);
assert(right ? active[1] > kPacketFrames / 2 : active[1] == 0);
}
void native_samples_through_real_packets() {
reset();
constexpr uint8_t slot = 3; // Selection is not hard-wired to physical slot zero.
assert(haptics_experiment_request(2, slot));
assert(haptics_experiment_native_selected(slot, 103));
assert(!haptics_experiment_native_selected(0, 100));
const uint64_t started = now_us;
NativeHapticsFrame frame{};
frame.actuators[0] = {3, {{385, 481, 1023, 0}, {385, 481, 0, 0}, {385, 481, 512, 0}}};
frame.actuators[1] = {2, {{385, 481, 0, 0}, {385, 481, 0, 1023}}};
assert(haptics_experiment_submit_native(slot, 103, now_us, frame));
assert(snapshot().last_pcm_end_us == 0); // Mailbox admission is not PCM delivery.
haptics_experiment_poll();
verify_report(pcm.front(), 0);
assert(snapshot().last_pcm_end_us == 0); // State-only setup has no sample interval.
run_until(due(started, 2) + 1000);
assert(pcm.size() == 3 && pcm[1].cid == devices[slot].conn.interrupt_cid);
assert(snapshot().last_pcm_end_us == static_cast<uint32_t>(due(started, 2)));
verify_report(pcm[1], 1);
const auto* first = samples(pcm[1]);
unsigned active_left = 0, active_right = 0;
for (unsigned n = 0; n < 32; ++n) {
if (n < 16) {
assert(first[2 * n + 1] == 0);
active_left += first[2 * n] != 0;
} else {
assert(first[2 * n] == 0);
active_right += first[2 * n + 1] != 0;
}
}
assert(active_left > 10 && active_right > 10); // Fixed 16-frame native spacing.
verify_channels(pcm[2], true, true); // Third left substep begins at sample 32.
run_until(started + 80000);
verify_silence(pcm.back()); // Original per-side 50 ms watchdog, no held PCM replay.
assert(generic_sent.empty());
assert(haptics_experiment_request(0, slot));
assert(haptics_experiment_native_selected(slot, 103));
assert(!haptics_experiment_submit_native(slot, 103, now_us, frame));
haptics_experiment_poll();
verify_silence(pcm.back()); // Drain emits an actual all-zero PCM stop.
assert(haptics_experiment_native_selected(slot, 103)); // Restore still owns output.
run_until(now_us + 10000);
assert(snapshot().state == HapticsExperimentState::kStopped);
assert(!haptics_experiment_native_selected(slot, 103));
assert(generic_sent.size() == 2); // Only the exclusive compatibility restoration.
}
void native_cancellation_generation_and_feedback() {
reset();
constexpr uint8_t slot = 2;
assert(haptics_experiment_request(2, slot));
const uint64_t started = now_us;
auto both = native_frame(true, true);
assert(haptics_experiment_submit_native(slot, 102, now_us, both));
haptics_experiment_poll();
run_until(due(started, 1) + 1000);
verify_channels(pcm.back(), true, true);
// Remove both already-synthesized history and mailbox work on just left.
assert(haptics_experiment_submit_native(slot, 102, now_us, both));
haptics_experiment_cancel_native(slot, 102, 1);
haptics_experiment_attach(slot, 202, &devices[slot]);
assert(snapshot().state == HapticsExperimentState::kRunning);
assert(haptics_experiment_native_selected(slot, 202));
assert(!haptics_experiment_native_selected(slot, 102));
assert(!haptics_experiment_submit_native(slot, 102, now_us, both));
haptics_experiment_cancel_native(slot, 102, 2); // Old generation cannot stop right.
run_until(due(started, 2) + 1000);
verify_channels(pcm.back(), false, true);
assert(haptics_experiment_native_feedback(&devices[slot], now_us, 200, 0, 30));
haptics_experiment_cancel_native(slot, 202, 3);
run_until(now_us + 22000);
verify_channels(pcm.back(), true, false); // Side cue survives host cancellation.
run_until(now_us + 50000);
verify_silence(pcm.back()); // Canceled right must not resume after the cue.
assert(haptics_experiment_native_feedback(&devices[slot], now_us, 0, 200, 30));
run_until(now_us + 22000);
verify_channels(pcm.back(), false, true);
assert(generic_sent.empty());
run_until(now_us + 50000);
verify_silence(pcm.back());
auto right = native_frame(false, true);
assert(haptics_experiment_submit_native(slot, 202, now_us, right));
run_until(now_us + 22000);
verify_channels(pcm.back(), false, true);
haptics_experiment_detach(&devices[slot]);
haptics_experiment_attach(slot, 203, &devices[slot]);
assert(!haptics_experiment_submit_native(slot, 202, now_us, both));
assert(haptics_experiment_request(2, slot));
haptics_experiment_poll();
run_until(now_us + 30000);
verify_silence(pcm.back()); // A real reconnect does not retain a native effect.
}
void native_mailbox_validation_and_pending_cancel() {
reset();
assert(haptics_experiment_request(2, 1));
auto both = native_frame(true, true);
for (unsigned i = 0; i < 16; ++i) {
assert(haptics_experiment_submit_native(1, 101, now_us, both));
}
auto invalid = both;
invalid.actuators[0].samples[0].low_amplitude = 0;
invalid.actuators[1].samples[0].high_frequency_code = 671;
assert(!haptics_experiment_submit_native(1, 101, now_us + 1000, invalid));
invalid.actuators[1].samples[0].high_frequency_code = 481;
invalid.actuators[1].samples[0].high_amplitude = 1024;
assert(!haptics_experiment_submit_native(1, 101, now_us + 1000, invalid));
invalid.actuators[1].sample_count = 4;
assert(!haptics_experiment_submit_native(1, 101, now_us + 1000, invalid));
assert(haptics_experiment_native_selected(1, 101)); // Rejection never enables fallback.
haptics_experiment_cancel_native(1, 101, 1);
haptics_experiment_attach(1, 201, &devices[1]); // Generation can also migrate Pending.
assert(!haptics_experiment_submit_native(1, 101, now_us, both));
assert(haptics_experiment_submit_native(1, 201, now_us, native_frame(false, true)));
haptics_experiment_poll();
run_until(now_us + 22000);
verify_channels(pcm.back(), false, true);
assert(snapshot().host_updates == 17 && snapshot().dropped_updates == 1);
assert(snapshot().connection_generation == 201);
assert(generic_sent.empty());
}
void native_pcm_delivery_watermark() {
reset((uint64_t{1} << 32) - 15000);
assert(haptics_experiment_request(2, 0));
assert(haptics_experiment_submit_native(0, 100, now_us, native_frame(true, false)));
haptics_experiment_poll();
const uint64_t started = now_us;
assert(snapshot().last_pcm_end_us == 0);
fail_sends = 1;
run_until(due(started, 1) + 1000);
assert(snapshot().send_failures == 1 && snapshot().last_pcm_end_us == 0);
run_until(due(started, 2) + 1000);
assert(snapshot().last_pcm_end_us == static_cast<uint32_t>(due(started, 2)));
verify_channels(pcm.back(), true, false);
const uint32_t delivered_end = snapshot().last_pcm_end_us;
assert(haptics_experiment_request(0, 0));
haptics_experiment_poll();
verify_silence(pcm.back());
assert(snapshot().last_pcm_end_us == delivered_end); // Urgent drain is not cue evidence.
}
} // namespace
// Transport attribution has its own native fixture; this fixture isolates PCM
@ -996,5 +1154,9 @@ int main(int argc, char** argv) {
stateful_rumble_prepare_feedback_and_zero();
stateful_rumble_generation_and_overflow();
gameplay_missing_callback_is_bounded();
native_samples_through_real_packets();
native_cancellation_generation_and_feedback();
native_mailbox_validation_and_pending_cancel();
native_pcm_delivery_watermark();
std::cout << "haptics experiment behavioral regressions passed\n";
}

View file

@ -8,6 +8,9 @@ extern "C" bool uni_hid_parser_wii_rumble_ready(uni_hid_device_t*);
#undef main
#undef profile_service_active_profile_snapshot
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#include "input/switch_hd_rumble_synth.h"
#endif
namespace {
void (*during_profile_resolution)() = nullptr;
bool native_wii_ready = true;
@ -26,6 +29,20 @@ extern "C" bool uni_hid_parser_wii_rumble_ready(uni_hid_device_t*) {
}
namespace {
// Test data only: construct the raw native frame whose conventional peak is
// the requested old magnitude. Production has no magnitude-only ingress.
bool submit_native_magnitudes(uint8_t instance, const uint8_t* magnitudes, uint8_t count) {
if (magnitudes == nullptr)
return bluepad32_input_backend_native_rumble_submit(instance, nullptr);
NativeHapticsActuatorFrame frame{};
frame.sample_count = count;
if (count <= 3)
for (uint8_t sample = 0; sample < count; ++sample)
frame.samples[sample] = {
385, 481, static_cast<uint16_t>((uint32_t{magnitudes[sample]} * 1023u + 127u) / 255u), 0};
return bluepad32_input_backend_native_rumble_submit(instance, &frame);
}
struct SensorFixture {
uni_hid_device_t* device = nullptr;
uni_native_motion_snapshot_t metadata{};
@ -628,8 +645,8 @@ void gameplay_timeline() {
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),
require(submit_native_magnitudes(0, right, 3) &&
submit_native_magnitudes(1, left, 2),
"mixed sample counts must be accepted independently");
right[0] = 255;
process_rumble_timer(&g_rumble_timer);
@ -643,7 +660,7 @@ void gameplay_timeline() {
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),
require(submit_native_magnitudes(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,
@ -659,7 +676,7 @@ void gameplay_timeline() {
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");
require(submit_native_magnitudes(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 &&
@ -670,7 +687,7 @@ void gameplay_timeline() {
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");
require(submit_native_magnitudes(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");
@ -688,11 +705,11 @@ void gameplay_availability() {
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");
require(submit_native_magnitudes(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");
require(submit_native_magnitudes(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;
@ -703,10 +720,10 @@ void gameplay_availability() {
const uint8_t next = 210;
during_dualsense_dispatch = [] {
const uint8_t replacement = 33;
require(bluepad32_input_backend_native_rumble_submit(0, &replacement, 1),
require(submit_native_magnitudes(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");
require(submit_native_magnitudes(0, &next, 1), "dispatch race must queue");
process_rumble_timer(&g_rumble_timer);
during_dualsense_dispatch = nullptr;
process_rumble_timer(&g_rumble_timer);
@ -716,7 +733,7 @@ void gameplay_availability() {
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");
require(submit_native_magnitudes(0, old, 3), "stale block must queue");
now_ms += 50;
process_rumble_timer(&g_rumble_timer);
dualsense_transport_available = true;
@ -732,7 +749,7 @@ void gameplay_priority() {
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),
submit_native_magnitudes(0, &game, 1),
"gameplay must replace a pending cue on the same side");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 45 &&
@ -744,14 +761,14 @@ void gameplay_priority() {
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),
require(submit_native_magnitudes(0, &game, 1) &&
submit_native_magnitudes(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),
require(!submit_native_magnitudes(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 &&
@ -762,7 +779,7 @@ void gameplay_priority() {
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");
require(submit_native_magnitudes(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");
@ -778,26 +795,26 @@ void gameplay_source_epochs() {
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),
require(!submit_native_magnitudes(0, nullptr, 1) &&
!submit_native_magnitudes(0, &game, 0) &&
!submit_native_magnitudes(0, &game, 4) &&
!submit_native_magnitudes(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),
require(!submit_native_magnitudes(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");
require(submit_native_magnitudes(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");
require(submit_native_magnitudes(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),
!submit_native_magnitudes(0, &game, 1),
"disconnect must retire the driver's finite timer and refuse new work");
pad = dualsense(0);
platform_on_device_connected(&pad);
@ -806,7 +823,7 @@ void gameplay_source_epochs() {
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),
require(!submit_native_magnitudes(0, &game, 1),
"source generation must be rechecked after unlocked profile callbacks");
during_profile_resolution = nullptr;
process_rumble_timer(&g_rumble_timer);
@ -825,8 +842,10 @@ void gameplay_profile_gain() {
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");
const NativeHapticsActuatorFrame conventional{1, {{1023, 0, 512, 1023}}};
require(bluepad32_input_backend_native_rumble_submit(0, &conventional) &&
bluepad32_input_backend_native_rumble_submit(1, &conventional),
"conventional motors must ignore carrier codes they do not render");
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");
@ -834,8 +853,8 @@ void gameplay_profile_gain() {
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");
require(submit_native_magnitudes(0, &maximum, 1) &&
submit_native_magnitudes(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");
@ -854,7 +873,7 @@ void gameplay_two_pairs() {
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),
require(submit_native_magnitudes(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 &&
@ -886,11 +905,11 @@ void gameplay_paired_revision() {
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),
require(submit_native_magnitudes(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");
require(submit_native_magnitudes(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,
@ -900,7 +919,7 @@ void gameplay_paired_revision() {
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");
require(submit_native_magnitudes(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");
@ -912,8 +931,8 @@ void gameplay_wii() {
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");
require(submit_native_magnitudes(0, &right, 1) &&
submit_native_magnitudes(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");
@ -1316,11 +1335,321 @@ void stable_ble_reservation() {
"resolving a new BLE connection address must recover the original pair reservation without reviving cached controls");
}
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
uni_hid_device_t hd_dualsense(int index) {
auto pad = dualsense(index);
pad.conn.connected = true;
pad.conn.interrupt_cid = static_cast<uint16_t>(0x80 + 2 * index);
return pad;
}
NativeHapticsActuatorFrame native_waveform(uint8_t count = 3) {
return {count, {{387, 484, 870, 321}, {411, 509, 439, 731}, {433, 538, 680, 511}}};
}
NativeHapticsActuatorFrame scale_native_fixture(
NativeHapticsActuatorFrame frame, uint8_t low, uint8_t high) {
for (uint8_t sample = 0; sample < frame.sample_count; ++sample) {
frame.samples[sample].low_amplitude = static_cast<uint16_t>(
(uint32_t{frame.samples[sample].low_amplitude} * low + 127u) / 255u);
frame.samples[sample].high_amplitude = static_cast<uint16_t>(
(uint32_t{frame.samples[sample].high_amplitude} * high + 127u) / 255u);
}
return frame;
}
void compare_native_packets(SwitchHdRumbleSynth& reference, size_t& cursor, uint16_t cid) {
while (cursor < native_packets.size()) {
const auto& packet = native_packets[cursor++];
require(packet.cid == cid, "PCM must target the selected physical interrupt CID");
if (packet.data[3] != 0x91) continue; // Audio setup has no PCM interval.
require(packet.data[12] == 0x92 && packet.data[13] == 64,
"native HD must retain the guarded 32-frame packet format");
uint8_t expected[64]{};
reference.render(uint64_t{packet.data[11]} * 32, 32, expected);
require(memcmp(expected, packet.data.data() + 14, sizeof(expected)) == 0,
"PCM must preserve native timing, both full-precision bands, profile gains and side identity");
}
}
void hd_second_pair(bool first_is_wii) {
start_pairing_backend();
initialize_runtime_profile_storage();
auto first = first_is_wii ? wii_device(0) : device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto selected = hd_dualsense(2);
selected.outgoing_buffer.queued = 1;
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS,
"mixed controllers must bind A and B while HD selects physical slot one");
const uint32_t generation = g_slots[1].connection_generation;
require(haptics_experiment_native_selected(1, generation),
"auto-arm pending ownership must include a nonzero physical slot");
const auto identity = identity_for_device(&selected);
auto profile = controller_profile_default(identity, 0);
profile.strong_rumble_scale = 64;
profile.weak_rumble_scale = 128;
require(runtime_profile_storage.set(identity, 0, profile) == ProfileStorageResult::kOk,
"HD profile gains must be persisted before host ingress");
auto right = native_waveform();
const auto left = native_waveform(2);
const auto scaled_right = scale_native_fixture(right, 64, 128);
now_ms = 100;
now_sub_ms_us = 137;
const uint64_t right_us = time_us_64();
during_profile_resolution = [] {
during_profile_resolution = nullptr;
now_ms += 7;
};
require(bluepad32_input_backend_native_rumble_submit(2, &right),
"pending selected HD must admit a full native frame before stream startup");
right.samples[0] = {}; // Submission must own its copy.
const uint64_t left_us = time_us_64();
require(bluepad32_input_backend_native_rumble_submit(3, &left),
"B left must independently update physical actuator zero");
const uint8_t conventional = 73;
require(submit_native_magnitudes(0, &conventional, 1),
"non-HD first pair must retain conventional output");
process_rumble_timer(&g_rumble_timer);
const int compatibility_calls = selected.rumble_calls;
require(first.last_high == conventional && selected.last_high == 0 && selected.last_low == 0,
"only unsupported first-pair gameplay may use compatibility reports");
selected.outgoing_buffer.queued = 0;
advance_native_backend(25);
HapticsExperimentDiagnostics status{};
haptics_experiment_snapshot(&status);
require(status.state == HapticsExperimentState::kRunning && status.slot == 1 &&
status.host_updates == 2 && selected.rumble_calls == compatibility_calls,
"pending native frames must reach the real selected PCM queue without double writes");
SwitchHdRumbleSynth reference;
reference.reset(status.start_us);
NativeHapticsFrame stereo{};
stereo.actuators[1] = scaled_right;
require(reference.push_native(stereo, right_us), "reference right timeline must accept original receipt");
stereo = {};
stereo.actuators[0] = scale_native_fixture(left, 64, 128);
require(reference.push_native(stereo, left_us), "reference left timeline must accept independent receipt");
size_t cursor = 0;
compare_native_packets(reference, cursor, selected.conn.interrupt_cid);
bluepad32_input_backend_native_rumble_cancel(0);
advance_native_backend(5);
compare_native_packets(reference, cursor, selected.conn.interrupt_cid);
bluepad32_input_backend_native_rumble_cancel(2);
reference.cancel_native(2);
advance_native_backend(10);
compare_native_packets(reference, cursor, selected.conn.interrupt_cid);
advance_native_backend(40);
compare_native_packets(reference, cursor, selected.conn.interrupt_cid);
require_native_channels(false, false);
require(selected.rumble_calls == compatibility_calls,
"watchdog and side cancellation must remain PCM-only");
auto invalid = native_waveform(1);
invalid.samples[0].low_frequency_code = 671;
require(!bluepad32_input_backend_native_rumble_submit(2, &invalid),
"malformed active native carriers cannot mutate the selected stream");
require(haptics_experiment_request(0, 1), "selected HD stream must accept stop");
require(!bluepad32_input_backend_native_rumble_submit(2, &left),
"selected stopping HD must reject rather than queue compatibility fallback");
process_rumble_timer(&g_rumble_timer);
require(haptics_experiment_native_selected(1, generation) &&
!bluepad32_input_backend_native_rumble_submit(3, &left) &&
selected.last_high == 0 && selected.last_low == 0,
"drain/restoration must reject host fallback while allowing the compatibility zero stop");
advance_native_backend(30);
require(selected.last_high == 0 && selected.last_low == 0,
"compatibility restoration cannot replay rejected native host work");
}
void hd_unselected_dualsense() {
start_pairing_backend();
auto selected = hd_dualsense(0);
auto unselected = hd_dualsense(1);
require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&unselected) == UNI_ERROR_SUCCESS,
"two DualSenses must retain the existing single selected HD policy");
advance_native_backend(30);
const uint8_t magnitude = 93;
require(submit_native_magnitudes(2, &magnitude, 1),
"unselected DualSense must accept conventional native gameplay");
process_rumble_timer(&g_rumble_timer);
require(unselected.rumble_calls == 1 && unselected.last_high == magnitude &&
last_native_cid == selected.conn.interrupt_cid,
"later DualSense must not steal HD or lose bounded compatibility");
require_native_channels(false, false);
}
void hd_cues() {
start_pairing_backend();
auto first = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto selected = hd_dualsense(1);
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS,
"HD cue source must occupy the second pair");
advance_native_backend(30);
const int compatibility_calls = selected.rumble_calls;
uint64_t cue;
require(bluepad32_input_backend_native_sample_request(2, 1, &cue),
"right built-in cue must enter its bounded scheduler");
process_rumble_timer(&g_rumble_timer);
require(bluepad32_input_backend_native_sample_result(2, cue) == 0,
"overlay admission must not masquerade as source-driver completion");
advance_native_backend(20);
require_native_channels(false, true);
require(bluepad32_input_backend_native_sample_result(2, cue) == 1,
"successful later PCM packet submission must complete the built-in cue");
const auto left = native_waveform(1);
require(bluepad32_input_backend_native_rumble_submit(3, &left),
"left host waveform must coexist with a right local cue");
advance_native_backend(15);
require_native_channels(true, true);
bluepad32_input_backend_native_sample_cancel(2);
process_rumble_timer(&g_rumble_timer);
advance_native_backend(25); // Drain the 32-frame lookback and the next send interval.
require_native_channels(true, false);
const uint32_t generation = g_slots[1].connection_generation;
bluepad32_input_backend_queue_profile_feedback(
1, generation, 1, ControllerProfileConfirmationPolicy::kRumble);
advance_native_backend(20);
require_native_channels(true, true);
bluepad32_input_backend_native_rumble_cancel(2);
bluepad32_input_backend_native_rumble_cancel(3);
advance_native_backend(10);
require_native_channels(true, true);
advance_native_backend(250); // Finish both 75 ms confirmation pulses and their gap.
require_native_channels(false, false);
require(bluepad32_input_backend_native_sample_request(2, 3, &cue),
"short cue must enter the same real PCM scheduler");
process_rumble_timer(&g_rumble_timer);
now_ms += 100; // No transport callback covers the first 25 ms cue pulse.
advance_native_backend(12);
require(bluepad32_input_backend_native_sample_result(2, cue) == -1,
"a successful late packet beyond the admitted pulse must not falsely acknowledge that cue");
require(selected.rumble_calls == compatibility_calls,
"local cues, profile feedback and USB side cancellation must not emit compatibility writes");
}
void hd_reselection() {
start_pairing_backend();
auto first = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto selected = hd_dualsense(1);
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS,
"HD epoch source must occupy the second pair");
advance_native_backend(30);
auto wave = native_waveform(1);
require(bluepad32_input_backend_native_rumble_submit(2, &wave) &&
bluepad32_input_backend_native_rumble_submit(3, &wave),
"both selected actuator timelines must start");
advance_native_backend(15);
HapticsExperimentDiagnostics original{};
haptics_experiment_snapshot(&original);
bluepad32_input_backend_select_native_source(0, first.conn.btaddr);
advance_native_backend(10);
require_native_channels(true, true);
require(haptics_experiment_native_selected(1, original.connection_generation),
"first-pair source epochs must not invalidate selected second-pair HD");
bluepad32_input_backend_select_native_source(1, selected.conn.btaddr);
require(!bluepad32_input_backend_native_rumble_submit(2, &wave),
"Core0 epoch migration must reject until BT attachment sync, not fall back");
advance_native_backend(20);
HapticsExperimentDiagnostics current{};
haptics_experiment_snapshot(&current);
require(current.run_id == original.run_id &&
current.connection_generation != original.connection_generation &&
haptics_experiment_native_selected(1, current.connection_generation),
"logical reselection must synchronize generation without losing selected HD");
require_native_channels(false, false);
NativeHapticsFrame stereo{};
stereo.actuators[1] = wave;
require(!haptics_experiment_submit_native(1, original.connection_generation, time_us_64(), stereo) &&
bluepad32_input_backend_native_rumble_submit(2, &wave),
"old attachment generation must reject while fresh host work resumes");
platform_on_device_disconnected(&selected);
auto replacement = hd_dualsense(2);
memcpy(replacement.conn.btaddr, selected.conn.btaddr, 6);
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS,
"replacement selected identity must reconnect at another physical index");
advance_native_backend(30);
require_native_channels(false, false);
require(!haptics_experiment_submit_native(1, current.connection_generation, time_us_64(), stereo),
"replacement must never inherit old-generation PCM work");
}
void hd_delayed_cue() {
start_pairing_backend();
auto first = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto selected = hd_dualsense(1);
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS,
"delayed cue must use the selected second-pair PCM stream");
advance_native_backend(30);
now_ms = 43;
uint64_t token;
require(bluepad32_input_backend_native_sample_request(2, 3, &token),
"short native cue must be admitted");
NativeGamepadCueDispatch command{};
state_lock_enter();
const bool prepared = prepare_native_cues(1, now_ms, false, false, &command);
state_lock_exit();
require(prepared, "short cue must prepare for dispatch");
now_ms += 4;
dispatch_native_cues(command);
// The 25 ms pulse prepared at 43 ms actually runs [47,68), not [47,72).
// Skip transport until the successful PCM block covers [69,79.666) ms.
now_ms = 80;
advance_native_backend(1);
process_rumble_timer(&g_rumble_timer);
require_native_channels(false, false);
require(bluepad32_input_backend_native_sample_result(2, token) == -1,
"a packet after the shortened pulse must not acknowledge an undelivered cue");
}
void hd_admission_cancel() {
start_pairing_backend();
auto first = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
auto selected = hd_dualsense(1);
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS,
"cancellation race must use the selected second-pair PCM stream");
advance_native_backend(30);
const auto wave = native_waveform(1);
require(bluepad32_input_backend_native_rumble_submit(3, &wave),
"unrelated left waveform must start before the race");
advance_native_backend(15);
after_backend_state_unlock = [] {
static unsigned unlocks = 0;
if (++unlocks == 2) {
// Retire the right side immediately after the final backend check.
after_backend_state_unlock = nullptr;
bluepad32_input_backend_native_rumble_cancel(2);
}
};
require(bluepad32_input_backend_native_rumble_submit(2, &wave),
"the waveform can be accepted before its concurrent cancellation");
advance_native_backend(25);
require_native_channels(true, false);
bluepad32_input_backend_native_rumble_cancel(3);
bluepad32_input_backend_native_rumble_cancel(2);
require(bluepad32_input_backend_native_rumble_submit(2, &wave),
"a fresh waveform after cancellation returns must remain admissible");
advance_native_backend(25);
require_native_channels(false, true);
}
#endif
} // namespace
int main(int argc, char** argv) {
require(argc == 2, "scenario required");
const std::string scenario = argv[1];
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
if (scenario == "hd-second-pair-wii") { hd_second_pair(true); return 0; }
if (scenario == "hd-second-pair-other") { hd_second_pair(false); return 0; }
if (scenario == "hd-unselected-dualsense") { hd_unselected_dualsense(); return 0; }
if (scenario == "hd-cues") { hd_cues(); return 0; }
if (scenario == "hd-reselection") { hd_reselection(); return 0; }
if (scenario == "hd-delayed-cue") { hd_delayed_cue(); return 0; }
if (scenario == "hd-admission-cancel") { hd_admission_cancel(); return 0; }
#endif
if (scenario == "source-isolation") source_isolation();
else if (scenario == "cue-lifetime") cue_lifetime();
else if (scenario == "cue-races") cue_races();

View file

@ -25,6 +25,7 @@ static unsigned completions, missed_tokens;
static uint32_t phase;
static char serial_bytes[2 * LOG_CAPACITY];
static size_t serial_size;
static bool uart_backpressure;
void native_test_service_interrupt(void) {
if (pending_completion && !native_test_interrupt_mask) {
@ -47,9 +48,21 @@ uint32_t native_hub_trace_phase(uint32_t next) {
return previous;
}
bool uart_is_writable(void* uart) { (void)uart; return serial_size < sizeof(serial_bytes); }
bool uart_is_writable(void* uart) { (void)uart; return !uart_backpressure && serial_size < sizeof(serial_bytes); }
void uart_putc_raw(void* uart, char value) { (void)uart; serial_bytes[serial_size++] = value; }
static void blocking_stdio_out(const char* bytes, int length) {
assert(!uart_backpressure && "stdio waited for UART instead of queuing");
for (int i = 0; i < length; ++i) uart_putc_raw(uart0,bytes[i]);
}
static void blocking_stdio_flush(void) {
assert(!uart_backpressure && "stdio flush waited for UART");
}
stdio_driver_t stdio_uart = {
.out_chars = blocking_stdio_out,
.out_flush = blocking_stdio_flush,
};
int main(int argc, char** argv) {
if (argc == 2) {
if (strcmp(argv[1], "core") == 0) test_core = 1;
@ -99,6 +112,43 @@ int main(int argc, char** argv) {
drain_log();
assert(serial_size == strlen("caller owns mask"));
assert(memcmp(serial_bytes, "caller owns mask", serial_size) == 0);
// Pico libc/Bluepad32 output must share the existing ordered queue. UART
// backpressure cannot stall radio polling or prevent USB IRQ completion.
buffer_uart_stdio();
serial_size = 0;
log_read = log_written = LOG_CAPACITY - 5;
missed_tokens = 0;
uart_backpressure = true;
stdio_uart.out_chars("radio first\n",12);
assert(probe_debug_printf("probe\n") == 6);
stdio_uart.out_chars("radio last\n",11);
if (stdio_uart.out_flush) stdio_uart.out_flush();
drain_log();
assert(serial_size == 0 && !pending_completion && missed_tokens == 0);
uart_backpressure = false;
drain_log();
const char expected[] = "radio first\nprobe\nradio last\n";
assert(serial_size == sizeof(expected)-1);
assert(memcmp(serial_bytes,expected,serial_size) == 0);
// Reject a whole stdout chunk when full; queued bytes remain intact.
inject_completion = false;
serial_size = 0; log_read = 0; log_written = LOG_CAPACITY;
memset(log_bytes,'s',sizeof(log_bytes));
uint32_t dropped = log_dropped;
stdio_uart.out_chars("overflow",8);
drain_log();
assert(serial_size == LOG_CAPACITY && log_dropped == dropped+8);
for (size_t i = 0; i < serial_size; ++i) assert(serial_bytes[i] == 's');
// Panic/IRQ stdio must not recurse into assertions or corrupt this
// foreground-only ring. Count those discarded bytes instead.
serial_size = 0; dropped = log_dropped;
test_core = 1; stdio_uart.out_chars("core",4); test_core = 0;
test_exception = 16; stdio_uart.out_chars("irq",3); test_exception = 0;
drain_log();
assert(serial_size == 0 && log_dropped == dropped+7);
puts("native logging preserved USB progress, message order and caller IRQ state");
return 0;
}

View file

@ -1,10 +1,11 @@
#include "hardware_stub.h"
#include "router.h"
#include <assert.h>
#include <stdio.h>
// The real router tables and token-header decision run on the host. Only the
// clock/pad registers and the SIE bank-selection receiver are modeled here;
// the timing loop is compiled but never run against a simulated USB wire.
// The real router tables, deadline sampler and token-header decision run on
// the host. Scripted register reads exercise timing boundaries, not physical
// pad latency, instruction timing or USB signal integrity.
#define PICO_RP2350 1
#undef SIO_GPIO_HI_IN_USB_DP_BITS
#undef SIO_GPIO_HI_IN_USB_DM_BITS
@ -15,11 +16,34 @@
#define __wfe() ((void)0)
#define __dsb() ((void)0)
#define __isb() ((void)0)
static struct {
typedef struct {
volatile uint32_t mtime, mtimeh, mtimecmp, mtimecmph, mtime_ctrl, gpio_hi_in;
} router_test_sio;
} router_test_registers;
static router_test_registers router_test_sio;
enum { MANUAL_READS, IDLE_READS, DRAIN_READS };
static unsigned read_mode, register_reads, nonidle_read;
static uint32_t simulated_start, simulated_elapsed;
static router_test_registers* router_test_read_registers(void) {
if (read_mode == IDLE_READS) {
++register_reads;
router_test_sio.mtime = simulated_start +
simulated_elapsed * (register_reads - 1u) / (FS_IDLE_POLLS + 1u);
router_test_sio.gpio_hi_in = (register_reads == nonidle_read ? 2u : 1u) << 24;
} else if (read_mode == DRAIN_READS) {
++register_reads;
// SE0 is observed at zero and returns to J after the requested delay.
// A subsequent K either becomes SOP after qualified EOP or remains
// untrusted packet data until the real capture drain timeout expires.
router_test_sio.mtime = register_reads < 4u ? 0u :
register_reads < 9u ? simulated_elapsed : FS_CLOCK_HZ / 10000u;
router_test_sio.gpio_hi_in =
(register_reads < 4u ? 0u : register_reads < 7u ? 1u : 2u) << 24;
}
return &router_test_sio;
}
#undef sio_hw
#define sio_hw (&router_test_sio)
#define sio_hw router_test_read_registers()
#include "router.c"
usb_hw_t native_test_usb;
@ -37,6 +61,15 @@ bool native_hub_select_device(uint8_t address, uint8_t owner, uint32_t cutoff) {
return accept_selection;
}
#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
void native_hub_note_selected_token(uint8_t address, uint8_t owner, uint32_t cutoff, uint8_t pid) {
(void)address; (void)owner; (void)cutoff; (void)pid;
}
void native_hub_note_failed_select(uint8_t address, uint8_t owner, uint32_t cutoff, uint8_t pid) {
(void)address; (void)owner; (void)cutoff; (void)pid;
}
#endif
static const routing_table* current_table(void) {
uint32_t generation;
return acquire_table(&generation);
@ -99,7 +132,163 @@ static void expect_prefixes(const routing_table* table, const uint8_t* addresses
}
}
static void test_clock_and_phase_guards(void) {
const uint32_t bad_clocks[] = {
FS_CLOCK_HZ == 240000000u ? 300000000u : 240000000u,
FS_CLOCK_HZ + 1u,
150000000u,
};
for (unsigned i = 0; i < sizeof(bad_clocks) / sizeof(bad_clocks[0]); ++i) {
probe_router_init(bad_clocks[i]);
assert(!probe_router_set_phase(0));
// Even a stale ready flag cannot arm a differently compiled receiver.
counters.ready = 1;
probe_router_enable(true);
selections = 0;
raw_packet packet = {0};
route_header(current_table(),0,TOKEN_SETUP_SIGNATURE,127,100,&packet);
assert(!selections && !packet.retargets);
}
probe_router_init(FS_CLOCK_HZ);
assert(probe_router_set_phase(0));
assert(probe_router_set_phase(FS_BIT_CYCLES - 1u));
assert(!probe_router_set_phase(FS_BIT_CYCLES));
assert(!probe_router_set_phase(UINT32_MAX));
counters.ready = 1;
probe_router_enable(true);
assert(!probe_router_set_phase(0));
probe_router_enable(false);
assert(probe_router_set_phase(0));
}
static void test_sample_deadlines(void) {
const uint32_t bit_cycles = FS_CLOCK_MHZ == 300u ? 25u : 20u;
uint32_t deadline = 1000u, line = LINE_SE1;
router_test_sio.mtime = deadline;
router_test_sio.gpio_hi_in = LINE_J << 24;
assert(sample_line(&deadline,&line));
assert(line == LINE_J && deadline == 1000u + bit_cycles);
router_test_sio.mtime = deadline + bit_cycles - 1u;
router_test_sio.gpio_hi_in = LINE_K << 24;
assert(sample_line(&deadline,&line));
assert(line == LINE_K && deadline == 1000u + 2u * bit_cycles);
router_test_sio.mtime = deadline + bit_cycles;
router_test_sio.gpio_hi_in = LINE_J << 24;
assert(!sample_line(&deadline,&line));
assert(line == LINE_K && deadline == 1000u + 2u * bit_cycles);
deadline = UINT32_MAX - bit_cycles + 1u;
router_test_sio.mtime = deadline;
assert(sample_line(&deadline,&line));
assert(line == LINE_J && deadline == 0u);
router_test_sio.mtime = bit_cycles - 1u;
assert(sample_line(&deadline,&line) && deadline == bit_cycles);
}
static void test_drain_qualification(void) {
const uint32_t bit_cycles = FS_CLOCK_MHZ == 300u ? 25u : 20u;
read_mode = IDLE_READS;
simulated_start = UINT32_MAX - 100u;
simulated_elapsed = 8u * bit_cycles - 1u;
register_reads = nonidle_read = 0;
assert(!observe_idle_j());
simulated_elapsed = 8u * bit_cycles;
register_reads = 0;
assert(observe_idle_j());
register_reads = 0;
nonidle_read = FS_IDLE_POLLS / 2u;
assert(!observe_idle_j());
read_mode = DRAIN_READS;
simulated_elapsed = (bit_cycles + 1u) / 2u - 1u;
register_reads = 0;
raw_packet packet = capture_packet(PROBE_ROUTER_DEFAULT_PHASE,current_table(),true);
assert(!packet.sop && packet.resync);
simulated_elapsed = (bit_cycles + 1u) / 2u;
register_reads = 0;
packet = capture_packet(PROBE_ROUTER_DEFAULT_PHASE,current_table(),true);
assert(packet.sop && packet.late);
read_mode = MANUAL_READS;
}
#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
static void test_root_response_attribution(void) {
const uint32_t nak = 0x96a5a666u, data1 = 0x965aa666u;
probe_router_stats snapshot;
probe_router_init(FS_CLOCK_HZ);
counters.ready = 1;
probe_router_enable(true);
uint8_t addresses[PROBE_ROUTER_SLOTS];
for (unsigned i = 0; i < PROBE_ROUTER_SLOTS; ++i) addresses[i] = 5u+i;
probe_router_publish(addresses,PROBE_ROUTER_UNASSIGNED);
const routing_table* table = current_table();
raw_packet packet = {0};
// Two decisions for one physical token must retain a single observation.
route_header(table,5,TOKEN_IN_SIGNATURE,0,1000,&packet);
route_header(table,5,TOKEN_IN_SIGNATURE,0,1000,&packet);
router_test_sio.mtime = 1100;
observe_discarded_header(nak);
root_observe_eop(1200,true);
probe_router_snapshot(&snapshot);
assert(snapshot.root_in_count == 1 && snapshot.root_header == nak);
assert(snapshot.root_header_cycle == 1100 && snapshot.root_eop_cycle == 1200);
// Recovery's own response cannot erase the pre-SETUP NAK observation.
route_header(table,5,TOKEN_SETUP_SIGNATURE,0,2000,&packet);
route_header(table,5,TOKEN_IN_SIGNATURE,0,2200,&packet);
router_test_sio.mtime = 2250;
observe_discarded_header(data1);
root_observe_eop(2300,true);
probe_router_snapshot(&snapshot);
assert(snapshot.root_header == data1);
assert(snapshot.before_setup_in_count == 1 && snapshot.before_setup_in_cutoff == 1000);
assert(snapshot.before_setup_header == nak && snapshot.before_setup_header_cycle == 1100);
assert(snapshot.before_setup_eop_cycle == 1200);
// A child token, even a rejected or unmapped one, ends root attribution.
for (unsigned kind = 0; kind < 3; ++kind) {
route_header(table,5,TOKEN_IN_SIGNATURE,0,3000+kind*1000,&packet);
accept_selection = kind != 2;
route_header(table,kind == 1 ? 127 : 6,TOKEN_IN_SIGNATURE,0,3100+kind*1000,&packet);
accept_selection = true;
observe_discarded_header(nak);
root_observe_eop(3200+kind*1000,true);
probe_router_snapshot(&snapshot);
assert(snapshot.root_header == 0 && snapshot.root_eop_cycle == 0);
}
// Idle qualification is not an observed EOP; a later packet cannot fill it.
route_header(table,5,TOKEN_IN_SIGNATURE,0,6000,&packet);
observe_discarded_header(nak);
root_observe_eop(6100,false);
root_observe_eop(6200,true);
probe_router_snapshot(&snapshot);
assert(snapshot.root_header == nak && snapshot.root_eop_cycle == 0);
counters.root_in_count = UINT32_MAX;
route_header(table,5,TOKEN_IN_SIGNATURE,0,7000,&packet);
route_header(table,5,TOKEN_IN_SIGNATURE,0,7000,&packet);
probe_router_snapshot(&snapshot);
assert(snapshot.root_in_count == 0);
probe_router_init(FS_CLOCK_HZ);
observe_discarded_header(nak);
probe_router_snapshot(&snapshot);
assert(snapshot.root_header == 0 && snapshot.before_setup_header == 0);
}
#endif
int main(void) {
test_clock_and_phase_guards();
test_sample_deadlines();
test_drain_qualification();
#if defined(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
test_root_response_attribution();
#endif
probe_router_init(FS_CLOCK_HZ);
// Simulate observer readiness, not USB timing; this enables the actual
// routing decision without starting the hardware-bound sampling loop.

View file

@ -4,6 +4,9 @@
#include "hardware_stub.h"
static uint32_t startup_clock_hz = NATIVE_TEST_SYS_CLOCK_HZ;
#define clock_get_hz(clock) ((void)(clock), startup_clock_hz)
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);
@ -195,7 +198,7 @@ static void startup_wait(void) {
void probe_router_init(uint32_t hz) {
observe_pullup();
assert(hz == 240000000u);
assert(hz == FS_CLOCK_HZ);
observer_initialized = true;
observer_start_us = native_test_time_us;
memset(routed_addresses,NONE,sizeof(routed_addresses));
@ -217,7 +220,7 @@ void probe_router_enable(bool enabled) {
}
bool probe_router_set_phase(uint32_t phase) {
(void)phase;
assert(phase == PROBE_ROUTER_DEFAULT_PHASE && phase < FS_BIT_CYCLES);
observe_pullup();
return true;
}
@ -247,6 +250,10 @@ 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 if (strcmp(argv[1],"mismatched-clock") == 0)
startup_clock_hz = FS_CLOCK_HZ == 240000000u ? 300000000u : 240000000u;
else if (strcmp(argv[1],"unsupported-clock") == 0) startup_clock_hz = 150000000u;
else if (strcmp(argv[1],"inexact-clock") == 0) startup_clock_hz = FS_CLOCK_HZ + 1u;
else assert(strcmp(argv[1],"ready") == 0);
// No native_test_initialize/startup helper: call the actual initializer
@ -254,6 +261,13 @@ int main(int argc, char** argv) {
assert(!physical_pullup());
bool initialized = native_hub_init();
observe_pullup();
if (startup_clock_hz != FS_CLOCK_HZ) {
assert(!initialized && !started && !physical_pullup());
assert(!attach_edges && !detach_edges && !enumeration_done && !setup_irqs);
assert(!controller_resets && !observer_initialized && !observer_launched);
assert(!irq_enabled && !addresses_published && !routing_enabled && !wait_us);
return 0;
}
assert(controller_resets == 1 && detach_edges == 0);
if (observer_never_ready) {
assert(!initialized && !started && !physical_pullup());

View file

@ -10,6 +10,11 @@
#define __dmb() ((void)0)
typedef struct { unsigned unused; } spin_lock_t;
typedef struct {
void (*out_chars)(const char*, int);
void (*out_flush)(void);
} stdio_driver_t;
extern stdio_driver_t stdio_uart;
extern uint32_t native_test_interrupt_mask;
void native_test_service_interrupt(void);
static inline uint32_t save_and_disable_interrupts(void) {
@ -51,7 +56,7 @@ typedef struct {
uint8_t ep0_buf_a[64];
uint8_t padding[3776];
} usb_device_dpram_t;
typedef struct { volatile uint32_t mtime, gpio_hi_oe_clr; } sio_hw_t;
typedef struct { volatile uint32_t mtime, gpio_hi_oe_clr, gpio_hi_in; } sio_hw_t;
extern usb_hw_t native_test_usb;
extern usb_device_dpram_t native_test_dpram;
extern sio_hw_t native_test_sio;
@ -104,7 +109,12 @@ static inline void hw_set_bits(volatile uint32_t* address, uint32_t bits) {
#define USBCTRL_IRQ 0u
#define clk_sys 0u
static inline uint32_t clock_get_hz(unsigned clock) { (void)clock; return 240000000u; }
#ifdef SWITCH_PICO_SYS_CLOCK_MHZ
#define NATIVE_TEST_SYS_CLOCK_HZ (SWITCH_PICO_SYS_CLOCK_MHZ * 1000000u)
#else
#define NATIVE_TEST_SYS_CLOCK_HZ 240000000u
#endif
static inline uint32_t clock_get_hz(unsigned clock) { (void)clock; return NATIVE_TEST_SYS_CLOCK_HZ; }
static inline void reset_block(uint32_t mask) { (void)mask; }
static inline void unreset_block_wait(uint32_t mask) { (void)mask; }
static inline void multicore_launch_core1(void (*entry)(void)) { (void)entry; }

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -0,0 +1,2 @@
#pragma once
#include "hardware_stub.h"

View file

@ -1437,6 +1437,72 @@ static void approved_status_reset_during_completion(void) {
expect_no_control_packets();
}
static bool reset_hook_seen, reset_hook_bus_reset;
static void service_during_child_reset(uint8_t instance) {
const uint8_t target = CHILDREN > 2 ? 3 : 2;
assert(instance == target-1u);
native_test_reset_hook = NULL;
reset_hook_seen = true;
if (reset_hook_bus_reset) {
native_test_bus_reset(false);
return;
}
// A sibling SETUP can arrive while reset bookkeeping reads stored state.
// Its IRQ must release SETUP_REC before the next root interrupt poll.
const tusb_control_request_t descriptor = descriptor_request();
assert(native_test_setup(1,&descriptor,false));
uint8_t packet[PACKET];
uint16_t length = 0;
assert(native_test_private_in(0,0x8f,packet,&length) &&
"child reset callback blocked the next hub status-change poll");
assert(length == 1 && packet[0] == (1u << target));
}
static void port_reset_interrupt_progress(bool bus_reset) {
const uint8_t target = CHILDREN > 2 ? 3 : 2;
uint8_t packet[PACKET];
uint16_t length = 0;
const tusb_control_request_t prepare[] = {
{.bRequest = TUSB_REQ_SET_ADDRESS, .wValue = 9},
{.bRequest = TUSB_REQ_SET_CONFIGURATION, .wValue = 1},
{.bmRequestType = 0x23, .bRequest = TUSB_REQ_SET_FEATURE,
.wValue = 8, .wIndex = target},
};
for (unsigned i = 0; i < sizeof(prepare)/sizeof(prepare[0]); ++i) {
assert(native_test_setup(0,&prepare[i],true));
assert(native_test_in(0,packet,&length,true) && length == 0);
}
const tusb_control_request_t reset = {
.bmRequestType = 0x23, .bRequest = TUSB_REQ_SET_FEATURE,
.wValue = 4, .wIndex = target,
};
assert(native_test_setup(0,&reset,true));
reset_hook_bus_reset = bus_reset;
native_test_reset_hook = service_during_child_reset;
assert(native_test_in(0,packet,&length,true) && length == 0);
assert(reset_hook_seen);
const tusb_control_request_t descriptor = descriptor_request();
if (bus_reset) {
assert(default_device == 0 && addresses[0] == 0 &&
devices[0].configuration == 0);
for (unsigned p = 0; p < CHILDREN; ++p)
assert(ports[p].status == 0 && ports[p].change == 0);
assert(native_test_setup(0,&descriptor,true));
assert(native_test_in(0,packet,&length,true) && length == 18);
assert(native_test_out(0,NULL,0,true));
return;
}
assert(native_test_in(1,packet,&length,true) && length == 18);
assert(memcmp(packet,hub_device,length) == 0);
assert(native_test_out(1,NULL,0,true));
native_test_advance(10000u);
assert(default_device == target && addresses[target] == 0);
assert(native_test_setup(target,&descriptor,true));
assert(native_test_in(target,packet,&length,true) && length == 18);
assert(native_test_out(target,NULL,0,true));
}
static void approved_status_port_reset(bool queued_status) {
const uint8_t target = CHILDREN;
uint8_t data[PACKET];
@ -2208,6 +2274,8 @@ int main(int argc, char** argv) {
else if (strcmp(argv[1],"approved-status-reset-during-completion") == 0) approved_status_reset_during_completion();
else if (strcmp(argv[1],"approved-status-port-reset-ready") == 0) approved_status_port_reset(false);
else if (strcmp(argv[1],"approved-status-port-reset-queued") == 0) approved_status_port_reset(true);
else if (strcmp(argv[1],"port-reset-interrupt-progress") == 0) port_reset_interrupt_progress(false);
else if (strcmp(argv[1],"port-reset-interrupt-reset") == 0) port_reset_interrupt_progress(true);
else if (strcmp(argv[1],"approved-status-invalid-length") == 0) approved_status_invalid_length();
else if (strcmp(argv[1],"approved-status-watch") == 0) approved_status_watch();
else if (strcmp(argv[1],"status-out-rejected-data") == 0) status_out_rejected_data();

View file

@ -14,6 +14,7 @@ uint32_t native_test_received_count[CHILDREN][2];
uint16_t native_test_received_length[CHILDREN][2];
uint8_t native_test_received_data[CHILDREN][2][PACKET];
static bool servicing_interrupt;
static void (*native_test_reset_hook)(uint8_t instance);
#ifndef NATIVE_TEST_EXTERNAL_IRQ
void native_test_service_interrupt(void) {
@ -42,7 +43,9 @@ const uint8_t* native_joycon_configuration_descriptor(uint8_t instance) { (void)
const uint16_t* native_joycon_string_descriptor(uint8_t instance, uint8_t index, uint16_t language) {
(void)instance; (void)language; return hub_string(index);
}
void native_joycon_usb_reset(uint8_t instance) { (void)instance; }
void native_joycon_usb_reset(uint8_t instance) {
if (native_test_reset_hook) native_test_reset_hook(instance);
}
const uint8_t* tud_hid_descriptor_report_cb(uint8_t instance) { (void)instance; return NULL; }
uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t id, hid_report_type_t type, uint8_t* data, uint16_t length) {
(void)instance; (void)id; (void)type; (void)data; (void)length; return 0;
@ -86,6 +89,7 @@ void native_test_initialize(void) {
native_test_abort_stuck = false;
native_test_interrupt_mask = 0;
servicing_interrupt = false;
native_test_reset_hook = NULL;
failed = bus_suspended = false;
bank_lock = spin_lock_instance(0);
active_device = default_device = 0;

View file

@ -76,7 +76,7 @@ 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) {
bool bluepad32_input_backend_native_rumble_submit(uint8_t, const NativeHapticsActuatorFrame*) {
assert(false && "gameplay motor dispatch belongs to the native backend fixture");
return false;
}

View file

@ -681,10 +681,21 @@ 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}};
NativeHapticsActuatorFrame output;
memset(&output, 0xa5, sizeof(output));
NativeHapticsActuatorFrame before;
memcpy(&before, &output, sizeof(before));
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);
assert(memcmp(&output, &before, sizeof(output)) == 0);
}
static void expect_wave(const NativeHapticsSample* sample, uint16_t low_frequency,
uint16_t high_frequency, uint16_t low_amplitude,
uint16_t high_amplitude) {
assert(sample->low_frequency_code == low_frequency);
assert(sample->high_frequency_code == high_frequency);
assert(sample->low_amplitude == low_amplitude);
assert(sample->high_amplitude == high_amplitude);
}
static void test_native_rumble(void) {
@ -695,49 +706,52 @@ static void test_native_rumble(void) {
{0x50, 0x81, 0x01, 0x10, 0x1e, 0x00},
{0x52, 0x9f, 0x19, 0xe0, 0x9d, 0x00},
};
probe_rumble_frame output;
NativeHapticsActuatorFrame 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(output.sample_count == 1);
expect_wave(&output.samples[0], i ? 415 : 385, i ? 478 : 481, i ? 6 : 0, i ? 2 : 0);
assert(probe_protocol_decode_rumble(1, wire + 1, 63, &output));
assert(output.count == 1 && output.magnitude[0] == i);
assert(output.sample_count == 1);
expect_wave(&output.samples[0], i ? 415 : 385, i ? 478 : 481, i ? 6 : 0, i ? 2 : 0);
}
// 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.
// All four fields cross byte boundaries. The decoder preserves codes even
// outside a particular output actuator's renderable frequency range.
static const struct {
uint8_t sample[5];
uint8_t expected;
uint16_t low, high;
} 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
{{0xff, 0x03, 0xf0, 0x3f, 0x00}, 0, 0},
{{0xff, 0x0b, 0xf0, 0x3f, 0x00}, 2, 0},
{{0xff, 0x03, 0xf0, 0xff, 0x00}, 0, 3},
{{0xff, 0xff, 0xf0, 0x3f, 0x00}, 63, 0},
{{0xff, 0x03, 0xf1, 0x3f, 0x00}, 64, 0},
{{0xff, 0xff, 0xf7, 0x3f, 0x80}, 511, 512},
{{0xff, 0x03, 0xf8, 0xff, 0x7f}, 512, 511},
{{0xff, 0xff, 0xff, 0x3f, 0x00}, 1023, 0},
{{0xff, 0x03, 0xf0, 0xff, 0xff}, 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);
assert(output.sample_count == 1);
expect_wave(&output.samples[0], 1023, 1023, boundaries[i].low, boundaries[i].high);
}
// 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
0x81, 0x05, 0x18, 0x5e, 0x40, // codes 385/481, amplitudes 513/257
0x82, 0x05, 0x18, 0x5e, 0x40, // adjacent frequency survives, no 7-bit quantization
0x83, 0x05, 0x18, 0x5e, 0x40,
};
static const size_t lengths[] = {17, 42, 64};
memcpy(wire + 1, ordered, sizeof(ordered));
@ -748,9 +762,9 @@ static void test_native_rumble(void) {
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);
assert(output.sample_count == count);
for (unsigned sample = 0; sample < count; ++sample)
expect_wave(&output.samples[sample], (uint16_t)(385 + sample), 481, 513, 257);
}
}
}
@ -758,9 +772,9 @@ static void test_native_rumble(void) {
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(output.sample_count == 0);
assert(probe_protocol_decode_rumble(1, wire + 1, 16, &output));
assert(output.count == 0);
assert(output.sample_count == 0);
// Complete 16-byte block required even for HOLD or a one-sample update.
for (unsigned count = 0; count <= 3; ++count) {

View file

@ -34,6 +34,21 @@ SwitchHapticsFrame one_side(unsigned side, SwitchHapticsSample sample = state())
return frame;
}
NativeHapticsSample native_state(uint16_t low_code = 385, uint16_t low = 1023,
uint16_t high_code = 481, uint16_t high = 0) {
return {low_code, high_code, low, high};
}
NativeHapticsFrame native_side(unsigned side, NativeHapticsSample sample = native_state()) {
NativeHapticsFrame frame{};
frame.actuators[side] = {1, {sample}};
return frame;
}
uint16_t native_q15(uint16_t amplitude) {
return static_cast<uint16_t>((uint32_t{amplitude} * 32768 + 511) / 1023);
}
std::vector<uint8_t> render(SwitchHdRumbleSynth& synth, uint64_t first,
uint32_t frames) {
std::vector<uint8_t> pcm(static_cast<size_t>(frames) * 2, 0xcc);
@ -556,6 +571,194 @@ void test_duplicate_order_and_invalid_frames() {
}, "duplicate timestamp last-wins without phase reset or malformed-state mutation");
}
void test_native_precision_and_bands() {
for (uint16_t code : {1, 193, 385, 481, 482, 483, 670}) {
for (unsigned band = 0; band < 2; ++band) {
SwitchHdRumbleSynth synth;
synth.reset(0);
const unsigned side = band;
const auto frame = native_side(side, native_state(code, band ? 0 : 1023,
code, band ? 1023 : 0));
std::vector<uint8_t> pcm(12000);
for (unsigned first = 0; first < 6000; first += 60) {
expect(synth.push_native(frame, first * 1000 / 3),
"native periodic refresh accepted");
synth.render(first, 60, pcm.data() + first * 2);
}
const double hz = 10 * std::exp2((code - 1) / 96.0);
expect_wave(pcm, side, [hz](size_t n) { return wave(hz * n / 3000); },
"native 96-step frequency retains wire precision");
expect_wave(pcm, 1 - side, [](size_t) { return 0; },
"native bands stay on their physical actuator");
expect(spectral_amplitude(pcm, side, hz) > 125,
"native PCM has its expected physical spectral peak");
if (code >= 481 && code <= 483) {
const double adjacent = 10 * std::exp2(code / 96.0);
expect(spectral_amplitude(pcm, side, adjacent) < 15,
"adjacent native codes are spectrally distinct, not rounded to Switch indices");
}
}
}
SwitchHdRumbleSynth synth;
synth.reset(0);
synth.push_native(native_side(0, native_state(385, 682, 481, 341)), 0);
expect_wave(render(synth, 0, 150), 0, [](size_t n) {
return 127.0 * (2 * std::sin(kTau * 160 * n / 3000) +
std::sin(kTau * 320 * n / 3000)) / 3;
}, "native joint gain preserves independent band mixture");
std::vector<uint8_t> previous;
for (uint16_t amplitude : {128, 129}) {
synth.reset(0);
synth.push_native(native_side(0, native_state(385, amplitude)), 0);
auto pcm = render(synth, 0, 150);
expect_wave(pcm, 0, [amplitude](size_t n) {
return wave(160.0 * n / 3000, native_q15(amplitude));
}, "native amplitude normalizes all ten bits before existing gain");
if (!previous.empty()) expect(previous != pcm, "adjacent ten-bit amplitudes remain distinguishable");
previous = pcm;
}
}
void test_native_windows_watchdogs_and_legacy() {
SwitchHdRumbleSynth synth;
synth.reset(0);
NativeHapticsFrame frame{};
frame.actuators[0] = {3, {native_state(), native_state(385, 0), native_state(385, 512)}};
frame.actuators[1] = {2, {native_state(385, 0), native_state(385, 0, 481, 1023)}};
synth.push_native(frame, 0);
synth.push_native(native_side(0, native_state(385, 512)), 20000);
synth.push_native(NativeHapticsFrame{}, 40000);
const auto pcm = render(synth, 0, 230);
expect_wave(pcm, 0, [](size_t n) {
return n < 210 ? wave(160.0 * n / 3000,
n < 16 ? 32768 : n < 32 ? 0 : native_q15(512)) : 0;
}, "native samples use fixed 16-frame spacing with an independent refreshed watchdog");
expect_wave(pcm, 1, [](size_t n) {
return n >= 16 && n < 150 ? wave(320.0 * n / 3000) : 0;
}, "native two-sample update uses 16-frame spacing and untouched side expires at 50 ms");
synth.reset(0);
synth.push_native(frame, 0);
auto legacy = one_side(0);
legacy.actuators[0] = {3, {state(), state(64, 0), state(64, 16384)}};
synth.push(legacy, 0);
const auto mixed = render(synth, 0, 40);
expect_wave(mixed, 0, [](size_t n) {
return wave(160.0 * n / 3000, n < 8 ? 32768 : n < 16 ? 0 : 16384);
}, "legacy replacement uses its own 8 ms window in a native stream");
expect_wave(mixed, 1, [](size_t n) { return n < 16 ? 0 : wave(320.0 * n / 3000); },
"legacy partial update does not shorten the other native window");
expect(!synth.push_native(frame, UINT64_MAX),
"native and legacy updates share host timestamp ordering");
synth.reset(0);
synth.push_rumble(255, 255, 0);
synth.push_native(native_side(0, native_state(385, 0)), 1000);
auto held = render(synth, 0, 300);
expect_wave(held, 0, [](size_t n) { return n < 3 ? wave(160.0 * n / 3000) : 0; },
"native zero stops the targeted persistent motor");
expect_wave(held, 1, [](size_t n) { return wave(320.0 * n / 3000); },
"native zero-count side preserves stateful XInput output");
}
void test_native_late_overflow_and_cancellation() {
NativeHapticsFrame steps{};
steps.actuators[0] = {3, {native_state(), native_state(385, 512), native_state(385, 256)}};
SwitchHdRumbleSynth synth;
synth.reset(10000);
expect(synth.push_native(steps, 4000), "recent pre-epoch native update accepted");
expect_wave(render(synth, 0, 150), 0, [](size_t n) {
return n < 132 ? wave(160.0 * n / 3000, native_q15(n < 14 ? 512 : 256)) : 0;
}, "pre-epoch native update keeps original 16-frame sample positions and expiry");
synth.reset(0);
render(synth, 0, 40);
synth.push_native(steps, 0);
expect_wave(render(synth, 40, 130), 0, [](size_t n) {
return n + 40 < 150 ? wave(160.0 * (n + 40) / 3000, native_q15(256)) : 0;
}, "late native update skips elapsed substeps without refreshing expiry");
synth.reset(100000);
expect(!synth.push_native(steps, 50000), "expired native pre-epoch command rejected");
SwitchHdRumbleSynth reference;
synth.reset(0);
reference.reset(0);
for (unsigned n = 0; n < 40; ++n) {
const auto frame = native_side(n % 2, native_state(static_cast<uint16_t>(385 + n % 5)));
synth.push_native(frame, n * 1000);
reference.push_native(frame, n * 1000);
render(reference, n * 3, 3);
}
expect(synth.dropped_updates() > 0, "native bounded timeline accounts for overflow");
expect_silent(render(synth, 0, 30), "native overflow never replays discarded history");
expect(render(synth, 120, 120) == render(reference, 120, 120),
"native overflow preserves partial sides and full-precision accumulated phases");
synth.reset(0);
auto both = native_side(0);
both.actuators[1] = both.actuators[0];
synth.push_native(both, 0);
render(synth, 0, 15);
synth.push_native(both, 10000); // Queued update must also be canceled.
synth.feedback(5000, 5000, 0, 255);
synth.cancel_native(1);
const auto canceled = render(synth, 15, 90);
expect_wave(canceled, 0, [](size_t n) {
return n < 15 ? feedback_wave(320.0 * (n + 15) / 3000) : 0;
}, "native cancel preserves overlay but removes live and queued left host work");
expect_wave(canceled, 1, [](size_t n) {
return n < 15 ? feedback_wave(320.0 * (n + 15) / 3000) :
wave(160.0 * (n + 15) / 3000);
}, "native cancellation leaves the other side and its queued updates intact");
synth.push_native(native_side(0), 35000);
expect_wave(render(synth, 105, 30), 0, [](size_t n) {
return wave(160.0 * (n + 105) / 3000);
}, "fresh native command after cancellation resumes without oscillator reset");
synth.cancel_native(3);
expect_silent(render(synth, 135, 120), "both-side cancellation is a lasting stop");
synth.push_native(both, 100000);
synth.reset(100000);
expect_silent(render(synth, 0, 150), "stream reset discards pending native work");
}
void test_native_validation_is_atomic() {
SwitchHdRumbleSynth synth;
synth.reset(0);
synth.push_native(native_side(0), 0);
auto invalid = native_side(0, native_state(385, 0));
invalid.actuators[1] = {1, {native_state(671)}};
expect(!synth.push_native(invalid, 2000), "unmeasured active native frequency rejects whole frame");
invalid.actuators[1] = {1, {native_state(0)}};
expect(!synth.push_native(invalid, 2000), "active code zero cannot generate DC");
invalid.actuators[1] = {1, {native_state(385, 1024)}};
expect(!synth.push_native(invalid, 2000), "native amplitude overflow rejected");
invalid.actuators[1].sample_count = 4;
expect(!synth.push_native(invalid, 2000), "native count overflow rejected");
expect(synth.push_native(native_side(1, native_state(0, 0, 1023, 0)), 1000),
"invalid frames do not advance timestamp ordering; silent bands accept wire range");
const auto pcm = render(synth, 0, 120);
expect_wave(pcm, 0, [](size_t n) { return wave(160.0 * n / 3000); },
"malformed right side cannot partially stop left host state");
expect_wave(pcm, 1, [](size_t) { return 0; }, "silent arbitrary codes never produce DC");
}
void test_native_side_feedback_preserves_host() {
SwitchHdRumbleSynth synth;
synth.reset(0);
auto both = native_side(0);
both.actuators[1] = both.actuators[0];
synth.push_native(both, 0);
synth.feedback_native(5000, 10000, 0, 255);
const auto pcm = render(synth, 0, 90);
expect_wave(pcm, 0, [](size_t n) { return wave(160.0 * n / 3000); },
"right native cue does not mute or attenuate the untouched left host");
expect_wave(pcm, 1, [](size_t n) {
return n >= 15 && n < 45 ? feedback_wave(320.0 * n / 3000) :
wave(160.0 * n / 3000);
}, "native cue overlays only the requested side and resumes live host on expiry");
}
} // namespace
int main() {
@ -573,6 +776,11 @@ int main() {
test_stateful_rumble_hd_order_and_watchdogs();
test_stateful_rumble_feedback_resume();
test_stateful_rumble_overflow_and_reset();
test_native_precision_and_bands();
test_native_windows_watchdogs_and_legacy();
test_native_late_overflow_and_cancellation();
test_native_validation_is_atomic();
test_native_side_feedback_preserves_host();
if (failures) {
std::cerr << failures << " synthesis scenarios failed\n";
return 1;

View file

@ -10,15 +10,14 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
for bluetooth_mode, native, short_packets, wii_bridge in (
("mixed", False, False, False),
("mixed", True, False, False),
("mixed", True, True, False),
("ble", False, False, False),
("classic", False, False, False),
("mixed", False, False, True),
for bluetooth_mode, native, wii_bridge in (
("mixed", False, False),
("mixed", True, False),
("ble", False, False),
("classic", False, False),
("mixed", False, True),
):
suffix = "_wii_bridge" if wii_bridge else "_native32" if short_packets else "_native64" if native else ""
suffix = "_wii_bridge" if wii_bridge else "_native32" if native else ""
executable = (
tmp_path / f"bluepad32_backend_lifecycle_test_{bluetooth_mode}{suffix}"
)
@ -42,7 +41,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
"-DSWITCH_PICO_HAPTICS_EXPERIMENT=1",
"-DSWITCH_PICO_HD_RUMBLE=1",
"-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=0",
f"-DSWITCH_PICO_HD_PACKET_FRAMES={32 if short_packets else 64}",
"-DSWITCH_PICO_HD_PACKET_FRAMES=32",
str(root / "src" / "firmware" / "input" / "haptics_experiment.cpp"),
str(
root
@ -68,7 +67,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
),
]
)
if short_packets:
if native:
command.extend(
[
"-DSWITCH_PICO_CYW43_PACKET_READ=1",
@ -116,7 +115,11 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
)
subprocess.run(command, check=True, cwd=root)
if wii_bridge:
for scenario in ("wii-bridge-sensors", "wii-bridge-cues", "wii-bridge-cue-races"):
for scenario in (
"wii-bridge-sensors",
"wii-bridge-cues",
"wii-bridge-cue-races",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)
continue
subprocess.run([str(executable), "transport-policy"], check=True, cwd=root)

View file

@ -0,0 +1,73 @@
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
def test_bluepad32_classic_discovery_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
sdk = Path(
os.environ.get("PICO_SDK_PATH", root / "build" / "_deps" / "pico_sdk-src")
)
btstack = sdk / "lib" / "btstack"
bluepad = root / "external" / "bluepad32" / "src" / "components" / "bluepad32"
if (
not (btstack / "src" / "btstack.h").is_file()
or not (bluepad / "bt" / "uni_bt_bredr.c").is_file()
):
pytest.skip("requires Pico SDK and Bluepad32 checkout")
compiler = shutil.which("cc") or shutil.which("gcc")
assert compiler is not None, "a host C compiler is required"
git = shutil.which("git")
assert git is not None, "Git is required to prepare the project patch"
relative = Path("src/components/bluepad32/bt/uni_bt_bredr.c")
patched = tmp_path / relative
patched.parent.mkdir(parents=True)
shutil.copyfile(bluepad / "bt" / "uni_bt_bredr.c", patched)
subprocess.run(
[
git,
"apply",
"--no-index",
f"--include={relative.as_posix()}",
str(root / "patches" / "bluepad32-sdl3-imu.patch"),
],
cwd=tmp_path,
check=True,
)
executable = tmp_path / "classic_discovery_test"
subprocess.run(
[
compiler,
"-std=c11",
"-O2",
"-Wall",
"-Wextra",
"-Werror",
"-ffunction-sections",
"-fdata-sections",
f"-I{root / 'tests' / 'btstack_credit_batch_native_stubs'}",
f"-I{root / 'bluepad32_config'}",
f"-I{bluepad / 'include'}",
f"-I{btstack / 'src'}",
f"-I{btstack / 'platform' / 'embedded'}",
f"-I{btstack / '3rd-party' / 'bluedroid' / 'encoder' / 'include'}",
f"-I{btstack / '3rd-party' / 'bluedroid' / 'decoder' / 'include'}",
f"-I{btstack / '3rd-party' / 'yxml'}",
str(root / "tests" / "bluepad32_classic_discovery_test.c"),
str(patched),
str(bluepad / "bt" / "uni_bt_conn.c"),
str(btstack / "src" / "btstack_util.c"),
str(btstack / "src" / "btstack_run_loop.c"),
"-Wl,--gc-sections",
"-o",
str(executable),
],
cwd=root,
check=True,
)
subprocess.run([str(executable)], cwd=root, check=True)

View file

@ -7,10 +7,7 @@ import pytest
@pytest.mark.parametrize("ram", [0, 1], ids=["flash", "sram"])
@pytest.mark.parametrize("packet_frames", [32, 64], ids=["32frames", "64frames"])
def test_haptics_experiment_native(
tmp_path: Path, ram: int, packet_frames: int
) -> None:
def test_haptics_experiment_native(tmp_path: Path, ram: int) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
@ -27,16 +24,10 @@ def test_haptics_experiment_native(
"-pedantic",
"-DSWITCH_PICO_HAPTICS_EXPERIMENT=1",
f"-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM={ram}",
f"-DSWITCH_PICO_HD_PACKET_FRAMES={packet_frames}",
*(
[
"-DSWITCH_PICO_CYW43_PACKET_READ=1",
"-DSWITCH_PICO_HCI_CREDIT_BATCH=1",
"-DSWITCH_PICO_SYS_CLOCK_MHZ=300",
]
if packet_frames == 32
else []
),
"-DSWITCH_PICO_HD_PACKET_FRAMES=32",
"-DSWITCH_PICO_CYW43_PACKET_READ=1",
"-DSWITCH_PICO_HCI_CREDIT_BATCH=1",
"-DSWITCH_PICO_SYS_CLOCK_MHZ=300",
f"-I{root / 'tests' / 'haptics_experiment_native_stubs'}",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "haptics_experiment_test.cpp"),
@ -52,7 +43,7 @@ def test_haptics_experiment_native(
)
subprocess.run([str(executable), str(corpus)], check=True, cwd=root)
reports = corpus.read_bytes()
assert len(reports) == (18432 // packet_frames) * 143
assert len(reports) == (18432 // 32) * 143
# Independent standard-library CRC across real module-generated packets:
# A2 is covered once, CRC itself excluded, and stored little-endian.
for offset in range(0, len(reports), 143):

View file

@ -7,11 +7,9 @@ from pathlib import Path
import pytest
@pytest.mark.parametrize("source", ("GAMEPAD", "DUALSENSE"))
@pytest.mark.parametrize("controller_count", (2, 4))
def test_native_gamepad_backend_native(
tmp_path: Path, source: str, controller_count: int
) -> None:
def compile_native_backend(
tmp_path: Path, source: str, controller_count: int, hd: bool = False
) -> tuple[Path, Path]:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
assert compiler is not None, "a host C++ compiler is required"
@ -28,6 +26,25 @@ def test_native_gamepad_backend_native(
firmware / "input" / "controller_macro_capture.cpp",
root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c",
]
hd_flags = []
if hd:
hd_flags = [
"-DSWITCH_PICO_HAPTICS_EXPERIMENT=1",
"-DSWITCH_PICO_HD_RUMBLE=1",
"-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=0",
"-DSWITCH_PICO_HD_PACKET_FRAMES=32",
"-DSWITCH_PICO_CYW43_PACKET_READ=1",
"-DSWITCH_PICO_HCI_CREDIT_BATCH=1",
"-DSWITCH_PICO_SYS_CLOCK_MHZ=300",
]
sources.extend(
[
firmware / "input" / "haptics_experiment.cpp",
firmware / "input" / "native_output_scheduler.cpp",
firmware / "input" / "switch_hd_rumble_synth.cpp",
firmware / "usb" / "switch" / "switch_haptics.cpp",
]
)
subprocess.run(
[
compiler,
@ -43,6 +60,7 @@ def test_native_gamepad_backend_native(
"-DSWITCH2_BRIDGE_FULL_INPUT=1",
f"-DSWITCH2_BRIDGE_{source}_INPUT=1",
f"-DPROBE_CONTROLLER_COUNT={controller_count}",
*hd_flags,
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
f"-I{firmware}",
f"-I{root / 'bluepad32_config'}",
@ -53,6 +71,15 @@ def test_native_gamepad_backend_native(
check=True,
cwd=root,
)
return root, executable
@pytest.mark.parametrize("source", ("GAMEPAD", "DUALSENSE"))
@pytest.mark.parametrize("controller_count", (2, 4))
def test_native_gamepad_backend_native(
tmp_path: Path, source: str, controller_count: int
) -> None:
root, executable = compile_native_backend(tmp_path, source, controller_count)
scenarios = [
"stable-logical-slot",
"cue-lifetime",
@ -96,3 +123,17 @@ def test_native_gamepad_backend_native(
)
for scenario in scenarios:
subprocess.run([str(executable), scenario], check=True, cwd=root)
def test_native_gamepad_hd_backend_native(tmp_path: Path) -> None:
root, executable = compile_native_backend(tmp_path, "GAMEPAD", 4, hd=True)
for scenario in (
"hd-second-pair-wii",
"hd-second-pair-other",
"hd-unselected-dualsense",
"hd-cues",
"hd-reselection",
"hd-delayed-cue",
"hd-admission-cancel",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)

View file

@ -5,13 +5,14 @@ from pathlib import Path
import pytest
@pytest.mark.parametrize("clock_mhz", [240, 300], ids=["240MHz", "300MHz"])
@pytest.mark.parametrize(
("controller_count", "neutral_input"),
[(2, False), (2, True), (4, True)],
ids=["native-management", "neutral-one-pair", "neutral-two-pair"],
)
def test_native_hub_management_native(
tmp_path: Path, controller_count: int, neutral_input: bool
tmp_path: Path, controller_count: int, neutral_input: bool, clock_mhz: int
) -> None:
root = Path(__file__).resolve().parents[1]
cc = shutil.which("cc") or shutil.which("gcc")
@ -33,6 +34,7 @@ def test_native_hub_management_native(
"-fdata-sections",
"-DSWITCH2_PROBE_HUB=1",
f"-DPROBE_CONTROLLER_COUNT={controller_count}",
f"-DSWITCH_PICO_SYS_CLOCK_MHZ={clock_mhz}",
]
flags.append(f"-DSWITCH2_PROBE_NEUTRAL_INPUT={int(neutral_input)}")
transport = tmp_path / "native_hub_transport.o"
@ -78,33 +80,40 @@ def test_native_hub_management_native(
)
for reboot_slot in ("root", "child"):
subprocess.run([str(executable), reboot_slot], check=True, cwd=root)
router_executable = tmp_path / "native_hub_router_test"
subprocess.run(
[
cc,
"-std=c11",
*flags,
*includes,
str(root / "tests" / "native_hub_router_test.c"),
"-o",
str(router_executable),
],
check=True,
cwd=root,
)
subprocess.run([str(router_executable)], check=True, cwd=root)
for trace in (False, True):
router_executable = tmp_path / f"native_hub_router_test_{int(trace)}"
trace_flags = ["-DSWITCH2_PROBE_TRACE_NATIVE_INPUT=1"] if trace else []
subprocess.run(
[
cc,
"-std=c11",
*flags,
*trace_flags,
*includes,
str(root / "tests" / "native_hub_router_test.c"),
"-o",
str(router_executable),
],
check=True,
cwd=root,
)
subprocess.run([str(router_executable)], check=True, cwd=root)
@pytest.mark.parametrize("clock_mhz", [240, 300], ids=["240MHz", "300MHz"])
@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
tmp_path: Path, controller_count: int, trace_enabled: bool, clock_mhz: int
) -> 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}"]
flags = [
f"-DPROBE_CONTROLLER_COUNT={controller_count}",
f"-DSWITCH_PICO_SYS_CLOCK_MHZ={clock_mhz}",
]
if trace_enabled:
flags.append("-DSWITCH2_PROBE_TRACE_NATIVE_INPUT=1")
subprocess.run(
@ -131,5 +140,12 @@ def test_native_hub_cold_startup(
check=True,
cwd=root,
)
for scenario in ("ready", "delayed", "timeout"):
for scenario in (
"ready",
"delayed",
"timeout",
"mismatched-clock",
"unsupported-clock",
"inexact-clock",
):
subprocess.run([str(executable), scenario], check=True, cwd=root)

View file

@ -60,6 +60,8 @@ def test_native_hub_trace_lifecycle(tmp_path: Path, controller_count: int) -> No
"approved-status-reset-during-completion",
"approved-status-port-reset-ready",
"approved-status-port-reset-queued",
"port-reset-interrupt-progress",
"port-reset-interrupt-reset",
"approved-status-invalid-length",
"approved-status-watch",
"status-out-rejected-data",

View file

@ -101,6 +101,7 @@ def test_switch2_usb_probe_protocol(
f"-DSWITCH2_PROBE_NEUTRAL_INPUT={int(hub)}",
*([f"-DSWITCH2_PROBE_{imu_mode}=1"] if imu_mode else []),
f"-I{probe}",
f"-I{root / 'src' / 'firmware'}",
f"-I{tmp_path}",
f"-I{mbedtls / 'include'}",
str(probe / "protocol.c"),

View file

@ -790,7 +790,7 @@ std::vector<uint8_t> read_haptics_payload() {
require(read_u32(control_payload, 16) ==
configuration_crc32(payload.data(), payload.size()),
"experiment response CRC is invalid");
require(payload[71] == 0 && (payload[73] == 32 || payload[73] == 64) &&
require(payload[71] == 0 && payload[73] == 32 &&
payload[74] <= 1 && payload[75] == 0,
"experiment reserved payload bytes must remain zero");
return payload;
@ -851,7 +851,7 @@ void test_haptics_experiment_requests() {
}
#else
std::vector<uint8_t> expected(84, 0);
expected[73] = 64;
expected[73] = 32;
perform_haptics_out(3, 0, false);
perform_haptics_out(1, 4, false);
perform_haptics_out(0, 0xff, false);
@ -884,11 +884,30 @@ void test_haptics_experiment_requests() {
"busy start overwrote the accepted run");
// Model the independently progressing Core 1 service, not a USB echo.
current_haptics = {
1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4,
123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210,
1100000, HapticsExperimentState::kRunning, 2, 0, 1, 0x89abcdef, 0x12345678, 64, false,
};
current_haptics = {};
current_haptics.run_id = 1;
current_haptics.connection_generation = 0x11223344;
current_haptics.start_us = 0xffff0000;
current_haptics.generated_packets = 103;
current_haptics.sent_packets = 101;
current_haptics.skipped_packets = 2;
current_haptics.send_failures = 3;
current_haptics.can_send_requests = 106;
current_haptics.synchronous_callbacks = 4;
current_haptics.max_generate_us = 123;
current_haptics.max_send_gap_us = 22000;
current_haptics.max_lateness_us = 11001;
current_haptics.max_request_wait_us = 9876;
current_haptics.first_tone_due_us = 0xfffffff0;
current_haptics.first_tone_sent_us = 0x30;
current_haptics.last_sent_us = 0x76543210;
current_haptics.last_pcm_end_us = 0xfedcba98; // Internal cue coverage is not wire data.
current_haptics.elapsed_us = 1100000;
current_haptics.state = HapticsExperimentState::kRunning;
current_haptics.slot = 2;
current_haptics.mode = 1;
current_haptics.host_updates = 0x89abcdef;
current_haptics.dropped_updates = 0x12345678;
const uint32_t fields[] = {
1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4,
123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210, 1100000,