feat: add Set B profiles and low-latency native haptics

Add schema-6 profiles, atomic catalog migration, shortcuts, Shift, configurable Turbo/Burst, macro recording and playback. Promote qualified 300 MHz native DualSense transport to AIO/XInput defaults with bounded bus transactions, credit batching and generation-safe persistent rumble.
This commit is contained in:
Joey Yakimowich-Payne 2026-09-05 21:56:19 -06:00
commit 3c2d9fa723
75 changed files with 9599 additions and 1819 deletions

View file

@ -2,6 +2,11 @@
#include <cstring>
#include <iostream>
#include <string>
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
#include <algorithm>
#include <array>
#include <vector>
#endif
#include <uni.h>
#include "platform/pico/controller_color_config.h"
@ -433,7 +438,9 @@ void cyw43_arch_gpio_put(int, bool enabled) {
++observed_status_led_writes;
}
void multicore_launch_core1(void (*)()) {
void multicore_launch_core1_with_stack(void (*)(), uint32_t* stack, size_t size) {
require(stack != nullptr && size % 8 == 0,
"core 1 launch needs an aligned bounded stack");
++core1_launch_calls;
}
@ -445,6 +452,10 @@ uint32_t btstack_run_loop_get_time_ms() {
return now_ms;
}
uint32_t time_us_32() {
return now_ms * 1000u;
}
void switch2_wake_initialize() {
++switch2_wake_initializations;
@ -466,6 +477,55 @@ void switch2_wake_diagnostics(Switch2WakeDiagnostics*) {
#include "core/controller_identity.cpp"
#include "input/bluepad32_input_backend.cpp"
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
namespace {
std::vector<btstack_timer_source_t*> native_timers;
std::array<uint8_t, 143> last_native_packet{};
uint16_t last_native_cid = 0;
}
void native_test_add_timer(btstack_timer_source_t* timer) {
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) {
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; }
uint8_t l2cap_request_can_send_now_event(uint16_t cid) {
for (const auto& slot : g_slots) {
if (slot.device != nullptr && slot.device->conn.interrupt_cid == cid) {
require(haptics_experiment_on_can_send_now(slot.device, cid),
"native send permission was not consumed");
return ERROR_CODE_SUCCESS;
}
}
require(false, "native permission targeted a detached connection");
return 1;
}
uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) {
require(size == last_native_packet.size(), "native report size changed");
std::copy(data, data + size, last_native_packet.begin());
last_native_cid = cid;
return ERROR_CODE_SUCCESS;
}
void haptics_transport_probe_prepare() {}
void haptics_transport_probe_begin(uint32_t, uint32_t, uint16_t) {}
void haptics_transport_probe_end() {}
void haptics_transport_probe_timer(uint32_t) {}
void haptics_transport_probe_permission(uint32_t) {}
void haptics_transport_probe_send(uint32_t, uint32_t, bool) {}
#endif
namespace {
void require_clear_completion_pending() {
if (expected_pending_clear_token != 0) {
@ -2476,11 +2536,220 @@ void test_flash_core_init_fatal() {
"flash-safe Core1 init failure must halt before CYW43 init");
}
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) && defined(SWITCH_PICO_USB_OUTPUT_MODES)
void advance_native_backend(uint32_t duration_ms) {
const uint32_t end_ms = now_ms + duration_ms;
while (now_ms < end_ms) {
++now_ms;
for (;;) {
const auto due = std::find_if(
native_timers.begin(), native_timers.end(),
[](const auto* timer) {
return timer != &g_rumble_timer &&
timer != &g_configuration_timer &&
timer->due_ms <= now_ms;
});
if (due == native_timers.end()) break;
auto* timer = *due;
native_timers.erase(due);
timer->handler(timer);
}
if (now_ms % kRumblePollIntervalMs == 0)
process_rumble_timer(&g_rumble_timer);
}
}
void require_native_channels(bool left, bool right) {
HapticsExperimentDiagnostics status;
haptics_experiment_snapshot(&status);
require(status.state == HapticsExperimentState::kRunning &&
status.mode == 1,
"stateful host rumble lost native gameplay ownership");
unsigned active[2]{};
for (unsigned frame = 0; frame < status.packet_frames; ++frame) {
active[0] += last_native_packet[10 + frame * 2] != 0;
active[1] += last_native_packet[11 + frame * 2] != 0;
}
require((left ? active[0] > status.packet_frames / 2u : active[0] == 0) &&
(right ? active[1] > status.packet_frames / 2u : active[1] == 0),
"native PCM did not preserve the requested stateful channels");
}
void test_native_stateful_routing() {
start_pairing_backend();
test_adapter_mode = AdapterUsbMode::kXInput;
auto selected = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
selected.vendor_id = 0x054c;
selected.product_id = 0x0ce6;
selected.conn.connected = true;
selected.conn.interrupt_cid = 0x80;
selected.outgoing_buffer.queued = 1;
require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS,
"selected DualSense was rejected");
process_rumble_timer(&g_rumble_timer);
HapticsExperimentDiagnostics status;
haptics_experiment_snapshot(&status);
require(status.mode == 1 && status.state == HapticsExperimentState::kPending,
"XInput DualSense did not auto-arm into native Prepare");
const uint32_t generation = g_slots[0].connection_generation;
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{180, 0});
advance_native_backend(70);
const int prepare_calls = selected.rumble_calls;
selected.outgoing_buffer.queued = 0;
advance_native_backend(350);
require_native_channels(true, false);
require(selected.rumble_calls == prepare_calls &&
last_native_cid == selected.conn.interrupt_cid,
"XInput interleaved compatibility rumble into native PCM");
auto unselected = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
unselected.vendor_id = selected.vendor_id;
unselected.product_id = selected.product_id;
unselected.conn.connected = true;
unselected.conn.interrupt_cid = 0x82;
require(platform_on_device_ready(&unselected) == UNI_ERROR_SUCCESS,
"unselected DualSense was rejected");
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{22, 33});
advance_native_backend(10);
require(unselected.rumble_calls == 1 && unselected.last_low == 22 &&
unselected.last_high == 33,
"unselected controller lost compatibility fallback");
require(!haptics_experiment_submit_rumble(1, generation, time_us_64(), 255, 255),
"native stream accepted an unselected slot");
bluepad32_input_backend_queue_profile_feedback(
0, generation, 1, ControllerProfileConfirmationPolicy::kRumble);
advance_native_backend(40);
require_native_channels(true, true);
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 170});
advance_native_backend(220);
require_native_channels(false, true);
require(selected.rumble_calls == prepare_calls,
"profile feedback escaped the native overlay");
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 0});
advance_native_backend(80);
require_native_channels(false, false);
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{150, 0});
advance_native_backend(80);
require(haptics_experiment_request(0, 0), "native stop was rejected");
advance_native_backend(20);
require(!haptics_experiment_owns(&selected) && selected.last_low == 150 &&
selected.last_high == 0 &&
selected.last_rumble_duration_ms == kXInputHostRumbleDurationMs,
"explicit native stop lost the latest compatibility fallback");
require(haptics_experiment_request(2, 0), "manual gameplay rearm failed");
advance_native_backend(300);
require_native_channels(true, false);
const int rearm_calls = selected.rumble_calls;
advance_native_backend(300);
require(selected.rumble_calls == rearm_calls,
"held XInput state required periodic compatibility refresh");
require(haptics_experiment_request(0, 0), "second native stop failed");
advance_native_backend(20);
require(selected.last_low == 150 && selected.last_high == 0 &&
selected.last_rumble_duration_ms == kXInputHostRumbleDurationMs,
"manual rearm discarded held state needed by the next Stop");
require(haptics_experiment_request(1, 0), "fixture start failed");
advance_native_backend(10);
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{0, 160});
advance_native_backend(6200);
require(haptics_experiment_request(2, 0), "post-fixture gameplay arm failed");
advance_native_backend(300);
require_native_channels(false, true);
// A real HD frame replaces stateful XInput semantics and keeps its watchdog.
test_adapter_mode = AdapterUsbMode::kSwitchProbe;
ControllerRumbleOutput hd{};
hd.hd.actuators[0].sample_count = 1;
hd.hd.actuators[1].sample_count = 1;
hd.hd.actuators[0].samples[0].low_amplitude_q15 = 20000;
hd.hd.actuators[0].samples[0].low_frequency_index = 64;
bluepad32_input_backend_queue_rumble(0, hd);
advance_native_backend(40);
require_native_channels(true, false);
advance_native_backend(100);
require_native_channels(false, false);
test_adapter_mode = AdapterUsbMode::kXInput;
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{255, 0});
platform_on_device_disconnected(&selected);
require(!haptics_experiment_submit_rumble(0, generation, time_us_64(), 255, 255),
"disconnected generation remained eligible for native output");
require(platform_on_device_ready(&selected) == UNI_ERROR_SUCCESS,
"replacement DualSense was rejected");
advance_native_backend(300);
require_native_channels(false, false);
require(!haptics_experiment_submit_rumble(0, generation, time_us_64(), 255, 255),
"old generation reached a replacement native stream");
require(haptics_experiment_request(0, 0), "replacement stop failed");
advance_native_backend(20);
platform_on_device_disconnected(&selected);
platform_on_device_disconnected(&unselected);
}
void test_native_second_slot_selection() {
start_pairing_backend();
test_adapter_mode = AdapterUsbMode::kXInput;
auto pro = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
pro.vendor_id = 0x057e;
pro.product_id = 0x2009;
pro.conn.connected = true;
pro.conn.interrupt_cid = 0x80;
require(platform_on_device_ready(&pro) == UNI_ERROR_SUCCESS,
"first-slot Switch Pro was rejected");
auto dualsense = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
dualsense.vendor_id = 0x054c;
dualsense.product_id = 0x0ce6;
dualsense.conn.connected = true;
dualsense.conn.interrupt_cid = 0x82;
require(platform_on_device_ready(&dualsense) == UNI_ERROR_SUCCESS,
"second-slot DualSense was rejected");
advance_native_backend(30);
HapticsExperimentDiagnostics status;
haptics_experiment_snapshot(&status);
require(status.state == HapticsExperimentState::kRunning && status.slot == 1,
"native auto-arm ignored the first eligible DualSense outside slot zero");
bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{90, 0});
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{20, 0});
advance_native_backend(200);
require_native_channels(true, false);
require(pro.rumble_calls == 1 && last_native_cid == 0x82,
"mixed controller slots lost their independent rumble paths");
auto later = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
later.vendor_id = dualsense.vendor_id;
later.product_id = dualsense.product_id;
later.conn.connected = true;
later.conn.interrupt_cid = 0x84;
require(platform_on_device_ready(&later) == UNI_ERROR_SUCCESS,
"later DualSense was rejected");
advance_native_backend(30);
haptics_experiment_snapshot(&status);
require(status.slot == 1 && last_native_cid == 0x82,
"a later DualSense stole the selected native stream");
require(haptics_experiment_request(0, 1), "selected stream did not stop");
advance_native_backend(20);
platform_on_device_disconnected(&dualsense);
platform_on_device_disconnected(&pro);
platform_on_device_disconnected(&later);
}
#endif
} // namespace
int main(int argc, char** argv) {
require(argc == 2, "scenario argument required");
const std::string scenario = argv[1];
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) && defined(SWITCH_PICO_USB_OUTPUT_MODES)
if (scenario == "native-stateful") {
test_native_stateful_routing();
return 0;
}
if (scenario == "native-second-slot") {
test_native_second_slot_selection();
return 0;
}
#endif
if (scenario == "ready-forward") {
test_ready_order(false);
} else if (scenario == "ready-reverse") {

View file

@ -0,0 +1,8 @@
#pragma once
#include "btstack_run_loop.h"
#include "uni.h"
uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid);
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);

View file

@ -6,8 +6,14 @@ struct btstack_timer_source_t {
void (*handler)(btstack_timer_source_t*);
uint32_t timeout_ms;
uint32_t add_count;
uint64_t due_ms;
};
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
void native_test_add_timer(btstack_timer_source_t* timer);
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer);
#endif
inline void btstack_run_loop_set_timer_handler(
btstack_timer_source_t* timer,
void (*handler)(btstack_timer_source_t*)) {
@ -21,6 +27,9 @@ inline void btstack_run_loop_set_timer(btstack_timer_source_t* timer,
inline void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
++timer->add_count;
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
native_test_add_timer(timer);
#endif
}
uint32_t btstack_run_loop_get_time_ms();
inline void btstack_run_loop_execute() {}

View file

@ -1,3 +1,5 @@
#pragma once
void multicore_launch_core1(void (*entry)());
#include <stddef.h>
#include <stdint.h>
void multicore_launch_core1_with_stack(void (*entry)(), uint32_t* stack, size_t size);

View file

@ -1,3 +1,6 @@
#pragma once
#include <stdint.h>
uint32_t time_us_32();
uint64_t time_us_64();
void tight_loop_contents();

View file

@ -101,7 +101,8 @@ struct uni_controller_t {
uint8_t battery;
};
struct uni_hid_device_t;
struct uni_hid_device_s;
typedef struct uni_hid_device_s uni_hid_device_t;
typedef void (*btstack_packet_handler_t)(uint8_t, uint16_t, uint8_t*,
uint16_t);
struct btstack_packet_callback_registration_t {
@ -131,9 +132,19 @@ struct uni_bt_conn_t {
bd_addr_t btaddr;
hci_con_handle_t handle;
uni_bt_conn_protocol_t protocol;
bool connected;
uint16_t interrupt_cid;
uint16_t control_cid;
};
struct uni_hid_device_t {
struct uni_circular_buffer_t {
unsigned queued;
};
inline uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer) {
return buffer->queued == 0;
}
struct uni_hid_device_s {
uint16_t vendor_id;
uint16_t product_id;
uni_bt_conn_t conn;
@ -151,6 +162,7 @@ struct uni_hid_device_t {
uint8_t lightbar_blue;
int player_led_calls;
uint8_t player_leds;
uni_circular_buffer_t outgoing_buffer;
};
struct uni_platform {

View file

@ -0,0 +1,14 @@
#ifndef SWITCH_PICO_CREDIT_BATCH_TEST_CONFIG_H
#define SWITCH_PICO_CREDIT_BATCH_TEST_CONFIG_H
#define ENABLE_CLASSIC
#define ENABLE_HCI_CONTROLLER_TO_HOST_FLOW_CONTROL
#define ENABLE_BTSTACK_ASSERT
#define HAVE_MALLOC
#define HCI_ACL_PAYLOAD_SIZE 1021
#define HCI_HOST_ACL_PACKET_LEN 1021
#define HCI_HOST_ACL_PACKET_NUM 3
#define HCI_HOST_SCO_PACKET_LEN 64
#define HCI_HOST_SCO_PACKET_NUM 3
#endif

View file

@ -0,0 +1,338 @@
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
// Compile the real patched SDK implementation, not a model of its credit logic.
#include "hci.c"
static uint32_t clock_ms;
static void (*receive_packet)(uint8_t, uint8_t *, uint16_t);
static bool transport_ready;
static unsigned acl_delivered;
static unsigned sco_delivered;
static unsigned wire_count;
static uint8_t wire[32][64];
static int wire_size[32];
static void (*during_send)(void);
noreturn void btstack_assert_failed(const char *file, uint16_t line){
fprintf(stderr, "BTstack assertion: %s:%u\n", file, line);
abort();
}
static void set_timer(btstack_timer_source_t *timer, uint32_t timeout_ms){
// Match pico_btstack's millisecond quantization, including its extra tick.
timer->timeout = clock_ms + timeout_ms + 1;
}
static uint32_t get_time_ms(void){
return clock_ms;
}
static const btstack_run_loop_t run_loop = {
.init = btstack_run_loop_base_init,
.set_timer = set_timer,
.add_timer = btstack_run_loop_base_add_timer,
.remove_timer = btstack_run_loop_base_remove_timer,
.get_time_ms = get_time_ms,
};
static void advance(uint32_t ms){
clock_ms += ms;
btstack_run_loop_base_process_timers(clock_ms);
}
static void register_receiver(void (*handler)(uint8_t, uint8_t *, uint16_t)){
receive_packet = handler;
}
static int transport_open(void){
return 0;
}
static int transport_close(void){
return 0;
}
static int can_send(uint8_t packet_type){
(void) packet_type;
return transport_ready;
}
static int send_packet(uint8_t type, uint8_t *packet, int size){
assert(type == HCI_COMMAND_DATA_PACKET);
assert(wire_count < 32 && size <= 64);
memcpy(wire[wire_count], packet, (size_t) size);
wire_size[wire_count++] = size;
if (during_send != NULL){
void (*callback)(void) = during_send;
during_send = NULL;
callback();
}
return 0;
}
static hci_transport_t transport = {
.name = "credit-regression",
.open = transport_open,
.close = transport_close,
.register_packet_handler = register_receiver,
.send_packet = send_packet,
};
static void on_acl(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size){
(void) channel;
assert(type == HCI_ACL_DATA_PACKET && size == 9 && packet[8] == 0x5a);
acl_delivered++;
}
static void on_sco(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size){
(void) channel;
assert(type == HCI_SCO_DATA_PACKET && size == 4 && packet[3] == 0x5a);
sco_delivered++;
}
static void working(void){
// Fixture bypasses controller initialization, leaving the production receive/run paths intact.
hci_stack->state = HCI_STATE_WORKING;
hci_stack->gap_tasks_classic = 0;
hci_stack->num_cmd_packets = 1;
hci_register_acl_packet_handler(on_acl);
hci_register_sco_packet_handler(on_sco);
}
static void begin(uint32_t now, bool asynchronous){
clock_ms = now;
transport_ready = true;
transport.can_send_packet_now = asynchronous ? can_send : NULL;
during_send = NULL;
wire_count = acl_delivered = sco_delivered = 0;
btstack_run_loop_init(&run_loop);
btstack_memory_init();
hci_init(&transport, NULL);
working();
}
static void finish(void){
if (hci_stack != NULL){
// Isolate close's timer cancellation from the unrelated asynchronous shutdown FSM.
hci_stack->state = HCI_STATE_OFF;
hci_close();
}
advance(10);
btstack_memory_deinit();
btstack_run_loop_deinit();
}
static void add_connection(uint16_t handle, bd_addr_type_t type){
hci_connection_t *connection = btstack_memory_hci_connection_get();
assert(connection != NULL);
connection->con_handle = handle;
connection->address_type = type;
hci_connection_init(connection);
connection->state = OPEN;
btstack_linked_list_add_tail(&hci_stack->connections, (btstack_linked_item_t *) connection);
}
static void acl(uint16_t handle){
uint8_t packet[] = {0, 0x20, 5, 0, 1, 0, 0x40, 0, 0x5a};
little_endian_store_16(packet, 0, handle | 0x2000);
receive_packet(HCI_ACL_DATA_PACKET, packet, sizeof(packet));
}
static void disconnected(uint16_t handle){
uint8_t packet[] = {HCI_EVENT_DISCONNECTION_COMPLETE, 4, 0, 0, 0, 0x13};
little_endian_store_16(packet, 3, handle);
receive_packet(HCI_EVENT_PACKET, packet, sizeof(packet));
}
static void expect_credits(unsigned index, uint16_t handle, uint16_t count){
const uint8_t *packet = wire[index];
assert(wire_size[index] == 8);
assert(packet[0] == 0x35 && packet[1] == 0x0c && packet[2] == 5 && packet[3] == 1);
assert(little_endian_read_16(packet, 4) == handle);
assert(little_endian_read_16(packet, 6) == count);
}
#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
static void test_batch_and_ordinary_command(void){
begin(100, false);
add_connection(0x41, BD_ADDR_TYPE_ACL);
acl(0x41);
assert(acl_delivered == 1 && wire_count == 0);
gap_set_class_of_device(0x010203);
assert(wire_count == 1 && little_endian_read_16(wire[0], 0) == HCI_OPCODE_HCI_WRITE_CLASS_OF_DEVICE);
// Completed packets remain sendable with zero command credits.
assert(!hci_can_send_command_packet_now());
acl(0x41);
assert(acl_delivered == 2 && wire_count == 2);
expect_credits(1, 0x41, 2);
advance(2);
assert(wire_count == 2);
finish();
}
static void test_deadline_and_retry(void){
begin(UINT32_MAX - 1, true);
add_connection(0x41, BD_ADDR_TYPE_ACL);
acl(0x41);
advance(1);
assert(wire_count == 0);
// Repeated POWER_ON must leave the original deadline intact.
hci_power_control(HCI_POWER_ON);
transport_ready = false;
advance(1);
assert(wire_count == 0);
transport_ready = true;
// No incoming event is needed to retry after a busy transport.
advance(2);
assert(wire_count == 1);
expect_credits(0, 0x41, 1);
uint8_t sent[] = {HCI_EVENT_TRANSPORT_PACKET_SENT, 0};
receive_packet(HCI_EVENT_PACKET, sent, sizeof(sent));
advance(2);
assert(wire_count == 1);
finish();
}
static void test_multiple_handles_and_disconnect(void){
begin(100, false);
add_connection(0x41, BD_ADDR_TYPE_ACL);
add_connection(0x42, BD_ADDR_TYPE_ACL);
acl(0x41);
acl(0x42);
assert(wire_count == 1 && wire_size[0] == 12);
const uint8_t expected[] = {0x35, 0x0c, 9, 2, 0x41, 0, 1, 0, 0x42, 0, 1, 0};
assert(memcmp(wire[0], expected, sizeof(expected)) == 0);
acl(0x41);
advance(1);
disconnected(0x41);
add_connection(0x41, BD_ADDR_TYPE_ACL);
acl(0x41);
advance(1);
assert(wire_count == 1); // Removed handle's deadline cannot flush its replacement early.
advance(1);
assert(wire_count == 2);
expect_credits(1, 0x41, 1);
acl(0x42);
disconnected(0x42);
advance(2);
assert(wire_count == 2); // No empty completion command after the last pending handle disappears.
finish();
}
static void test_sco_and_malformed_acl(void){
begin(100, false);
add_connection(0x41, BD_ADDR_TYPE_ACL);
add_connection(0x42, BD_ADDR_TYPE_SCO);
uint8_t sco[] = {0x42, 0, 1, 0x5a};
receive_packet(HCI_SCO_DATA_PACKET, sco, sizeof(sco));
assert(sco_delivered == 1 && wire_count == 1);
expect_credits(0, 0x42, 1);
// An orphan continuation is counted by HCI before parsing rejects it.
uint8_t malformed[] = {0x41, 0x10, 1, 0, 0x5a};
receive_packet(HCI_ACL_DATA_PACKET, malformed, sizeof(malformed));
receive_packet(HCI_ACL_DATA_PACKET, malformed, sizeof(malformed));
assert(acl_delivered == 0 && wire_count == 2);
expect_credits(1, 0x41, 2);
finish();
}
static void receive_during_send(void){
acl(0x41);
}
static void replace_stack_during_send(void){
hci_stack->state = HCI_STATE_OFF;
hci_close();
hci_init(&transport, NULL);
working();
hci_reserve_packet_buffer();
}
static void test_synchronous_callbacks(void){
begin(100, false);
add_connection(0x41, BD_ADDR_TYPE_ACL);
acl(0x41);
during_send = receive_during_send;
advance(2);
assert(acl_delivered == 2 && wire_count == 1);
expect_credits(0, 0x41, 1);
advance(2);
assert(wire_count == 2);
expect_credits(1, 0x41, 1); // Incoming callback's new count/timer survived the previous send.
acl(0x41);
during_send = replace_stack_during_send;
advance(2);
assert(wire_count == 3 && !hci_can_send_command_packet_now());
// The returning old send must not release the new stack's reserved buffer.
hci_release_packet_buffer();
advance(2);
assert(wire_count == 3);
finish();
}
static void test_lifecycle(void){
begin(100, false);
add_connection(0x41, BD_ADDR_TYPE_ACL);
acl(0x41);
hci_stack->state = HCI_STATE_OFF;
hci_close();
advance(2);
assert(wire_count == 0);
hci_init(&transport, NULL);
working();
add_connection(0x42, BD_ADDR_TYPE_ACL);
acl(0x42);
// Re-init cancels before memset; preserve connection allocation for explicit cleanup.
hci_connection_t *old_connection = hci_connection_for_handle(0x42);
hci_init(&transport, NULL);
btstack_memory_hci_connection_free(old_connection);
working();
advance(2);
assert(wire_count == 0);
add_connection(0x43, BD_ADDR_TYPE_ACL);
acl(0x43);
old_connection = hci_connection_for_handle(0x43);
hci_deinit();
btstack_memory_hci_connection_free(old_connection);
advance(2);
assert(wire_count == 0);
btstack_memory_deinit();
btstack_run_loop_deinit();
begin(100, false);
add_connection(0x44, BD_ADDR_TYPE_ACL);
acl(0x44);
hci_power_control(HCI_POWER_SLEEP);
unsigned after_power_transition = wire_count;
advance(2);
assert(wire_count == after_power_transition);
finish();
}
#endif
int main(void){
#ifdef SWITCH_PICO_HCI_CREDIT_BATCH
test_batch_and_ordinary_command();
test_deadline_and_retry();
test_multiple_handles_and_disconnect();
test_sco_and_malformed_acl();
test_synchronous_callbacks();
test_lifecycle();
#else
begin(100, false);
add_connection(0x41, BD_ADDR_TYPE_ACL);
acl(0x41);
assert(acl_delivered == 1 && wire_count == 1);
expect_credits(0, 0x41, 1);
acl(0x41);
assert(acl_delivered == 2 && wire_count == 2);
expect_credits(1, 0x41, 1);
advance(2);
assert(wire_count == 2);
finish();
#endif
puts("BTstack credit behavior passed");
return 0;
}

View file

@ -0,0 +1,522 @@
#include "input/controller_macro_capture.h"
#include <cstdlib>
#include <iostream>
namespace {
void require(bool condition, const char* message) {
if (!condition) {
std::cerr << message << '\n';
std::exit(1);
}
}
CaptureEvent captured_event(const ControllerMacroCapture& capture,
uint16_t index) {
CaptureEvent event{};
require(capture.event(index, &event), "recorded event was inaccessible");
return event;
}
void require_terminal_frozen(ControllerMacroCapture& capture,
CaptureState expected, uint32_t elapsed) {
const uint16_t count = capture.event_count();
capture.stop(90000000);
capture.disconnect(capture.slot(), capture.generation(), 90000001);
capture.observe(capture.slot(), capture.generation(), 90000002,
controller_neutral_state());
capture.tick(90000003);
require(capture.state() == expected &&
capture.elapsed_us(90000004) == elapsed &&
capture.event_count() == count,
"a terminal capture changed state, end time or event count");
}
void test_initial_snapshot_and_start_validation() {
ControllerMacroCapture capture;
CaptureEvent output{};
require(capture.state() == CaptureState::kIdle &&
capture.elapsed_us(1234) == 0 &&
!capture.event(0, &output),
"idle capture exposed an event or running clock");
capture.stop(10);
capture.disconnect(0, 0, 11);
capture.tick(12);
require(capture.state() == CaptureState::kIdle,
"an operation without a run changed idle state");
CaptureOptions options;
options.channels = 31;
ControllerState initial = controller_neutral_state();
initial.button_south = true;
initial.button_left_stick = true;
initial.dpad_up = true;
initial.left_stick_x = -768;
initial.right_stick_y = 768;
initial.left_trigger = 1536;
initial.right_trigger = UINT16_MAX;
require(capture.start(3, 77, options, 100, initial),
"valid capture did not start");
const uint32_t run = capture.run_id();
const CaptureEvent first = captured_event(capture, 0);
require(run != 0 && capture.slot() == 3 && capture.generation() == 77 &&
capture.event_count() == 1 && first.at_us == 0 &&
first.buttons == ((1u << 0) | (1u << 10) | (1u << 12)) &&
first.left_x == -1024 && first.right_y == 1024 &&
first.left_trigger == 2048 && first.right_trigger == UINT16_MAX,
"capture did not retain the quantized initial state at time zero");
require(!capture.start(0, 1, options, 200, controller_neutral_state()) &&
capture.run_id() == run && capture.elapsed_us(180) == 80 &&
captured_event(capture, 0).buttons == first.buttons,
"start replaced an active capture");
require(!capture.event(1, &output) && !capture.event(0, nullptr),
"event accessor accepted an absent event or null output");
capture.stop(180);
const CaptureOptions invalid[] = {
{0, 512, 1024, 8, 10000},
{32, 512, 1024, 8, 10000},
{1, 0, 1024, 8, 10000},
{1, 32768, 1024, 8, 10000},
{1, 512, 0, 8, 10000},
{1, 512, 1024, 0, 10000},
{1, 512, 1024, 129, 10000},
{1, 512, 1024, 8, 0},
{1, 512, 1024, 8, 80001},
};
for (const CaptureOptions& bad : invalid) {
require(!capture.start(0, 1, bad, 200, initial),
"invalid capture options were accepted");
}
require(!capture.start(4, 1, options, 200, initial) &&
capture.run_id() == run &&
capture.state() == CaptureState::kStopped &&
capture.elapsed_us(200) == 80 &&
captured_event(capture, 0).buttons == first.buttons,
"rejected starts damaged the stopped recording");
options.axis_quantum = INT16_MAX;
options.trigger_quantum = UINT16_MAX;
options.max_events = 128;
options.max_duration_ms = 80000;
require(capture.start(0, 1, options, 300, controller_neutral_state()) &&
capture.run_id() == run + 1 && capture.event_count() == 1 &&
captured_event(capture, 0).buttons == 0 &&
capture.elapsed_us(300) == 0,
"a successful restart did not replace the old run at valid bounds");
}
void test_positional_logical_buttons() {
constexpr bool ControllerState::* buttons[] = {
&ControllerState::button_south,
&ControllerState::button_east,
&ControllerState::button_west,
&ControllerState::button_north,
&ControllerState::button_left_shoulder,
&ControllerState::button_right_shoulder,
&ControllerState::button_select,
&ControllerState::button_start,
&ControllerState::button_system,
&ControllerState::button_capture,
&ControllerState::button_left_stick,
&ControllerState::button_right_stick,
&ControllerState::dpad_up,
&ControllerState::dpad_down,
&ControllerState::dpad_left,
&ControllerState::dpad_right,
};
ControllerMacroCapture capture;
CaptureOptions options;
options.max_events = 17;
require(capture.start(0, 1, options, 0, controller_neutral_state()),
"button capture did not start");
for (uint16_t i = 0; i < 16; ++i) {
ControllerState state = controller_neutral_state();
state.*buttons[i] = true;
capture.observe(0, 1, i + 1, state);
const CaptureEvent event = captured_event(capture, i + 1);
require(event.buttons == (1u << i) && event.at_us == i + 1u,
"a positional button mapped to the wrong logical bit");
}
}
ControllerState channel_state(uint8_t channels) {
ControllerState state = controller_neutral_state();
state.button_south = (channels & 1u) != 0;
if ((channels & 2u) != 0) {
state.left_stick_x = 1279;
state.left_stick_y = -1279;
}
if ((channels & 4u) != 0) {
state.right_stick_x = -1791;
state.right_stick_y = 1791;
}
if ((channels & 8u) != 0) {
state.left_trigger = 2047;
}
if ((channels & 16u) != 0) {
state.right_trigger = 3071;
}
state.motion_sample_count = 1;
state.motion_samples[0].gyro_x = 1000;
return state;
}
void test_selected_channels_ignore_unrelated_changes() {
for (uint8_t channel = 1; channel <= 16; channel <<= 1) {
ControllerMacroCapture capture;
CaptureOptions options;
options.channels = channel;
require(capture.start(0, 1, options, 10, controller_neutral_state()),
"single-channel capture did not start");
capture.observe(0, 1, 20, channel_state(31u ^ channel));
require(capture.event_count() == 1,
"unselected channels or motion produced a capture edge");
capture.observe(0, 1, 30, channel_state(31));
require(capture.event_count() == 2,
"selected channel did not produce exactly one edge");
const CaptureEvent event = captured_event(capture, 1);
require(event.at_us == 20 &&
event.buttons == (channel == 1 ? 1 : 0) &&
event.left_x == (channel == 2 ? 1024 : 0) &&
event.left_y == (channel == 2 ? -1024 : 0) &&
event.right_x == (channel == 4 ? -1536 : 0) &&
event.right_y == (channel == 4 ? 1536 : 0) &&
event.left_trigger == (channel == 8 ? 2048 : 0) &&
event.right_trigger == (channel == 16 ? 3072 : 0),
"selected output leaked or omitted a channel");
}
}
void test_analog_quantization_symmetry_and_endpoints() {
ControllerMacroCapture capture;
CaptureOptions options;
options.channels = 30;
ControllerState state = controller_neutral_state();
state.left_stick_x = 255;
state.left_stick_y = -255;
state.left_trigger = 511;
state.right_trigger = 511;
require(capture.start(0, 1, options, 100, state),
"analog capture did not start");
CaptureEvent event = captured_event(capture, 0);
require(event.left_x == 0 && event.left_y == 0 &&
event.left_trigger == 0 && event.right_trigger == 0,
"near-zero inputs did not quantize to rest");
state.left_stick_x = 256;
state.left_stick_y = -256;
state.right_stick_x = -768;
state.right_stick_y = 768;
state.left_trigger = 512;
state.right_trigger = 1536;
capture.observe(0, 1, 101, state);
event = captured_event(capture, 1);
require(event.left_x == 512 && event.left_y == -512 &&
event.right_x == -1024 && event.right_y == 1024 &&
event.left_trigger == 1024 && event.right_trigger == 2048,
"half-quantum ties did not round symmetrically away from zero");
state.left_stick_x = 767;
state.left_stick_y = -767;
state.right_stick_x = -1279;
state.right_stick_y = 1279;
state.left_trigger = 1535;
state.right_trigger = 2559;
capture.observe(0, 1, 102, state);
require(capture.event_count() == 2,
"jitter within a quantized bucket produced another edge");
state.left_stick_x = INT16_MIN;
state.left_stick_y = INT16_MAX;
state.right_stick_x = -INT16_MAX;
state.right_stick_y = INT16_MAX - 1;
state.left_trigger = 0;
state.right_trigger = UINT16_MAX;
capture.observe(0, 1, 103, state);
event = captured_event(capture, 2);
require(event.left_x == INT16_MIN && event.left_y == INT16_MAX &&
event.right_x == -INT16_MAX && event.right_y == INT16_MAX &&
event.left_trigger == 0 && event.right_trigger == UINT16_MAX,
"quantization wrapped, lost full scale or broke signed symmetry");
capture.observe(0, 1, 104, controller_neutral_state());
event = captured_event(capture, 3);
require(event.left_x == 0 && event.left_y == 0 && event.right_x == 0 &&
event.right_y == 0 && event.left_trigger == 0 &&
event.right_trigger == 0,
"returning to exact rest retained a quantized analog value");
capture.stop(105);
options.axis_quantum = 3;
options.trigger_quantum = 3;
state.left_stick_x = 1;
state.left_stick_y = -1;
state.right_stick_x = 2;
state.right_stick_y = -2;
state.left_trigger = 1;
state.right_trigger = 2;
require(capture.start(0, 2, options, 200, state),
"odd-quantum capture did not start");
event = captured_event(capture, 0);
require(event.left_x == 0 && event.left_y == 0 && event.right_x == 3 &&
event.right_y == -3 && event.left_trigger == 0 &&
event.right_trigger == 3,
"odd quanta shifted the signed or unsigned rounding threshold");
}
void test_same_timestamp_coalesces_before_budget_check() {
ControllerMacroCapture capture;
CaptureOptions options;
options.max_events = 2;
ControllerState state = controller_neutral_state();
require(capture.start(0, 1, options, 10, state),
"coalescing capture did not start");
state.button_south = true;
capture.observe(0, 1, 10, state);
state.button_south = false;
capture.observe(0, 1, 10, state);
require(capture.event_count() == 1 &&
captured_event(capture, 0).buttons == 0,
"same-time initial changes made zero-length intermediate events");
state.button_south = true;
capture.observe(0, 1, 20, state);
state.button_south = false;
state.button_west = true;
capture.observe(0, 1, 20, state);
capture.observe(0, 1, 21, state);
require(capture.state() == CaptureState::kRecording &&
capture.event_count() == 2 &&
captured_event(capture, 1).buttons == (1u << 2),
"a full event budget rejected coalescing or an unchanged sample");
state.button_west = false;
state.button_north = true;
capture.observe(0, 1, 22, state);
require(capture.state() == CaptureState::kFull &&
capture.elapsed_us(30) == 12 && capture.event_count() == 2 &&
captured_event(capture, 1).at_us == 10 &&
captured_event(capture, 1).buttons == (1u << 2),
"overflow did not retain the last complete recorded state");
require_terminal_frozen(capture, CaptureState::kFull, 12);
}
void test_full_static_capacity() {
ControllerMacroCapture capture;
CaptureOptions options;
options.max_events = 128;
ControllerState state = controller_neutral_state();
require(capture.start(0, 1, options, 0, state),
"maximum-capacity capture did not start");
for (uint16_t i = 1; i < 128; ++i) {
state.button_south = (i & 1u) != 0;
capture.observe(0, 1, i, state);
}
require(capture.event_count() == 128 &&
capture.state() == CaptureState::kRecording,
"capture stopped before an edge exceeded its full capacity");
state.button_south = false;
capture.observe(0, 1, 128, state);
require(capture.state() == CaptureState::kFull &&
capture.event_count() == 128 && capture.elapsed_us(129) == 128 &&
captured_event(capture, 127).at_us == 127 &&
captured_event(capture, 127).buttons == 1,
"capacity overflow overwrote retained events or lost its end time");
}
void test_clock_wrap_and_stopped_end() {
ControllerMacroCapture capture;
CaptureOptions options;
ControllerState state = controller_neutral_state();
constexpr uint32_t started = UINT32_MAX - 100u;
require(capture.start(0, 1, options, started, state),
"wrap capture did not start");
state.button_east = true;
capture.observe(0, 1, 49, state);
require(captured_event(capture, 1).at_us == 150 &&
capture.elapsed_us(49) == 150,
"microsecond clock wrap changed elapsed event timing");
capture.stop(149);
require_terminal_frozen(capture, CaptureState::kStopped, 250);
}
void test_long_holds_and_exact_timeout() {
ControllerMacroCapture capture;
CaptureOptions options;
options.max_duration_ms = 80000;
ControllerState held = controller_neutral_state();
held.button_south = true;
constexpr uint32_t start = 123;
require(capture.start(0, 1, options, start, held),
"long-hold capture did not start");
capture.tick(start + 10000000);
require(capture.event_count() == 2 &&
captured_event(capture, 1).at_us == 10000000,
"a live ten-second hold did not split at its boundary");
capture.observe(0, 1, start + 15000000, held);
require(capture.event_count() == 2,
"unchanged observation added a spurious hold edge");
capture.tick(start + 35000000);
require(capture.event_count() == 4,
"late tick omitted required ten-second hold boundaries");
capture.tick(start + 90000000);
require(capture.state() == CaptureState::kTimedOut &&
capture.event_count() == 8 &&
capture.elapsed_us(start + 90000000) == 80000000,
"long hold did not clip at the exact duration and fit eight steps");
for (uint16_t i = 0; i < 8; ++i) {
const CaptureEvent event = captured_event(capture, i);
require(event.at_us == i * 10000000u && event.buttons == 1,
"hold split changed the captured state or interval length");
}
require_terminal_frozen(capture, CaptureState::kTimedOut, 80000000);
options.max_duration_ms = 25000;
require(capture.start(0, 1, options, 0, held),
"partial-hold timeout capture did not start");
capture.tick(40000000);
require(capture.state() == CaptureState::kTimedOut &&
capture.event_count() == 3 &&
captured_event(capture, 2).at_us == 20000000 &&
capture.elapsed_us(40000000) == 25000000,
"timeout beyond a hold boundary lost the partial final interval");
options.max_duration_ms = 10000;
options.max_events = 1;
require(capture.start(0, 1, options, 0, held),
"single-step timeout capture did not start");
capture.tick(10000000);
require(capture.state() == CaptureState::kTimedOut &&
capture.event_count() == 1 &&
capture.elapsed_us(20000000) == 10000000,
"an exact-duration hold created a zero-length edge or false full");
}
void test_hold_splits_follow_edges_and_coalesce() {
ControllerMacroCapture capture;
CaptureOptions options;
options.max_events = 3;
options.max_duration_ms = 80000;
ControllerState state = controller_neutral_state();
state.button_south = true;
require(capture.start(0, 1, options, 0, state),
"edge-relative hold capture did not start");
state.button_south = false;
state.button_east = true;
capture.observe(0, 1, 5000000, state);
capture.tick(15000000);
state.button_east = false;
state.button_west = true;
capture.observe(0, 1, 15000000, state);
require(capture.state() == CaptureState::kRecording &&
capture.event_count() == 3 &&
captured_event(capture, 1).at_us == 5000000 &&
captured_event(capture, 2).at_us == 15000000 &&
captured_event(capture, 2).buttons == (1u << 2),
"hold boundary did not follow or coalesce with a real edge");
capture.stop(25000000);
require(capture.state() == CaptureState::kStopped &&
capture.event_count() == 3 &&
capture.elapsed_us(30000000) == 25000000,
"stop at the final hold boundary added an unnecessary event");
}
void test_hold_budget_exhaustion_and_stop_catchup() {
ControllerMacroCapture capture;
CaptureOptions options;
options.max_events = 2;
options.max_duration_ms = 80000;
ControllerState state = controller_neutral_state();
state.button_south = true;
require(capture.start(0, 1, options, 0, state),
"bounded hold capture did not start");
capture.tick(UINT32_MAX);
require(capture.state() == CaptureState::kFull &&
capture.elapsed_us(UINT32_MAX) == 20000000 &&
capture.event_count() == 2 &&
captured_event(capture, 1).at_us == 10000000 &&
captured_event(capture, 1).buttons == 1,
"large tick did not stop full at the first missing hold boundary");
require_terminal_frozen(capture, CaptureState::kFull, 20000000);
options.max_events = 4;
require(capture.start(0, 1, options, 0, state),
"stop-catchup capture did not start");
capture.stop(35000000);
require(capture.state() == CaptureState::kStopped &&
capture.event_count() == 4 &&
captured_event(capture, 3).at_us == 30000000 &&
capture.elapsed_us(50000000) == 35000000,
"stop without intervening ticks omitted long-hold splits");
}
void test_generation_replacement_disconnect_and_slot_isolation() {
ControllerMacroCapture capture;
CaptureOptions options;
ControllerState initial = controller_neutral_state();
initial.button_south = true;
ControllerState replacement = controller_neutral_state();
replacement.button_east = true;
require(capture.start(2, 7, options, 100, initial),
"generation capture did not start");
capture.observe(3, 99, 150, replacement);
capture.disconnect(3, 7, 170);
capture.disconnect(2, 6, 180);
require(capture.state() == CaptureState::kRecording &&
capture.event_count() == 1,
"another slot or stale disconnect interrupted capture");
capture.observe(2, 8, 200, replacement);
require(capture.state() == CaptureState::kDisconnected &&
capture.generation() == 7 && capture.event_count() == 1 &&
captured_event(capture, 0).buttons == 1 &&
capture.elapsed_us(250) == 100,
"generation replacement recorded the new device or lost the end");
require_terminal_frozen(capture, CaptureState::kDisconnected, 100);
require(capture.start(2, 8, options, 300, replacement),
"replacement generation could not start a new capture");
capture.disconnect(2, 7, 400);
require(capture.state() == CaptureState::kRecording,
"old-generation disconnect ended a replacement run");
capture.disconnect(2, 8, 450);
require_terminal_frozen(capture, CaptureState::kDisconnected, 150);
}
void test_deadline_precedes_late_edges_and_terminal_requests() {
ControllerMacroCapture capture;
CaptureOptions options;
options.max_duration_ms = 1;
ControllerState state = controller_neutral_state();
require(capture.start(0, 1, options, 0, state),
"deadline capture did not start");
state.button_south = true;
capture.observe(0, 1, 1000, state);
require(capture.state() == CaptureState::kTimedOut &&
capture.event_count() == 1 &&
captured_event(capture, 0).buttons == 0 &&
capture.elapsed_us(2000) == 1000,
"an edge at the deadline escaped timeout clipping");
require(capture.start(0, 1, options, 0, state),
"late-stop capture did not start");
capture.stop(2000);
require_terminal_frozen(capture, CaptureState::kTimedOut, 1000);
require(capture.start(0, 1, options, 0, state),
"late-disconnect capture did not start");
capture.disconnect(0, 1, 2000);
require_terminal_frozen(capture, CaptureState::kTimedOut, 1000);
}
} // namespace
int main() {
test_initial_snapshot_and_start_validation();
test_positional_logical_buttons();
test_selected_channels_ignore_unrelated_changes();
test_analog_quantization_symmetry_and_endpoints();
test_same_timestamp_coalesces_before_budget_check();
test_full_static_capacity();
test_clock_wrap_and_stopped_end();
test_long_holds_and_exact_timeout();
test_hold_splits_follow_edges_and_coalesce();
test_hold_budget_exhaustion_and_stop_catchup();
test_generation_replacement_disconnect_and_slot_isolation();
test_deadline_precedes_late_edges_and_terminal_requests();
return 0;
}

View file

@ -4,7 +4,7 @@
#include "profile/controller_profile.h"
constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = {
constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE] = {
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
@ -24,7 +24,7 @@ constexpr uint8_t kLegacyDefaultProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = {
};
constexpr uint8_t
kLegacyNarrowRawRangeProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = {
kLegacyNarrowRawRangeProfile[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE] = {
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
@ -43,7 +43,7 @@ constexpr uint8_t
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};
constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_ENCODED_SIZE] = {
constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE] = {
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
0x0c, 0x0d, 0x0e, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x01, 0x00, 0x00,

View file

@ -28,6 +28,8 @@ unsigned active_snapshot_count = 0;
std::array<ActivationAttempt, 32> activation_attempts{};
size_t activation_attempt_count = 0;
unsigned activation_busy_attempts = 0;
ConfigurationTransactionStatus activation_result =
ConfigurationTransactionStatus::kPending;
uint8_t last_motion_toggle_slot = 0;
uint32_t last_motion_toggle_connection_generation = 0;
unsigned motion_toggle_count = 0;
@ -57,6 +59,7 @@ void prepare_profiles() {
activation_attempts = {};
activation_attempt_count = 0;
activation_busy_attempts = 0;
activation_result = ConfigurationTransactionStatus::kPending;
last_motion_toggle_slot = 0;
last_motion_toggle_connection_generation = 0;
motion_toggle_count = 0;
@ -875,6 +878,288 @@ void test_runtime_slot_synthetic_isolation() {
"slot-local configured cancel affected another slot");
}
void test_shortcut_arbitration_latching_and_commit_feedback() {
prepare_profiles();
ControllerProfile& profile = rows[0].profiles[0];
constexpr uint16_t modifier = 1u << 9;
constexpr uint16_t chord = modifier | 1u;
profile.shortcuts.modifier = 9;
profile.shortcuts.selectors[2] = 0;
profile.shortcuts.selectors[3] = 1;
profile.switching_chord = chord;
profile.motion_toggle_chord = chord;
profile.shift.mode = ControllerProfileShiftMode::kToggle;
profile.shift.modifier = 9;
profile.shift.button_map[2] = 3;
profile.macros[0] = {chord, CONTROLLER_PROFILE_NO_BUTTON, 0, 1};
profile.macro_step_count = 1;
profile.macro_steps[0] = {kControllerProfileOverrideButtons, 100, 8};
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 0);
apply_button_mask(chord | 2u, &snapshot);
auto output = runtime_transform(0, snapshot, 1);
require(activation_attempt_count == 0 && motion_toggle_count == 0 &&
controller_profile_extract_button_mask(output.state) == 0,
"ambiguous direct selectors triggered a lower-priority action");
apply_button_mask(chord, &snapshot);
output = runtime_transform(0, snapshot, 2);
require(activation_attempt_count == 0 &&
controller_profile_extract_button_mask(output.state) == 0,
"ambiguous chord chose a target when one selector was released");
apply_button_mask(modifier, &snapshot);
(void)runtime_transform(0, snapshot, 3);
apply_button_mask(chord | 4u, &snapshot);
output = runtime_transform(0, snapshot, 4);
require(activation_attempt_count == 1 &&
activation_attempts[0].profile_index == 2 &&
motion_toggle_count == 0 && output.state.button_west &&
!output.state.button_north && !output.state.button_south &&
!output.state.button_capture,
"direct shortcut did not outrank cycle, motion, Shift and macro");
bool available = true;
(void)take_profile_change(0, &available);
require(!available, "accepted but uncommitted shortcut produced feedback");
ControllerProfile& committed = rows[0].profiles[2];
committed.shortcuts.modifier = 9;
committed.shortcuts.selectors[4] = 0;
committed.button_map[2] = 3;
committed.confirmation_policy = ControllerProfileConfirmationPolicy::kLed;
rows[0].active_profile = 2;
++database_generation;
output = runtime_transform(0, snapshot, 5);
const auto event = take_profile_change(0, &available);
require(available && event.active_profile_number == 3 &&
event.policy == ControllerProfileConfirmationPolicy::kLed &&
activation_attempt_count == 1 && output.state.button_north &&
!output.state.button_capture && !output.state.button_south,
"committed shortcut redirected a held chord or lost commit feedback");
(void)take_profile_change(0, &available);
require(!available, "shortcut published duplicate commit feedback");
apply_button_mask(modifier, &snapshot);
(void)runtime_transform(0, snapshot, 6);
activation_result = ConfigurationTransactionStatus::kStorageError;
apply_button_mask(chord, &snapshot);
output = runtime_transform(0, snapshot, 7);
(void)runtime_transform(0, snapshot, 8);
(void)take_profile_change(0, &available);
require(activation_attempt_count == 2 &&
activation_attempts[1].profile_index == 4 && !available &&
controller_profile_extract_button_mask(output.state) == 0,
"failed activation retried, leaked its chord, or produced feedback");
prepare_profiles();
rows[0].profiles[0].shortcuts.modifier = 9;
rows[0].profiles[0].shortcuts.selectors[0] = 0;
snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 0);
apply_button_mask(chord, &snapshot);
output = runtime_transform(0, snapshot, 1);
(void)take_profile_change(0, &available);
require(activation_attempt_count == 0 && !available &&
controller_profile_extract_button_mask(output.state) == 0,
"already-active direct target was not a consumed no-op");
}
void test_busy_shortcut_target_and_generation_isolation() {
prepare_profiles();
constexpr uint16_t chord = (1u << 9) | 1u;
rows[0].profiles[0].shortcuts.modifier = 9;
rows[0].profiles[0].shortcuts.selectors[3] = 0;
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 0);
activation_busy_attempts = 3;
apply_button_mask(chord, &snapshot);
(void)runtime_transform(0, snapshot, 1);
rows[0].active_profile = 1;
rows[0].profiles[1].shortcuts.modifier = 9;
rows[0].profiles[1].shortcuts.selectors[6] = 0;
++database_generation;
(void)runtime_transform(0, snapshot, 2);
require(activation_attempt_count == 2 &&
activation_attempts[1].profile_index == 3 &&
activation_attempts[0].transaction_id ==
activation_attempts[1].transaction_id,
"profile refresh changed a busy shortcut's latched target");
++snapshot.connection_generation;
auto output = runtime_transform(0, snapshot, 3);
require(activation_attempt_count == 2 &&
controller_profile_extract_button_mask(output.state) == 0,
"new connection generation inherited or retriggered held activation");
apply_button_mask(0, &snapshot);
(void)runtime_transform(0, snapshot, 4);
apply_button_mask(chord, &snapshot);
(void)runtime_transform(0, snapshot, 5);
require(activation_attempt_count == 3 &&
activation_attempts[2].profile_index == 6 &&
activation_attempts[2].transaction_id !=
activation_attempts[0].transaction_id,
"new generation could not rearm with its own target");
output = runtime_transform(0, snapshot, 6, AdapterUsbMode::kXInput);
(void)runtime_transform(0, snapshot, 7, AdapterUsbMode::kXInput);
require(activation_attempt_count == 3 &&
controller_profile_extract_button_mask(output.state) == 0,
"output-mode reset leaked a pending activation retry");
}
void test_cycle_motion_arbitration_and_held_refresh() {
prepare_profiles();
constexpr uint16_t chord = (1u << 9) | 1u;
rows[0].profiles[0].switching_chord = chord;
rows[0].profiles[0].motion_toggle_chord = chord;
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 0);
apply_button_mask(chord, &snapshot);
auto output = runtime_transform(0, snapshot, 1);
require(activation_attempt_count == 1 && motion_toggle_count == 0 &&
controller_profile_extract_button_mask(output.state) == 0,
"one cycle chord also toggled motion");
apply_button_mask(1u, &snapshot);
(void)runtime_transform(0, snapshot, 2);
apply_button_mask(chord, &snapshot);
(void)runtime_transform(0, snapshot, 2);
require(activation_attempt_count == 2 && motion_toggle_count == 0,
"re-completing a released cycle chord did not rearm");
apply_button_mask(1u, &snapshot);
(void)runtime_transform(0, snapshot, 2);
rows[0].profiles[0].motion_toggle_chord = 1u;
rows[0].profiles[0].switching_chord = 2u;
++database_generation;
output = runtime_transform(0, snapshot, 3);
require(motion_toggle_count == 0 && !output.state.button_south,
"partial held chord became a new action after profile refresh");
apply_button_mask(0, &snapshot);
(void)runtime_transform(0, snapshot, 4);
apply_button_mask(1u, &snapshot);
output = runtime_transform(0, snapshot, 5);
require(motion_toggle_count == 1 && !output.state.button_south,
"motion did not rearm after the higher-priority chord released");
++database_generation;
output = runtime_transform(0, snapshot, 6);
require(motion_toggle_count == 1 && !output.state.button_south,
"profile refresh phantom-toggled held motion chord");
}
void test_shift_slot_and_context_resets() {
prepare_profiles();
std::array<Bluepad32SlotSnapshot, 4> snapshots{};
constexpr uint8_t outputs[4] = {1, 2, 3, 12};
for (uint8_t slot = 0; slot < 4; ++slot) {
auto& profile = rows[slot].profiles[0];
profile.shift.mode = ControllerProfileShiftMode::kToggle;
profile.shift.modifier = 9;
profile.shift.button_map[0] = outputs[slot];
snapshots[slot] = make_snapshot(slot);
(void)runtime_transform(slot, snapshots[slot], 0);
apply_button_mask((1u << 9) | 1u, &snapshots[slot]);
const auto output = runtime_transform(slot, snapshots[slot], 1);
require(controller_profile_extract_button_mask(output.state) ==
(1u << outputs[slot]),
"Shift activation was not physical, consumed and slot-local");
}
++snapshots[0].connection_generation;
auto output = runtime_transform(0, snapshots[0], 2);
require(output.state.button_south && !output.state.button_capture,
"connection reset phantom-toggled held Shift");
output = runtime_transform(1, snapshots[1], 2);
require(output.state.button_west && !output.state.button_south,
"one slot's Shift reset disturbed another slot");
output = runtime_transform(1, snapshots[1], 3, AdapterUsbMode::kXInput);
require(output.state.button_south && !output.state.button_west,
"mode reset retained or retriggered toggle Shift");
rows[2].profiles[1].shift = rows[2].profiles[0].shift;
rows[2].active_profile = 1;
++database_generation;
output = runtime_transform(2, snapshots[2], 4);
require(output.state.button_south && !output.state.button_north &&
!output.state.button_capture,
"profile activation retained or phantom-toggled Shift");
output = runtime_transform(3, snapshots[3], 4);
require(output.state.button_south && !output.state.dpad_up,
"database refresh did not reset the remaining Shift slot");
apply_button_mask(1u, &snapshots[3]);
(void)runtime_transform(3, snapshots[3], 5);
apply_button_mask((1u << 9) | 1u, &snapshots[3]);
output = runtime_transform(3, snapshots[3], 6);
require(output.state.dpad_up && !output.state.button_south,
"Shift could not rearm after reset and physical release");
++configuration_reset_generation;
output = runtime_transform(3, snapshots[3], 7);
require(output.state.button_south && !output.state.dpad_up,
"configuration reset retained Shift toggle state");
}
void test_held_synthetic_sources_and_disconnect_rearming() {
prepare_profiles();
auto& profile = rows[0].profiles[0];
profile.macros[0] = {1u, 9, 0, 1};
profile.macros[0].mode = ControllerProfileMacroMode::kToggle;
profile.macro_step_count = 1;
profile.macro_steps[0].override_flags = kControllerProfileOverrideLeftStick;
profile.macro_steps[0].duration_ms = 10;
profile.macro_steps[0].left_stick_x = 12345;
profile.turbo_modes[1] = ControllerProfileTurboMode::kBurst;
profile.turbo_modes[2] = ControllerProfileTurboMode::kAutoBurst;
profile.shortcuts.modifier = 9;
profile.shortcuts.selectors[2] = 3;
Bluepad32SlotSnapshot snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 0);
apply_button_mask(7u, &snapshot);
auto output = runtime_transform(0, snapshot, 1);
require(output.state.left_stick_x == 12345 &&
output.state.button_east && output.state.button_west &&
!output.state.button_south,
"independent looping macro and Burst sources did not start");
++database_generation;
output = runtime_transform(0, snapshot, 2);
require(output.state.left_stick_x == 0 && output.state.button_south &&
!output.state.button_east && !output.state.button_west,
"refresh phantom-restarted a held edge-triggered synthetic source");
apply_button_mask(0, &snapshot);
(void)runtime_transform(0, snapshot, 3);
apply_button_mask(7u, &snapshot);
output = runtime_transform(0, snapshot, 4);
require(output.state.left_stick_x == 12345 &&
output.state.button_east && output.state.button_west,
"synthetic sources could not rearm after refresh and release");
snapshot.active = false;
(void)runtime_transform(0, snapshot, 5);
snapshot.active = true;
apply_button_mask((1u << 9) | 8u, &snapshot);
output = runtime_transform(0, snapshot, 6);
require(activation_attempt_count == 0 &&
controller_profile_extract_button_mask(output.state) == 0 &&
output.state.left_stick_x == 0,
"reconnect treated an already-held shortcut as a new command");
apply_button_mask(0, &snapshot);
(void)runtime_transform(0, snapshot, 7);
apply_button_mask((1u << 9) | 8u, &snapshot);
(void)runtime_transform(0, snapshot, 8);
require(activation_attempt_count == 1 &&
activation_attempts[0].profile_index == 2,
"reconnected shortcut did not rearm on physical release");
}
void test_shortcut_selector_rollover_without_modifier_release() {
prepare_profiles();
auto& profile = rows[0].profiles[0];
profile.shortcuts.modifier = 9;
profile.shortcuts.selectors[1] = 0;
profile.shortcuts.selectors[2] = 1;
auto snapshot = make_snapshot(0);
(void)runtime_transform(0, snapshot, 0);
apply_button_mask((1u << 9) | 1u, &snapshot);
(void)runtime_transform(0, snapshot, 1);
apply_button_mask((1u << 9) | 2u, &snapshot);
const auto output = runtime_transform(0, snapshot, 2);
require(activation_attempt_count == 2 &&
activation_attempts[0].profile_index == 1 &&
activation_attempts[1].profile_index == 2 &&
controller_profile_extract_button_mask(output.state) == 0,
"swapping shortcut selectors while holding the modifier lost the new action");
}
} // namespace
bool bluepad32_input_backend_toggle_motion(
@ -903,7 +1188,7 @@ ConfigurationTransactionStatus profile_service_activate_internal(
--activation_busy_attempts;
return ConfigurationTransactionStatus::kBusy;
}
return ConfigurationTransactionStatus::kPending;
return activation_result;
}
@ -941,5 +1226,11 @@ int main() {
test_switching_slot_isolation();
test_all_runtime_cancellation_causes();
test_runtime_slot_synthetic_isolation();
test_shortcut_arbitration_latching_and_commit_feedback();
test_busy_shortcut_target_and_generation_isolation();
test_cycle_motion_arbitration_and_held_refresh();
test_shift_slot_and_context_resets();
test_held_synthetic_sources_and_disconnect_rearming();
test_shortcut_selector_rollover_without_modifier_release();
return 0;
}

View file

@ -45,9 +45,9 @@ void test_profile_wire_schema() {
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
"default profile did not encode");
require(encoded[0] == 5 && encoded[1] == 0 &&
encoded[2] == 0 && encoded[3] == 1,
"profile header is not little-endian v5/256");
require(encoded[0] == 6 && encoded[1] == 0 &&
encoded[2] == 0x80 && encoded[3] == 1,
"profile header is not little-endian v6/384");
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
require(encoded[4 + index] == index,
@ -174,7 +174,7 @@ void test_profile_wire_schema() {
"reversed raw trigger range was accepted");
invalid = profile;
invalid.turbo_modes[0] =
static_cast<ControllerProfileTurboMode>(3);
static_cast<ControllerProfileTurboMode>(4);
require(!controller_profile_validate(invalid),
"invalid Turbo mode was accepted");
invalid = profile;
@ -225,7 +225,7 @@ void test_legacy_profile_migration() {
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) &&
encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL,
"migrated default profile did not encode as v5");
"migrated default profile did not encode as v6");
require(controller_profile_decode(
kLegacyNarrowRawRangeProfile,
@ -269,7 +269,7 @@ void test_legacy_profile_migration() {
kLegacyCustomThresholdProfile[79],
"legacy macro trigger was not migrated to descriptor zero");
uint8_t previous_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
uint8_t previous_encoded[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE]{};
memcpy(previous_encoded, kLegacyDefaultProfile,
sizeof(previous_encoded));
previous_encoded[0] = static_cast<uint8_t>(
@ -300,7 +300,17 @@ void test_legacy_profile_migration() {
static_cast<uint8_t>(
ControllerProfileLogicalButton::kCapture) &&
migrated.motion_toggle_chord == 0,
"v2 profile controls did not migrate to v5");
"v2 profile controls did not migrate to v6");
previous_encoded[80] = 2;
previous_encoded[100] = 0;
previous_encoded[101] = kControllerProfileOverrideButtons;
previous_encoded[102] = 25;
previous_encoded[104] = 1;
require(controller_profile_decode(
previous_encoded, sizeof(previous_encoded), &migrated) &&
migrated.macros[0].step_count == 1 &&
migrated.macro_steps[0].output_button_mask == 1,
"legacy nonempty macro did not migrate into shared pool");
ControllerProfile current =
controller_profile_default(controller_identity_global(), 0);
@ -346,7 +356,7 @@ void test_database_round_trip_and_capacity() {
require(encoded_database[4] ==
CONTROLLER_PROFILE_DATABASE_SCHEMA_VERSION &&
encoded_database[5] == 0,
"database encoder did not emit v2");
"database encoder did not emit v3");
require(controller_profile_database_decode(
read_encoded_database, nullptr, &decoded_database),
"database did not decode");
@ -360,10 +370,150 @@ void test_database_round_trip_and_capacity() {
"nonzero database header reservation was accepted");
}
void test_set_b_sparse_extension_and_migration() {
ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
profile.shortcuts.modifier = 16;
profile.shortcuts.selectors[0] = 0;
profile.shortcuts.selectors[7] = 15;
profile.shift.mode = ControllerProfileShiftMode::kToggle;
profile.shift.modifier = 17;
profile.shift.button_map[0] = CONTROLLER_PROFILE_NO_BUTTON;
profile.turbo_modes[15] = ControllerProfileTurboMode::kBurst;
profile.turbo_defaults = {30, 99, 255};
profile.turbo_override_mask = (1u << 2) | (1u << 15);
profile.turbo_overrides[2] = {1, 1, 1};
profile.turbo_overrides[15] = {23, 37, 17};
profile.turbo_overrides[0] = {0, 0, 0};
profile.macros[0] = {1, 0xff, 0, 8, ControllerProfileMacroMode::kRepeat, 255};
profile.macro_step_count = 8;
for (uint8_t macro = 1; macro < CONTROLLER_PROFILE_MACRO_COUNT; ++macro) {
profile.macros[macro].first_step = 8;
profile.macros[macro].mode = static_cast<ControllerProfileMacroMode>(macro - 1);
}
for (uint8_t step = 0; step < 8; ++step) {
profile.macro_steps[step] = {31, 25, 1, -2, 3, -4, 5, 0x1234, 0xabcd};
}
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
ControllerProfile decoded{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)) &&
controller_profile_decode(encoded, sizeof(encoded), &decoded),
"full sparse stream and Set B extension did not round trip");
require(encoded[254] == 0xcd && encoded[255] == 0xab &&
encoded[256] == 16 && encoded[264] == 15 &&
encoded[265] == 2 && encoded[266] == 17 &&
encoded[267] == 0xff && encoded[283] == 30 &&
encoded[286] == 4 && encoded[287] == 0x80 &&
encoded[288] == 1 && encoded[291] == 23 &&
encoded[336] == 3 && encoded[337] == 255,
"Set B fields overlap macro data or use wrong sparse ordering");
require(decoded.turbo_overrides[15].duty_percent == 37 &&
decoded.macros[0].repeat_count == 255 &&
decoded.shortcuts.selectors[7] == 15,
"Set B extension settings were not decoded");
for (size_t offset = 294; offset < 336; ++offset) {
require(encoded[offset] == 0, "absent override was not canonical zero");
}
encoded[294] = 1;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"nonzero sparse Turbo padding was accepted");
encoded[294] = 0;
encoded[383] = 1;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"nonzero extension reservation was accepted");
encoded[383] = 0;
encoded[337] = 0;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"zero macro repeat count was accepted");
encoded[337] = 255;
uint8_t legacy[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE]{};
memcpy(legacy, encoded, sizeof(legacy));
legacy[0] = 5;
legacy[2] = 0;
legacy[95] = 2;
require(controller_profile_decode(legacy, sizeof(legacy), &decoded) &&
decoded.macros[0].step_count == 8 &&
decoded.macro_steps[7].right_trigger == 0xabcd &&
decoded.macros[0].mode == ControllerProfileMacroMode::kOnce &&
decoded.shortcuts.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
decoded.turbo_override_mask == 0,
"schema5 full136-byte macro stream did not migrate");
require(controller_profile_encode(decoded, encoded, sizeof(encoded)) &&
memcmp(&legacy[4], &encoded[4], sizeof(legacy) - 4) == 0,
"schema5 migration changed existing profile data");
legacy[0] = 6;
require(!controller_profile_decode(legacy, sizeof(legacy), &decoded),
"schema6 accepted a legacy-sized payload");
profile.shortcuts.selectors[7] = 0;
require(!controller_profile_validate(profile), "duplicate shortcut was accepted");
profile.shortcuts.selectors[7] = 15;
profile.shift.button_map[0] = 16;
require(!controller_profile_validate(profile), "analog Shift output was accepted");
profile.shift.button_map[0] = 0;
profile.turbo_overrides[2].rate_hz = 31;
require(!controller_profile_validate(profile), "out-of-range Turbo rate was accepted");
profile.turbo_overrides[2].rate_hz = 1;
for (uint8_t step = 0; step < 8; ++step) {
profile.macro_steps[step].duration_ms = 0;
}
require(!controller_profile_validate(profile), "zero-duration looping macro was accepted");
profile.macros[0].mode = ControllerProfileMacroMode::kOnce;
require(controller_profile_validate(profile), "zero-duration once macro was rejected");
}
void test_legacy_database_strides() {
for (uint8_t version = 1; version <= 2; ++version) {
const uint8_t count = version == 1 ? 4 : 8;
const size_t entry_size = 16 + count * 256;
const size_t entries_offset = 32 + count * 256;
const size_t total_size = entries_offset + 16 * entry_size;
memset(encoded_database, 0, sizeof(encoded_database));
memcpy(encoded_database, "SPDB", 4);
encoded_database[4] = version;
encoded_database[6] = static_cast<uint8_t>(total_size);
encoded_database[7] = static_cast<uint8_t>(total_size >> 8);
encoded_database[8] = 16;
encoded_database[9] = count;
encoded_database[10] = count - 1;
encoded_database[11] = 1;
for (uint8_t index = 0; index < count; ++index) {
memcpy(&encoded_database[32 + index * 256], kLegacyDefaultProfile, 256);
}
const size_t last_entry = entries_offset + 15 * entry_size;
require(controller_identity_encode(identity(16), &encoded_database[last_entry], 14),
"legacy database identity did not encode");
encoded_database[last_entry + 14] = count - 1;
encoded_database[last_entry + 15] = 1;
for (uint8_t index = 0; index < count; ++index) {
memcpy(&encoded_database[last_entry + 16 + index * 256],
kLegacyCustomThresholdProfile, 256);
}
require(controller_profile_database_decode(
read_encoded_database, nullptr, &decoded_database),
"legacy database strides were not preserved");
const ControllerProfileDatabaseEntry* entry =
controller_profile_database_find(decoded_database, identity(16));
require(entry != nullptr && entry->active_profile == count - 1 &&
entry->profiles[count - 1].triggers[1].digital_threshold == 0xabcd,
"last legacy bank profile was read from the wrong offset");
if (count == 4) {
require(entry->profiles[7].triggers[0].digital_threshold ==
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD,
"new profile slots were not defaulted during migration");
}
encoded_database[4] = 3;
require(!controller_profile_database_decode(
read_encoded_database, nullptr, &decoded_database),
"current database version accepted legacy strides");
}
}
} // namespace
int main() {
test_profile_wire_schema();
test_legacy_profile_migration();
test_database_round_trip_and_capacity();
test_set_b_sparse_extension_and_migration();
test_legacy_database_strides();
return 0;
}

View file

@ -527,6 +527,217 @@ void test_four_contexts_are_isolated() {
"cancelling one slot changed another slot's Auto Burst state");
}
void test_parameterized_turbo_and_finite_burst() {
ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
profile.turbo_defaults = {10, 25, 3};
profile.turbo_modes[0] = ControllerProfileTurboMode::kBurst;
profile.turbo_modes[1] = ControllerProfileTurboMode::kTurbo;
profile.turbo_override_mask = 1u << 1;
profile.turbo_overrides[1] = {20, 80, 1};
profile.button_map[0] = 3;
profile.button_map[1] = 2;
ControllerSyntheticInputContext context{};
const ControllerState pressed = state_with_buttons(3);
auto output = controller_synthetic_input_apply(&context, pressed, profile, 0);
require(output.state.button_north && output.state.button_west,
"parameterized bindings did not start immediately");
output = controller_synthetic_input_apply(&context, pressed, profile, 24);
require(output.state.button_north && output.state.button_west,
"duty window ended early");
output = controller_synthetic_input_apply(&context, pressed, profile, 25);
require(!output.state.button_north && output.state.button_west,
"defaults or physical-source override selected the wrong duty");
output = controller_synthetic_input_apply(&context, pressed, profile, 40);
require(!output.state.button_north && !output.state.button_west,
"override duty boundary remained ON");
output = controller_synthetic_input_apply(&context, pressed, profile, 50);
require(!output.state.button_north && output.state.button_west,
"override period followed the default rate");
output = controller_synthetic_input_apply(
&context, controller_neutral_state(), profile, 100);
require(output.state.button_north && !output.state.button_west,
"finite Burst release cancelled its windows or hold Turbo latched");
output = controller_synthetic_input_apply(&context, pressed, profile, 101);
require(output.state.button_north, "fresh Burst press did not restart");
output = controller_synthetic_input_apply(&context, pressed, profile, 325);
require(output.state.button_north, "third Burst ON window ended early");
output = controller_synthetic_input_apply(&context, pressed, profile, 326);
require(!output.state.button_north, "Burst did not stop at final ON end");
output = controller_synthetic_input_apply(&context, pressed, profile, 401);
require(!output.state.button_north, "held completed Burst rearmed itself");
context = {};
profile.turbo_defaults.burst_count = 1;
constexpr uint32_t start = UINT32_MAX - 10u;
(void)controller_synthetic_input_apply(&context, pressed, profile, start);
output = controller_synthetic_input_apply(&context, pressed, profile, 13);
require(output.state.button_north, "one-window Burst ended early at wrap");
output = controller_synthetic_input_apply(&context, pressed, profile, 14);
require(!output.state.button_north, "one-window Burst crossed its deadline");
context = {};
profile.turbo_defaults = {1, 99, 255};
(void)controller_synthetic_input_apply(&context, pressed, profile, 0);
output = controller_synthetic_input_apply(&context, pressed, profile, 254989);
require(output.state.button_north, "maximum Burst lost its final window");
output = controller_synthetic_input_apply(&context, pressed, profile, 254990);
require(!output.state.button_north, "maximum Burst count overflowed");
context = {};
(void)controller_synthetic_input_apply(&context, pressed, profile, 0);
output = controller_synthetic_input_apply(
&context, pressed, profile, 4000000000u);
require(!output.state.button_north && output.state.button_west,
"long-gap catch-up replayed missed Burst pulses or overflowed Turbo");
context = {};
profile.turbo_modes[0] = ControllerProfileTurboMode::kTurbo;
profile.turbo_defaults = {30, 1, 1};
output = controller_synthetic_input_apply(&context, pressed, profile, 0);
require(output.state.button_north, "narrow Turbo duty did not start ON");
output = controller_synthetic_input_apply(&context, pressed, profile, 1);
require(!output.state.button_north,
"narrow duty was silently clamped to the polling cadence");
output = controller_synthetic_input_apply(&context, pressed, profile, 100);
require(output.state.button_north, "narrow duty lost its absolute cycle phase");
context = {};
profile.turbo_defaults = {30, 99, 1};
(void)controller_synthetic_input_apply(&context, pressed, profile, 0);
output = controller_synthetic_input_apply(&context, pressed, profile, 33);
require(!output.state.button_north, "99-percent duty included its OFF boundary");
output = controller_synthetic_input_apply(&context, pressed, profile, 34);
require(output.state.button_north, "fractional period accumulated polling drift");
}
void test_shift_maps_consumption_and_physical_bindings() {
ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSelect);
profile.shift.mode = ControllerProfileShiftMode::kHold;
profile.shift.modifier = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
profile.shift.button_map[0] = 3;
profile.button_map[0] = CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
profile.sticks[0].invert_x = true;
profile.triggers[1].lower_deadzone = 1000;
profile.turbo_modes[0] = ControllerProfileTurboMode::kTurbo;
profile.turbo_defaults = {10, 20, 3};
ControllerState input = state_with_buttons(1);
input.left_trigger = UINT16_MAX;
input.right_trigger = 32768;
input.left_stick_x = 12345;
const auto base = controller_profile_transform(input, profile);
ControllerSyntheticInputContext context{};
auto output = controller_synthetic_input_apply(&context, input, profile, 0);
require(output.state.button_north && !output.state.button_south &&
output.state.left_trigger == 0 &&
output.state.right_trigger < UINT16_MAX &&
output.state.left_stick_x == base.state.left_stick_x,
"Shift did not consume analog modifier or replaced base analog tuning");
const uint16_t tuned_trigger = output.state.right_trigger;
output = controller_synthetic_input_apply(&context, input, profile, 20);
require(!output.state.button_north &&
output.state.right_trigger == tuned_trigger,
"Shift moved Turbo settings to the mapped output");
input.left_trigger = 0;
output = controller_synthetic_input_apply(&context, input, profile, 100);
require(!output.state.button_north &&
output.state.right_trigger == UINT16_MAX,
"releasing hold Shift did not restore base button-to-trigger mapping");
context = {};
profile.shift.mode = ControllerProfileShiftMode::kToggle;
profile.shift.modifier = button_index(ControllerProfileLogicalButton::kSelect);
profile.turbo_modes[0] = ControllerProfileTurboMode::kOff;
profile.macros[0].step_count = profile.macro_step_count = 1;
profile.macro_steps[0] = {kControllerProfileOverrideButtons, 100, 2};
input = state_with_buttons(1u | (1u << profile.shift.modifier));
output = controller_synthetic_input_apply(&context, input, profile, 0);
require(output.state.button_north && !output.state.button_select &&
!output.state.button_east,
"Shift modifier leaked into mapping or its competing macro");
output = controller_synthetic_input_apply(&context, input, profile, 1);
require(output.state.button_north, "held toggle Shift retriggered");
output = controller_synthetic_input_apply(
&context, state_with_buttons(1), profile, 2);
require(output.state.button_north, "toggle Shift did not latch after release");
output = controller_synthetic_input_apply(&context, input, profile, 3);
require(!output.state.button_north && output.state.right_trigger == UINT16_MAX,
"second Shift rising edge did not restore base mapping");
controller_synthetic_input_cancel(
&context, controller_profile_extract_control_mask(input, profile));
output = controller_synthetic_input_apply(&context, input, profile, 4);
require(!output.state.button_north, "cancel phantom-toggled a held modifier");
(void)controller_synthetic_input_apply(
&context, controller_neutral_state(), profile, 5);
output = controller_synthetic_input_apply(&context, input, profile, 6, 0, true);
require(!output.state.button_north, "winning hotkey did not suppress Shift");
output = controller_synthetic_input_apply(&context, input, profile, 7);
require(!output.state.button_north, "suppression release phantom-toggled Shift");
}
void test_macro_playback_modes_and_bounded_cycle_skips() {
ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSouth);
profile.macros[0].trigger_mask |= 1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
profile.macros[0].step_count = profile.macro_step_count = 3;
profile.macro_steps[0] = {kControllerProfileOverrideButtons, 10, 8};
profile.macro_steps[1] = {kControllerProfileOverrideButtons, 0, 2};
profile.macro_steps[2] = {kControllerProfileOverrideButtons, 20, 4};
ControllerState held = state_with_buttons(1);
held.left_trigger = UINT16_MAX;
ControllerSyntheticInputContext context{};
profile.macros[0].mode = ControllerProfileMacroMode::kWhileHeld;
auto output = controller_synthetic_input_apply(&context, held, profile, 0);
require(output.state.button_north && output.state.left_trigger == 0,
"while-held macro did not consume its entire physical trigger");
output = controller_synthetic_input_apply(&context, held, profile, 10);
require(output.state.button_west && !output.state.button_east,
"zero-duration interior step delayed the next timed state");
output = controller_synthetic_input_apply(&context, held, profile, 30);
require(output.state.button_north, "while-held macro did not repeat");
output = controller_synthetic_input_apply(
&context, state_with_buttons(1), profile, 31);
require(!output.state.button_north && !output.state.button_west,
"partial trigger release did not stop while-held playback");
output = controller_synthetic_input_apply(&context, held, profile, 32);
require(output.state.button_north, "completed trigger did not rearm playback");
context = {};
profile.macros[0].mode = ControllerProfileMacroMode::kRepeat;
profile.macros[0].repeat_count = 3;
(void)controller_synthetic_input_apply(&context, held, profile, 0);
output = controller_synthetic_input_apply(&context, held, profile, 89);
require(output.state.button_west, "repeat count omitted its last cycle");
output = controller_synthetic_input_apply(&context, held, profile, 90);
require(!output.state.button_west && !output.state.button_north,
"finite macro repeated past its exact cycle count");
output = controller_synthetic_input_apply(&context, held, profile, 120);
require(!output.state.button_west && !output.state.button_north,
"held finite macro phantom-restarted after completing");
context = {};
profile.macros[0].mode = ControllerProfileMacroMode::kToggle;
constexpr uint32_t start = UINT32_MAX - 5u;
(void)controller_synthetic_input_apply(&context, held, profile, start);
output = controller_synthetic_input_apply(
&context, controller_neutral_state(), profile, 4);
require(output.state.button_west, "toggle macro lost phase over clock wrap");
output = controller_synthetic_input_apply(
&context, controller_neutral_state(), profile, 3999999994u);
require(output.state.button_west,
"long-gap toggle macro overflowed or iterated missed cycles");
output = controller_synthetic_input_apply(
&context, held, profile, 3999999995u);
require(!output.state.button_north && !output.state.button_west &&
output.state.left_trigger == 0,
"matching rising trigger did not toggle active macro off");
(void)controller_synthetic_input_apply(
&context, controller_neutral_state(), profile, 3999999996u);
(void)controller_synthetic_input_apply(
&context, held, profile, 3999999997u);
output = controller_synthetic_input_apply(
&context, state_with_buttons(1u << 9), profile, 3999999998u);
require(controller_profile_extract_button_mask(output.state) == 0,
"configured cancel did not kill looping macro output");
}
} // namespace
int main() {
@ -539,5 +750,8 @@ int main() {
test_multiple_macro_bindings_share_step_pool();
test_auto_burst_toggle_cancel_and_external_cancel();
test_four_contexts_are_isolated();
test_parameterized_turbo_and_finite_burst();
test_shift_maps_consumption_and_physical_bindings();
test_macro_playback_modes_and_bounded_cycle_skips();
return 0;
}

View file

@ -0,0 +1,37 @@
#pragma once
#include <stdint.h>
/* Subset of the Pico SDK's exposed shared-bus ABI used by this transport. */
#define BTSDIO_FWBUF_SIZE 0x1000
typedef enum {
CYBT_SUCCESS = 0,
CYBT_ERR_BADARG = 0xB1,
CYBT_ERR_TIMEOUT = 0xB3,
CYBT_ERR_HCI_READ_FAILED = 0xB9,
} cybt_result_t;
typedef enum {
H2B_BUF_ADDR_IDX = 0x10,
H2B_BUF_IN_ADDR_IDX,
H2B_BUF_OUT_ADDR_IDX,
B2H_BUF_ADDR_IDX,
B2H_BUF_IN_ADDR_IDX,
B2H_BUF_OUT_ADDR_IDX,
} cybt_addr_idx_t;
typedef struct {
uint32_t host2bt_in_val;
uint32_t host2bt_out_val;
uint32_t bt2host_in_val;
uint32_t bt2host_out_val;
} cybt_fw_membuf_index_t;
cybt_result_t cybt_set_bt_awake(int value);
int cybt_awake(void);
cybt_result_t cybt_get_bt_buf_index(cybt_fw_membuf_index_t *indices);
cybt_result_t cybt_mem_read_idx(cybt_addr_idx_t index, uint32_t offset,
uint8_t *buffer, uint32_t length);
cybt_result_t cybt_reg_write_idx(cybt_addr_idx_t index, uint32_t value);
cybt_result_t cybt_toggle_bt_intr(void);

View file

@ -0,0 +1,10 @@
#pragma once
#include <stdint.h>
typedef struct _cyw43_ll_t {
int unused;
} cyw43_ll_t;
int cyw43_btbus_init(cyw43_ll_t *self);
int cyw43_btbus_read(uint8_t *buffer, uint32_t capacity, uint32_t *size);

View file

@ -0,0 +1,11 @@
#pragma once
#include <stdint.h>
void cyw43_thread_enter(void);
void cyw43_thread_exit(void);
void cyw43_delay_ms(uint32_t milliseconds);
_Noreturn void panic(const char *message, ...);
#define CYW43_THREAD_ENTER cyw43_thread_enter();
#define CYW43_THREAD_EXIT cyw43_thread_exit();

View file

@ -0,0 +1,8 @@
#include "cyw43_btbus.h"
int test_cyw43_btbus_init(cyw43_ll_t *self);
/* Separate translation unit: GNU --wrap only redirects unresolved references. */
int cyw43_btbus_init(cyw43_ll_t *self) {
return test_cyw43_btbus_init(self);
}

View file

@ -0,0 +1,409 @@
#include <assert.h>
#include <setjmp.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cyw43_btbus.h"
#include "cyw43_config.h"
#include "cybt_shared_bus_driver.h"
static uint8_t ring[BTSDIO_FWBUF_SIZE];
static cybt_fw_membuf_index_t indices;
static unsigned lock_depth;
static unsigned enters;
static unsigned exits;
static unsigned wake_polls;
static unsigned delays;
static unsigned snapshots;
static unsigned reads;
static unsigned publications;
static unsigned notifications;
static unsigned fail_read;
static bool fail_wake;
static bool fail_snapshot;
static bool fail_publication;
static bool fail_notification;
static int awake_value;
static bool append_on_header;
static bool expect_panic;
static jmp_buf panic_jump;
void cyw43_thread_enter(void) {
++lock_depth;
++enters;
}
void cyw43_thread_exit(void) {
assert(lock_depth != 0);
--lock_depth;
++exits;
}
void cyw43_delay_ms(uint32_t milliseconds) {
assert(lock_depth != 0);
assert(milliseconds == 1);
++delays;
}
_Noreturn void panic(const char *message, ...) {
if (!expect_panic) {
fprintf(stderr, "unexpected panic: %s\n", message);
abort();
}
assert(strcmp(message, "cyw43 buffer overflow") == 0);
longjmp(panic_jump, 1);
}
int test_cyw43_btbus_init(cyw43_ll_t *self) {
(void)self;
assert(lock_depth != 0);
memset(&indices, 0, sizeof(indices));
return 0;
}
cybt_result_t cybt_set_bt_awake(int value) {
assert(lock_depth != 0);
assert(value == 1);
return fail_wake ? CYBT_ERR_HCI_READ_FAILED : CYBT_SUCCESS;
}
int cybt_awake(void) {
assert(lock_depth != 0);
++wake_polls;
return awake_value;
}
cybt_result_t cybt_get_bt_buf_index(cybt_fw_membuf_index_t *out) {
assert(lock_depth != 0);
++snapshots;
if (fail_snapshot) {
return CYBT_ERR_HCI_READ_FAILED;
}
*out = indices;
return CYBT_SUCCESS;
}
static uint32_t put_packet(uint32_t offset, uint32_t payload_length, uint8_t type) {
uint32_t wire_length = 4 + ((payload_length + 3) & ~3u);
assert(wire_length <= BTSDIO_FWBUF_SIZE);
for (uint32_t byte = 0; byte < wire_length; ++byte) {
uint8_t value = (uint8_t)(byte - 4);
if (byte < 3) {
value = (uint8_t)(payload_length >> (byte * 8));
} else if (byte == 3) {
value = type;
}
ring[(offset + byte) & (BTSDIO_FWBUF_SIZE - 1)] = value;
}
return (offset + wire_length) & (BTSDIO_FWBUF_SIZE - 1);
}
cybt_result_t cybt_mem_read_idx(cybt_addr_idx_t index, uint32_t offset,
uint8_t *buffer, uint32_t length) {
assert(lock_depth != 0);
assert(index == B2H_BUF_ADDR_IDX);
assert(length >= 4 && (length & 3) == 0);
assert((offset & 3) == 0 && offset + length <= BTSDIO_FWBUF_SIZE);
assert(((uintptr_t)buffer & 3) == 0);
++reads;
if (reads == fail_read) {
/* Model a failed SPI transfer that has already modified its destination. */
buffer[0] = 0xEF;
return CYBT_ERR_HCI_READ_FAILED;
}
memcpy(buffer, ring + offset, length);
if (append_on_header && reads == 1) {
indices.bt2host_in_val = put_packet(indices.bt2host_in_val, 4, 2);
}
return CYBT_SUCCESS;
}
cybt_result_t cybt_reg_write_idx(cybt_addr_idx_t index, uint32_t value) {
assert(lock_depth != 0);
assert(index == B2H_BUF_OUT_ADDR_IDX);
assert(value < BTSDIO_FWBUF_SIZE && (value & 3) == 0);
++publications;
if (fail_publication) {
return CYBT_ERR_HCI_READ_FAILED;
}
indices.bt2host_out_val = value;
return CYBT_SUCCESS;
}
cybt_result_t cybt_toggle_bt_intr(void) {
assert(lock_depth != 0);
assert(publications > notifications);
++notifications;
return fail_notification ? CYBT_ERR_HCI_READ_FAILED : CYBT_SUCCESS;
}
static void reset(void) {
lock_depth = 0;
expect_panic = false;
assert(cyw43_btbus_init(NULL) == 0);
memset(ring, 0, sizeof(ring));
enters = exits = wake_polls = delays = snapshots = reads = publications = notifications = 0;
fail_read = 0;
fail_wake = fail_snapshot = fail_publication = fail_notification = false;
append_on_header = false;
awake_value = 1;
}
static void check_unconsumed(uint32_t original_out) {
assert(indices.bt2host_out_val == original_out);
assert(notifications == 0);
assert(lock_depth == 0 && enters == exits);
}
static void check_packet(const uint8_t *buffer, uint32_t length, uint8_t type) {
assert(buffer[0] == (uint8_t)length);
assert(buffer[1] == (uint8_t)(length >> 8));
assert(buffer[2] == (uint8_t)(length >> 16));
assert(buffer[3] == type);
for (uint32_t byte = 0; byte < length; ++byte) {
assert(buffer[4 + byte] == (uint8_t)byte);
}
}
static void test_empty_and_partial(void) {
_Alignas(4) uint8_t buffer[32];
uint32_t size = 123;
reset();
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0);
assert(snapshots == 1 && reads == 0 && publications == 0);
check_unconsumed(0);
uint32_t end = put_packet(0, 5, 4);
for (uint32_t published = 1; published <= 3; ++published) {
indices.bt2host_in_val = published;
size = 123;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0);
assert(reads == 0 && publications == 0);
check_unconsumed(0);
}
const uint32_t partial_lengths[] = {4, 8, 9, 11};
for (unsigned i = 0; i < sizeof(partial_lengths) / sizeof(partial_lengths[0]); ++i) {
indices.bt2host_in_val = partial_lengths[i];
size = 123;
unsigned before_reads = reads;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0);
assert(reads == before_reads + 1 && publications == 0);
check_unconsumed(0);
}
indices.bt2host_in_val = end;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9);
check_packet(buffer, 5, 4);
assert(indices.bt2host_out_val == end && publications == 1 && notifications == 1);
}
static void test_complete_wrapped_and_full(void) {
_Alignas(4) uint8_t buffer[BTSDIO_FWBUF_SIZE];
const uint32_t starts[] = {0, BTSDIO_FWBUF_SIZE - 4, BTSDIO_FWBUF_SIZE - 8};
for (unsigned i = 0; i < sizeof(starts) / sizeof(starts[0]); ++i) {
reset();
indices.bt2host_out_val = starts[i];
indices.bt2host_in_val = put_packet(starts[i], 9, 2);
uint32_t size = 0;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 13);
check_packet(buffer, 9, 2);
assert(indices.bt2host_out_val == indices.bt2host_in_val);
assert(snapshots == 1 && publications == 1 && notifications == 1);
assert(lock_depth == 0 && enters == exits);
}
reset();
indices.bt2host_in_val = put_packet(0, BTSDIO_FWBUF_SIZE - 8, 2);
uint32_t size = 0;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0);
assert(size == BTSDIO_FWBUF_SIZE - 4);
check_packet(buffer, BTSDIO_FWBUF_SIZE - 8, 2);
assert(indices.bt2host_out_val == indices.bt2host_in_val);
reset();
indices.bt2host_in_val = put_packet(0, 0, 0);
assert(cyw43_btbus_read(buffer, 4, &size) == 0 && size == 4);
assert(reads == 1 && publications == 1 && notifications == 1);
}
static void test_exact_capacity_and_snapshot_growth(void) {
_Alignas(4) uint8_t buffer[32];
uint32_t size = 0;
reset();
memset(buffer, 0xA5, sizeof(buffer));
indices.bt2host_in_val = put_packet(0, 5, 4);
assert(cyw43_btbus_read(buffer, 9, &size) == 0 && size == 9);
check_packet(buffer, 5, 4);
for (unsigned i = 9; i < sizeof(buffer); ++i) {
assert(buffer[i] == 0xA5);
}
reset();
indices.bt2host_in_val = put_packet(0, 5, 4);
uint32_t first_end = indices.bt2host_in_val;
append_on_header = true;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9);
check_packet(buffer, 5, 4);
assert(indices.bt2host_out_val == first_end && snapshots == 1);
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 8);
check_packet(buffer, 4, 2);
assert(indices.bt2host_out_val == indices.bt2host_in_val);
assert(publications == 2 && notifications == 2);
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0);
assert(publications == 2 && notifications == 2);
}
static void test_oversized_and_invalid(void) {
_Alignas(4) uint8_t buffer[BTSDIO_FWBUF_SIZE * 2];
uint32_t size = 123;
reset();
indices.bt2host_in_val = put_packet(0, 5, 4);
assert(cyw43_btbus_read(buffer, 8, &size) != 0 && size == 0);
check_unconsumed(0);
assert(reads == 1 && publications == 0);
ring[0] = ring[1] = ring[2] = 0xFF;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
check_unconsumed(0);
reset();
/* This would occupy the reserved word and make full indistinguishable from empty. */
put_packet(0, BTSDIO_FWBUF_SIZE - 4, 2);
indices.bt2host_in_val = 4;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
check_unconsumed(0);
reset();
assert(cyw43_btbus_read(NULL, sizeof(buffer), &size) != 0 && size == 0);
assert(cyw43_btbus_read(buffer, sizeof(buffer), NULL) != 0);
assert(cyw43_btbus_read(buffer + 1, sizeof(buffer) - 1, &size) != 0);
for (uint32_t capacity = 0; capacity < 4; ++capacity) {
assert(cyw43_btbus_read(buffer, capacity, &size) != 0 && size == 0);
}
assert(enters == 0 && snapshots == 0 && publications == 0);
check_unconsumed(0);
for (unsigned field = 0; field < 4; ++field) {
reset();
switch (field) {
case 0: indices.host2bt_in_val = BTSDIO_FWBUF_SIZE; break;
case 1: indices.host2bt_out_val = BTSDIO_FWBUF_SIZE; break;
case 2: indices.bt2host_in_val = BTSDIO_FWBUF_SIZE; break;
case 3: indices.bt2host_out_val = BTSDIO_FWBUF_SIZE; break;
}
uint32_t original_out = indices.bt2host_out_val;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
check_unconsumed(original_out);
assert(reads == 0 && publications == 0);
}
reset();
indices.bt2host_out_val = 1;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
check_unconsumed(1);
reset();
indices.bt2host_in_val = BTSDIO_FWBUF_SIZE - 1;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
check_unconsumed(0);
}
static void test_status_failures_and_retry(void) {
_Alignas(4) uint8_t buffer[32];
uint32_t size = 123;
reset();
fail_wake = true;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
assert(snapshots == 0 && wake_polls == 0);
check_unconsumed(0);
reset();
awake_value = -1;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
assert(snapshots == 0 && wake_polls == 1);
check_unconsumed(0);
reset();
awake_value = 0;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
assert(snapshots == 0 && wake_polls == 301 && delays == 301);
check_unconsumed(0);
reset();
fail_snapshot = true;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
assert(reads == 0 && publications == 0);
check_unconsumed(0);
/* Fail header, first wrapped payload segment, then second payload segment. */
for (unsigned failure = 1; failure <= 3; ++failure) {
reset();
indices.bt2host_out_val = BTSDIO_FWBUF_SIZE - 8;
indices.bt2host_in_val = put_packet(indices.bt2host_out_val, 9, 2);
fail_read = failure;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
assert(publications == 0);
check_unconsumed(BTSDIO_FWBUF_SIZE - 8);
fail_read = 0;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 13);
check_packet(buffer, 9, 2);
assert(indices.bt2host_out_val == indices.bt2host_in_val);
}
reset();
indices.bt2host_in_val = put_packet(0, 5, 4);
fail_read = 3; /* Final padded word when the caller provides exact capacity. */
assert(cyw43_btbus_read(buffer, 9, &size) != 0 && size == 0);
assert(publications == 0);
check_unconsumed(0);
reset();
indices.bt2host_in_val = put_packet(0, 5, 4);
fail_publication = true;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
check_unconsumed(0);
assert(publications == 1);
reset();
indices.bt2host_in_val = put_packet(0, 5, 4);
fail_notification = true;
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) != 0 && size == 0);
/* Notification follows the irreversible commit; rollback would violate ownership. */
assert(indices.bt2host_out_val == indices.bt2host_in_val);
assert(publications == 1 && notifications == 1);
assert(lock_depth == 0 && enters == exits);
}
static void test_overflow_and_controller_reset(void) {
_Alignas(4) uint8_t buffer[32];
uint32_t size = 0;
reset();
uint32_t first_end = put_packet(0, 5, 4);
indices.bt2host_in_val = put_packet(first_end, 8, 2);
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9);
assert(indices.bt2host_out_val == first_end);
indices.bt2host_in_val -= 4; /* Published unread data disappeared. */
expect_panic = true;
if (setjmp(panic_jump) == 0) {
cyw43_btbus_read(buffer, sizeof(buffer), &size);
assert(false && "overflow must retain the SDK's fatal check");
}
expect_panic = false;
assert(indices.bt2host_out_val == first_end);
assert(publications == 1 && notifications == 1);
lock_depth = 0; /* panic does not return on hardware. */
reset();
first_end = put_packet(0, 5, 4);
indices.bt2host_in_val = put_packet(first_end, 8, 2);
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 9);
assert(cyw43_btbus_init(NULL) == 0);
assert(cyw43_btbus_read(buffer, sizeof(buffer), &size) == 0 && size == 0);
assert(indices.bt2host_out_val == 0 && lock_depth == 0);
}
int main(void) {
test_empty_and_partial();
test_complete_wrapped_and_full();
test_exact_capacity_and_snapshot_growth();
test_oversized_and_invalid();
test_status_failures_and_retry();
test_overflow_and_controller_reset();
puts("cyw43 packet transport regression checks passed");
return 0;
}

View file

@ -223,8 +223,8 @@ uint64_t start(uint8_t slot = 0) {
return now_us;
}
uint64_t due(uint64_t started, uint32_t packet) {
return started + (static_cast<uint64_t>(packet) * 64000 + 2) / 3;
uint64_t due(uint64_t started, uint32_t packet, uint32_t frames = 64) {
return started + (static_cast<uint64_t>(packet) * frames * 1000 + 2) / 3;
}
void verify_block(const Pcm& packet, uint32_t index, bool forced_silence = false) {
@ -622,10 +622,15 @@ void gameplay_timeline_and_lifecycle() {
assert(haptics_experiment_gameplay_owns(&devices[0]));
now_us = started + 8000;
feed(false);
run_until(due(started, 1) + 1000);
const uint32_t frames = snapshot().packet_frames;
run_until(due(started, 1, frames) + 1000);
assert(pcm.size() == 2 && snapshot().host_updates == 2);
assert(pcm[1].bytes[7] == frames / 32);
assert(pcm[1].bytes[8] == (frames == 32 ? 0x92 : 0xd2));
for (unsigned byte = 10 + frames * 2; byte < 139; ++byte)
assert(pcm[1].bytes[byte] == 0);
unsigned left_nonzero = 0, right_nonzero = 0;
for (unsigned frame = 0; frame < 64; ++frame) {
for (unsigned frame = 0; frame < frames; ++frame) {
const auto left = pcm[1].bytes[10 + frame * 2];
const auto right = pcm[1].bytes[11 + frame * 2];
if (frame < 24) {
@ -636,7 +641,7 @@ void gameplay_timeline_and_lifecycle() {
right_nonzero += right != 0;
}
}
assert(left_nonzero > 10 && right_nonzero > 20);
assert(left_nonzero > 10 && right_nonzero > (frames - 24) / 2);
SwitchHapticsFrame stale{};
stale.actuators[0].sample_count = 1;
stale.actuators[0].samples[0].low_amplitude_q15 = 16000;
@ -695,6 +700,81 @@ void gameplay_queued_start_and_command_overflow() {
assert(snapshot().skipped_packets != 0 && pcm.size() == sent_before + 1);
}
void stateful_rumble_prepare_feedback_and_zero() {
reset();
devices[0].credit = false;
emit_generic(&devices[0], GenericKind::kLed);
assert(haptics_experiment_request(2, 0));
haptics_experiment_poll();
assert(snapshot().state == HapticsExperimentState::kPending);
assert(haptics_experiment_submit_rumble(0, 100, now_us, 180, 0));
assert(!haptics_experiment_submit_rumble(1, 101, now_us, 255, 255));
assert(!haptics_experiment_submit_rumble(0, 99, now_us, 255, 255));
run_until(now_us + 70000); // Preparation must not age out held strengths.
devices[0].credit = true;
assert(!dispatch(&devices[0], devices[0].conn.control_cid));
run_until(now_us + 3000);
assert(snapshot().state == HapticsExperimentState::kRunning);
const auto assert_channels = [](bool left, bool right) {
const uint32_t frames = snapshot().packet_frames;
unsigned active[2]{};
for (uint32_t frame = 0; frame < frames; ++frame) {
active[0] += pcm.back().bytes[10 + frame * 2] != 0;
active[1] += pcm.back().bytes[11 + frame * 2] != 0;
}
assert(left ? active[0] > frames / 2 : active[0] == 0);
assert(right ? active[1] > frames / 2 : active[1] == 0);
};
run_until(now_us + 300000);
assert_channels(true, false);
assert(haptics_experiment_feedback(&devices[0], 255, 255, 100));
assert(haptics_experiment_submit_rumble(0, 100, now_us, 0, 170));
run_until(now_us + 60000);
assert_channels(true, true);
run_until(now_us + 200000);
assert_channels(false, true); // Overlay reveals the newest held command.
assert(generic_sent.size() == 1 &&
generic_sent.front().kind == GenericKind::kLed);
assert(haptics_experiment_submit_rumble(0, 100, now_us, 0, 0));
run_until(now_us + 80000);
assert_channels(false, false);
assert(haptics_experiment_request(0, 0));
assert(!haptics_experiment_submit_rumble(0, 100, now_us, 255, 255));
haptics_experiment_poll();
run_until(now_us + 10000);
assert(snapshot().state == HapticsExperimentState::kStopped);
assert(haptics_experiment_request(1, 0));
assert(!haptics_experiment_submit_rumble(0, 100, now_us, 255, 255));
haptics_experiment_poll();
assert(snapshot().mode == 0 && snapshot().packet_frames == 64);
}
void stateful_rumble_generation_and_overflow() {
reset();
assert(haptics_experiment_request(2, 0));
assert(haptics_experiment_submit_rumble(0, 100, now_us, 255, 0));
haptics_experiment_detach(&devices[0]);
haptics_experiment_attach(0, 101, &devices[0]);
assert(!haptics_experiment_submit_rumble(0, 100, now_us, 255, 0));
haptics_experiment_poll();
assert(snapshot().state == HapticsExperimentState::kDisconnected);
assert(haptics_experiment_request(2, 0));
haptics_experiment_poll();
run_until(now_us + 100000);
for (unsigned byte = 10; byte < 138; ++byte)
assert(pcm.back().bytes[byte] == 0);
for (unsigned command = 0; command < 17; ++command) {
assert(haptics_experiment_submit_rumble(
0, 101, now_us, command == 16 ? 0 : 255, 0));
}
run_until(now_us + 80000);
assert(snapshot().host_updates == 17 && snapshot().dropped_updates == 1);
for (unsigned byte = 10; byte < 138; ++byte)
assert(pcm.back().bytes[byte] == 0);
assert(generic_sent.empty());
}
} // namespace
// Transport attribution has its own native fixture; this fixture isolates PCM
@ -812,6 +892,8 @@ int main(int argc, char** argv) {
timing_cost_reentrancy_and_wrap();
gameplay_timeline_and_lifecycle();
gameplay_queued_start_and_command_overflow();
stateful_rumble_prepare_feedback_and_zero();
stateful_rumble_generation_and_overflow();
gameplay_missing_callback_is_bounded();
std::cout << "haptics experiment behavioral regressions passed\n";
}

View file

@ -42,7 +42,6 @@ HapticsTransportProbe snapshot() {
void unchanged(const HapticsTransportProbe& expected) {
const auto actual = snapshot();
static_assert(sizeof(actual) == 128);
assert(std::memcmp(&actual, &expected, sizeof(actual)) == 0);
}

View file

@ -89,6 +89,7 @@ void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id,
uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance);
void tud_mount_cb(void);
void tud_umount_cb(void);
void tud_suspend_cb(bool remote_wakeup_en);
uint8_t const* tud_descriptor_device_cb(void);
uint8_t const* tud_descriptor_configuration_cb(uint8_t index);
uint16_t const* tud_descriptor_string_cb(uint8_t index, uint16_t langid);

View file

@ -133,6 +133,15 @@ void test_eight_profile_transactions_and_active_cache() {
ControllerProfile eighth = controller_profile_default(global, 7);
eighth.strong_rumble_scale = 37;
eighth.shortcuts.modifier = 4;
eighth.shortcuts.selectors[7] = 15;
eighth.shift.mode = ControllerProfileShiftMode::kHold;
eighth.shift.modifier = 17;
eighth.shift.button_map[0] = 2;
eighth.turbo_modes[0] = ControllerProfileTurboMode::kBurst;
eighth.turbo_defaults = {11, 37, 9};
eighth.turbo_override_mask = 1u << 15;
eighth.turbo_overrides[15] = {30, 99, 255};
write_profile(1, global, 7, eighth, 0);
require(profile_service_select(global, 7) ==
ConfigurationTransactionStatus::kCommitted,
@ -140,8 +149,11 @@ void test_eight_profile_transactions_and_active_cache() {
ProfileServiceSelectedSnapshot selected{};
profile_service_selected_snapshot(&selected);
require(selected.valid && selected.profile_index == 7 &&
selected.profile.strong_rumble_scale == 37,
"selected profile eight was not decoded on demand");
selected.profile.strong_rumble_scale == 37 &&
selected.profile.shortcuts.selectors[7] == 15 &&
selected.profile.shift.button_map[0] == 2 &&
selected.profile.turbo_overrides[15].burst_count == 255,
"selected profile eight lost its schema6 extension");
require(profile_service_activate(2, global, 7) ==
ConfigurationTransactionStatus::kPending,
@ -152,8 +164,10 @@ void test_eight_profile_transactions_and_active_cache() {
"profile eight activation did not persist");
active = active_snapshot(global);
require(active.valid && active.profile_index == 7 &&
active.profile.strong_rumble_scale == 37,
"active cache did not publish profile eight");
active.profile.strong_rumble_scale == 37 &&
active.profile.shift.modifier == 17 &&
active.profile.turbo_defaults.duty_percent == 37,
"active cache did not publish the complete schema6 profile");
const ControllerIdentity connected = stable_identity();
require(profile_service_observe_identity_on_storage_core(connected),
@ -173,11 +187,28 @@ void test_eight_profile_transactions_and_active_cache() {
require(profile_service_activate_internal(0x80000007u, connected, 6) ==
ConfigurationTransactionStatus::kPending,
"controller activation was not queued");
const auto host_before_activation = transaction_snapshot();
profile_service_task_on_storage_core(3999);
active = active_snapshot(connected);
require(active.valid && active.profile_index == 0,
"internal activation published before the one-second commit interval");
profile_service_task_on_storage_core(4000);
active = active_snapshot(connected);
require(active.valid && active.profile_index == 6 &&
active.profile.weak_rumble_scale == 61,
"controller activation did not refresh the active cache");
require(transaction_snapshot().transaction.transaction_id ==
host_before_activation.transaction.transaction_id &&
transaction_snapshot().transaction.status ==
host_before_activation.transaction.status,
"internal activation replaced the host-visible transaction snapshot");
const uint32_t activated_generation = active.metadata.generation;
require(profile_service_activate_internal(0x80000008u, connected, 6) ==
ConfigurationTransactionStatus::kPending,
"unchanged internal activation was not accepted");
profile_service_task_on_storage_core(5000);
require(active_snapshot(connected).metadata.generation == activated_generation,
"unchanged internal activation published a fake commit");
require(profile_service_reset(4, global, CONTROLLER_PROFILE_ALL) ==
ConfigurationTransactionStatus::kPending,
@ -246,6 +277,148 @@ void test_profile_bounds_and_transaction_namespace() {
"transaction namespaces were not enforced");
}
void test_schema6_validation_and_atomic_selection() {
const ControllerIdentity id = stable_identity();
require(profile_service_select(id, 6) ==
ConfigurationTransactionStatus::kCommitted,
"transaction validation baseline was not selected");
ProfileServiceSelectedSnapshot old_selection{};
profile_service_selected_snapshot(&old_selection);
ControllerProfile updated = old_selection.profile;
updated.shortcuts.modifier = 5;
updated.shortcuts.selectors[6] = 14;
updated.turbo_defaults = {30, 1, 255};
updated.macros[0].trigger_mask = 1u << 10;
updated.macros[0].step_count = 1;
updated.macros[0].mode = ControllerProfileMacroMode::kRepeat;
updated.macros[0].repeat_count = 255;
updated.macro_step_count = 1;
for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index)
updated.macros[index].first_step = 1;
updated.macro_steps[0].duration_ms = 7;
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
"extended service profile did not encode");
require(profile_service_begin(20, id, 6, 5, 256, 0) ==
ConfigurationTransactionStatus::kUnsupportedSchema &&
profile_service_begin(21, id, 6, 6, 256, 0) ==
ConfigurationTransactionStatus::kMalformed &&
profile_service_begin(22, id, 6, 6, 385, 0) ==
ConfigurationTransactionStatus::kTooLarge,
"service admitted old-schema or incorrectly-sized writes");
// A valid transport CRC cannot authorize an invalid extension.
encoded[283] = 0;
require(profile_service_begin(
23, id, 6, 6, sizeof(encoded),
profile_storage_crc32(encoded, sizeof(encoded))) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_append(23, 0, encoded, sizeof(encoded)) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_commit(23) ==
ConfigurationTransactionStatus::kMalformed,
"service admitted invalid schema6 turbo settings");
ProfileServiceSelectedSnapshot selected{};
profile_service_selected_snapshot(&selected);
require(selected.valid &&
selected.metadata.generation == old_selection.metadata.generation &&
selected.profile.turbo_defaults.rate_hz ==
old_selection.profile.turbo_defaults.rate_hz,
"rejected extension replaced the old selected snapshot");
require(controller_profile_encode(updated, encoded, sizeof(encoded)),
"valid replacement did not encode");
require(profile_service_begin(
24, id, 6, 6, sizeof(encoded),
profile_storage_crc32(encoded, sizeof(encoded))) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_append(24, 0, encoded, 256) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_append(24, 256, encoded + 256, 128) ==
ConfigurationTransactionStatus::kReceiving &&
profile_service_commit(24) ==
ConfigurationTransactionStatus::kPending,
"service did not receive both parts of the schema6 payload");
profile_service_selected_snapshot(&selected);
require(selected.metadata.generation == old_selection.metadata.generation &&
active_snapshot(id).profile.turbo_defaults.rate_hz ==
old_selection.profile.turbo_defaults.rate_hz,
"pending write replaced a selected or active profile before commit");
profile_service_task_on_storage_core(8000);
profile_service_selected_snapshot(&selected);
require(transaction_snapshot().transaction.status ==
ConfigurationTransactionStatus::kCommitted &&
selected.valid && selected.profile.shortcuts.selectors[6] == 14 &&
selected.profile.macros[0].repeat_count == 255 &&
active_snapshot(id).profile.turbo_defaults.rate_hz == 30,
"committed schema6 profile did not atomically refresh snapshots");
}
void test_catalog1_selected_and_active_snapshots_migrate() {
memset(flash.bytes, 0xff, sizeof(flash.bytes));
const ControllerIdentity id = controller_identity_global();
ControllerProfile original = controller_profile_default(id, 7);
original.weak_rumble_scale = 73;
original.macros[0].trigger_mask = 1u << 10;
original.macros[0].step_count = 1;
original.macro_step_count = 1;
for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index)
original.macros[index].first_step = 1;
original.macro_steps[0].duration_ms = 125;
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(original, payload, sizeof(payload)),
"old service snapshot fixture did not encode");
payload[0] = 5;
payload[2] = 0;
payload[3] = 1;
const auto put_u32 = [](uint8_t *output, uint32_t value) {
for (uint8_t byte = 0; byte < 4; ++byte) {
output[byte] = static_cast<uint8_t>(value >> (8 * byte));
}
};
for (uint8_t record_index = 0; record_index < 2; ++record_index) {
uint8_t *record = &flash.bytes[0][PROFILE_STORAGE_RECORDS_OFFSET +
record_index * PROFILE_STORAGE_RECORD_SIZE];
memset(record, 0, PROFILE_STORAGE_RECORD_SIZE);
memcpy(record, "SPCR", 4);
record[4] = 1;
record[6] = record_index == 0 ? 1 : 4;
record[7] = 7;
put_u32(record + 8, 41 + record_index);
if (record_index == 0) {
record[13] = 1;
record[14] = 5;
memcpy(record + 256, payload, 256);
put_u32(record + 16, profile_storage_crc32(payload, 256));
}
require(controller_identity_encode(id, record + 20,
CONTROLLER_IDENTITY_ENCODED_SIZE),
"old service identity did not encode");
put_u32(record + 34, profile_storage_crc32(record, 34));
}
uint8_t *superblock = flash.bytes[0];
memset(superblock, 0, PROFILE_STORAGE_SUPERBLOCK_SIZE);
memcpy(superblock, "SPCA", 4);
superblock[4] = 1;
put_u32(superblock + 8, 9);
put_u32(superblock + 12, profile_storage_crc32(superblock, 12));
profile_service_initialize_on_storage_core();
require(profile_service_select(id, 7) ==
ConfigurationTransactionStatus::kCommitted,
"migrated profile could not be selected");
ProfileServiceSelectedSnapshot selected{};
profile_service_selected_snapshot(&selected);
const auto active = active_snapshot(id);
require(selected.valid && active.valid && active.profile_index == 7 &&
selected.profile.weak_rumble_scale == 73 &&
active.profile.macro_steps[0].duration_ms == 125 &&
selected.profile.shortcuts.modifier == CONTROLLER_PROFILE_NO_BUTTON &&
selected.profile.shift.mode == ControllerProfileShiftMode::kOff &&
selected.profile.macros[0].mode == ControllerProfileMacroMode::kOnce &&
selected.metadata.generation > 42,
"old selection/activation snapshots lost migrated content or defaults");
}
} // namespace
ProfileStorageIo pico_profile_storage_io() { return fake_io(); }
@ -253,6 +426,8 @@ ProfileStorageIo pico_profile_storage_io() { return fake_io(); }
int main() {
test_eight_profile_transactions_and_active_cache();
test_profile_bounds_and_transaction_namespace();
test_schema6_validation_and_atomic_selection();
test_catalog1_selected_and_active_snapshots_migrate();
std::cout << "profile service tests passed\n";
return 0;
}

View file

@ -5,6 +5,7 @@
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <initializer_list>
namespace {
@ -13,6 +14,11 @@ struct FakeFlash {
int programs = 0;
int erases = 0;
int fail_after_programs = -1;
int fail_after_erases = -1;
size_t torn_page_bytes = 0;
int corrupt_previous_after_programs = -1;
uint8_t corrupt_arena = 0;
size_t corrupt_offset = 0;
bool corrupt_next_program = false;
};
@ -48,6 +54,11 @@ bool fake_erase(void *context, uint8_t arena) {
if (arena >= PROFILE_STORAGE_ARENA_COUNT) {
return false;
}
if (storage->fail_after_erases >= 0 &&
storage->erases >= storage->fail_after_erases) {
memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE / 2);
return false;
}
memset(storage->bytes[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE);
++storage->erases;
return true;
@ -59,9 +70,14 @@ bool fake_program(void *context, uint8_t arena, size_t offset,
if (arena >= PROFILE_STORAGE_ARENA_COUNT || page == nullptr ||
size != PROFILE_STORAGE_PAGE_SIZE ||
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
offset + size > PROFILE_STORAGE_ARENA_SIZE ||
(storage->fail_after_programs >= 0 &&
storage->programs >= storage->fail_after_programs)) {
offset + size > PROFILE_STORAGE_ARENA_SIZE) {
return false;
}
if (storage->fail_after_programs >= 0 &&
storage->programs >= storage->fail_after_programs) {
for (size_t index = 0; index < storage->torn_page_bytes; ++index) {
storage->bytes[arena][offset + index] &= page[index];
}
return false;
}
for (size_t index = 0; index < size; ++index) {
@ -72,6 +88,9 @@ bool fake_program(void *context, uint8_t arena, size_t offset,
storage->corrupt_next_program = false;
}
++storage->programs;
if (storage->programs == storage->corrupt_previous_after_programs) {
storage->bytes[storage->corrupt_arena][storage->corrupt_offset] ^= 1;
}
return memcmp(&storage->bytes[arena][offset], page, size) == 0;
}
@ -109,7 +128,16 @@ void write_u32(uint8_t *output, uint32_t value) {
output[3] = static_cast<uint8_t>(value >> 24);
}
void install_legacy_database() {
void encode_schema5(const ControllerProfile &profile, uint8_t *output) {
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
"profile fixture did not encode");
memcpy(output, encoded, CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE);
write_u16(output, CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION);
write_u16(output + 2, CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE);
}
void install_legacy_database(uint32_t generation = 41) {
constexpr size_t kLegacyStart =
PROFILE_STORAGE_TOTAL_SIZE - PROFILE_STORAGE_LEGACY_TOTAL_SIZE;
constexpr uint8_t kArena = 1;
@ -130,22 +158,20 @@ void install_legacy_database() {
payload[10] = 3;
payload[11] = 1;
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
uint8_t encoded[CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE]{};
const ControllerIdentity global = controller_identity_global();
for (uint8_t profile = 0; profile < PROFILE_STORAGE_LEGACY_PROFILE_COUNT;
++profile) {
ControllerProfile value = controller_profile_default(global, profile);
value.weak_rumble_scale = static_cast<uint8_t>(20 + profile);
require(controller_profile_encode(value, encoded, sizeof(encoded)),
"legacy fallback profile did not encode");
encode_schema5(value, encoded);
memcpy(payload + 32 + profile * sizeof(encoded), encoded, sizeof(encoded));
}
constexpr size_t kEntrySize = 16 + PROFILE_STORAGE_LEGACY_PROFILE_COUNT *
CONTROLLER_PROFILE_ENCODED_SIZE;
uint8_t *entry =
payload + 32 +
PROFILE_STORAGE_LEGACY_PROFILE_COUNT * CONTROLLER_PROFILE_ENCODED_SIZE;
PROFILE_STORAGE_LEGACY_PROFILE_COUNT *
CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE;
const ControllerIdentity stable = identity(7);
require(controller_identity_encode(stable, entry,
CONTROLLER_IDENTITY_ENCODED_SIZE),
@ -156,22 +182,184 @@ void install_legacy_database() {
++profile) {
ControllerProfile value = controller_profile_default(stable, profile);
value.strong_rumble_scale = static_cast<uint8_t>(40 + profile);
require(controller_profile_encode(value, encoded, sizeof(encoded)),
"legacy stable profile did not encode");
encode_schema5(value, encoded);
memcpy(entry + 16 + profile * sizeof(encoded), encoded, sizeof(encoded));
}
(void)kEntrySize;
memcpy(header, "SPPF", 4);
write_u16(&header[4], 1);
write_u16(&header[6], 2);
write_u32(&header[8], 41);
write_u32(&header[8], generation);
write_u32(&header[12], PROFILE_STORAGE_LEGACY_DATABASE_SIZE);
write_u32(&header[16], profile_storage_crc32(
payload, PROFILE_STORAGE_LEGACY_DATABASE_SIZE));
write_u32(&header[20], profile_storage_crc32(header, 20));
}
ControllerIdentity catalog_identity(uint8_t index) {
return index == 0 ? controller_identity_global() : identity(index);
}
ControllerProfile catalog_profile(uint8_t index, uint8_t slot, bool extended) {
ControllerProfile profile =
controller_profile_default(catalog_identity(index), slot);
profile.weak_rumble_scale = static_cast<uint8_t>(10 + index);
profile.strong_rumble_scale = static_cast<uint8_t>(30 + slot);
profile.macros[0].trigger_mask = 1u << 10;
profile.macros[0].step_count = 8;
profile.macros[1].trigger_mask = 1u << 11;
profile.macros[1].first_step = 8;
profile.macros[1].step_count = 8;
profile.macros[2].first_step = 16;
profile.macros[3].first_step = 16;
profile.macro_step_count = 16;
// Fill 132 of the old 136 stream bytes, including the old page tail.
for (uint8_t step = 0; step < profile.macro_step_count; ++step) {
auto &value = profile.macro_steps[step];
value.duration_ms = static_cast<uint16_t>(1 + step);
if (step < 14) {
value.override_flags = kControllerProfileOverrideButtons |
kControllerProfileOverrideLeftStick;
value.output_button_mask = static_cast<uint16_t>(1u << (step % 16));
value.left_stick_x = static_cast<int16_t>(100 * step - 500);
value.left_stick_y = static_cast<int16_t>(100 * slot + index);
}
}
if (extended) {
profile.shortcuts.modifier = 4;
const uint8_t selectors[8] = {0, 1, 2, 3, 12, 13, 14, 15};
memcpy(profile.shortcuts.selectors, selectors, sizeof(selectors));
profile.shift.mode = ControllerProfileShiftMode::kToggle;
profile.shift.modifier = 17;
profile.shift.button_map[0] = 15;
profile.turbo_modes[0] = ControllerProfileTurboMode::kBurst;
profile.turbo_defaults = {7, 33, 5};
profile.turbo_override_mask = UINT16_MAX;
for (uint8_t button = 0; button < 16; ++button) {
profile.turbo_overrides[button] = {
static_cast<uint8_t>(button + 1),
static_cast<uint8_t>(button + 2),
static_cast<uint8_t>(button + 3)};
}
profile.macros[0].mode = ControllerProfileMacroMode::kRepeat;
profile.macros[0].repeat_count = 255;
}
return profile;
}
void install_catalog_record(uint16_t version, size_t offset, uint8_t type,
const ControllerIdentity &identity_value,
uint8_t slot, uint32_t generation,
const uint8_t *payload, size_t size) {
uint8_t *record = &flash.bytes[0][offset];
memset(record, 0, PROFILE_STORAGE_RECORD_SIZE);
memcpy(record, "SPCR", 4);
write_u16(record + 4, version);
record[6] = type;
record[7] = slot;
write_u32(record + 8, generation);
write_u16(record + 12, static_cast<uint16_t>(size));
write_u16(record + 14, type == 1 ? (version == 1 ? 5 : 6) : 0);
write_u32(record + 16, profile_storage_crc32(payload, size));
require(controller_identity_encode(identity_value, record + 20,
CONTROLLER_IDENTITY_ENCODED_SIZE),
"catalog fixture identity did not encode");
write_u32(record + 34, profile_storage_crc32(record, 34));
if (size != 0) {
memcpy(record + (version == 1 ? 256 : 128), payload, size);
}
}
void install_populated_catalog(uint16_t version, bool fill_arena = false) {
size_t offset = PROFILE_STORAGE_RECORDS_OFFSET;
uint32_t generation = 1000;
for (uint8_t index = 0;
index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) {
const ControllerIdentity id = catalog_identity(index);
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
const ControllerProfile profile = catalog_profile(index, slot, version == 2);
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
if (version == 1) {
encode_schema5(profile, payload);
} else {
require(controller_profile_encode(profile, payload, sizeof(payload)),
"catalog2 fixture profile did not encode");
}
install_catalog_record(
version, offset, 1, id, slot, ++generation, payload,
version == 1 ? CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE
: CONTROLLER_PROFILE_ENCODED_SIZE);
offset += PROFILE_STORAGE_RECORD_SIZE;
}
uint8_t alias[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
alias[0] = 1;
alias[1] = static_cast<uint8_t>('A' + index);
install_catalog_record(version, offset, 5, id, CONTROLLER_PROFILE_ALL,
++generation, alias, sizeof(alias));
offset += PROFILE_STORAGE_RECORD_SIZE;
uint8_t names[PROFILE_STORAGE_PROFILE_NAMES_PAYLOAD_SIZE]{};
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
const size_t base = slot * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE;
names[base] = 2;
names[base + 1] = static_cast<uint8_t>('A' + index);
names[base + 2] = static_cast<uint8_t>('0' + slot);
}
install_catalog_record(version, offset, 6, id, CONTROLLER_PROFILE_ALL,
++generation, names, sizeof(names));
offset += PROFILE_STORAGE_RECORD_SIZE;
install_catalog_record(version, offset, 4, id, index % 8,
++generation, nullptr, 0);
offset += PROFILE_STORAGE_RECORD_SIZE;
}
while (fill_arena && offset < PROFILE_STORAGE_ARENA_SIZE) {
install_catalog_record(version, offset, 4, catalog_identity(0), 0,
++generation, nullptr, 0);
offset += PROFILE_STORAGE_RECORD_SIZE;
}
uint8_t *superblock = flash.bytes[0];
memset(superblock, 0, PROFILE_STORAGE_SUPERBLOCK_SIZE);
memcpy(superblock, "SPCA", 4);
write_u16(superblock + 4, version);
write_u32(superblock + 8, 20);
write_u32(superblock + 12, profile_storage_crc32(superblock, 12));
}
void require_populated_catalog(const ProfileStorage &storage, bool extended) {
require(storage.identity_count() ==
CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1,
"catalog lost a populated identity");
for (uint8_t index = 0;
index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) {
const ControllerIdentity id = catalog_identity(index);
const auto *entry = storage.find(id);
require(entry != nullptr && entry->active_profile == index % 8,
"catalog lost an active profile index");
char value[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(storage.get_alias(id, value, sizeof(value)) ==
ProfileStorageResult::kOk &&
value[0] == 'A' + index && value[1] == '\0',
"catalog lost a controller alias");
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
ControllerProfile actual{};
const ControllerProfile expected = catalog_profile(index, slot, extended);
uint8_t actual_bytes[CONTROLLER_PROFILE_ENCODED_SIZE]{};
uint8_t expected_bytes[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(storage.get(id, slot, &actual) == ProfileStorageResult::kOk &&
controller_profile_encode(actual, actual_bytes,
sizeof(actual_bytes)) &&
controller_profile_encode(expected, expected_bytes,
sizeof(expected_bytes)) &&
memcmp(actual_bytes, expected_bytes, sizeof(actual_bytes)) == 0,
"catalog lost profile content or a macro at the old page tail");
require(storage.get_profile_name(id, slot, value, sizeof(value)) ==
ProfileStorageResult::kOk &&
value[0] == 'A' + index && value[1] == '0' + slot &&
value[2] == '\0',
"catalog lost a profile name");
}
}
}
void test_empty_catalog_and_eight_profiles() {
erase_all();
ProfileStorage storage;
@ -247,6 +435,14 @@ void test_interrupted_and_corrupt_append_recovery() {
ProfileStorageResult::kOk &&
value.weak_rumble_scale == 11,
"corrupt newest record displaced the previous record");
second.weak_rumble_scale = 44;
require(after_corruption.set(global, 0, second) == ProfileStorageResult::kOk,
"interrupted second-page slot was reused instead of skipped");
ProfileStorage after_retry;
require(after_retry.initialize(fake_io()) &&
after_retry.get(global, 0, &value) == ProfileStorageResult::kOk &&
value.weak_rumble_scale == 44,
"append after interrupted and corrupt slots did not survive reload");
}
void test_profile_names_and_aliases_recover() {
@ -351,6 +547,186 @@ void test_legacy_migration_is_atomic_and_complete() {
"migrated catalog did not survive reload");
}
void test_populated_catalog_publication_power_loss() {
static FakeFlash baseline;
for (uint16_t version = 1; version <= 2; ++version) {
erase_all();
install_populated_catalog(version, true);
baseline = flash;
ProfileStorage completed;
require(completed.initialize(fake_io()), "populated catalog did not load");
require_populated_catalog(completed, version == 2);
if (version == 2) {
require(completed.set_alias(catalog_identity(0), "New", 3) ==
ProfileStorageResult::kOk,
"populated catalog could not compact all 187 live records");
}
const int publication_programs = flash.programs;
for (size_t torn_bytes : {size_t{0}, size_t{129}}) {
// Include interruption during arena erase and before every program page,
// including both pages of every record and the final superblock.
for (int cut = -1; cut < publication_programs; ++cut) {
flash = baseline;
flash.torn_page_bytes = torn_bytes;
ProfileStorage interrupted;
if (version == 2) {
require(interrupted.initialize(fake_io()),
"catalog2 power-loss baseline did not load");
}
if (cut < 0) {
flash.fail_after_erases = 0;
} else {
flash.fail_after_programs = cut;
}
if (version == 1) {
require(!interrupted.initialize(fake_io()),
"incomplete catalog1 migration silently initialized empty");
} else {
require(interrupted.set_alias(catalog_identity(0), "New", 3) ==
ProfileStorageResult::kIoError,
"incomplete compaction or append reported success");
}
require(memcmp(flash.bytes[0], baseline.bytes[0],
PROFILE_STORAGE_ARENA_SIZE) == 0,
"interrupted publication modified the old published arena");
flash.fail_after_erases = -1;
flash.fail_after_programs = -1;
flash.torn_page_bytes = 0;
ProfileStorage recovered;
require(recovered.initialize(fake_io()),
"catalog did not recover after an interrupted publication");
require_populated_catalog(recovered, version == 2);
require(recovered.snapshot().generation >= 1248,
"catalog publication regressed the stored generation");
}
}
}
}
void test_migration_verifies_all_records_before_publication() {
erase_all();
install_populated_catalog(1);
static FakeFlash baseline;
baseline = flash;
// Corrupt an already-verified first record while the last record is being
// copied. A per-write readback alone would publish an incomplete catalog.
flash.corrupt_previous_after_programs =
2 * PROFILE_STORAGE_MAX_LIVE_RECORDS;
flash.corrupt_arena = 1;
flash.corrupt_offset = PROFILE_STORAGE_RECORDS_OFFSET + 128 + 283;
ProfileStorage interrupted;
require(!interrupted.initialize(fake_io()) &&
flash.bytes[1][0] == 0xff &&
memcmp(flash.bytes[0], baseline.bytes[0],
PROFILE_STORAGE_ARENA_SIZE) == 0,
"migration published before fully verifying the copied arena");
flash.corrupt_previous_after_programs = -1;
ProfileStorage recovered;
require(recovered.initialize(fake_io()),
"migration did not retry after copy verification failed");
require_populated_catalog(recovered, false);
}
void test_retired_bank_migration_power_loss() {
erase_all();
install_legacy_database();
static FakeFlash baseline;
baseline = flash;
ProfileStorage complete;
require(complete.initialize(fake_io()), "retired bank did not migrate");
const int programs = flash.programs;
for (int cut = -1; cut < programs; ++cut) {
flash = baseline;
flash.torn_page_bytes = 129;
if (cut < 0) {
flash.fail_after_erases = 0;
} else {
flash.fail_after_programs = cut;
}
ProfileStorage interrupted;
require(!interrupted.initialize(fake_io()) &&
memcmp(flash.bytes[1], baseline.bytes[1],
PROFILE_STORAGE_ARENA_SIZE) == 0,
"interrupted retired-bank migration lost the old database");
flash.fail_after_erases = -1;
flash.fail_after_programs = -1;
flash.torn_page_bytes = 0;
ProfileStorage recovered;
ControllerProfile profile{};
require(recovered.initialize(fake_io()) &&
recovered.find(identity(7)) != nullptr &&
recovered.find(identity(7))->active_profile == 2 &&
recovered.get(identity(7), 2, &profile) ==
ProfileStorageResult::kOk &&
profile.strong_rumble_scale == 42 &&
recovered.get(catalog_identity(0), 3, &profile) ==
ProfileStorageResult::kOk &&
profile.weak_rumble_scale == 23 &&
recovered.snapshot().generation > 41,
"retired-bank migration did not recover its profiles/generation");
}
}
void test_late_second_page_program_is_not_reused() {
erase_all();
ProfileStorage storage;
require(storage.initialize(fake_io()), "catalog did not initialize");
// The first page and first bytes of page two are erased; only a late
// default-rate byte was programmed when power was lost.
flash.bytes[0][PROFILE_STORAGE_RECORDS_OFFSET + 128 + 283] = 0;
ProfileStorage recovered;
require(recovered.initialize(fake_io()), "partial record did not recover");
const auto id = catalog_identity(0);
ControllerProfile profile = catalog_profile(0, 7, true);
require(recovered.set(id, 7, profile) == ProfileStorageResult::kOk,
"scanner reused a partially programmed second page");
profile.turbo_defaults.rate_hz = 23;
require(recovered.set(id, 7, profile) == ProfileStorageResult::kOk,
"newest profile did not append");
const uint32_t newest = recovered.find(id)->profile_record[7];
flash.bytes[0][newest + 128 + 283] ^= 1;
ProfileStorage fallback;
ControllerProfile value{};
require(fallback.initialize(fake_io()) &&
fallback.get(id, 7, &value) == ProfileStorageResult::kOk &&
value.turbo_defaults.rate_hz == 7 &&
value.macros[0].repeat_count == 255,
"corrupted schema6 newest record did not fall back to its predecessor");
}
void test_unreadable_legacy_data_is_not_erased() {
erase_all();
install_legacy_database();
constexpr size_t base = PROFILE_STORAGE_ARENA_SIZE -
PROFILE_STORAGE_LEGACY_BANK_SIZE;
flash.bytes[1][base + PROFILE_STORAGE_LEGACY_HEADER_SIZE + 32] ^= 1;
static FakeFlash baseline;
baseline = flash;
ProfileStorage storage;
require(!storage.initialize(fake_io()) &&
memcmp(flash.bytes, baseline.bytes, sizeof(flash.bytes)) == 0,
"unreadable old data was silently replaced with an empty catalog");
}
void test_legacy_high_generation_remains_mutable() {
erase_all();
install_legacy_database(0x80000000u);
ProfileStorage storage;
require(storage.initialize(fake_io()),
"high-generation legacy bank could not migrate");
const auto global = controller_identity_global();
auto changed = controller_profile_default(global, 0);
changed.weak_rumble_scale = 77;
require(storage.set(global, 0, changed) == ProfileStorageResult::kOk,
"high-generation migrated profile could not be updated");
ProfileStorage reloaded;
ControllerProfile restored{};
require(reloaded.initialize(fake_io()) &&
reloaded.get(global, 0, &restored) == ProfileStorageResult::kOk &&
restored.weak_rumble_scale == 77,
"high-generation update was lost after reload");
}
} // namespace
int main() {
@ -360,6 +736,12 @@ int main() {
test_profile_names_and_aliases_recover();
test_compaction_preserves_latest_records();
test_legacy_migration_is_atomic_and_complete();
test_legacy_high_generation_remains_mutable();
test_populated_catalog_publication_power_loss();
test_migration_verifies_all_records_before_publication();
test_retired_bank_migration_power_loss();
test_late_second_page_program_is_not_reused();
test_unreadable_legacy_data_is_not_erased();
std::cout << "profile storage tests passed\n";
return 0;
}

View file

@ -364,6 +364,177 @@ void test_stall_and_overflow() {
"overflow preserves complete per-side baseline and accumulated phase");
}
void test_stateful_rumble_hold_channels_and_stop() {
for (unsigned side = 0; side < 2; ++side) {
SwitchHdRumbleSynth synth;
synth.reset(0);
expect(synth.push_rumble(side == 0 ? 128 : 0, side == 1 ? 128 : 0, 0),
"single-motor persistent command is accepted");
const auto pcm = render(synth, 0, 1200);
expect_wave(pcm, side, [side](size_t n) {
constexpr uint16_t amplitude = (128u * 32768 + 127) / 255;
return wave((side == 0 ? 160.0 : 320.0) * n / 3000, amplitude);
}, "stateful motor keeps its isolated frequency and host gain beyond 50 ms");
expect_wave(pcm, 1 - side, [](size_t) { return 0; },
"zero-scaled opposite motor and both unused bands stay silent");
}
SwitchHdRumbleSynth synth;
synth.reset(0);
synth.push_rumble(255, 255, 0);
synth.push_rumble(0, 0, 150001); // Ceil to sample 451.
const auto pcm = render(synth, 0, 600);
for (unsigned side = 0; side < 2; ++side) {
expect_wave(pcm, side, [side](size_t n) {
return n < 451 ? wave((side == 0 ? 160.0 : 320.0) * n / 3000) : 0;
}, "explicit zero stops both held motors at its timestamp without gain lift");
}
expect_silent(render(synth, 3000, 150), "stopped persistent state cannot reappear after a gap");
}
void test_stateful_rumble_pre_epoch_and_late() {
SwitchHdRumbleSynth synth;
synth.reset(1000000);
expect(synth.push_rumble(128, 255, 1000),
"persistent command predating the epoch by more than 50 ms is accepted");
auto pcm = render(synth, 0, 300);
expect_wave(pcm, 0, [](size_t n) {
return wave(160.0 * n / 3000, (128u * 32768 + 127) / 255);
}, "pre-epoch held low motor starts at stream phase zero without expiry");
expect_wave(pcm, 1, [](size_t n) { return wave(320.0 * n / 3000); },
"pre-epoch held high motor retains its independent frequency");
expect(synth.push_rumble(255, 128, 2000), "late ordered persistent command is accepted");
pcm = render(synth, 300, 300);
expect_wave(pcm, 0, [](size_t n) { return wave(160.0 * (n + 300) / 3000); },
"late held command applies at the cursor without resetting host phase");
expect_wave(pcm, 1, [](size_t n) {
return wave(320.0 * (n + 300) / 3000, (128u * 32768 + 127) / 255);
}, "late held command changes amplitude without replaying elapsed history");
expect_silent(render(synth, 0, 150), "late persistent updates do not make consumed PCM replayable");
const uint64_t epoch = UINT64_MAX - 1000;
synth.reset(epoch);
synth.push_rumble(255, 0, epoch - 100000);
synth.push_rumble(0, 255, epoch + 3000);
pcm = render(synth, 0, 300);
expect_wave(pcm, 0, [](size_t n) { return n < 9 ? wave(160.0 * n / 3000) : 0; },
"held low motor stops chronologically across clock rollover");
expect_wave(pcm, 1, [](size_t n) { return n < 9 ? 0 : wave(320.0 * n / 3000); },
"held high motor starts across clock rollover without phase reset");
}
void test_stateful_rumble_hd_order_and_watchdogs() {
SwitchHdRumbleSynth synth;
synth.reset(0);
auto initial = one_side(0, state(32));
initial.actuators[1] = one_side(1, state(64, 0, 32, 32768)).actuators[1];
synth.push(initial, 0);
synth.push_rumble(255, 255, 5000); // Sample 15.
expect(!synth.push(one_side(0, state(64, 0)), 4999),
"HD cannot overtake a newer stateful host command");
synth.push(one_side(0, state(96)), 20000); // Sample 60; left expires at 210.
expect(!synth.push_rumble(0, 0, 19999),
"stateful command cannot overtake a newer HD command");
synth.push(SwitchHapticsFrame{}, 80000);
expect(synth.dropped_updates() == 2, "both host APIs share chronological rejection accounting");
auto pcm = render(synth, 0, 300);
expect_wave(pcm, 0, [](size_t n) {
const double cycles = n < 15 ? n * 80.0 / 3000 :
n < 60 ? (15 * 80.0 + (n - 15) * 160.0) / 3000 :
(15 * 80.0 + 45 * 160.0 + (n - 60) * 320.0) / 3000;
return n < 210 ? wave(cycles) : 0;
}, "HD-stateful-HD transitions preserve phase and restore the updated side watchdog");
expect_wave(pcm, 1, [](size_t n) {
const double cycles = n < 15 ? n * 160.0 / 3000 :
(15 * 160.0 + (n - 15) * 320.0) / 3000;
return wave(cycles);
}, "zero-count HD sides leave the other motor persistent with continuous phase");
synth.reset(0);
synth.push_rumble(255, 255, 0);
synth.push(one_side(0, state(64, 0)), 0);
synth.push(one_side(1, state(64, 0, 64, 0)), 1000);
synth.push_rumble(64, 128, 1000);
pcm = render(synth, 0, 300);
expect_wave(pcm, 0, [](size_t n) {
return n < 3 ? 0 : wave(160.0 * n / 3000, (64u * 32768 + 127) / 255);
}, "same-timestamp HD supersedes persistent state in call order");
expect_wave(pcm, 1, [](size_t n) {
return wave(320.0 * n / 3000, n < 3 ? 32768 : (128u * 32768 + 127) / 255);
}, "same-timestamp persistent command supersedes HD and disables its watchdog");
}
void test_stateful_rumble_feedback_resume() {
SwitchHdRumbleSynth synth;
synth.reset(0);
synth.push_rumble(255, 0, 0);
synth.feedback(5001, 74999, 0, 255); // Samples [16,240).
synth.push_rumble(128, 255, 20000); // Update underneath the overlay.
auto pcm = render(synth, 0, 300);
expect_wave(pcm, 0, [](size_t n) {
if (n >= 16 && n < 240) return feedback_wave(320.0 * n / 3000);
return wave(160.0 * n / 3000, n < 16 ? 32768 : (128u * 32768 + 127) / 255);
}, "feedback expiry reveals the current persistent low motor and original host phase");
expect_wave(pcm, 1, [](size_t n) {
if (n < 16) return 0.0;
return n < 240 ? feedback_wave(320.0 * n / 3000) : wave(320.0 * n / 3000);
}, "feedback remains a both-side overlay and resumes the new persistent high motor");
synth.reset(0);
synth.push_rumble(255, 255, 0);
synth.feedback(10000, 70000, 255, 0);
synth.push_rumble(0, 0, 40000);
pcm = render(synth, 0, 300);
for (unsigned side = 0; side < 2; ++side) {
expect_wave(pcm, side, [side](size_t n) {
if (n < 30) return wave((side == 0 ? 160.0 : 320.0) * n / 3000);
return n < 240 ? feedback_wave(160.0 * n / 3000) : 0;
}, "host zero cannot cancel priority feedback or resurrect held state after its expiry");
}
}
void test_stateful_rumble_overflow_and_reset() {
SwitchHdRumbleSynth overflowing;
SwitchHdRumbleSynth reference;
overflowing.reset(0);
reference.reset(0);
overflowing.push_rumble(128, 255, 0);
reference.push_rumble(128, 255, 0);
render(reference, 0, 3);
for (unsigned n = 1; n <= 40; ++n) {
const auto frame = one_side(0, state(static_cast<uint8_t>(32 + n % 4 * 16)));
overflowing.push(frame, n * 1000);
reference.push(frame, n * 1000);
render(reference, n * 3, 3);
}
expect(overflowing.dropped_updates() > 0, "mixed host command overflow reports discarded history");
expect_silent(render(overflowing, 0, 30), "mixed command overflow preserves the discard watermark");
const auto pcm = render(overflowing, 123, 180);
expect(pcm == render(reference, 123, 180),
"evicted stateful command preserves per-side expiry mode and phase through HD updates");
expect_wave(pcm, 1, [](size_t n) { return wave(320.0 * (n + 123) / 3000); },
"overflow cannot discard the untouched persistent motor state");
overflowing.push_rumble(255, 255, 200000);
overflowing.feedback(200000, 1000000, 255, 255);
overflowing.reset(1000000);
expect_silent(render(overflowing, 0, 600),
"reset clears live persistence and queued host and feedback commands");
overflowing.reset(1000000);
expect(overflowing.push_rumble(0, 0, 0) && overflowing.dropped_updates() == 0,
"reset clears shared ordering and accepts an old profile-zero state");
expect_silent(render(overflowing, 0, 300), "profile-zero state is silent even with persistent gain");
overflowing.reset(1000000);
overflowing.push_rumble(255, 255, 0);
const auto restarted = render(overflowing, 0, 300);
for (unsigned side = 0; side < 2; ++side) {
expect_wave(restarted, side, [side](size_t n) {
return wave((side == 0 ? 160.0 : 320.0) * n / 3000);
}, "reset restores zero source phases for both persistent motors");
}
}
void test_duplicate_order_and_invalid_frames() {
SwitchHdRumbleSynth synth;
synth.reset(0);
@ -397,6 +568,11 @@ int main() {
test_late_commands_and_clock_rollover();
test_stall_and_overflow();
test_duplicate_order_and_invalid_frames();
test_stateful_rumble_hold_channels_and_stop();
test_stateful_rumble_pre_epoch_and_late();
test_stateful_rumble_hd_order_and_watchdogs();
test_stateful_rumble_feedback_resume();
test_stateful_rumble_overflow_and_reset();
if (failures) {
std::cerr << failures << " synthesis scenarios failed\n";
return 1;

View file

@ -10,8 +10,21 @@ 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 adapter_feasibility in (False, True):
suffix = "_adapter" if adapter_feasibility else ""
for adapter_feasibility, native, short_packets in (
(False, False, False),
(True, False, False),
(True, True, False),
(True, True, True),
):
suffix = (
"_native32"
if short_packets
else "_native64"
if native
else "_adapter"
if adapter_feasibility
else ""
)
executable = tmp_path / f"bluepad32_backend_lifecycle_test{suffix}"
command = [
compiler,
@ -24,16 +37,57 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
]
if adapter_feasibility:
command.append("-DSWITCH_PICO_USB_OUTPUT_MODES=1")
if native:
command.extend(
[
"-DSWITCH_PICO_HAPTICS_EXPERIMENT=1",
"-DSWITCH_PICO_HD_RUMBLE=1",
"-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM=0",
str(root / "src" / "firmware" / "input" / "haptics_experiment.cpp"),
str(
root
/ "src"
/ "firmware"
/ "input"
/ "switch_hd_rumble_synth.cpp"
),
str(
root
/ "src"
/ "firmware"
/ "usb"
/ "switch"
/ "switch_haptics.cpp"
),
]
)
if short_packets:
command.extend(
[
"-DSWITCH_PICO_CYW43_PACKET_READ=1",
"-DSWITCH_PICO_HCI_CREDIT_BATCH=1",
"-DSWITCH_PICO_SYS_CLOCK_MHZ=300",
]
)
command.extend(
[
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"),
str(
root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"
),
"-o",
str(executable),
]
)
subprocess.run(command, check=True, cwd=root)
if native:
subprocess.run([str(executable), "native-stateful"], check=True, cwd=root)
subprocess.run(
[str(executable), "native-second-slot"], check=True, cwd=root
)
continue
for scenario in (
"ready-forward",

View file

@ -0,0 +1,74 @@
import os
import shutil
import subprocess
from pathlib import Path
import pytest
@pytest.mark.parametrize("batching", [False, True], ids=["default", "batched"])
def test_btstack_credit_batch_native(tmp_path: Path, batching: bool) -> 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"
source = btstack / "src"
if not (source / "hci.c").is_file():
pytest.skip("requires a populated Pico SDK; set PICO_SDK_PATH")
compiler = shutil.which("cc") or shutil.which("gcc")
assert compiler is not None, "a host C compiler is required"
patch = shutil.which("patch")
assert patch is not None, "the patch utility is required"
# Patch only a temporary copy: neither SDK originals nor build/_deps are modified.
patched_source = tmp_path / "lib" / "btstack" / "src"
patched_source.mkdir(parents=True)
shutil.copyfile(source / "hci.c", patched_source / "hci.c")
subprocess.run(
[
patch,
"--batch",
"--forward",
"-p1",
"-i",
str(root / "patches" / "btstack-credit-batch.patch"),
],
check=True,
cwd=tmp_path,
)
executable = tmp_path / "btstack_credit_batch_test"
units = [
"ad_parser.c",
"btstack_linked_list.c",
"btstack_memory.c",
"btstack_memory_pool.c",
"btstack_run_loop.c",
"btstack_util.c",
"hci_cmd.c",
"hci_dump.c",
]
subprocess.run(
[
compiler,
"-std=c11",
"-O2",
"-Wall",
"-Wextra",
"-ffunction-sections",
"-fdata-sections",
*(["-DSWITCH_PICO_HCI_CREDIT_BATCH=1"] if batching else []),
f"-I{root / 'tests' / 'btstack_credit_batch_native_stubs'}",
f"-I{patched_source}",
f"-I{source}",
f"-I{btstack / 'platform' / 'embedded'}",
str(root / "tests" / "btstack_credit_batch_test.c"),
*(str(source / unit) for unit in units),
"-Wl,--gc-sections",
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
import shutil
import subprocess
from pathlib import Path
def test_controller_macro_capture_native(tmp_path: Path) -> 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"
executable = tmp_path / "controller_macro_capture_test"
subprocess.run(
[
compiler,
"-std=c++17",
"-O2",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "controller_macro_capture_test.cpp"),
str(root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -0,0 +1,40 @@
import shutil
import subprocess
from pathlib import Path
def test_cyw43_packet_transport_native(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("cc") or shutil.which("gcc")
assert compiler is not None, "a host C compiler is required"
executable = tmp_path / "cyw43_packet_transport_test"
stubs = root / "tests" / "cyw43_packet_transport_native_stubs"
subprocess.run(
[
compiler,
"-std=c11",
"-O2",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f"-I{stubs}",
str(root / "tests" / "cyw43_packet_transport_test.c"),
str(stubs / "sdk.c"),
str(
root
/ "src"
/ "firmware"
/ "platform"
/ "pico"
/ "cyw43_packet_transport.c"
),
"-Wl,--wrap=cyw43_btbus_read",
"-Wl,--wrap=cyw43_btbus_init",
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -7,7 +7,10 @@ import pytest
@pytest.mark.parametrize("ram", [0, 1], ids=["flash", "sram"])
def test_haptics_experiment_native(tmp_path: Path, ram: int) -> None:
@pytest.mark.parametrize("short_packets", [False, True], ids=["64frames", "32frames"])
def test_haptics_experiment_native(
tmp_path: Path, ram: int, short_packets: bool
) -> 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"
@ -24,6 +27,15 @@ def test_haptics_experiment_native(tmp_path: Path, ram: int) -> None:
"-pedantic",
"-DSWITCH_PICO_HAPTICS_EXPERIMENT=1",
f"-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM={ram}",
*(
[
"-DSWITCH_PICO_CYW43_PACKET_READ=1",
"-DSWITCH_PICO_HCI_CREDIT_BATCH=1",
"-DSWITCH_PICO_SYS_CLOCK_MHZ=300",
]
if short_packets
else []
),
f"-I{root / 'tests' / 'haptics_experiment_native_stubs'}",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "haptics_experiment_test.cpp"),

View file

@ -6,6 +6,7 @@ import urllib.error
import urllib.request
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import replace
from typing import Any
import pytest
@ -49,9 +50,7 @@ def request_json(
headers["Content-Type"] = "application/json"
if token is not None:
headers["X-Switch-Pico-Token"] = token
request = urllib.request.Request(
url, data=data, headers=headers, method=method
)
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=2) as response:
return response.status, json.loads(response.read())
@ -75,7 +74,6 @@ def test_editor_serves_assets_and_complete_schema(
artwork_size = len(response.read())
assert response.headers["Content-Type"] == "image/svg+xml"
status, schema = request_json(f"{base_url}/api/schema")
assert status == 200
@ -136,9 +134,7 @@ def test_editor_identifies_connected_controller_artwork(
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
assert [
identity["controller"] for identity in listing["identities"]
] == [
assert [identity["controller"] for identity in listing["identities"]] == [
{"model": "Generic controller", "style": "generic"},
{"model": "Nintendo Switch Pro Controller", "style": "switch"},
{"model": "Sony DualSense", "style": "playstation"},
@ -171,9 +167,7 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
"style": "xbox",
}
status, playtest = request_json(
f"{base_url}/api/profiles/1/8/playtest"
)
status, playtest = request_json(f"{base_url}/api/profiles/1/8/playtest")
assert status == 200
assert playtest["connected"] is True
assert playtest["label"] == "Xbox · 50:60"
@ -190,7 +184,10 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
assert playtest["triggers"] == {"left": 123, "right": 65000}
assert playtest["battery"] == 100
assert playtest["capabilities"] == [
"rumble", "lightbar", "player_leds", "motion"
"rumble",
"lightbar",
"player_leds",
"motion",
]
status, selected = request_json(f"{base_url}/api/profiles/1/8")
@ -212,7 +209,6 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
)
== custom_profile()
)
assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16]
status, renamed = request_json(
f"{base_url}/api/profiles/1/8/name",
method="PUT",
@ -221,9 +217,7 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
)
assert status == 200
assert renamed["stored_generation"] == 9
assert device.profile_names[
(device.stable_identity.to_bytes(), 7)
] == "Desktop"
assert device.profile_names[(device.stable_identity.to_bytes(), 7)] == "Desktop"
status, aliased = request_json(
f"{base_url}/api/identities/1/alias",
@ -241,25 +235,48 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
)
assert status == 200
assert identified == {"identified": True}
assert device.identified_identities == [
device.stable_identity.to_bytes()
]
assert device.identified_identities == [device.stable_identity.to_bytes()]
draft = config_manager.ControllerProfile.default().to_json_object()
draft["shortcuts"] = {
"modifier": "left_shoulder",
"profiles": ["south", None, None, None, None, None, None, "dpad_up"],
}
draft["shift"]["mode"] = "hold"
draft["shift"]["modifier"] = "right_shoulder"
draft["shift"]["button_map"]["south"] = "north"
draft["turbo"]["south"] = "burst"
draft["turbo_settings"]["overrides"]["south"] = {
"rate_hz": 7,
"duty_percent": 25,
"burst_count": 9,
}
draft["macros"][0]["playback"] = "repeat"
draft["macros"][0]["repeat_count"] = 4
status, copied = request_json(
f"{base_url}/api/profiles/1/8/copy",
method="POST",
value={"identity_index": 1, "profile_number": 4},
value={
"identity_index": 1,
"profile_number": 4,
"profile": draft,
"name": "Draft copy",
},
token=token,
)
assert status == 200
assert copied["stored_generation"] == 12
assert device.profiles[
(device.stable_identity.to_bytes(), 3)
] == custom_profile().to_bytes()
assert device.profile_names[
(device.stable_identity.to_bytes(), 3)
] == "Desktop"
assert (
device.profiles[(device.stable_identity.to_bytes(), 3)]
== config_manager.ControllerProfile.from_json_object(draft).to_bytes()
)
assert (
device.profile_names[(device.stable_identity.to_bytes(), 3)] == "Draft copy"
)
assert (
device.profiles[(device.stable_identity.to_bytes(), 7)]
== custom_profile().to_bytes()
)
status, activated = request_json(
f"{base_url}/api/profiles/1/8/activate",
@ -298,3 +315,82 @@ def test_editor_rejects_invalid_or_unauthorized_mutations(
assert status == 400
assert "outer_saturation" in invalid["error"]
assert device.profiles[(device.global_identity.to_bytes(), 0)] == original
def test_recorder_accepts_first_connection_generation_zero(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
device.playtest_connection_generation = 0
page = config_manager.MacroCapturePage(
0,
0,
0,
device.playtest_slot,
0,
1,
0,
0,
512,
1024,
10000,
8,
(),
)
def start(*args: Any, **kwargs: Any) -> config_manager.MacroCapturePage:
nonlocal page
page = replace(
page,
run_id=1,
state=1,
total_events=1,
events=(config_manager.MacroCaptureEvent(0, 0, 0, 0, 0, 0, 0, 0),),
)
return page
def stop(*args: Any) -> config_manager.MacroCapturePage:
nonlocal page
page = replace(page, state=2, elapsed_us=100000)
return page
monkeypatch.setattr(config_manager, "read_macro_capture", lambda *args: page)
monkeypatch.setattr(config_manager, "start_macro_capture", start)
monkeypatch.setattr(config_manager, "stop_macro_capture", stop)
monkeypatch.setattr(config_manager, "collect_macro_capture", lambda *args: page)
with running_server(monkeypatch, device) as (base_url, token):
status, _ = request_json(f"{base_url}/api/profiles/1/1/playtest")
assert status == 200
identity = device.stable_identity.to_bytes().hex()
status, started = request_json(
f"{base_url}/api/profiles/1/1/capture/start",
method="POST",
token=token,
value={
"capture_id": "first-connection",
"owner_key": identity,
"slot": device.playtest_slot,
"connection_generation": 0,
"macro_index": 0,
"profile": config_manager.ControllerProfile.default().to_json_object(),
"channels": 1,
"max_events": 8,
"axis_quantum": 512,
"trigger_quantum": 1024,
"max_duration_ms": 10000,
},
)
assert status == 200 and started["state_name"] == "recording"
status, stopped = request_json(
f"{base_url}/api/profiles/1/1/capture/stop",
method="POST",
token=token,
value={
"capture_id": "first-connection",
"owner_key": identity,
"connection_generation": 0,
"run_id": started["run_id"],
},
)
assert status == 200 and stopped["state_name"] == "stopped"
assert stopped["steps"][0]["duration_ms"] == 100

View file

@ -5,10 +5,10 @@ from pathlib import Path
import pytest
@pytest.mark.parametrize("experiment_enabled", [False, True])
def test_usb_configuration_management_native(
tmp_path: Path, experiment_enabled: bool,
tmp_path: Path,
experiment_enabled: bool,
) -> None:
root = Path(__file__).resolve().parents[1]
compiler = shutil.which("c++") or shutil.which("g++")
@ -27,6 +27,7 @@ def test_usb_configuration_management_native(
f"-I{root / 'tests' / 'usb_management_native_stubs'}",
f"-I{root / 'src' / 'firmware'}",
str(root / "tests" / "usb_configuration_management_test.cpp"),
str(root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"),
"-o",
str(executable),
],

View file

@ -501,7 +501,7 @@ void test_profile_vendor_requests() {
static_cast<uint8_t>(
CONTROLLER_PROFILE_SCHEMA_VERSION) &&
control_payload[kResponseHeaderSize + 1] == 0 &&
control_payload[kResponseHeaderSize + 2] == 0 &&
control_payload[kResponseHeaderSize + 2] == 0x80 &&
control_payload[kResponseHeaderSize + 3] == 1,
"selected profile response was not encoded");
@ -733,15 +733,15 @@ std::vector<uint8_t> read_haptics_payload() {
control_payload[6] == static_cast<uint8_t>(Status::kOk) &&
control_payload[7] == 0 &&
read_u16(control_payload, 8) == 84 &&
read_u16(control_payload, 10) == 3,
"experiment schema-3 envelope is invalid");
read_u16(control_payload, 10) == 5,
"experiment schema-5 envelope is invalid");
std::vector<uint8_t> payload(
control_payload.begin() + kResponseHeaderSize, control_payload.end());
require(read_u32(control_payload, 16) ==
configuration_crc32(payload.data(), payload.size()),
"experiment response CRC is invalid");
require(payload[71] == 0 && payload[73] == 0 &&
payload[74] == 0 && payload[75] == 0,
require(payload[71] == 0 && (payload[73] == 32 || payload[73] == 64) &&
payload[74] <= 1 && payload[75] == 0,
"experiment reserved payload bytes must remain zero");
return payload;
}
@ -788,6 +788,7 @@ void test_haptics_experiment_requests() {
}
std::vector<uint8_t> expected(84, 0);
expected[69] = 0xff;
expected[73] = 64;
#ifndef SWITCH_PICO_HAPTICS_EXPERIMENT
expected[68] = 6;
require(read_haptics_payload() == expected,
@ -943,7 +944,7 @@ void test_haptics_transport_probe_requests() {
require(control_payload.size() >= kResponseHeaderSize,
"transport probe response header is truncated");
require(control_payload[5] == 0x41 && control_payload[7] == 0 &&
read_u16(control_payload, 10) == 2,
read_u16(control_payload, 10) == 3,
"transport probe operation, flags, or schema are invalid");
const std::vector<uint8_t> payload(
control_payload.begin() + kResponseHeaderSize, control_payload.end());
@ -951,18 +952,18 @@ void test_haptics_transport_probe_requests() {
configuration_crc32(payload.data(), payload.size()),
"transport probe response CRC is invalid");
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
require(control_payload.size() == kResponseHeaderSize + 128 &&
read_u16(control_payload, 8) == 128 &&
require(control_payload.size() == kResponseHeaderSize + 176 &&
read_u16(control_payload, 8) == 176 &&
control_payload[6] == static_cast<uint8_t>(Status::kOk) &&
read_u32(control_payload, 12) == 0x10203040,
"transport probe schema-2 envelope is invalid");
"transport probe schema-3 envelope is invalid");
const uint32_t fields[] = {
0x10203040, 0x50607080, 0xffff, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
0xfffffff0, 1, 27, 0xffffffff, 29, 30,
1021, 10,
};
std::vector<uint8_t> expected(128);
std::vector<uint8_t> expected(176);
for (size_t index = 0; index < 32; ++index) {
write_u32(&expected, index * 4, fields[index]);
}
@ -1141,6 +1142,43 @@ void profile_service_transaction_snapshot(
*output = current_profile_transaction;
}
namespace {
ControllerMacroCapture capture_fixture;
}
bool bluepad32_input_backend_capture_start(
uint8_t slot, uint32_t generation, const CaptureOptions& options) {
return capture_fixture.start(slot, generation, options, 0,
controller_neutral_state());
}
bool bluepad32_input_backend_capture_stop(uint32_t run_id) {
if (run_id == 0 || run_id != capture_fixture.run_id()) return false;
capture_fixture.stop(100000);
return true;
}
bool bluepad32_input_backend_capture_page(
uint32_t run_id, uint16_t first, Bluepad32CaptureSnapshot* output) {
if (output == nullptr || (run_id && run_id != capture_fixture.run_id()) ||
first > capture_fixture.event_count()) return false;
*output = {};
output->run_id = capture_fixture.run_id();
output->connection_generation = capture_fixture.generation();
output->elapsed_us = capture_fixture.elapsed_us(100000);
output->slot = capture_fixture.slot();
output->state = capture_fixture.state();
output->options = capture_fixture.options();
output->total_events = capture_fixture.event_count();
output->first_index = first;
while (output->event_count < BLUEPAD32_CAPTURE_PAGE_EVENTS &&
capture_fixture.event(first + output->event_count,
&output->events[output->event_count])) {
++output->event_count;
}
return true;
}
void bluepad32_input_backend_request_pairing_snapshot() {
refresh_requested = true;
}

View file

@ -590,6 +590,13 @@ void test_xinput_boundary_dispatch() {
static_cast<uint8_t>(0x40 + instance),
"XInput rumble crossed instance boundaries");
}
driver->reset(0);
for (uint8_t instance = 0; instance < kInstanceCount; ++instance) {
expect(xinput_rumble_events[instance].count == 2 &&
xinput_rumble_events[instance].output.low_frequency_magnitude == 0 &&
xinput_rumble_events[instance].output.high_frequency_magnitude == 0,
"USB reset left the host's stateful rumble running");
}
expect_usb_string(1, "Switch Pico",
"XInput manufacturer string changed");