feat: drive native DualSense haptics from Switch HD rumble
This commit is contained in:
parent
0c626fb3b3
commit
6188fcb517
23 changed files with 1836 additions and 182 deletions
|
|
@ -404,6 +404,28 @@ void test_rumble_scaling_and_confirmation_policy() {
|
|||
UINT8_MAX,
|
||||
"full rumble scaling did not saturate at uint8 maximum");
|
||||
|
||||
ControllerRumbleOutput hd_input{};
|
||||
hd_input.hd.actuators[0].sample_count = 2;
|
||||
hd_input.hd.actuators[0].samples[0] = {48, 96, 18000, 10000};
|
||||
hd_input.hd.actuators[0].samples[1] = {49, 97, 31000, 0};
|
||||
hd_input.hd.actuators[1].sample_count = 1;
|
||||
hd_input.hd.actuators[1].samples[0] = {20, 70, 12000, 10000};
|
||||
ControllerProfile hd_profile = profile;
|
||||
hd_profile.strong_rumble_scale = 0;
|
||||
hd_profile.weak_rumble_scale = 128;
|
||||
const auto hd_output = controller_profile_scale_host_rumble(hd_input, hd_profile);
|
||||
require(hd_output.hd.actuators[0].sample_count == 2 &&
|
||||
hd_output.hd.actuators[1].sample_count == 1 &&
|
||||
hd_output.hd.actuators[0].samples[1].low_frequency_index == 49 &&
|
||||
hd_output.hd.actuators[1].samples[0].high_frequency_index == 70,
|
||||
"profile gain lost HD substeps or side-specific frequencies");
|
||||
require(hd_output.hd.actuators[0].samples[0].low_amplitude_q15 == 0 &&
|
||||
hd_output.hd.actuators[1].samples[0].low_amplitude_q15 == 0 &&
|
||||
hd_output.hd.actuators[0].samples[0].high_amplitude_q15 == 5020 &&
|
||||
hd_output.hd.actuators[1].samples[0].high_amplitude_q15 == 5020 &&
|
||||
hd_output.hd.actuators[0].samples[1].high_amplitude_q15 == 0,
|
||||
"profile band gains were not applied to linear HD amplitudes");
|
||||
|
||||
profile.confirmation_policy = ControllerProfileConfirmationPolicy::kLed;
|
||||
require(controller_profile_confirmation_policy(profile) ==
|
||||
ControllerProfileConfirmationPolicy::kLed,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "input/haptics_experiment.h"
|
||||
#include "input/haptics_transport_probe.h"
|
||||
#include "usb/switch/switch_haptics.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
|
|
@ -527,7 +528,7 @@ void reconnect_and_pending_generation() {
|
|||
|
||||
void support_and_transport_errors() {
|
||||
reset();
|
||||
assert(!haptics_experiment_request(2, 0));
|
||||
assert(!haptics_experiment_request(3, 0));
|
||||
assert(!haptics_experiment_request(1, 4));
|
||||
devices[0].remote_mtu = 142;
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
|
|
@ -597,6 +598,103 @@ void timing_cost_reentrancy_and_wrap() {
|
|||
assert(wrapped.max_send_gap_us <= 22000 && wrapped.sent_packets == 288);
|
||||
}
|
||||
|
||||
void gameplay_timeline_and_lifecycle() {
|
||||
reset();
|
||||
SwitchHapticsDecoder decoder;
|
||||
const auto feed = [&](bool left) {
|
||||
const uint32_t active = (1u << 30) | (96u << 23) | (64u << 16) | (64u << 2);
|
||||
const uint32_t words[] = {left ? active : 0x40400100u,
|
||||
left ? 0x40400100u : active};
|
||||
uint8_t bytes[8]{};
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
for (unsigned byte = 0; byte < 4; ++byte) {
|
||||
bytes[side * 4 + byte] = static_cast<uint8_t>(words[side] >> (8 * byte));
|
||||
}
|
||||
}
|
||||
const auto decoded = decoder.decode(bytes);
|
||||
assert(haptics_experiment_submit(0, 100, now_us, decoded.hd));
|
||||
};
|
||||
assert(haptics_experiment_request(2, 0));
|
||||
const uint64_t started = now_us;
|
||||
feed(true); // A sole first command survives the Pending -> Running boundary.
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().mode == 1 && snapshot().state == HapticsExperimentState::kRunning);
|
||||
assert(haptics_experiment_gameplay_owns(&devices[0]));
|
||||
now_us = started + 8000;
|
||||
feed(false);
|
||||
run_until(due(started, 1) + 1000);
|
||||
assert(pcm.size() == 2 && snapshot().host_updates == 2);
|
||||
unsigned left_nonzero = 0, right_nonzero = 0;
|
||||
for (unsigned frame = 0; frame < 64; ++frame) {
|
||||
const auto left = pcm[1].bytes[10 + frame * 2];
|
||||
const auto right = pcm[1].bytes[11 + frame * 2];
|
||||
if (frame < 24) {
|
||||
assert(right == 0);
|
||||
left_nonzero += left != 0;
|
||||
} else {
|
||||
assert(left == 0);
|
||||
right_nonzero += right != 0;
|
||||
}
|
||||
}
|
||||
assert(left_nonzero > 10 && right_nonzero > 20);
|
||||
SwitchHapticsFrame stale{};
|
||||
stale.actuators[0].sample_count = 1;
|
||||
stale.actuators[0].samples[0].low_amplitude_q15 = 16000;
|
||||
assert(!haptics_experiment_submit(0, 101, now_us, stale));
|
||||
run_until(started + 6300000);
|
||||
assert(snapshot().state == HapticsExperimentState::kRunning);
|
||||
assert(snapshot().sent_packets > 288 && snapshot().skipped_packets == 0);
|
||||
for (unsigned byte = 10; byte < 138; ++byte) assert(pcm.back().bytes[byte] == 0);
|
||||
assert(snapshot().dropped_updates == 0 && generic_sent.empty());
|
||||
assert(haptics_experiment_feedback(&devices[0], 100, 60, 30));
|
||||
run_until(now_us + 22000);
|
||||
assert(generic_sent.empty()); // Local confirmation must not leave PCM mode.
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
run_until(now_us + 10000);
|
||||
assert(snapshot().state == HapticsExperimentState::kStopped && snapshot().mode == 1);
|
||||
assert(!haptics_experiment_owns(&devices[0]) && generic_sent.size() == 2);
|
||||
}
|
||||
|
||||
void gameplay_missing_callback_is_bounded() {
|
||||
reset();
|
||||
delivery = Delivery::kNever;
|
||||
assert(haptics_experiment_request(2, 0));
|
||||
haptics_experiment_poll();
|
||||
run_until(now_us + 105000);
|
||||
assert(snapshot().state == HapticsExperimentState::kError && snapshot().last_error == 4);
|
||||
assert(!haptics_experiment_owns(&devices[0]) && timers.empty());
|
||||
}
|
||||
|
||||
void gameplay_queued_start_and_command_overflow() {
|
||||
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 && pcm.empty());
|
||||
run_until(now_us + 10000);
|
||||
devices[0].credit = true;
|
||||
assert(!dispatch(&devices[0], devices[0].conn.control_cid));
|
||||
run_until(now_us + 3000);
|
||||
assert(snapshot().state == HapticsExperimentState::kRunning && pcm.size() == 1);
|
||||
assert(generic_queue.empty() && generic_sent.size() == 1);
|
||||
assert(generic_sent.front().kind == GenericKind::kLed);
|
||||
|
||||
SwitchHapticsFrame frame{};
|
||||
frame.actuators[0].sample_count = 1;
|
||||
frame.actuators[0].samples[0].low_amplitude_q15 = 20000;
|
||||
const size_t sent_before = pcm.size();
|
||||
for (unsigned i = 0; i < 17; ++i) {
|
||||
now_us += 8000;
|
||||
assert(haptics_experiment_submit(0, 100, now_us, frame));
|
||||
}
|
||||
run_until(now_us);
|
||||
assert(snapshot().state == HapticsExperimentState::kRunning);
|
||||
assert(snapshot().host_updates == 17 && snapshot().dropped_updates == 1);
|
||||
assert(snapshot().skipped_packets != 0 && pcm.size() == sent_before + 1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Transport attribution has its own native fixture; this fixture isolates PCM
|
||||
|
|
@ -712,5 +810,8 @@ int main(int argc, char** argv) {
|
|||
reconnect_and_pending_generation();
|
||||
support_and_transport_errors();
|
||||
timing_cost_reentrancy_and_wrap();
|
||||
gameplay_timeline_and_lifecycle();
|
||||
gameplay_queued_start_and_command_overflow();
|
||||
gameplay_missing_callback_is_bounded();
|
||||
std::cout << "haptics experiment behavioral regressions passed\n";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,6 +197,58 @@ void test_output_report_normalization() {
|
|||
}
|
||||
}
|
||||
|
||||
void test_full_fidelity_states() {
|
||||
const auto check = [](bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
++failures;
|
||||
}
|
||||
};
|
||||
SwitchHapticsDecoder decoder;
|
||||
auto bytes = payload(type_2(80, 100, 32, 127), type_2(16, 16, 100, 32));
|
||||
auto decoded = decoder.decode(bytes.data());
|
||||
const auto& left = decoded.hd.actuators[0];
|
||||
const auto& right = decoded.hd.actuators[1];
|
||||
check(left.sample_count == 1 && right.sample_count == 1,
|
||||
"full state must retain both independent actuators");
|
||||
check(left.samples[0].low_frequency_index == 32 &&
|
||||
left.samples[0].high_frequency_index == 80 &&
|
||||
right.samples[0].low_frequency_index == 100 &&
|
||||
right.samples[0].high_frequency_index == 16,
|
||||
"per-side frequencies were collapsed");
|
||||
check(left.samples[0].low_amplitude_q15 == 32066 &&
|
||||
right.samples[0].low_amplitude_q15 == 4096,
|
||||
"per-side linear amplitudes were collapsed or incorrectly decoded");
|
||||
|
||||
decoder.reset();
|
||||
bytes = payload(type_2(64, 16, 64, 16), 0x40400100u);
|
||||
decoder.decode(bytes.data());
|
||||
bytes = payload(type_1_three_samples(17, 17, 29, 29, 24, 24), 0x40400100u);
|
||||
decoded = decoder.decode(bytes.data());
|
||||
const auto sequence = decoded.hd.actuators[0];
|
||||
check(sequence.sample_count == 3 &&
|
||||
sequence.samples[0].low_frequency_index == 65 &&
|
||||
sequence.samples[1].low_frequency_index == 66 &&
|
||||
sequence.samples[2].low_frequency_index == 66,
|
||||
"ordered frequency substeps were lost");
|
||||
check(sequence.samples[0].low_amplitude_q15 >
|
||||
sequence.samples[1].low_amplitude_q15 &&
|
||||
sequence.samples[1].low_amplitude_q15 ==
|
||||
sequence.samples[2].low_amplitude_q15,
|
||||
"amplitude substeps were replaced by their peak or final value");
|
||||
decoded = decoder.decode(bytes.data());
|
||||
check(decoded.hd.actuators[0].sample_count == 1 &&
|
||||
decoded.hd.actuators[0].samples[0].low_frequency_index == 66 &&
|
||||
decoded.hd.actuators[0].samples[0].low_amplitude_q15 ==
|
||||
sequence.samples[2].low_amplitude_q15,
|
||||
"repeated compressed commands replayed an old substep sequence");
|
||||
bytes = payload(0, type_2(64, 16, 64, 16));
|
||||
decoded = decoder.decode(bytes.data());
|
||||
check(decoded.hd.actuators[0].samples[0].low_amplitude_q15 == 0 &&
|
||||
decoded.hd.actuators[1].samples[0].low_amplitude_q15 != 0,
|
||||
"neutral on one side stopped the other side");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
|
|
@ -207,6 +259,7 @@ int main() {
|
|||
test_left_right_peak_combination();
|
||||
test_type_3_and_type_4_frames();
|
||||
test_malformed_and_reserved_words_preserve_state();
|
||||
test_full_fidelity_states();
|
||||
test_output_report_normalization();
|
||||
|
||||
if (failures != 0) {
|
||||
|
|
|
|||
394
tests/switch_hd_rumble_synth_test.cpp
Normal file
394
tests/switch_hd_rumble_synth_test.cpp
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
#include "input/switch_hd_rumble_synth.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr double kTau = 6.2831853071795864769;
|
||||
int failures = 0;
|
||||
|
||||
void expect(bool condition, const char* scenario) {
|
||||
if (!condition) {
|
||||
std::cerr << scenario << '\n';
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
int signed_byte(uint8_t value) {
|
||||
return value < 128 ? value : static_cast<int>(value) - 256;
|
||||
}
|
||||
|
||||
SwitchHapticsSample state(uint8_t low_index = 64, uint16_t low = 32768,
|
||||
uint8_t high_index = 64, uint16_t high = 0) {
|
||||
return SwitchHapticsSample{low_index, high_index, low, high};
|
||||
}
|
||||
|
||||
SwitchHapticsFrame one_side(unsigned side, SwitchHapticsSample sample = state()) {
|
||||
SwitchHapticsFrame frame;
|
||||
frame.actuators[side].sample_count = 1;
|
||||
frame.actuators[side].samples[0] = sample;
|
||||
return frame;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> render(SwitchHdRumbleSynth& synth, uint64_t first,
|
||||
uint32_t frames) {
|
||||
std::vector<uint8_t> pcm(static_cast<size_t>(frames) * 2, 0xcc);
|
||||
synth.render(first, frames, pcm.data());
|
||||
return pcm;
|
||||
}
|
||||
|
||||
double wave(double cycles, uint16_t amplitude = 32768) {
|
||||
return 95.25 * std::sin(kTau * cycles) * amplitude / 32768;
|
||||
}
|
||||
|
||||
template <typename Function>
|
||||
void expect_wave(const std::vector<uint8_t>& pcm, unsigned side,
|
||||
Function expected, const char* scenario) {
|
||||
for (size_t sample = 0; sample < pcm.size() / 2; ++sample) {
|
||||
const double wanted = expected(sample);
|
||||
const int actual = signed_byte(pcm[sample * 2 + side]);
|
||||
if (std::abs(actual - wanted) > 0.65) {
|
||||
std::cerr << scenario << ": sample " << sample << " side " << side
|
||||
<< " expected " << wanted << ", got " << actual << '\n';
|
||||
++failures;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void expect_silent(const std::vector<uint8_t>& pcm, const char* scenario) {
|
||||
expect(std::all_of(pcm.begin(), pcm.end(), [](uint8_t v) { return v == 0; }),
|
||||
scenario);
|
||||
}
|
||||
|
||||
double spectral_amplitude(const std::vector<uint8_t>& pcm, unsigned side,
|
||||
double frequency) {
|
||||
double real = 0;
|
||||
double imaginary = 0;
|
||||
const size_t frames = pcm.size() / 2;
|
||||
for (size_t n = 0; n < frames; ++n) {
|
||||
const double angle = kTau * frequency * n / 3000;
|
||||
const int value = signed_byte(pcm[2 * n + side]);
|
||||
real += value * std::cos(angle);
|
||||
imaginary += value * std::sin(angle);
|
||||
}
|
||||
return 2 * std::hypot(real, imaginary) / frames;
|
||||
}
|
||||
|
||||
void test_physical_frequency_and_channels() {
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
for (unsigned band = 0; band < 2; ++band) {
|
||||
for (uint8_t index : {0, 32, 64, 96, 127}) {
|
||||
SwitchHdRumbleSynth synth;
|
||||
synth.reset(123456);
|
||||
const auto tone = one_side(side, state(index, band ? 0 : 32768,
|
||||
index, band ? 32768 : 0));
|
||||
std::vector<uint8_t> pcm(2400);
|
||||
for (unsigned first = 0; first < 1200; first += 60) {
|
||||
expect(synth.push(tone, 123456 + first * 1000 / 3),
|
||||
"periodic host refresh accepted");
|
||||
synth.render(first, 60, pcm.data() + first * 2);
|
||||
}
|
||||
const double frequency = (band ? 80 : 40) * std::exp2(index / 32.0);
|
||||
expect_wave(pcm, side, [frequency](size_t n) {
|
||||
return wave(frequency * n / 3000);
|
||||
}, "physical frequency and free-running phase");
|
||||
expect_wave(pcm, 1 - side, [](size_t) { return 0; },
|
||||
"opposite actuator remains silent");
|
||||
double peak_frequency = 0;
|
||||
double peak_amplitude = 0;
|
||||
for (int offset = -12; offset <= 12; ++offset) {
|
||||
const double candidate = frequency + offset * 0.25;
|
||||
const double amplitude = spectral_amplitude(pcm, side, candidate);
|
||||
if (amplitude > peak_amplitude) {
|
||||
peak_amplitude = amplitude;
|
||||
peak_frequency = candidate;
|
||||
}
|
||||
}
|
||||
expect(std::abs(peak_frequency - frequency) <= 0.5 &&
|
||||
peak_amplitude > 92 && peak_amplitude < 99,
|
||||
"DFT peak matches physical frequency including extreme indices");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void test_linear_mix_headroom() {
|
||||
SwitchHdRumbleSynth synth;
|
||||
synth.reset(0);
|
||||
auto frame = one_side(0, state(64, 32768, 32, 32768)); // Both bands 160 Hz.
|
||||
frame.actuators[1] = one_side(1, state(64, 32768, 64, 32768)).actuators[1];
|
||||
synth.push(frame, 0);
|
||||
const auto pcm = render(synth, 0, 150);
|
||||
expect_wave(pcm, 0, [](size_t n) { return 2 * wave(160.0 * n / 3000) / 1.5; },
|
||||
"coherent full-scale bands use headroom without waveform clipping");
|
||||
expect_wave(pcm, 1, [](size_t n) {
|
||||
return (wave(160.0 * n / 3000) + wave(320.0 * n / 3000)) / 1.5;
|
||||
}, "full-scale two-band balance is preserved by the joint gain ceiling");
|
||||
int sum = 0;
|
||||
for (size_t n = 0; n < pcm.size() / 2; ++n) {
|
||||
sum += signed_byte(pcm[n * 2]);
|
||||
expect(signed_byte(pcm[n * 2]) != -128, "PCM never overflows signed headroom");
|
||||
}
|
||||
expect(std::abs(sum) <= 1, "symmetric rounding does not add DC bias");
|
||||
|
||||
synth.reset(0);
|
||||
synth.push(one_side(0, state(64, 32768, 64, 16384)), 0);
|
||||
expect_wave(render(synth, 0, 150), 0, [](size_t n) {
|
||||
return 127.0 * (2 * std::sin(kTau * 160.0 * n / 3000) +
|
||||
std::sin(kTau * 320.0 * n / 3000)) / 3;
|
||||
}, "headroom-limited boost retains a 2:1 band amplitude ratio");
|
||||
|
||||
synth.reset(0);
|
||||
synth.push(one_side(0, state(127, 0, 127, 0)), 0);
|
||||
expect_silent(render(synth, 0, 180), "profile-zero amplitudes are never boosted");
|
||||
}
|
||||
|
||||
void test_substeps_and_preemption() {
|
||||
SwitchHdRumbleSynth synth;
|
||||
synth.reset(0);
|
||||
SwitchHapticsFrame frame;
|
||||
frame.actuators[0] = {3, {state(), state(64, 0), state(64, 16384)}};
|
||||
frame.actuators[1] = {2, {state(64, 0), state(64, 0, 64, 32768)}};
|
||||
synth.push(frame, 0);
|
||||
auto pcm = render(synth, 0, 32);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
return wave(160.0 * n / 3000, n < 8 ? 32768 : n < 16 ? 0 : 16384);
|
||||
}, "three left substeps occupy 8/8/8 samples then hold");
|
||||
expect_wave(pcm, 1, [](size_t n) {
|
||||
return n < 12 ? 0 : wave(320.0 * n / 3000);
|
||||
}, "two right substeps independently occupy 12/12 samples");
|
||||
|
||||
synth.reset(0);
|
||||
synth.push(frame, 0);
|
||||
synth.push(one_side(0, state(64, 0)), 3000); // Sample 9 cancels old step 3.
|
||||
pcm = render(synth, 0, 32);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
return n < 8 ? wave(160.0 * n / 3000) : 0;
|
||||
}, "new batch preempts future old substeps, not already elapsed samples");
|
||||
expect_wave(pcm, 1, [](size_t n) {
|
||||
return n < 12 ? 0 : wave(320.0 * n / 3000);
|
||||
}, "zero-count side preserves pending substeps on the other actuator");
|
||||
}
|
||||
|
||||
void test_multiple_usb_updates_and_watchdogs() {
|
||||
SwitchHdRumbleSynth synth;
|
||||
synth.reset(1000);
|
||||
synth.push(one_side(0), 1000);
|
||||
synth.push(one_side(0, state(64, 0)), 6001); // Ceil to sample 16.
|
||||
synth.push(one_side(0, state(64, 16384)), 12000); // Sample 33.
|
||||
auto pcm = render(synth, 0, 64);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
return wave(160.0 * n / 3000, n < 16 ? 32768 : n < 33 ? 0 : 16384);
|
||||
}, "all USB updates within one 21.333 ms PCM interval are rendered");
|
||||
|
||||
synth.reset(0);
|
||||
auto both = one_side(0);
|
||||
both.actuators[1] = both.actuators[0];
|
||||
synth.push(both, 0);
|
||||
synth.push(one_side(0), 20000);
|
||||
synth.push(SwitchHapticsFrame{}, 40000); // Must not refresh either side.
|
||||
pcm = render(synth, 0, 230);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
return n < 210 ? wave(160.0 * n / 3000) : 0;
|
||||
}, "left watchdog expires exactly 50 ms after its own update");
|
||||
expect_wave(pcm, 1, [](size_t n) {
|
||||
return n < 150 ? wave(160.0 * n / 3000) : 0;
|
||||
}, "zero-count right side does not refresh its watchdog");
|
||||
}
|
||||
|
||||
void test_phase_continuity_and_partitioning() {
|
||||
SwitchHdRumbleSynth whole;
|
||||
SwitchHdRumbleSynth partitioned;
|
||||
whole.reset(0);
|
||||
partitioned.reset(0);
|
||||
for (SwitchHdRumbleSynth* synth : {&whole, &partitioned}) {
|
||||
synth->push(one_side(0), 0);
|
||||
synth->push(one_side(0, state(96)), 5000); // Change frequency at sample 15.
|
||||
synth->push(one_side(0, state(96)), 9000); // Identical state must not reset phase.
|
||||
synth->push(one_side(0, state(96, 8192)), 12000);
|
||||
}
|
||||
const auto pcm = render(whole, 0, 80);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
const double cycles = n < 15 ? n * 160.0 / 3000
|
||||
: (15 * 160.0 + (n - 15) * 320.0) / 3000;
|
||||
return wave(cycles, n < 36 ? 32768 : 8192);
|
||||
}, "frequency and amplitude transitions preserve accumulated phase");
|
||||
std::vector<uint8_t> split(160);
|
||||
for (unsigned n = 0; n < 80; ++n) {
|
||||
partitioned.render(n, 1, split.data() + n * 2);
|
||||
}
|
||||
expect(split == pcm, "PCM is independent of render block partitioning");
|
||||
}
|
||||
|
||||
void test_feedback_returns_to_live_host() {
|
||||
SwitchHdRumbleSynth synth;
|
||||
synth.reset(0);
|
||||
synth.push(one_side(0), 0);
|
||||
// Feedback may be delivered before an older USB frame drains on Core 1.
|
||||
synth.feedback(5001, 4999, 0, 255); // Samples [16,30), not a whole PCM block.
|
||||
synth.push(one_side(0, state(96, 16384)), 8000); // Sample 24, underneath overlay.
|
||||
auto pcm = render(synth, 0, 64);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
if (n >= 16 && n < 30) {
|
||||
return wave(320.0 * n / 3000) / 1.5;
|
||||
}
|
||||
const double cycles = n < 24 ? n * 160.0 / 3000
|
||||
: (24 * 160.0 + (n - 24) * 320.0) / 3000;
|
||||
return wave(cycles, n < 24 ? 32768 : 16384);
|
||||
}, "partial feedback expiry returns to live host state and host phase");
|
||||
expect_wave(pcm, 1, [](size_t n) {
|
||||
return n >= 16 && n < 30 ? wave(320.0 * n / 3000) / 1.5 : 0;
|
||||
}, "feedback overrides both sides only for its actual duration");
|
||||
|
||||
synth.reset(0);
|
||||
synth.push(one_side(0), 0);
|
||||
synth.feedback(0, 100000, 128, 0);
|
||||
synth.feedback(4000, 100000, 0, 0);
|
||||
synth.feedback(8000, 100000, 0, 255);
|
||||
synth.feedback(12000, 0, 255, 255);
|
||||
pcm = render(synth, 0, 60);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
if (n < 12) {
|
||||
return wave(160.0 * n / 3000, static_cast<uint16_t>((128u * 32768 + 127) / 255)) / 1.5;
|
||||
}
|
||||
const bool feedback = n >= 24 && n < 36;
|
||||
return wave((feedback ? 320.0 : 160.0) * n / 3000) / (feedback ? 1.5 : 1);
|
||||
}, "zero magnitudes and zero duration cancel override without cancelling host");
|
||||
|
||||
synth.reset(0);
|
||||
synth.push(one_side(0), 0);
|
||||
synth.feedback(0, 80000, 0, 255);
|
||||
pcm = render(synth, 0, 270);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
return n < 240 ? wave(320.0 * n / 3000) / 1.5 : 0;
|
||||
}, "feedback expiry cannot resurrect an expired host effect");
|
||||
}
|
||||
|
||||
void test_late_commands_and_clock_rollover() {
|
||||
SwitchHdRumbleSynth synth;
|
||||
synth.reset(10000);
|
||||
SwitchHapticsFrame steps;
|
||||
steps.actuators[0] = {3, {state(64, 32768), state(64, 16384), state(64, 8192)}};
|
||||
expect(synth.push(steps, 4000), "recent pre-epoch effect is accepted");
|
||||
auto pcm = render(synth, 0, 150);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
return n < 132 ? wave(160.0 * n / 3000, 8192) : 0;
|
||||
}, "pre-epoch effect starts at current substep and keeps original expiry");
|
||||
synth.reset(100000);
|
||||
expect(!synth.push(steps, 50000) && synth.dropped_updates() == 1,
|
||||
"already expired pre-epoch effect is rejected");
|
||||
expect_silent(render(synth, 0, 64), "expired pre-epoch effect never replays");
|
||||
|
||||
synth.reset(0);
|
||||
render(synth, 0, 40);
|
||||
expect(synth.push(steps, 0), "late but ordered frame is accepted");
|
||||
pcm = render(synth, 40, 130);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
return n + 40 < 150 ? wave(160.0 * (n + 40) / 3000, 8192) : 0;
|
||||
}, "late frame skips old substeps and does not restart watchdog");
|
||||
expect(!synth.push(one_side(0), UINT64_MAX), "out-of-order timestamp is rejected");
|
||||
|
||||
const uint64_t epoch = UINT64_MAX - 1000;
|
||||
synth.reset(epoch);
|
||||
synth.push(one_side(0), epoch);
|
||||
synth.push(one_side(0, state(64, 16384)), epoch + 3000);
|
||||
synth.feedback(epoch + 4000, 1000, 0, 255);
|
||||
pcm = render(synth, 0, 30);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
if (n >= 12 && n < 15) {
|
||||
return wave(320.0 * n / 3000) / 1.5;
|
||||
}
|
||||
return wave(160.0 * n / 3000, n < 9 ? 32768 : 16384);
|
||||
}, "64-bit microsecond clock rollover preserves order and duration");
|
||||
expect(synth.dropped_updates() == 0, "clock rollover is not an out-of-order update");
|
||||
}
|
||||
|
||||
void test_stall_and_overflow() {
|
||||
SwitchHdRumbleSynth skipped;
|
||||
SwitchHdRumbleSynth rendered;
|
||||
skipped.reset(0);
|
||||
rendered.reset(0);
|
||||
SwitchHapticsFrame steps;
|
||||
steps.actuators[0] = {3, {state(32), state(96), state(127)}};
|
||||
for (SwitchHdRumbleSynth* synth : {&skipped, &rendered}) {
|
||||
synth->push(steps, 0);
|
||||
synth->push(one_side(0, state(64)), 12000);
|
||||
synth->push(one_side(0, state(32)), 40000);
|
||||
}
|
||||
render(rendered, 0, 180);
|
||||
expect(render(skipped, 180, 80) == render(rendered, 180, 80),
|
||||
"forward gap analytically integrates every queued frequency transition");
|
||||
|
||||
constexpr uint64_t far = 3000000000ull;
|
||||
expect_silent(render(skipped, far, 64), "giant stall skips stale sound without a PCM backlog");
|
||||
expect(skipped.push(one_side(0), (far + 64) * 1000 / 3),
|
||||
"fresh effect after giant stall is accepted");
|
||||
const auto fresh = render(skipped, far + 64, 150);
|
||||
const double fresh_amplitude = spectral_amplitude(fresh, 0, 160);
|
||||
expect(fresh_amplitude > 93 && fresh_amplitude < 98,
|
||||
"fresh 160 Hz effect resumes at full band amplitude after giant stall");
|
||||
expect_wave(fresh, 1, [](size_t) { return 0; },
|
||||
"resuming after stall does not activate the other actuator");
|
||||
expect_silent(render(skipped, far, 64), "already consumed PCM is not replayable");
|
||||
|
||||
SwitchHdRumbleSynth overflowing;
|
||||
SwitchHdRumbleSynth reference;
|
||||
overflowing.reset(0);
|
||||
reference.reset(0);
|
||||
for (unsigned n = 0; n < 40; ++n) {
|
||||
const auto frame = one_side(n % 2, 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, "bounded command ring reports discarded history");
|
||||
expect_silent(render(overflowing, 0, 30), "overflow watermark silences discarded past");
|
||||
expect(render(overflowing, 120, 120) == render(reference, 120, 120),
|
||||
"overflow preserves complete per-side baseline and accumulated phase");
|
||||
}
|
||||
|
||||
void test_duplicate_order_and_invalid_frames() {
|
||||
SwitchHdRumbleSynth synth;
|
||||
synth.reset(0);
|
||||
synth.push(one_side(0), 1000);
|
||||
synth.push(one_side(0, state(64, 8192)), 1000);
|
||||
expect(!synth.push(one_side(0, state(64, 0)), 999),
|
||||
"older timestamp cannot override newest accepted state");
|
||||
auto invalid = one_side(0);
|
||||
invalid.actuators[0].sample_count = 4;
|
||||
expect(!synth.push(invalid, 2000), "too many substeps rejects whole batch");
|
||||
invalid = one_side(0, state(128));
|
||||
expect(!synth.push(invalid, 2000), "out-of-range frequency rejects whole batch");
|
||||
invalid = one_side(0, state(64, 32769));
|
||||
expect(!synth.push(invalid, 2000), "out-of-range linear amplitude rejects whole batch");
|
||||
expect(synth.dropped_updates() == 4, "rejected batches are counted");
|
||||
const auto pcm = render(synth, 0, 30);
|
||||
expect_wave(pcm, 0, [](size_t n) {
|
||||
return n < 3 ? 0 : wave(160.0 * n / 3000, 8192);
|
||||
}, "duplicate timestamp last-wins without phase reset or malformed-state mutation");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_physical_frequency_and_channels();
|
||||
test_linear_mix_headroom();
|
||||
test_substeps_and_preemption();
|
||||
test_multiple_usb_updates_and_watchdogs();
|
||||
test_phase_continuity_and_partitioning();
|
||||
test_feedback_returns_to_live_host();
|
||||
test_late_commands_and_clock_rollover();
|
||||
test_stall_and_overflow();
|
||||
test_duplicate_order_and_invalid_frames();
|
||||
if (failures) {
|
||||
std::cerr << failures << " synthesis scenarios failed\n";
|
||||
return 1;
|
||||
}
|
||||
std::cout << "Switch HD rumble synthesis scenarios passed\n";
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -2121,17 +2121,19 @@ def haptics_response(
|
|||
state: int = 0, *, run_id: int = 0, slot: int = 0xFF,
|
||||
last_error: int = 0, sent_packets: int = 0,
|
||||
first_tone_due_us: int = 0, first_tone_sent_us: int = 0,
|
||||
elapsed_us: int = 0, connection_generation: int = 9,
|
||||
elapsed_us: int = 0, connection_generation: int = 9, mode: int = 0,
|
||||
host_updates: int = 0, dropped_updates: int = 0,
|
||||
) -> bytes:
|
||||
return make_response(
|
||||
config_manager.OP_HAPTICS_EXPERIMENT,
|
||||
struct.pack(
|
||||
"<17I4B", run_id, connection_generation, 100, 105,
|
||||
"<17I8B2I", run_id, connection_generation, 100, 105,
|
||||
sent_packets, 2, 3, 109, 4,
|
||||
123, 22000, 11001, 9876, first_tone_due_us, first_tone_sent_us,
|
||||
0x76543210, elapsed_us, state, slot, last_error, 0,
|
||||
0x76543210, elapsed_us, state, slot, last_error, 0, mode, 0, 0, 0,
|
||||
host_updates, dropped_updates,
|
||||
),
|
||||
schema=2, generation=run_id,
|
||||
schema=3, generation=run_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2142,8 +2144,6 @@ class HapticsDevice(FakeDevice):
|
|||
) -> None:
|
||||
super().__init__()
|
||||
self.haptics_responses = responses
|
||||
self.haptics_reads = 0
|
||||
self.haptics_reads_at_out: list[int] = []
|
||||
self.transport_response = transport_response
|
||||
|
||||
def ctrl_transfer(
|
||||
|
|
@ -2167,7 +2167,6 @@ class HapticsDevice(FakeDevice):
|
|||
assert index == config_manager.REQUEST_INDEX
|
||||
self.requests.append(request)
|
||||
if bm_request_type == 0xC0:
|
||||
self.haptics_reads += 1
|
||||
response = self.haptics_responses[0]
|
||||
if len(self.haptics_responses) > 1:
|
||||
self.haptics_responses.pop(0)
|
||||
|
|
@ -2185,7 +2184,6 @@ class HapticsDevice(FakeDevice):
|
|||
)
|
||||
assert crc == zlib.crc32(payload) & 0xFFFFFFFF
|
||||
self.out_requests.append((request, payload, encoded))
|
||||
self.haptics_reads_at_out.append(self.haptics_reads)
|
||||
return len(encoded)
|
||||
|
||||
|
||||
|
|
@ -2206,7 +2204,8 @@ def test_haptics_schema_timing_and_wraparound() -> None:
|
|||
haptics_response(
|
||||
2, run_id=17, slot=2, sent_packets=101,
|
||||
first_tone_due_us=0xFFFFFFF0, first_tone_sent_us=0x30,
|
||||
elapsed_us=1100000,
|
||||
elapsed_us=1100000, mode=1,
|
||||
host_updates=0x89ABCDEF, dropped_updates=0x12345678,
|
||||
),
|
||||
])
|
||||
snapshot = config_manager.read_haptics_experiment(device)
|
||||
|
|
@ -2224,35 +2223,54 @@ def test_haptics_schema_timing_and_wraparound() -> None:
|
|||
assert snapshot.last_sent_us == 0x76543210
|
||||
assert snapshot.elapsed_us == 1100000
|
||||
assert snapshot.to_json_object()["first_tone_submission_delay_us"] == 64
|
||||
assert snapshot.mode_name == "gameplay"
|
||||
assert snapshot.host_updates == 0x89ABCDEF
|
||||
assert snapshot.dropped_updates == 0x12345678
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "message"),
|
||||
[
|
||||
("schema", "unsupported haptics experiment schema"),
|
||||
("size", "payload size"),
|
||||
("short", "payload size"),
|
||||
("long", "payload size"),
|
||||
("old_size", "payload size"),
|
||||
("mode_only_size", "payload size"),
|
||||
("state", "state"),
|
||||
("slot", "slot"),
|
||||
("active_without_slot", "slot"),
|
||||
("reserved", "reserved"),
|
||||
("mode", "mode"),
|
||||
("reserved71", "reserved"),
|
||||
("reserved73", "reserved"),
|
||||
("reserved74", "reserved"),
|
||||
("reserved75", "reserved"),
|
||||
("flags", "reserved"),
|
||||
],
|
||||
)
|
||||
def test_haptics_rejects_malformed_diagnostics(mutation: str, message: str) -> None:
|
||||
payload = bytearray(haptics_response(2, slot=0)[20:])
|
||||
schema, flags = 2, 0
|
||||
schema, flags = 3, 0
|
||||
if mutation == "schema":
|
||||
schema = 1
|
||||
elif mutation == "size":
|
||||
schema = 2
|
||||
del payload[72:]
|
||||
elif mutation == "short":
|
||||
payload.pop()
|
||||
elif mutation == "long":
|
||||
payload.append(0)
|
||||
elif mutation == "old_size":
|
||||
del payload[72:]
|
||||
elif mutation == "mode_only_size":
|
||||
del payload[76:]
|
||||
elif mutation == "state":
|
||||
payload[68] = 8
|
||||
elif mutation == "slot":
|
||||
payload[69] = 4
|
||||
elif mutation == "active_without_slot":
|
||||
payload[69] = 0xFF
|
||||
elif mutation == "reserved":
|
||||
payload[71] = 1
|
||||
elif mutation == "mode":
|
||||
payload[72] = 2
|
||||
elif mutation.startswith("reserved"):
|
||||
payload[int(mutation.removeprefix("reserved"))] = 1
|
||||
else:
|
||||
flags = 1
|
||||
response = make_response(
|
||||
|
|
@ -2272,9 +2290,12 @@ def test_haptics_disabled_firmware_is_readable_but_cannot_start(
|
|||
status = json.loads(captured.out)
|
||||
assert status["state_name"] == "unsupported"
|
||||
assert status["firmware_supported"] is False
|
||||
assert status["mode"] == 0
|
||||
assert "SWITCH_PICO_HAPTICS_EXPERIMENT=ON" in captured.err
|
||||
assert config_manager.main(["haptics-experiment", "start"]) == 1
|
||||
assert "unsupported" in capsys.readouterr().err
|
||||
assert config_manager.main(["haptics-experiment", "gameplay"]) == 1
|
||||
assert "unsupported" in capsys.readouterr().err
|
||||
assert device.out_requests == []
|
||||
|
||||
|
||||
|
|
@ -2296,26 +2317,38 @@ def test_haptics_old_firmware_stall_is_actionable_without_hiding_disconnect(
|
|||
assert raised.value is disconnected
|
||||
|
||||
|
||||
def test_haptics_start_waits_for_firmware_not_usb_ack(
|
||||
@pytest.mark.parametrize(("action", "mode"), [("start", 0), ("gameplay", 1)])
|
||||
def test_haptics_arming_waits_for_firmware_not_usb_ack(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float],
|
||||
haptics_clock: list[float], action: str, mode: int,
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(3, run_id=40, slot=0),
|
||||
haptics_response(1, run_id=41, slot=0),
|
||||
haptics_response(2, run_id=41, slot=0, sent_packets=1),
|
||||
haptics_response(1, run_id=41, slot=0, mode=mode),
|
||||
haptics_response(2, run_id=41, slot=0, sent_packets=1, mode=mode),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "start"]) == 0
|
||||
assert config_manager.main(["haptics-experiment", action, "--json"]) == 0
|
||||
captured = capsys.readouterr()
|
||||
assert device.haptics_reads_at_out == [1]
|
||||
assert device.out_requests[0][1] == b"\x01\x00"
|
||||
assert device.haptics_reads == 3
|
||||
row = json.loads(captured.out)
|
||||
assert row["state_name"] == "running"
|
||||
assert (row["run_id"], row["slot"], row["mode"]) == (41, 0, mode)
|
||||
assert device.out_requests[0][1] == bytes((1 if action == "start" else 2, 0))
|
||||
assert haptics_clock[0] >= 0.1
|
||||
assert "pending firmware confirmation" in captured.out
|
||||
assert "Haptics experiment: running" in captured.out
|
||||
assert "not physical actuator" in captured.out
|
||||
assert "1.024 s" in captured.out and "6.144 s" in captured.out
|
||||
if mode == 1:
|
||||
assert "pattern" not in row
|
||||
assert row["gameplay"] == {
|
||||
"sample_rate_hz": 3000,
|
||||
"stereo_frames_per_packet": 64,
|
||||
"lookback_us": pytest.approx(21333.333333333),
|
||||
"command_window_us": 8000,
|
||||
"watchdog_us": 50000,
|
||||
"host_gain": 1.5,
|
||||
}
|
||||
assert row["first_tone_submission_delay_us"] is None
|
||||
else:
|
||||
assert row["pattern"]["duration_us"] == 6144000
|
||||
assert "gameplay" not in row
|
||||
|
||||
|
||||
def test_haptics_start_watch_captures_correlated_measurement_series(
|
||||
|
|
@ -2360,21 +2393,24 @@ def test_haptics_start_watch_captures_correlated_measurement_series(
|
|||
(7, 6, "wait for prior output to drain"),
|
||||
],
|
||||
)
|
||||
def test_haptics_start_reports_asynchronous_rejection(
|
||||
def test_haptics_gameplay_reports_asynchronous_rejection(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float], state: int, error: int, description: str,
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(),
|
||||
haptics_response(1, run_id=1, slot=0),
|
||||
haptics_response(state, run_id=1, slot=0, last_error=error),
|
||||
haptics_response(1, run_id=1, slot=0, mode=1),
|
||||
haptics_response(state, run_id=1, slot=0, last_error=error, mode=1),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "start"]) == 1
|
||||
assert config_manager.main(["haptics-experiment", "gameplay", "--json"]) == 1
|
||||
captured = capsys.readouterr()
|
||||
assert description in captured.err
|
||||
assert f"last_error={error}" in captured.err
|
||||
assert "Haptics experiment: running" not in captured.out
|
||||
row = json.loads(captured.out)
|
||||
assert (row["run_id"], row["slot"], row["mode"]) == (1, 0, 1)
|
||||
assert row["state"] == state and row["last_error"] == error
|
||||
assert "pattern" not in row
|
||||
|
||||
|
||||
def test_haptics_status_watch_reports_connection_loss_after_running(
|
||||
|
|
@ -2432,32 +2468,37 @@ def test_haptics_watch_is_bounded_and_rejects_run_replacement(
|
|||
def test_haptics_busy_start_and_wrong_slot_stop_do_not_mutate_active_run(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = HapticsDevice([haptics_response(2, run_id=1, slot=3)])
|
||||
device = HapticsDevice([haptics_response(2, run_id=1, slot=3, mode=1)])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "start"]) == 1
|
||||
assert "already running" in capsys.readouterr().err
|
||||
assert config_manager.main(["haptics-experiment", "gameplay"]) == 1
|
||||
assert "already running" in capsys.readouterr().err
|
||||
assert config_manager.main(["haptics-experiment", "stop"]) == 1
|
||||
assert "not requested slot 0" in capsys.readouterr().err
|
||||
assert device.out_requests == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1])
|
||||
def test_haptics_stop_waits_for_service_completion(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float],
|
||||
haptics_clock: list[float], mode: int,
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(2, run_id=1, slot=2),
|
||||
haptics_response(2, run_id=1, slot=2),
|
||||
haptics_response(4, run_id=1, slot=2),
|
||||
haptics_response(2, run_id=1, slot=2, mode=mode),
|
||||
haptics_response(2, run_id=1, slot=2, mode=mode),
|
||||
haptics_response(4, run_id=1, slot=2, mode=mode),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main([
|
||||
"haptics-experiment", "stop", "--slot", "2", "--json",
|
||||
]) == 0
|
||||
captured = capsys.readouterr()
|
||||
assert json.loads(captured.out)["state_name"] == "stopped"
|
||||
row = json.loads(captured.out)
|
||||
assert row["state_name"] == "stopped"
|
||||
assert (row["run_id"], row["slot"], row["mode"]) == (1, 2, mode)
|
||||
assert device.out_requests[0][1] == b"\x00\x02"
|
||||
assert device.haptics_reads == 3
|
||||
assert haptics_clock[0] >= 0.1
|
||||
|
||||
|
||||
def test_haptics_invalid_slot_is_rejected_before_discovery(
|
||||
|
|
@ -2538,14 +2579,15 @@ def test_haptics_profile_decodes_exact_wire_order_and_correlates_live_run(
|
|||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(2, run_id=17, slot=0, sent_packets=10),
|
||||
haptics_response(2, run_id=17, slot=0, sent_packets=11),
|
||||
haptics_response(2, run_id=17, slot=0, sent_packets=10, mode=1),
|
||||
haptics_response(2, run_id=17, slot=0, sent_packets=11, mode=1),
|
||||
], transport_response=transport_response(active=1))
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 0
|
||||
row = json.loads(capsys.readouterr().out)
|
||||
assert row["run_id"] == 17 and row["connection_generation"] == 9
|
||||
assert row["sent_packets"] == 11
|
||||
assert row["mode_name"] == "gameplay" and "pattern" not in row
|
||||
transport = row["transport"]
|
||||
transport.pop("evidence_note")
|
||||
assert transport == {
|
||||
|
|
@ -2639,13 +2681,14 @@ def test_haptics_profile_accepts_retained_failed_run_with_invalid_handle(
|
|||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = HapticsDevice(
|
||||
[haptics_response(5, run_id=17, slot=0, last_error=3)],
|
||||
[haptics_response(5, run_id=17, slot=0, last_error=3, mode=1)],
|
||||
transport_response=transport_response(connection_handle=0xFFFF),
|
||||
)
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 0
|
||||
row = json.loads(capsys.readouterr().out)
|
||||
assert row["state_name"] == "disconnected" and row["last_error"] == 3
|
||||
assert row["mode"] == 1 and "pattern" not in row
|
||||
assert row["transport"]["connection_handle"] == 0xFFFF
|
||||
assert row["transport"]["active"] is False
|
||||
|
||||
|
|
@ -2689,24 +2732,29 @@ def test_haptics_profile_does_not_hide_usb_disconnect_as_unsupported() -> None:
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("after_run", "after_generation", "probe_run", "probe_generation"),
|
||||
("after_run", "after_generation", "probe_run", "probe_generation",
|
||||
"after_slot", "after_mode"),
|
||||
[
|
||||
(17, 9, 16, 9), # A stale probe must not attach to the current run.
|
||||
(18, 9, 17, 9), # A new run starts after reading the probe.
|
||||
(18, 9, 18, 9), # A new run starts before reading the probe.
|
||||
(17, 10, 17, 9), # A connection changes after reading the probe.
|
||||
(17, 10, 17, 10), # A connection changes before reading the probe.
|
||||
(17, 9, 17, 8), # Matching run IDs cannot mask stale connection data.
|
||||
(17, 9, 16, 9, 0, 1), # A stale probe cannot attach to the current run.
|
||||
(18, 9, 17, 9, 0, 1), # A new run starts after reading the probe.
|
||||
(18, 9, 18, 9, 0, 1), # A new run starts before reading the probe.
|
||||
(17, 10, 17, 9, 0, 1), # A connection changes after reading the probe.
|
||||
(17, 10, 17, 10, 0, 1), # A connection changes before reading the probe.
|
||||
(17, 9, 17, 8, 0, 1), # A matching run ID cannot mask stale connection data.
|
||||
(17, 9, 17, 9, 1, 1), # A different slot must not inherit the profile.
|
||||
(17, 9, 17, 9, 0, 0), # A fixture cannot impersonate gameplay.
|
||||
],
|
||||
)
|
||||
def test_haptics_profile_never_publishes_cross_run_metrics(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
after_run: int, after_generation: int, probe_run: int, probe_generation: int,
|
||||
after_slot: int, after_mode: int,
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(2, run_id=17, slot=0),
|
||||
haptics_response(2, run_id=17, slot=0, mode=1),
|
||||
haptics_response(
|
||||
2, run_id=after_run, slot=0, connection_generation=after_generation,
|
||||
2, run_id=after_run, slot=after_slot, connection_generation=after_generation,
|
||||
mode=after_mode,
|
||||
),
|
||||
], transport_response=transport_response(
|
||||
run_id=probe_run, connection_generation=probe_generation,
|
||||
|
|
@ -2716,3 +2764,79 @@ def test_haptics_profile_never_publishes_cross_run_metrics(
|
|||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert "cannot attribute measurements" in captured.err
|
||||
|
||||
|
||||
def test_haptics_gameplay_watch_timeout_leaves_stream_armed(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float],
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(3, run_id=8, slot=0),
|
||||
haptics_response(1, run_id=9, slot=3, mode=1),
|
||||
haptics_response(
|
||||
2, run_id=9, slot=3, mode=1, sent_packets=2, host_updates=4,
|
||||
dropped_updates=1,
|
||||
),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main([
|
||||
"--timeout", "0.2", "haptics-experiment", "gameplay", "--slot", "3",
|
||||
"--watch", "--json",
|
||||
]) == 1
|
||||
captured = capsys.readouterr()
|
||||
series = [json.loads(line) for line in captured.out.splitlines()]
|
||||
assert [row["state_name"] for row in series] == ["pending", "running", "running"]
|
||||
assert all((row["run_id"], row["slot"], row["mode"]) == (9, 3, 1) for row in series)
|
||||
assert all("pattern" not in row for row in series)
|
||||
assert all(row["first_tone_submission_delay_us"] is None for row in series)
|
||||
assert series[-1]["host_updates"] == 4 and series[-1]["dropped_updates"] == 1
|
||||
assert haptics_clock[0] == pytest.approx(0.2)
|
||||
assert "still armed" in captured.err and "stop --slot 3" in captured.err
|
||||
assert [request[1] for request in device.out_requests] == [b"\x02\x03"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("action", "response_run", "response_slot", "response_mode"),
|
||||
[
|
||||
("gameplay", 42, 0, 1),
|
||||
("gameplay", 41, 1, 1),
|
||||
("gameplay", 41, 0, 0),
|
||||
("start", 41, 0, 1),
|
||||
("stop", 40, 0, 0),
|
||||
],
|
||||
)
|
||||
def test_haptics_confirmation_never_attributes_another_run_slot_or_mode(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
action: str, response_run: int, response_slot: int, response_mode: int,
|
||||
) -> None:
|
||||
stopping = action == "stop"
|
||||
device = HapticsDevice([
|
||||
haptics_response(2 if stopping else 3, run_id=40, slot=0, mode=int(stopping)),
|
||||
haptics_response(
|
||||
4 if stopping else 2, run_id=response_run, slot=response_slot,
|
||||
mode=response_mode,
|
||||
),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", action, "--json"]) == 1
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert "run changed" in captured.err or "belongs to another" in captured.err
|
||||
|
||||
|
||||
def test_haptics_gameplay_watch_rejects_fixture_with_same_run_and_slot(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float],
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(2, run_id=7, slot=0, mode=1),
|
||||
haptics_response(3, run_id=7, slot=0, mode=0),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main([
|
||||
"haptics-experiment", "status", "--watch", "--json",
|
||||
]) == 1
|
||||
captured = capsys.readouterr()
|
||||
row = json.loads(captured.out)
|
||||
assert row["state_name"] == "running" and row["mode"] == 1
|
||||
assert "cannot attribute measurements" in captured.err
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ def test_haptics_experiment_native(tmp_path: Path, ram: int) -> None:
|
|||
f"-I{root / 'src' / 'firmware'}",
|
||||
str(root / "tests" / "haptics_experiment_test.cpp"),
|
||||
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"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
|
|
|
|||
31
tests/test_switch_hd_rumble_synth_native.py
Normal file
31
tests/test_switch_hd_rumble_synth_native.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_switch_hd_rumble_synth_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 / "switch_hd_rumble_synth_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-I{root / 'src' / 'firmware'}",
|
||||
str(root / "src" / "firmware" / "input" / "switch_hd_rumble_synth.cpp"),
|
||||
str(root / "tests" / "switch_hd_rumble_synth_test.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root, timeout=15)
|
||||
|
|
@ -728,18 +728,21 @@ std::vector<uint8_t> read_haptics_payload() {
|
|||
require(usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_SETUP, &request),
|
||||
"experiment diagnostics IN was rejected");
|
||||
require(control_payload.size() == kResponseHeaderSize + 72 &&
|
||||
require(control_payload.size() == kResponseHeaderSize + 84 &&
|
||||
control_payload[5] == 0x40 &&
|
||||
control_payload[6] == static_cast<uint8_t>(Status::kOk) &&
|
||||
control_payload[7] == 0 &&
|
||||
read_u16(control_payload, 8) == 72 &&
|
||||
read_u16(control_payload, 10) == 2,
|
||||
"experiment schema-2 envelope is invalid");
|
||||
read_u16(control_payload, 8) == 84 &&
|
||||
read_u16(control_payload, 10) == 3,
|
||||
"experiment schema-3 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,
|
||||
"experiment reserved payload bytes must remain zero");
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
|
@ -783,13 +786,13 @@ void test_haptics_experiment_requests() {
|
|||
0, CONTROL_STAGE_SETUP, &request),
|
||||
"malformed experiment control size was accepted");
|
||||
}
|
||||
std::vector<uint8_t> expected(72, 0);
|
||||
std::vector<uint8_t> expected(84, 0);
|
||||
expected[69] = 0xff;
|
||||
#ifndef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
expected[68] = 6;
|
||||
require(read_haptics_payload() == expected,
|
||||
"disabled firmware must expose only the unsupported snapshot");
|
||||
for (uint8_t action : {0, 1}) {
|
||||
for (uint8_t action : {0, 1, 2}) {
|
||||
next_out_payload = make_request(Operation::kHapticsExperiment, {action, 0});
|
||||
tusb_control_request_t request = setup_request(
|
||||
Operation::kHapticsExperiment, TUSB_DIR_OUT,
|
||||
|
|
@ -799,9 +802,7 @@ void test_haptics_experiment_requests() {
|
|||
"disabled firmware accepted experiment control");
|
||||
}
|
||||
#else
|
||||
require(read_haptics_payload() == expected,
|
||||
"enabled firmware must initially be idle without a selected slot");
|
||||
perform_haptics_out(2, 0, false);
|
||||
perform_haptics_out(3, 0, false);
|
||||
perform_haptics_out(1, 4, false);
|
||||
perform_haptics_out(0, 0xff, false);
|
||||
require(haptics_request_count == 0,
|
||||
|
|
@ -820,15 +821,15 @@ void test_haptics_experiment_requests() {
|
|||
require(haptics_request_count == 0,
|
||||
"bad CRC control reached the experiment service");
|
||||
|
||||
perform_haptics_out(1, 2);
|
||||
perform_haptics_out(2, 2);
|
||||
auto payload = read_haptics_payload();
|
||||
require(payload[68] == 1 && payload[69] == 2 &&
|
||||
read_u32(payload, 0) == 1 && read_u32(payload, 16) == 0 &&
|
||||
haptics_request_count == 1,
|
||||
require(payload[68] == 1 && payload[69] == 2 && payload[72] == 1 &&
|
||||
read_u32(payload, 0) == 1 && read_u32(payload, 16) == 0,
|
||||
"USB acceptance must remain pending until the service starts");
|
||||
perform_haptics_out(1, 1, false);
|
||||
perform_haptics_out(2, 1, false);
|
||||
payload = read_haptics_payload();
|
||||
require(payload[68] == 1 && payload[69] == 2 &&
|
||||
require(payload[68] == 1 && payload[69] == 2 && payload[72] == 1 &&
|
||||
read_u32(payload, 0) == 1,
|
||||
"busy start overwrote the accepted run");
|
||||
|
||||
|
|
@ -836,7 +837,7 @@ void test_haptics_experiment_requests() {
|
|||
current_haptics = {
|
||||
1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4,
|
||||
123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210,
|
||||
1100000, HapticsExperimentState::kRunning, 2, 0,
|
||||
1100000, HapticsExperimentState::kRunning, 2, 0, 1, 0x89abcdef, 0x12345678,
|
||||
};
|
||||
const uint32_t fields[] = {
|
||||
1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4,
|
||||
|
|
@ -847,15 +848,19 @@ void test_haptics_experiment_requests() {
|
|||
}
|
||||
expected[68] = 2;
|
||||
expected[69] = 2;
|
||||
expected[72] = 1;
|
||||
write_u32(&expected, 76, 0x89abcdef);
|
||||
write_u32(&expected, 80, 0x12345678);
|
||||
require(read_haptics_payload() == expected,
|
||||
"schema-2 timing fields are not in little-endian wire order");
|
||||
"schema-3 timing and gameplay mode fields are not in wire order");
|
||||
|
||||
perform_haptics_out(0, 2);
|
||||
payload = read_haptics_payload();
|
||||
require(payload[68] == 2 && read_u32(payload, 0) == 1,
|
||||
require(payload[68] == 2 && payload[72] == 1 && read_u32(payload, 0) == 1,
|
||||
"USB stop ACK must not fabricate terminal completion");
|
||||
current_haptics.state = HapticsExperimentState::kStopped;
|
||||
require(read_haptics_payload()[68] == 4,
|
||||
payload = read_haptics_payload();
|
||||
require(payload[68] == 4 && payload[72] == 1,
|
||||
"service stop transition was not observable");
|
||||
|
||||
next_out_payload = make_request(Operation::kHapticsExperiment, {1, 3});
|
||||
|
|
@ -879,7 +884,7 @@ void test_haptics_experiment_requests() {
|
|||
current_haptics.last_error = 3;
|
||||
payload = read_haptics_payload();
|
||||
require(payload[68] == 5 && payload[69] == 3 && payload[70] == 3 &&
|
||||
read_u32(payload, 0) == 2,
|
||||
read_u32(payload, 0) == 2 && payload[72] == 0,
|
||||
"asynchronous connection failure lost request correlation");
|
||||
#endif
|
||||
}
|
||||
|
|
@ -1163,17 +1168,18 @@ void bluepad32_input_backend_diagnostics(
|
|||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
bool haptics_experiment_request(uint8_t action, uint8_t slot) {
|
||||
++haptics_request_count;
|
||||
if (action == 1 &&
|
||||
if ((action == 1 || action == 2) &&
|
||||
(current_haptics.state == HapticsExperimentState::kPending ||
|
||||
current_haptics.state == HapticsExperimentState::kRunning)) {
|
||||
return false;
|
||||
}
|
||||
if (action == 1) {
|
||||
if (action == 1 || action == 2) {
|
||||
const uint32_t run_id = current_haptics.run_id + 1;
|
||||
current_haptics = {};
|
||||
current_haptics.run_id = run_id;
|
||||
current_haptics.slot = slot;
|
||||
current_haptics.state = HapticsExperimentState::kPending;
|
||||
current_haptics.mode = action == 2 ? 1 : 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue