feat: preserve native HD rumble on Switch 2 controllers
This commit is contained in:
parent
a87917bf0b
commit
006b573ee8
25 changed files with 1722 additions and 60 deletions
|
|
@ -2,14 +2,15 @@
|
|||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#endif
|
||||
|
||||
#include <uni.h>
|
||||
#include "parser/uni_hid_parser_switch2.h"
|
||||
#include "parser/uni_switch2_haptics.h"
|
||||
#include "parser/uni_switch2_pairing.h"
|
||||
#include "platform/pico/controller_color_config.h"
|
||||
#include "input/switch2_wake.h"
|
||||
|
|
@ -75,6 +76,18 @@ uint8_t switch2_pairing_types[UNI_SWITCH2_PAIRING_CAPACITY]{};
|
|||
uint8_t switch2_pairing_count = 0;
|
||||
bool switch2_clear_succeeds = true;
|
||||
|
||||
struct Switch2HostEvent {
|
||||
uni_hid_device_t* device;
|
||||
uni_switch2_haptics_frame_t frame;
|
||||
uint32_t received_ms;
|
||||
uint16_t duration_ms;
|
||||
uint8_t weak;
|
||||
uint8_t strong;
|
||||
bool hd;
|
||||
};
|
||||
std::vector<Switch2HostEvent> switch2_host_events;
|
||||
uint32_t switch2_output_drops = 0;
|
||||
|
||||
struct CoreStopped {};
|
||||
|
||||
|
||||
|
|
@ -184,6 +197,45 @@ extern "C" bool uni_hid_parser_switch2_is_ble_device(
|
|||
device->product_id == UNI_SW2_JOYCON_R_PID);
|
||||
}
|
||||
|
||||
extern "C" bool uni_hid_parser_switch2_queue_haptics(
|
||||
uni_hid_device_t* device, const uni_switch2_haptics_frame_t* frame,
|
||||
uint32_t received_ms) {
|
||||
require(uni_switch2_haptics_valid(frame), "backend emitted invalid physical HD frame");
|
||||
const bool stop = device->product_id == UNI_SW2_PRO_PID ?
|
||||
uni_switch2_haptics_is_stop(frame) : [&]() {
|
||||
uni_switch2_haptics_frame_t stereo = *frame;
|
||||
stereo.sides[1] = stereo.sides[0];
|
||||
return uni_switch2_haptics_is_stop(&stereo);
|
||||
}();
|
||||
if (device->switch2_host_blocked && !stop) return false;
|
||||
++device->switch2_host_calls;
|
||||
switch2_host_events.push_back({device, *frame, received_ms, 0, 0, 0, true});
|
||||
return true;
|
||||
}
|
||||
|
||||
extern "C" bool uni_hid_parser_switch2_queue_rumble(
|
||||
uni_hid_device_t* device, uint8_t weak, uint8_t strong,
|
||||
uint16_t duration_ms, uint32_t received_ms) {
|
||||
if (device->switch2_host_blocked && (weak | strong) != 0) return false;
|
||||
++device->switch2_host_calls;
|
||||
device->last_high = weak;
|
||||
device->last_low = strong;
|
||||
device->last_rumble_duration_ms = duration_ms;
|
||||
switch2_host_events.push_back({device, {}, received_ms, duration_ms, weak, strong, false});
|
||||
return true;
|
||||
}
|
||||
|
||||
extern "C" void uni_hid_parser_switch2_reset_haptics(uni_hid_device_t* device) {
|
||||
++device->switch2_haptics_resets;
|
||||
device->last_high = 0;
|
||||
device->last_low = 0;
|
||||
device->last_rumble_duration_ms = 0;
|
||||
}
|
||||
|
||||
extern "C" uint32_t uni_hid_parser_switch2_haptics_dropped(void) {
|
||||
return switch2_output_drops;
|
||||
}
|
||||
|
||||
extern "C" uint8_t uni_hid_parser_switch2_extra_buttons(
|
||||
const uni_hid_device_t* device) {
|
||||
return uni_hid_parser_switch2_is_ble_device(device)
|
||||
|
|
@ -859,6 +911,347 @@ Bluepad32SlotSnapshot slot_snapshot(uint8_t index) {
|
|||
return result;
|
||||
}
|
||||
|
||||
ControllerRumbleOutput ordered_switch2_hd(uint8_t frequency = 40) {
|
||||
ControllerRumbleOutput rumble{17, 29};
|
||||
rumble.hd.actuators[0].sample_count = 3;
|
||||
rumble.hd.actuators[1].sample_count = 2;
|
||||
rumble.hd.actuators[0].samples[0] = {frequency, 61, 24000, 4000};
|
||||
rumble.hd.actuators[0].samples[1] = {41, 62, 5000, 25000};
|
||||
rumble.hd.actuators[0].samples[2] = {42, 63, 20000, 14000};
|
||||
rumble.hd.actuators[1].samples[0] = {81, 91, 3000, 21000};
|
||||
rumble.hd.actuators[1].samples[1] = {82, 92, 23000, 7000};
|
||||
return rumble;
|
||||
}
|
||||
|
||||
void require_switch2_sample(const uint8_t encoded[5],
|
||||
const SwitchHapticsSample& source) {
|
||||
uint64_t bits = 0;
|
||||
for (unsigned index = 0; index < 5; ++index) {
|
||||
bits |= uint64_t{encoded[index]} << (index * 8);
|
||||
}
|
||||
require((bits & 1023u) == 193u + 3u * source.low_frequency_index &&
|
||||
((bits >> 20) & 1023u) == 289u + 3u * source.high_frequency_index &&
|
||||
((bits >> 10) & 1023u) ==
|
||||
((uint32_t{source.low_amplitude_q15} * 29000u / 32767u) >> 6) &&
|
||||
((bits >> 30) & 1023u) ==
|
||||
((uint32_t{source.high_amplitude_q15} * 29000u / 32767u) >> 6),
|
||||
"native physical sample lost its measured frequency or independent band amplitude");
|
||||
}
|
||||
|
||||
void test_switch2_hd_pro() {
|
||||
start_pairing_backend();
|
||||
auto pro = switch2_device(0, UNI_SW2_PRO_PID);
|
||||
ready_switch2(pro);
|
||||
now_ms = 100;
|
||||
const auto first = ordered_switch2_hd(40);
|
||||
const auto second = ordered_switch2_hd(50);
|
||||
bluepad32_input_backend_queue_rumble(0, first);
|
||||
now_ms = 101;
|
||||
bluepad32_input_backend_queue_rumble(0, second);
|
||||
now_ms = 105;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 2 && pro.rumble_calls == 0 &&
|
||||
switch2_host_events[0].received_ms == 100 &&
|
||||
switch2_host_events[1].received_ms == 101,
|
||||
"rapid HD commands must bypass the compatibility mailbox in original order and time");
|
||||
for (unsigned event = 0; event < 2; ++event) {
|
||||
const auto& input = event == 0 ? first : second;
|
||||
const auto& output = switch2_host_events[event].frame;
|
||||
require(output.sides[0].count == 3 && output.sides[1].count == 2,
|
||||
"Pro output must preserve both native side counts");
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
for (unsigned step = 0; step < output.sides[side].count; ++step) {
|
||||
require_switch2_sample(output.sides[side].samples[step],
|
||||
input.hd.actuators[side].samples[step]);
|
||||
}
|
||||
}
|
||||
}
|
||||
ControllerRumbleOutput partial{};
|
||||
partial.hd.actuators[0].sample_count = 1;
|
||||
bluepad32_input_backend_queue_rumble(0, first);
|
||||
bluepad32_input_backend_queue_rumble(0, partial);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 4 &&
|
||||
switch2_host_events.back().frame.sides[1].count == 0,
|
||||
"a silent partial update must neither flush older commands nor invent a right stop");
|
||||
|
||||
pro.switch2_host_blocked = true;
|
||||
bluepad32_input_backend_queue_rumble(0, first);
|
||||
ControllerRumbleOutput stop{};
|
||||
stop.hd.actuators[0].sample_count = 1;
|
||||
stop.hd.actuators[1].sample_count = 1;
|
||||
bluepad32_input_backend_queue_rumble(0, stop);
|
||||
now_ms += 100;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 5 &&
|
||||
uni_switch2_haptics_is_stop(&switch2_host_events.back().frame),
|
||||
"explicit native stop must clear older ingress and bypass age/full barriers");
|
||||
pro.switch2_host_blocked = false;
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{19, 23});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(!switch2_host_events.back().hd && switch2_host_events.back().strong == 19 &&
|
||||
switch2_host_events.back().weak == 23 && pro.rumble_calls == 0,
|
||||
"both empty HD sides must use the dedicated conventional host API");
|
||||
}
|
||||
|
||||
void test_switch2_hd_solo(bool right) {
|
||||
start_pairing_backend();
|
||||
auto solo = switch2_device(0, right ? UNI_SW2_JOYCON_R_PID : UNI_SW2_JOYCON_L_PID);
|
||||
ready_switch2(solo);
|
||||
const auto input = ordered_switch2_hd();
|
||||
bluepad32_input_backend_queue_rumble(0, input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 1 &&
|
||||
switch2_host_events[0].frame.sides[0].count == 3 &&
|
||||
switch2_host_events[0].frame.sides[1].count == 0,
|
||||
"either solo half must receive a mono sequence in physical side zero");
|
||||
const SwitchHapticsSample expected[] = {
|
||||
{40, 91, 24000, 21000}, {82, 62, 23000, 25000}, {82, 63, 23000, 14000}};
|
||||
for (unsigned index = 0; index < 3; ++index) {
|
||||
require_switch2_sample(switch2_host_events[0].frame.sides[0].samples[index],
|
||||
expected[index]);
|
||||
}
|
||||
auto tie = input;
|
||||
tie.hd.actuators[0].sample_count = 1;
|
||||
tie.hd.actuators[1].sample_count = 1;
|
||||
tie.hd.actuators[1].samples[0].low_amplitude_q15 = 24000;
|
||||
tie.hd.actuators[1].samples[0].high_amplitude_q15 = 4000;
|
||||
bluepad32_input_backend_queue_rumble(0, tie);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require_switch2_sample(switch2_host_events.back().frame.sides[0].samples[0],
|
||||
tie.hd.actuators[0].samples[0]);
|
||||
auto one_side = input;
|
||||
one_side.hd.actuators[0].sample_count = 0;
|
||||
bluepad32_input_backend_queue_rumble(0, one_side);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.back().frame.sides[0].count == 2,
|
||||
"solo absent source side must not fabricate substeps");
|
||||
require_switch2_sample(switch2_host_events.back().frame.sides[0].samples[1],
|
||||
one_side.hd.actuators[1].samples[1]);
|
||||
}
|
||||
|
||||
void test_switch2_hd_pair() {
|
||||
start_pairing_backend();
|
||||
auto right = switch2_device(0, UNI_SW2_JOYCON_R_PID);
|
||||
auto left = switch2_device(1, UNI_SW2_JOYCON_L_PID);
|
||||
ready_switch2(right);
|
||||
ready_switch2(left);
|
||||
right.switch2_host_blocked = true;
|
||||
now_ms = 10;
|
||||
const auto input = ordered_switch2_hd();
|
||||
bluepad32_input_backend_queue_rumble(0, input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
now_ms = 15;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 1 && switch2_host_events[0].device == &left,
|
||||
"one blocked half must not duplicate acceptance on its ready partner");
|
||||
right.switch2_host_blocked = false;
|
||||
now_ms = 20;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 2 && switch2_host_events[1].device == &right &&
|
||||
switch2_host_events[1].received_ms == 10,
|
||||
"paired retry must retain the source timestamp and original missing half");
|
||||
for (unsigned half = 0; half < 2; ++half) {
|
||||
const auto& output = switch2_host_events[half].frame;
|
||||
require(output.sides[0].count == (half == 0 ? 3 : 2) && output.sides[1].count == 0,
|
||||
"pair stereo must map selected source side onto each physical side zero");
|
||||
for (unsigned step = 0; step < output.sides[0].count; ++step) {
|
||||
require_switch2_sample(output.sides[0].samples[step],
|
||||
input.hd.actuators[half].samples[step]);
|
||||
}
|
||||
}
|
||||
auto right_only = input;
|
||||
right_only.hd.actuators[0].sample_count = 0;
|
||||
bluepad32_input_backend_queue_rumble(0, right_only);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 3 && switch2_host_events.back().device == &right,
|
||||
"count zero on one paired half must not submit an invented neutral update");
|
||||
right.switch2_host_blocked = true;
|
||||
bluepad32_input_backend_queue_rumble(0, input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
now_ms += 50;
|
||||
right.switch2_host_blocked = false;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
Bluepad32BackendDiagnostics diagnostics{};
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(switch2_host_events.size() == 4 && diagnostics.switch2_ingress_drops == 1,
|
||||
"an expired partly accepted pair must drop visibly without late or duplicate delivery");
|
||||
right.switch2_host_blocked = true;
|
||||
bluepad32_input_backend_queue_rumble(0, input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
ControllerRumbleOutput stop{};
|
||||
stop.hd.actuators[0].sample_count = 1;
|
||||
stop.hd.actuators[1].sample_count = 1;
|
||||
bluepad32_input_backend_queue_rumble(0, stop);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 7 &&
|
||||
switch2_host_events[5].device == &left &&
|
||||
switch2_host_events[6].device == &right &&
|
||||
switch2_host_events[5].frame.sides[0].count == 1 &&
|
||||
switch2_host_events[6].frame.sides[0].count == 1,
|
||||
"paired explicit stop must reach both halves even after partial acceptance and backpressure");
|
||||
}
|
||||
|
||||
void test_switch2_hd_overflow() {
|
||||
start_pairing_backend();
|
||||
auto pro = switch2_device(0, UNI_SW2_PRO_PID);
|
||||
ready_switch2(pro);
|
||||
now_ms = 100;
|
||||
for (uint8_t command = 0; command < 17; ++command) {
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(40 + command));
|
||||
}
|
||||
Bluepad32BackendDiagnostics diagnostics{};
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(diagnostics.switch2_ingress_drops == 1 && diagnostics.rumble_pending_slots == 1,
|
||||
"bounded ingress overflow must expose one oldest-command drop");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 16,
|
||||
"one drain must preserve all sixteen surviving commands, not a latest-value mailbox");
|
||||
for (uint8_t index = 0; index < 16; ++index) {
|
||||
const auto input = ordered_switch2_hd(41 + index);
|
||||
require_switch2_sample(switch2_host_events[index].frame.sides[0].samples[0],
|
||||
input.hd.actuators[0].samples[0]);
|
||||
}
|
||||
pro.switch2_host_blocked = true;
|
||||
now_ms = UINT32_MAX - 20;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
now_ms = 28; // 49ms across wrap.
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(diagnostics.rumble_pending_slots == 1 && diagnostics.switch2_ingress_drops == 1,
|
||||
"backpressure must retain an unexpired command across clock wrap");
|
||||
now_ms = 29;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(diagnostics.rumble_pending_slots == 0 && diagnostics.switch2_ingress_drops == 2 &&
|
||||
switch2_host_events.size() == 16,
|
||||
"original 50ms age must release a backpressured ingress head exactly at expiry");
|
||||
pro.switch2_host_blocked = false;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
auto& stale = g_slots[0].switch2_ingress;
|
||||
--stale.commands[stale.head].envelope.connection_generation;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(switch2_host_events.size() == 16 && diagnostics.switch2_ingress_drops == 3,
|
||||
"stale logical generation must be rejected before physical submission");
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
--stale.commands[stale.head].generation;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(switch2_host_events.size() == 16 && diagnostics.switch2_ingress_drops == 4,
|
||||
"stale haptics epoch must not survive a same-connection mode reset");
|
||||
switch2_output_drops = 7;
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(diagnostics.switch2_output_drops == 7,
|
||||
"physical output loss must remain visible separately from ingress loss");
|
||||
}
|
||||
|
||||
void test_switch2_hd_epochs() {
|
||||
start_pairing_backend();
|
||||
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
|
||||
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
|
||||
ready_switch2(left);
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
const unsigned left_resets = left.switch2_haptics_resets;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
ready_switch2(right);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 1 && left.switch2_haptics_resets > left_resets &&
|
||||
right.switch2_haptics_resets != 0,
|
||||
"merge must reset accepted physical output and drop queued solo commands");
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
const unsigned pair_resets = left.switch2_haptics_resets;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
platform_on_device_disconnected(&right);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 3 && left.switch2_haptics_resets > pair_resets,
|
||||
"survivor transition must flush both physical and ingress pair state");
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
platform_on_device_disconnected(&left);
|
||||
auto replacement = switch2_device(0, UNI_SW2_PRO_PID);
|
||||
ready_switch2(replacement);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 3,
|
||||
"reconnect must not inherit a former logical generation's host output");
|
||||
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{27, 35});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
const unsigned mode_resets = replacement.switch2_haptics_resets;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
test_adapter_mode = AdapterUsbMode::kSwitchProbe;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 4 && replacement.switch2_haptics_resets > mode_resets,
|
||||
"mode boundary without new input must neutralize held output and queued old-mode HD");
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(50));
|
||||
test_adapter_mode = AdapterUsbMode::kXInput;
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{45, 55});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 5 && !switch2_host_events.back().hd &&
|
||||
switch2_host_events.back().duration_ms == UINT16_MAX &&
|
||||
switch2_host_events.back().strong == 45,
|
||||
"producer-side mode transition must discard old epoch before accepting new host state");
|
||||
#endif
|
||||
}
|
||||
|
||||
void test_switch2_hd_feedback() {
|
||||
start_pairing_backend();
|
||||
auto pro = switch2_device(0, UNI_SW2_PRO_PID);
|
||||
ready_switch2(pro);
|
||||
bluepad32_input_backend_queue_profile_feedback(
|
||||
0, slot_snapshot(0).connection_generation, 2,
|
||||
ControllerProfileConfirmationPolicy::kRumble);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pro.rumble_calls == 1, "profile confirmation must retain local-feedback ownership");
|
||||
now_ms = 10;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
now_ms = 20;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(50));
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 2 && pro.rumble_calls == 1 &&
|
||||
switch2_host_events[0].received_ms == 10 &&
|
||||
switch2_host_events[1].received_ms == 20,
|
||||
"host substeps must advance during feedback without compatibility dispatch or fresh timestamps");
|
||||
now_ms = 300;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 2,
|
||||
"feedback completion must not replay historical host substeps");
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{33, 44});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 3 && !switch2_host_events.back().hd &&
|
||||
switch2_host_events.back().duration_ms == host_rumble_duration_ms(),
|
||||
"conventional host vibration must share the bounded host queue, not local feedback");
|
||||
now_ms += 1000;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 3,
|
||||
"stateful host output must not require backend periodic resubmission");
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 4 && switch2_host_events.back().duration_ms == 0 &&
|
||||
switch2_host_events.back().weak == 0 && switch2_host_events.back().strong == 0,
|
||||
"conventional all-zero host command must explicitly stop retained state");
|
||||
pro.switch2_host_blocked = true;
|
||||
const uint32_t received_ms = now_ms;
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{65, 75});
|
||||
now_ms += 1000;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
pro.switch2_host_blocked = false;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
if (host_rumble_duration_ms() == UINT16_MAX) {
|
||||
require(switch2_host_events.size() == 5 &&
|
||||
switch2_host_events.back().received_ms == received_ms &&
|
||||
switch2_host_events.back().strong == 65,
|
||||
"held XInput host state must survive backpressure without retimestamping");
|
||||
} else {
|
||||
Bluepad32BackendDiagnostics diagnostics{};
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(switch2_host_events.size() == 4 && diagnostics.switch2_ingress_drops == 1,
|
||||
"finite conventional host state must expire under feedback/backpressure, not revive");
|
||||
}
|
||||
}
|
||||
|
||||
void test_switch2_pair_lifecycle(bool right_first) {
|
||||
start_pairing_backend();
|
||||
uni_hid_device_t left = switch2_device(
|
||||
|
|
@ -3262,7 +3655,21 @@ int main(int argc, char** argv) {
|
|||
return 0;
|
||||
}
|
||||
#endif
|
||||
if (scenario == "switch2-forward") {
|
||||
if (scenario == "switch2-hd-pro") {
|
||||
test_switch2_hd_pro();
|
||||
} else if (scenario == "switch2-hd-solo-left") {
|
||||
test_switch2_hd_solo(false);
|
||||
} else if (scenario == "switch2-hd-solo-right") {
|
||||
test_switch2_hd_solo(true);
|
||||
} else if (scenario == "switch2-hd-pair") {
|
||||
test_switch2_hd_pair();
|
||||
} else if (scenario == "switch2-hd-overflow") {
|
||||
test_switch2_hd_overflow();
|
||||
} else if (scenario == "switch2-hd-epochs") {
|
||||
test_switch2_hd_epochs();
|
||||
} else if (scenario == "switch2-hd-feedback") {
|
||||
test_switch2_hd_feedback();
|
||||
} else if (scenario == "switch2-forward") {
|
||||
test_switch2_pair_lifecycle(false);
|
||||
} else if (scenario == "switch2-reverse") {
|
||||
test_switch2_pair_lifecycle(true);
|
||||
|
|
|
|||
|
|
@ -166,6 +166,9 @@ struct uni_hid_device_s {
|
|||
uint8_t switch2_extra_buttons;
|
||||
bool switch2_identity_valid;
|
||||
uint8_t switch2_identity_address_type;
|
||||
bool switch2_host_blocked;
|
||||
unsigned switch2_host_calls;
|
||||
unsigned switch2_haptics_resets;
|
||||
};
|
||||
|
||||
void uni_hid_parser_xboxone_play_dual_rumble(
|
||||
|
|
|
|||
70
tests/switch2_haptics_test.c
Normal file
70
tests/switch2_haptics_test.c
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include "parser/uni_switch2_haptics.h"
|
||||
|
||||
static uint64_t unpack(const uint8_t sample[5]) {
|
||||
uint64_t result = 0;
|
||||
for (unsigned byte = 0; byte < 5; ++byte) result |= (uint64_t)sample[byte] << (byte * 8);
|
||||
return result;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
uni_switch2_haptics_frame_t frame;
|
||||
uni_switch2_haptics_silence(&frame);
|
||||
assert(uni_switch2_haptics_valid(&frame));
|
||||
assert(uni_switch2_haptics_is_stop(&frame));
|
||||
uint64_t sample = unpack(frame.sides[0].samples[0]);
|
||||
assert((sample & 1023) == 385); // Measured160Hz anchor.
|
||||
assert(((sample >> 20) & 1023) == 481); // Measured320Hz anchor.
|
||||
assert(((sample >> 10) & 1023) == 0 && ((sample >> 30) & 1023) == 0);
|
||||
|
||||
uni_switch2_haptics_encode_sample(frame.sides[0].samples[0], 96, 96, 32767, 16384);
|
||||
sample = unpack(frame.sides[0].samples[0]);
|
||||
assert((sample & 1023) == 481);
|
||||
assert(((sample >> 20) & 1023) == 577); // Upper frequency bit is frequency, not tone mode.
|
||||
assert(((sample >> 10) & 1023) == 453 && ((sample >> 30) & 1023) == 226);
|
||||
assert(!uni_switch2_haptics_is_stop(&frame));
|
||||
|
||||
frame.sides[0].count = 3;
|
||||
uni_switch2_haptics_encode_sample(frame.sides[0].samples[1], 32, 64, 8192, 0);
|
||||
uni_switch2_haptics_encode_sample(frame.sides[0].samples[2], 64, 127, 0, 32767);
|
||||
uint8_t block[16];
|
||||
assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 0xab));
|
||||
assert(block[0] == 0x7b);
|
||||
assert((unpack(block + 1) & 1023) == 481);
|
||||
assert((unpack(block + 6) & 1023) == 289);
|
||||
assert(((unpack(block + 6) >> 10) & 1023) == 113);
|
||||
assert(((unpack(block + 11) >> 20) & 1023) == 670);
|
||||
assert(((unpack(block + 11) >> 10) & 1023) == 0);
|
||||
assert(((unpack(block + 11) >> 30) & 1023) == 453);
|
||||
|
||||
frame.sides[0].count = 2;
|
||||
assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 15));
|
||||
assert(block[0] == 0x6f);
|
||||
for (unsigned i = 11; i < 16; ++i) assert(block[i] == 0);
|
||||
frame.sides[0].count = 1;
|
||||
assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 16));
|
||||
assert(block[0] == 0x50);
|
||||
for (unsigned i = 6; i < 16; ++i) assert(block[i] == 0);
|
||||
|
||||
uni_switch2_haptics_encode_sample(frame.sides[0].samples[0], 0, 255, 65535, 65535);
|
||||
sample = unpack(frame.sides[0].samples[0]);
|
||||
assert((sample & 1023) == 196 && ((sample >> 20) & 1023) == 670);
|
||||
assert(((sample >> 10) & 1023) == 453 && ((sample >> 30) & 1023) == 453);
|
||||
assert(uni_switch2_haptics_valid(&frame));
|
||||
// A malformed internal native frame cannot bypass the amplitude envelope.
|
||||
frame.sides[0].samples[0][2] |= 0x0f;
|
||||
assert(!uni_switch2_haptics_valid(&frame));
|
||||
|
||||
uni_switch2_haptics_silence(&frame);
|
||||
frame.sides[1].count = 0;
|
||||
assert(uni_switch2_haptics_valid(&frame));
|
||||
assert(!uni_switch2_haptics_is_stop(&frame)); // A side-only mute must not clear its partner.
|
||||
frame.sides[0].count = 0;
|
||||
assert(!uni_switch2_haptics_valid(&frame));
|
||||
assert(!uni_switch2_haptics_write_block(block, &frame.sides[0], 0));
|
||||
frame.sides[0].count = 4;
|
||||
assert(!uni_switch2_haptics_valid(&frame));
|
||||
assert(!uni_switch2_haptics_write_block(block, &frame.sides[0], 0));
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
// The parser uses the SDK's actual GATT types/accessors. Only the asynchronous
|
||||
// radio, run loop, platform admission and persistence endpoints are simulated.
|
||||
enum fixture_query { QUERY_NONE, QUERY_SERVICE, QUERY_CHARACTERISTICS, QUERY_DESCRIPTORS, QUERY_CCCD, QUERY_WRITE };
|
||||
#define FIXTURE_RUMBLE_HISTORY 128
|
||||
struct fixture_peer {
|
||||
uni_hid_device_t device;
|
||||
bool used, link_alive;
|
||||
|
|
@ -16,7 +17,10 @@ struct fixture_peer {
|
|||
uint16_t command_length;
|
||||
const uint8_t* pending_write;
|
||||
uint16_t pending_length;
|
||||
uint8_t pending_snapshot[33];
|
||||
uint8_t rumble[33];
|
||||
uint16_t rumble_length;
|
||||
unsigned commands, rumbles;
|
||||
uint8_t rumble_history[FIXTURE_RUMBLE_HISTORY][33];
|
||||
uint32_t rumble_times[FIXTURE_RUMBLE_HISTORY];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -220,6 +220,9 @@ static uint8_t capture_write(hci_con_handle_t handle, uint16_t value_handle, uin
|
|||
assert(value_handle == RUMBLE_HANDLE && length <= sizeof(peer->rumble));
|
||||
memcpy(peer->rumble, value, length);
|
||||
peer->rumble_length = length;
|
||||
unsigned slot = peer->rumbles % FIXTURE_RUMBLE_HISTORY;
|
||||
memcpy(peer->rumble_history[slot], value, length);
|
||||
peer->rumble_times[slot] = now_ms;
|
||||
++peer->rumbles;
|
||||
}
|
||||
return 0;
|
||||
|
|
@ -235,6 +238,7 @@ uint8_t gatt_client_write_value_of_characteristic(btstack_packet_handler_t callb
|
|||
struct fixture_peer* peer = peer_for_handle(handle);
|
||||
peer->pending_write = value;
|
||||
peer->pending_length = length;
|
||||
memcpy(peer->pending_snapshot, value, length);
|
||||
return begin_query(callback, handle, QUERY_WRITE);
|
||||
}
|
||||
|
||||
|
|
@ -269,6 +273,8 @@ static void query_done(struct fixture_peer* peer, uint8_t status) {
|
|||
uint8_t data[9] = {GATT_EVENT_QUERY_COMPLETE};
|
||||
if (peer->query == QUERY_CCCD)
|
||||
assert(peer->pending_length == 2 && peer->pending_write[0] == 1 && peer->pending_write[1] == 0);
|
||||
if (peer->query == QUERY_WRITE)
|
||||
assert(memcmp(peer->pending_write, peer->pending_snapshot, peer->pending_length) == 0);
|
||||
peer->query = QUERY_NONE;
|
||||
data[8] = status;
|
||||
event(peer, data, sizeof(data));
|
||||
|
|
@ -617,7 +623,7 @@ static void test_rumble_delay_expiry_retry_and_teardown(void) {
|
|||
advance(24);
|
||||
assert(!rumble_active(peer));
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 12, 34);
|
||||
advance(70000); // Stateful host rumble does not expire at 65.535 seconds.
|
||||
advance(70000); // Held local feedback does not expire at65.535 seconds.
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 136 && ((rumble_frame(peer) >> 30) & 1023) == 48);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 100, 255, 255);
|
||||
advance(1);
|
||||
|
|
@ -664,6 +670,360 @@ static void test_write_request_buffers_and_failed_completion(void) {
|
|||
assert(peer->rumbles == writes);
|
||||
}
|
||||
|
||||
static struct fixture_peer* ready_peer(uint16_t pid, bool requests) {
|
||||
reset();
|
||||
request_writes = requests;
|
||||
struct fixture_peer* peer = connect_peer(pid, false);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
finish_setup(peer);
|
||||
return peer;
|
||||
}
|
||||
|
||||
static uni_switch2_haptics_frame_t native_frame(unsigned left, unsigned right, unsigned seed) {
|
||||
uni_switch2_haptics_frame_t frame = {0};
|
||||
frame.sides[0].count = left;
|
||||
frame.sides[1].count = right;
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
for (unsigned i = 0; i < frame.sides[side].count; ++i) {
|
||||
uni_switch2_haptics_encode_sample(frame.sides[side].samples[i],
|
||||
10 + seed + 5 * i + side, 80 + seed + i + side,
|
||||
1000 + seed * 100 + i * 200 + side * 3000,
|
||||
5000 + seed * 100 + i * 700 + side * 500);
|
||||
}
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
static uni_switch2_haptics_side_t final_hold(const uni_switch2_haptics_side_t* source) {
|
||||
uni_switch2_haptics_side_t side = {.count = 1};
|
||||
memcpy(side.samples[0], source->samples[source->count - 1], 5);
|
||||
return side;
|
||||
}
|
||||
|
||||
static void assert_block(const struct fixture_peer* peer, unsigned side,
|
||||
const uni_switch2_haptics_side_t* expected) {
|
||||
assert(peer->rumble_length >= 17 + side * 16);
|
||||
const uint8_t* block = peer->rumble + 1 + side * 16;
|
||||
assert((block[0] & 0xf0) == (0x40 | (expected->count << 4)));
|
||||
assert(memcmp(block + 1, expected->samples, 5 * expected->count) == 0);
|
||||
for (unsigned i = 1 + 5 * expected->count; i < 16; ++i)
|
||||
assert(block[i] == 0);
|
||||
}
|
||||
|
||||
static void assert_silent(const struct fixture_peer* peer, unsigned side) {
|
||||
const uint8_t* block = peer->rumble + 1 + side * 16;
|
||||
assert((block[0] & 0xf0) == 0x50);
|
||||
assert(!(block[2] & 0xfc) && !(block[3] & 0x0f) && !(block[4] & 0xc0) && !block[5]);
|
||||
for (unsigned i = 6; i < 16; ++i)
|
||||
assert(block[i] == 0);
|
||||
}
|
||||
|
||||
static void test_native_fifo_stereo_counts_and_retry(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 2, 1);
|
||||
uni_switch2_haptics_frame_t b = native_frame(2, 0, 2);
|
||||
uni_switch2_haptics_frame_t c = native_frame(1, 3, 3);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms));
|
||||
next_write_error = BTSTACK_ACL_BUFFERS_FULL;
|
||||
advance(1);
|
||||
assert(peer->rumbles == 0);
|
||||
advance(13);
|
||||
assert(peer->rumbles == 1 && peer->rumble_length == 33);
|
||||
assert(peer->rumble[1] == 0x70 && peer->rumble[17] == 0x60);
|
||||
assert_block(peer, 0, &a.sides[0]);
|
||||
assert_block(peer, 1, &a.sides[1]);
|
||||
// C arrives while A plays, with enough source lifetime for all three frames.
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &c, now_ms));
|
||||
advance(15);
|
||||
assert(peer->rumbles == 1); // Three samples cannot be interrupted at13ms.
|
||||
advance(1);
|
||||
assert(peer->rumbles == 2 && peer->rumble[1] == 0x61);
|
||||
assert_block(peer, 0, &b.sides[0]);
|
||||
uni_switch2_haptics_side_t right = final_hold(&a.sides[1]);
|
||||
assert_block(peer, 1, &right);
|
||||
advance(10);
|
||||
assert(peer->rumbles == 2);
|
||||
advance(1);
|
||||
assert(peer->rumbles == 3 && peer->rumble[1] == 0x52 && peer->rumble[17] == 0x72);
|
||||
assert_block(peer, 0, &c.sides[0]);
|
||||
assert_block(peer, 1, &c.sides[1]);
|
||||
assert(peer->rumble_times[1] - peer->rumble_times[0] == 16);
|
||||
assert(peer->rumble_times[2] - peer->rumble_times[1] == 11);
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops);
|
||||
advance(23); // C expires at receipt14 +50, not transmission41 +50.
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
}
|
||||
|
||||
static void test_native_hold_and_absent_side_watchdogs(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 2, 4);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
assert_block(peer, 0, &a.sides[0]);
|
||||
advance(15);
|
||||
assert(peer->rumbles == 1);
|
||||
advance(1);
|
||||
uni_switch2_haptics_side_t left = final_hold(&a.sides[0]);
|
||||
uni_switch2_haptics_side_t right = final_hold(&a.sides[1]);
|
||||
assert(peer->rumbles == 2);
|
||||
assert_block(peer, 0, &left);
|
||||
assert_block(peer, 1, &right);
|
||||
advance(3); // t20; only the left actuator receives a fresh source update.
|
||||
uni_switch2_haptics_frame_t b = native_frame(1, 0, 5);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms));
|
||||
advance(3); // Last count1 hold guarded until t23.
|
||||
assert_block(peer, 0, &b.sides[0]);
|
||||
assert_block(peer, 1, &right);
|
||||
advance(27);
|
||||
assert_block(peer, 0, &b.sides[0]);
|
||||
assert_silent(peer, 1); // Absent side must not gain a new50ms watchdog.
|
||||
advance(20);
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
}
|
||||
|
||||
static void test_feedback_advances_host_and_resumes_only_final_samples(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
uni_switch2_haptics_frame_t a = native_frame(2, 3, 6);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 40, 20, 30);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 120);
|
||||
assert((peer->rumble[1] & 0xf0) == 0x50);
|
||||
advance(9);
|
||||
uni_switch2_haptics_frame_t b = native_frame(3, 0, 7);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms));
|
||||
advance(1);
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 120);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0);
|
||||
advance(1);
|
||||
uni_switch2_haptics_side_t left = final_hold(&b.sides[0]);
|
||||
uni_switch2_haptics_side_t right = final_hold(&a.sides[1]);
|
||||
assert_block(peer, 0, &left);
|
||||
assert_block(peer, 1, &right);
|
||||
for (unsigned i = 0; i < peer->rumbles; ++i) {
|
||||
assert((peer->rumble_history[i][1] & 0xf0) == 0x50);
|
||||
assert((peer->rumble_history[i][17] & 0xf0) == 0x50);
|
||||
}
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 100, 50, 60);
|
||||
advance(48); // Both original host watchdogs elapse under feedback.
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0);
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops);
|
||||
}
|
||||
|
||||
static void test_full_fifo_stop_barrier_and_async_completion(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_R_PID, true);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 0, 8);
|
||||
uni_switch2_haptics_frame_t b = native_frame(2, 0, 9);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
assert(peer->query == QUERY_WRITE && peer->rumble_length == 17);
|
||||
const uint8_t* borrowed = peer->pending_write;
|
||||
uint8_t snapshot[17];
|
||||
memcpy(snapshot, borrowed, sizeof(snapshot));
|
||||
for (unsigned i = 1; i < 16; ++i)
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops);
|
||||
uni_switch2_haptics_frame_t stop;
|
||||
uni_switch2_haptics_silence(&stop);
|
||||
stop.sides[1].count = 0; // Physical Joy-Con stop is not a logical stereo stop.
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stop, now_ms - 100));
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops + 16);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms));
|
||||
advance(1);
|
||||
assert(memcmp(snapshot, borrowed, sizeof(snapshot)) == 0);
|
||||
query_done(peer, 0); // Completion of old epoch must not consume b/the stop.
|
||||
advance(1);
|
||||
assert(peer->rumbles == 2 && peer->rumble[1] == 0x51);
|
||||
assert_silent(peer, 0);
|
||||
query_done(peer, 0);
|
||||
advance(6);
|
||||
assert(peer->rumbles == 3 && peer->rumble[1] == 0x62);
|
||||
assert_block(peer, 0, &b.sides[0]);
|
||||
query_done(peer, 0);
|
||||
advance(11);
|
||||
uni_switch2_haptics_side_t hold = final_hold(&b.sides[0]);
|
||||
assert_block(peer, 0, &hold);
|
||||
query_done(peer, 0);
|
||||
}
|
||||
|
||||
static void test_async_expiry_and_topology_reset_do_not_revive_state(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, true);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 2, 10);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
advance(49); // Write request is still borrowing the original batch.
|
||||
assert(peer->rumbles == 1 && uni_hid_parser_switch2_haptics_dropped() == drops + 1);
|
||||
query_done(peer, 0);
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
query_done(peer, 0);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 40, 50);
|
||||
advance(6);
|
||||
assert(rumble_active(peer) && peer->query == QUERY_WRITE);
|
||||
uni_switch2_haptics_frame_t b = native_frame(2, 1, 11);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
uni_hid_parser_switch2_reset_haptics(&peer->device);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms));
|
||||
advance(1);
|
||||
query_done(peer, 0); // Old local overlay must not clear the reset neutral.
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
query_done(peer, 0);
|
||||
advance(6);
|
||||
assert_block(peer, 0, &b.sides[0]);
|
||||
assert_block(peer, 1, &b.sides[1]);
|
||||
query_done(peer, 0);
|
||||
}
|
||||
|
||||
static void test_host_stop_does_not_cancel_local_feedback(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 100, 200);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 3, 12);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 0, 0, 0, now_ms));
|
||||
advance(1);
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 800);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0);
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
}
|
||||
|
||||
static void test_conventional_host_lifetimes_and_invalid_native_input(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_L_PID, false);
|
||||
assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 255, 200, UINT16_MAX, now_ms));
|
||||
advance(70000);
|
||||
assert((rumble_frame(peer) & 1023) == 0xe1);
|
||||
assert(((rumble_frame(peer) >> 20) & 1023) == 0x1e1);
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 800);
|
||||
assert(((rumble_frame(peer) >> 30) & 1023) == 1020);
|
||||
assert((peer->rumble[1] & 0xf0) == 0x50);
|
||||
for (unsigned i = 7; i < 17; ++i)
|
||||
assert(peer->rumble[i] == 0);
|
||||
assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 1, 2, 20, now_ms - 10));
|
||||
advance(9);
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 8);
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
uni_switch2_haptics_frame_t invalid = native_frame(1, 0, 13);
|
||||
invalid.sides[0].count = 4;
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &invalid, now_ms));
|
||||
uni_switch2_haptics_frame_t stale = native_frame(1, 0, 14);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stale, now_ms - 50));
|
||||
assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 10, 20, 100, now_ms - 50));
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops + 3);
|
||||
advance(13);
|
||||
assert_silent(peer, 0);
|
||||
}
|
||||
|
||||
static void test_native_watchdog_clock_wrap(void) {
|
||||
reset();
|
||||
now_ms = UINT32_MAX - 20;
|
||||
struct fixture_peer* peer = connect_peer(UNI_SW2_JOYCON_L_PID, false);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
finish_setup(peer);
|
||||
uni_switch2_haptics_frame_t frame = native_frame(1, 0, 15);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
advance(49);
|
||||
assert_block(peer, 0, &frame.sides[0]);
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
}
|
||||
|
||||
static void test_identical_hold_coalescing_refreshes_borrowed_head(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_L_PID, true);
|
||||
uni_switch2_haptics_frame_t frame = native_frame(1, 0, 16);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
advance(1);
|
||||
const uint8_t* borrowed = peer->pending_write;
|
||||
uint8_t snapshot[17];
|
||||
memcpy(snapshot, borrowed, sizeof(snapshot));
|
||||
advance(39);
|
||||
for (unsigned i = 0; i < 32; ++i)
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
assert(memcmp(snapshot, borrowed, sizeof(snapshot)) == 0);
|
||||
advance(10); // The original t0 hold would expire now without source refresh.
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops);
|
||||
query_done(peer, 0);
|
||||
advance(39);
|
||||
assert_block(peer, 0, &frame.sides[0]);
|
||||
query_done(peer, 0);
|
||||
advance(1); // Refreshed deadline remains t40+50, not ATT completion+50.
|
||||
assert_silent(peer, 0);
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops);
|
||||
query_done(peer, 0);
|
||||
}
|
||||
|
||||
static void test_hold_coalescing_requires_identical_samples_and_side_masks(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, true);
|
||||
uni_switch2_haptics_frame_t frame;
|
||||
for (unsigned i = 0; i < 16; ++i) {
|
||||
frame = native_frame(1, 1, i + 1);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
}
|
||||
// An identical tail hold can refresh even at capacity. A partial-side update
|
||||
// is a different command, as is a changed sample; neither may erase history.
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
frame.sides[1].count = 0;
|
||||
assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
frame = native_frame(1, 1, 17);
|
||||
assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
advance(1);
|
||||
uni_switch2_haptics_frame_t first = native_frame(1, 1, 1);
|
||||
assert_block(peer, 0, &first.sides[0]);
|
||||
assert_block(peer, 1, &first.sides[1]);
|
||||
}
|
||||
|
||||
static void test_expired_history_does_not_starve_fresh_sequences(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 3, 20);
|
||||
uni_switch2_haptics_frame_t stale = native_frame(3, 3, 21);
|
||||
uni_switch2_haptics_frame_t fresh = native_frame(3, 3, 22);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
unsigned sent = peer->rumbles;
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stale, now_ms - 40));
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms));
|
||||
advance(10);
|
||||
assert(peer->rumbles == sent); // Unplayed stale history must not interrupt A.
|
||||
advance(6);
|
||||
assert_block(peer, 0, &fresh.sides[0]);
|
||||
assert_block(peer, 1, &fresh.sides[1]);
|
||||
|
||||
peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
a = native_frame(1, 1, 23);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms - 43));
|
||||
advance(1);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms));
|
||||
advance(6); // A's source watchdog expires as its playback guard ends.
|
||||
assert_block(peer, 0, &fresh.sides[0]); // No unnecessary silent/HOLD packet.
|
||||
|
||||
peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms - 40));
|
||||
advance(1);
|
||||
assert_silent(peer, 0); // Nine ms cannot contain a complete three-frame batch.
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops + 1);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_connected_callback_rejection();
|
||||
test_advertisement_bounds_and_admission();
|
||||
|
|
@ -672,6 +1032,17 @@ int main(void) {
|
|||
test_calibration_physical_inputs_and_sensor_units();
|
||||
test_rumble_delay_expiry_retry_and_teardown();
|
||||
test_write_request_buffers_and_failed_completion();
|
||||
test_native_fifo_stereo_counts_and_retry();
|
||||
test_native_hold_and_absent_side_watchdogs();
|
||||
test_feedback_advances_host_and_resumes_only_final_samples();
|
||||
test_full_fifo_stop_barrier_and_async_completion();
|
||||
test_async_expiry_and_topology_reset_do_not_revive_state();
|
||||
test_host_stop_does_not_cancel_local_feedback();
|
||||
test_conventional_host_lifetimes_and_invalid_native_input();
|
||||
test_native_watchdog_clock_wrap();
|
||||
test_identical_hold_coalescing_refreshes_borrowed_head();
|
||||
test_hold_coalescing_requires_identical_samples_and_side_masks();
|
||||
test_expired_history_does_not_starve_fresh_sequences();
|
||||
reset();
|
||||
puts("Switch2 protocol boundaries, setup failure, pairing, calibration, physical input, motion and rumble passed");
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
f"-I{root / 'src' / 'firmware'}",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"),
|
||||
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
|
||||
str(
|
||||
root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"
|
||||
),
|
||||
|
|
@ -98,6 +99,13 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
"switch2-multiple-pairs",
|
||||
"switch2-admission",
|
||||
"switch2-pairing-inventory",
|
||||
"switch2-hd-pro",
|
||||
"switch2-hd-solo-left",
|
||||
"switch2-hd-solo-right",
|
||||
"switch2-hd-pair",
|
||||
"switch2-hd-overflow",
|
||||
"switch2-hd-epochs",
|
||||
"switch2-hd-feedback",
|
||||
):
|
||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||
if native:
|
||||
|
|
|
|||
|
|
@ -311,7 +311,8 @@ class FakeDevice:
|
|||
if request == config_manager.OP_RUNTIME_DIAGNOSTICS:
|
||||
return make_response(
|
||||
request,
|
||||
struct.pack("<7I4B", 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1),
|
||||
struct.pack("<7I4B", 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1)
|
||||
+ getattr(self, "runtime_diagnostics_tail", b""),
|
||||
)
|
||||
if request == config_manager.OP_NATIVE_SWITCH_RUMBLE:
|
||||
return make_response(
|
||||
|
|
@ -1399,6 +1400,20 @@ def test_reenumeration_reports_missing_disappearance_and_return(
|
|||
config_manager._wait_for_reenumeration(snapshot, 0)
|
||||
|
||||
|
||||
def test_runtime_diagnostics_distinguishes_unreported_from_zero_drops() -> None:
|
||||
device = FakeDevice()
|
||||
legacy = config_manager.read_runtime_diagnostics(device)
|
||||
assert legacy.switch2_ingress_drops is None
|
||||
assert legacy.switch2_output_drops is None
|
||||
device.runtime_diagnostics_tail = struct.pack("<II", 0, 0xFFFFFFFF)
|
||||
current = config_manager.read_runtime_diagnostics(device)
|
||||
assert current.switch2_ingress_drops == 0
|
||||
assert current.switch2_output_drops == 0xFFFFFFFF
|
||||
device.runtime_diagnostics_tail = bytes(4)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.read_runtime_diagnostics(device)
|
||||
|
||||
|
||||
def test_requested_and_active_mode_response_validation() -> None:
|
||||
device = FakeDevice()
|
||||
device.configuration = struct.pack(
|
||||
|
|
|
|||
30
tests/test_switch2_haptics_native.py
Normal file
30
tests/test_switch2_haptics_native.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_switch2_haptics_encoding(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 / "switch2_haptics"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c11",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
|
||||
str(root / "tests" / "switch2_haptics_test.c"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
|
@ -46,6 +46,7 @@ def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None:
|
|||
f"-I{btstack.parent / '3rd-party' / 'yxml'}",
|
||||
str(root / "tests" / "switch2_parser_native_test.c"),
|
||||
str(root / "bluepad32_config" / "parser" / "uni_hid_parser_switch2.c"),
|
||||
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
|
||||
str(btstack / "btstack_util.c"),
|
||||
"-Wl,--gc-sections",
|
||||
"-o",
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ void test_vendor_requests() {
|
|||
"pairing read did not use the versioned envelope");
|
||||
|
||||
current_diagnostics = {
|
||||
6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1,
|
||||
6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1, 3, UINT32_MAX,
|
||||
};
|
||||
request = setup_request(
|
||||
Operation::kRuntimeDiagnostics, TUSB_DIR_IN,
|
||||
|
|
@ -241,7 +241,9 @@ void test_vendor_requests() {
|
|||
read_u32(control_payload, kResponseHeaderSize) == 6 &&
|
||||
read_u32(control_payload, kResponseHeaderSize + 4) == 1200 &&
|
||||
control_payload[kResponseHeaderSize + 28] == 2 &&
|
||||
control_payload[kResponseHeaderSize + 31] == 1,
|
||||
control_payload[kResponseHeaderSize + 31] == 1 &&
|
||||
read_u32(control_payload, kResponseHeaderSize + 32) == 3 &&
|
||||
read_u32(control_payload, kResponseHeaderSize + 36) == UINT32_MAX,
|
||||
"runtime diagnostics did not expose backend counters");
|
||||
|
||||
perform_out(Operation::kPairingRefresh, {});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue