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:
parent
44a474e103
commit
3c2d9fa723
75 changed files with 9599 additions and 1819 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
8
tests/bluepad32_native_stubs/btstack.h
Normal file
8
tests/bluepad32_native_stubs/btstack.h
Normal 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);
|
||||
|
|
@ -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() {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
#pragma once
|
||||
#include <stdint.h>
|
||||
uint32_t time_us_32();
|
||||
uint64_t time_us_64();
|
||||
|
||||
void tight_loop_contents();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
14
tests/btstack_credit_batch_native_stubs/btstack_config.h
Normal file
14
tests/btstack_credit_batch_native_stubs/btstack_config.h
Normal 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
|
||||
338
tests/btstack_credit_batch_test.c
Normal file
338
tests/btstack_credit_batch_test.c
Normal 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;
|
||||
}
|
||||
522
tests/controller_macro_capture_test.cpp
Normal file
522
tests/controller_macro_capture_test.cpp
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
10
tests/cyw43_packet_transport_native_stubs/cyw43_btbus.h
Normal file
10
tests/cyw43_packet_transport_native_stubs/cyw43_btbus.h
Normal 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);
|
||||
11
tests/cyw43_packet_transport_native_stubs/cyw43_config.h
Normal file
11
tests/cyw43_packet_transport_native_stubs/cyw43_config.h
Normal 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();
|
||||
8
tests/cyw43_packet_transport_native_stubs/sdk.c
Normal file
8
tests/cyw43_packet_transport_native_stubs/sdk.c
Normal 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);
|
||||
}
|
||||
409
tests/cyw43_packet_transport_test.c
Normal file
409
tests/cyw43_packet_transport_test.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
74
tests/test_btstack_credit_batch_native.py
Normal file
74
tests/test_btstack_credit_batch_native.py
Normal 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
29
tests/test_controller_macro_capture_native.py
Normal file
29
tests/test_controller_macro_capture_native.py
Normal 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)
|
||||
40
tests/test_cyw43_packet_transport_native.py
Normal file
40
tests/test_cyw43_packet_transport_native.py
Normal 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)
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue