fix: reduce IMU latency and refine native rumble response
This commit is contained in:
parent
6188fcb517
commit
65e635dc2b
9 changed files with 257 additions and 73 deletions
|
|
@ -56,7 +56,7 @@ The synthesizer has independent left/right low/high phase accumulators. Frequenc
|
|||
|
||||
One report interval (21.333 ms) of causal lookback preserves commands received between Bluetooth sends without predicting future input. Fixed 16-entry cross-core and synthesis command histories contain decoded states, not PCM. Overflow is counted; stale sample intervals are skipped, not replayed as a backlog. `host_updates` and `dropped_updates` expose command ingestion and loss.
|
||||
|
||||
Native gameplay gain is **1.5x after profile scaling**, following console feedback that the initial gain was weak. When the requested combined band weights exceed output headroom, both are reduced proportionally. This retains band balance and bounds samples to signed PCM range without clipping waveform peaks. Zero profile gains remain zero. Local profile confirmations retain their previous strength and temporarily override, rather than erase, the current host timeline.
|
||||
Native gameplay uses balanced **2x low/high gain after profile scaling**, followed by a gentle **0.8-power curve** on the combined amplitude. This lifts quiet and medium effects while retaining their low/high ratio. The curve is a 257-entry lookup with integer interpolation, not per-sample floating-point math. Combined output weights are capped at 65535, preventing coefficient overflow and waveform clipping. Zero remains zero. Frequencies, timing, and local-confirmation strength are unchanged. This response replaced the initial 1.5x gain and a low-band-only experiment after user comparison.
|
||||
|
||||
The gameplay stream continues with silence while idle. It is stopped on disconnect, explicit stop, or a stalled send-permission watchdog; it yields to compatibility behavior in XInput mode. Existing LED feedback can drain without switching the controller out of native haptics. Continuous idle streaming trades power for avoiding repeated audio-mode startup.
|
||||
|
||||
|
|
@ -212,4 +212,20 @@ Stop and re-arm were also exercised: stop confirmation was observed in about 19.
|
|||
|
||||
Final focused verification: **148 tests passed**, covering decoder fidelity, frequency/phase behavior, substeps, gain/headroom, watchdogs, overflow, startup/stop, profile gain/feedback, host controls, existing backend lifecycle, UART and build helpers. HD gameplay, normal all-in-one, deterministic experiment, and Pico/UART firmware builds succeeded. Configuration remained generation 9 / CRC `b740995b`; wake identity, clock and voltage were not changed.
|
||||
|
||||
The stronger HD gameplay image remains loaded and armed. This is a translation to DualSense actuators, not a promise of identical Nintendo force response. Physical onset still needs synchronized measurement. The user subsequently reported slight IMU aiming lag; that is being investigated separately from this completed haptics integration.
|
||||
The HD gameplay image auto-arms on connection. This is a translation to DualSense actuators, not a promise of identical Nintendo force response. Physical onset still needs synchronized measurement. Follow-on IMU and rumble-response refinements are recorded below.
|
||||
|
||||
### IMU scheduling correction
|
||||
|
||||
After HD integration was committed as `6188fcb`, the user reported a small aiming lag. The USB scheduler had two avoidable delays: unsuccessful motion sends advanced the shared timer, and control replies reset the same timer used for motion. It also mutated quaternion/timestamp state before successful USB submission.
|
||||
|
||||
The scheduler now uses an independent logical 15 ms motion clock, checks endpoint readiness before integrating, retries overdue data at the next ready opportunity, and rolls back motion state if queuing fails. Successful sends advance the logical clock rather than drifting with 8 ms USB polling. Long stalls skip missed periods instead of replaying a catch-up burst. Control replies and overdue motion get bounded service without one starving the other. No gyro scaling or smoothing was changed.
|
||||
|
||||
New regressions failed on the old implementation and passed with the correction. Live USB checks measured about 15 ms average motion-report spacing, including during control-reply traffic; four deliberate backpressure trials recovered with successive-read gaps of about 7.96–7.98 ms. The user retested aiming on the Switch and reported it was “pretty good now.”
|
||||
|
||||
### Accepted rumble response
|
||||
|
||||
The user described the 1.5x mix as thin/hollow and lacking body. A 2x low / 1.5x high comparison was still insufficient; the user requested more high-band response and selected a fuller amplitude curve. The final balanced 2x / 0.8-power response was tested on the device and in the same game, and the user selected **“Fuller and good.”**
|
||||
|
||||
The final USB-driven test delivered all **513 host commands**, with **zero dropped updates**, **zero Bluetooth skips**, and **zero send failures**. Controller input continued with 1,408 reports during the test. Maximum observed packet generation was 687 us and report gap 23,925 us. Local-feedback gain, frequencies, the 50 ms watchdog, the 21.333 ms lookback, and controller buffering remain unchanged.
|
||||
|
||||
Final checks: **151 focused tests passed**. HD gameplay, deterministic experiment, normal all-in-one, and Pico/UART builds succeeded. The accepted response and IMU scheduler correction are flashed; image files are `build-hd-rumble/switch-pico.elf` and `.uf2`. The stream remains armed. These follow-on refinements are separate from the committed HD integration.
|
||||
|
|
|
|||
27
src/firmware/input/switch_hd_rumble_envelope.h
Normal file
27
src/firmware/input/switch_hd_rumble_envelope.h
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// round(65535 * (i / 256)^0.8), i=0..256. Interpolate between entries
|
||||
// once per envelope segment, never run pow() in the PCM loop. Zero stays zero.
|
||||
namespace SwitchHdRumbleEnvelope {
|
||||
constexpr uint16_t kLevel[257] = {
|
||||
0, 776, 1351, 1869, 2352, 2812, 3254, 3681, 4096, 4501, 4896, 5284, 5665, 6040, 6409, 6773,
|
||||
7131, 7486, 7836, 8182, 8525, 8865, 9201, 9534, 9864, 10191, 10516, 10839, 11159, 11476, 11792, 12105,
|
||||
12417, 12726, 13034, 13339, 13643, 13946, 14247, 14546, 14843, 15139, 15434, 15727, 16019, 16310, 16599, 16887,
|
||||
17174, 17460, 17744, 18028, 18310, 18591, 18871, 19150, 19428, 19705, 19981, 20256, 20531, 20804, 21076, 21348,
|
||||
21618, 21888, 22157, 22425, 22693, 22959, 23225, 23490, 23755, 24018, 24281, 24543, 24805, 25065, 25325, 25585,
|
||||
25844, 26102, 26359, 26616, 26872, 27128, 27383, 27637, 27891, 28144, 28397, 28649, 28901, 29152, 29402, 29652,
|
||||
29902, 30151, 30399, 30647, 30895, 31141, 31388, 31634, 31879, 32124, 32369, 32613, 32856, 33100, 33342, 33585,
|
||||
33826, 34068, 34309, 34549, 34789, 35029, 35268, 35507, 35746, 35984, 36222, 36459, 36696, 36933, 37169, 37405,
|
||||
37640, 37875, 38110, 38344, 38578, 38812, 39045, 39278, 39510, 39743, 39975, 40206, 40437, 40668, 40899, 41129,
|
||||
41359, 41589, 41818, 42047, 42276, 42504, 42732, 42960, 43187, 43414, 43641, 43868, 44094, 44320, 44546, 44771,
|
||||
44996, 45221, 45446, 45670, 45894, 46118, 46341, 46564, 46787, 47010, 47232, 47455, 47676, 47898, 48119, 48341,
|
||||
48561, 48782, 49002, 49223, 49442, 49662, 49881, 50101, 50319, 50538, 50757, 50975, 51193, 51410, 51628, 51845,
|
||||
52062, 52279, 52496, 52712, 52928, 53144, 53360, 53575, 53790, 54006, 54220, 54435, 54649, 54864, 55078, 55291,
|
||||
55505, 55718, 55932, 56145, 56357, 56570, 56782, 56994, 57206, 57418, 57630, 57841, 58052, 58263, 58474, 58685,
|
||||
58895, 59105, 59315, 59525, 59735, 59945, 60154, 60363, 60572, 60781, 60989, 61198, 61406, 61614, 61822, 62030,
|
||||
62237, 62445, 62652, 62859, 63066, 63272, 63479, 63685, 63891, 64097, 64303, 64509, 64715, 64920, 65125, 65330,
|
||||
65535,
|
||||
};
|
||||
} // namespace SwitchHdRumbleEnvelope
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
#include "input/switch_hd_rumble_synth.h"
|
||||
#include "input/switch_hd_rumble_envelope.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stddef.h>
|
||||
|
|
@ -80,19 +81,23 @@ int32_t sine(uint32_t phase) {
|
|||
|
||||
void apply_host_gain(uint16_t& low, uint16_t& high) {
|
||||
if ((low | high) == 0) return;
|
||||
// Native gameplay calibration: 1.5x after profile gains. Limit both bands
|
||||
// together to the mixer's headroom, preserving their ratio and avoiding
|
||||
// waveform clipping. Local confirmations retain their original gain.
|
||||
const uint32_t boosted_low = (uint32_t{low} * 3 + 1) / 2;
|
||||
const uint32_t boosted_high = (uint32_t{high} * 3 + 1) / 2;
|
||||
const uint32_t total = boosted_low + boosted_high;
|
||||
if (total > 65536u) {
|
||||
low = static_cast<uint16_t>(boosted_low * 65536u / total);
|
||||
high = static_cast<uint16_t>(boosted_high * 65536u / total);
|
||||
} else {
|
||||
low = static_cast<uint16_t>(boosted_low);
|
||||
high = static_cast<uint16_t>(boosted_high);
|
||||
const uint32_t weighted_low = uint32_t{low} * 2;
|
||||
const uint32_t weighted_high = uint32_t{high} * 2;
|
||||
const uint32_t total = weighted_low + weighted_high;
|
||||
uint32_t target = 65535u;
|
||||
if (total < 65535u) {
|
||||
// A gentle 0.8-power curve lifts quiet/mid-level effects. Apply it
|
||||
// jointly so band balance is unchanged, not separately to each voice.
|
||||
const uint32_t index = total >> 8;
|
||||
const uint32_t fraction = total & 255u;
|
||||
const uint32_t first = SwitchHdRumbleEnvelope::kLevel[index];
|
||||
const uint32_t difference = SwitchHdRumbleEnvelope::kLevel[index + 1] - first;
|
||||
target = first + ((difference * fraction + 128u) >> 8);
|
||||
}
|
||||
// Product <= 65536*65535 fits uint32; floor rounding keeps the combined
|
||||
// weights <= 65535 and each weight representable in uint16. No clipping.
|
||||
low = static_cast<uint16_t>(weighted_low * target / total);
|
||||
high = static_cast<uint16_t>(weighted_high * target / total);
|
||||
}
|
||||
|
||||
uint8_t mix(uint32_t low_phase, uint32_t high_phase,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
// Single-core, allocation-free 3 kHz stereo PCM timeline. All times use the same
|
||||
// 64-bit microsecond clock; unsigned clock rollover is supported for intervals
|
||||
// shorter than 2^63 us. reset() establishes sample zero and zero oscillator phase.
|
||||
// Host PCM gets 1.5x gain after profile scaling, jointly limited to available
|
||||
// mixer headroom so two-band balance is preserved. Feedback gain is unchanged.
|
||||
// Host PCM gets balanced 2x gain and a joint 0.8-power amplitude curve after
|
||||
// profile scaling, bounded to mixer headroom. Feedback gain remains unchanged.
|
||||
class SwitchHdRumbleSynth {
|
||||
public:
|
||||
void reset(uint64_t epoch_us);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ struct SwitchProContext {
|
|||
SwitchProReport switch_report{};
|
||||
uint8_t last_report_counter = 0;
|
||||
uint32_t last_report_timer = 0;
|
||||
uint32_t last_imu_report_timer = 0; // Logical 15 ms cadence, not ACK timing.
|
||||
bool last_report_was_reply = false;
|
||||
bool is_ready = false;
|
||||
bool is_initialized = false;
|
||||
bool is_report_queued = false;
|
||||
|
|
@ -430,6 +432,8 @@ static void reset_context_runtime(SwitchProContext& context, uint32_t now,
|
|||
update_switch_report_from_state(context);
|
||||
context.last_report_counter = 0;
|
||||
context.last_report_timer = now;
|
||||
context.last_imu_report_timer = now;
|
||||
context.last_report_was_reply = false;
|
||||
context.is_ready = ready_before_mount;
|
||||
context.is_initialized = ready_before_mount;
|
||||
context.is_report_queued = false;
|
||||
|
|
@ -466,8 +470,9 @@ static bool send_report(uint8_t instance, SwitchProContext& context,
|
|||
uint16_t report_length) {
|
||||
bool result =
|
||||
tud_hid_n_report(instance, report_id, report_data, report_length);
|
||||
++context.last_report_counter;
|
||||
if (!result) {
|
||||
if (result) {
|
||||
++context.last_report_counter;
|
||||
} else {
|
||||
LOG_PRINTF("[HID %u] send_report failed id=%u len=%u\n", instance,
|
||||
report_id, report_length);
|
||||
}
|
||||
|
|
@ -832,59 +837,76 @@ bool switch_pro_task(uint8_t instance) {
|
|||
return false;
|
||||
}
|
||||
|
||||
uint32_t now = to_ms_since_boot(get_absolute_time());
|
||||
bool report_sent = false;
|
||||
bool regular_report_sent = false;
|
||||
|
||||
const uint32_t now = to_ms_since_boot(get_absolute_time());
|
||||
update_switch_report_from_state(*context);
|
||||
|
||||
if (tud_suspended()) {
|
||||
tud_remote_wakeup();
|
||||
}
|
||||
|
||||
if (context->is_report_queued) {
|
||||
if ((now - context->last_report_timer) >
|
||||
SWITCH_PRO_KEEPALIVE_TIMER) {
|
||||
if (tud_hid_n_ready(instance) &&
|
||||
send_report(instance, *context, 0, context->report_buffer,
|
||||
// Busy USB is not a sent sample. Keep the overdue deadline and the
|
||||
// unconsumed sensor state so the next ready opportunity uses fresh input.
|
||||
if (!tud_hid_n_ready(instance)) {
|
||||
return false;
|
||||
}
|
||||
const bool imu_due = context->is_ready &&
|
||||
(now - context->last_imu_report_timer) >= SWITCH_PRO_IMU_REPORT_TIMER;
|
||||
const bool prefer_imu = imu_due && context->last_report_was_reply &&
|
||||
context->report_buffer[0] == REPORT_OUTPUT_21;
|
||||
if (context->is_report_queued && !prefer_imu) {
|
||||
if ((now - context->last_report_timer) > SWITCH_PRO_KEEPALIVE_TIMER) {
|
||||
// A due IMU report may have interleaved since this ACK was built.
|
||||
if (context->report_buffer[0] == REPORT_OUTPUT_21) {
|
||||
context->report_buffer[1] = context->last_report_counter;
|
||||
}
|
||||
if (send_report(instance, *context, 0, context->report_buffer,
|
||||
SWITCH_PRO_ENDPOINT_SIZE)) {
|
||||
context->is_report_queued = false;
|
||||
context->last_report_timer = now;
|
||||
context->last_report_was_reply =
|
||||
context->report_buffer[0] == REPORT_OUTPUT_21;
|
||||
}
|
||||
}
|
||||
report_sent = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context->is_ready && !report_sent) {
|
||||
if ((now - context->last_report_timer) >=
|
||||
SWITCH_PRO_IMU_REPORT_TIMER) {
|
||||
// One timer tick per 5ms IMU frame; three frames per report.
|
||||
fill_imu_report_data(*context, context->input_state, now);
|
||||
context->switch_report.timestamp += 3;
|
||||
if (tud_hid_n_ready(instance) &&
|
||||
send_report(instance, *context, 0, &context->switch_report,
|
||||
sizeof(context->switch_report))) {
|
||||
context->input_state.motion_sample_count = 0;
|
||||
regular_report_sent = true;
|
||||
}
|
||||
if (imu_due) {
|
||||
const uint32_t periods =
|
||||
(now - context->last_imu_report_timer) / SWITCH_PRO_IMU_REPORT_TIMER;
|
||||
const MotionQuaternion previous_quaternion = context->motion_quaternion;
|
||||
const uint8_t previous_timestamp = context->switch_report.timestamp;
|
||||
uint8_t previous_imu[sizeof(context->switch_report.imuData)];
|
||||
memcpy(previous_imu, context->switch_report.imuData, sizeof(previous_imu));
|
||||
fill_imu_report_data(*context, context->input_state, now);
|
||||
context->switch_report.timestamp += static_cast<uint8_t>(periods * 3);
|
||||
if (send_report(instance, *context, 0, &context->switch_report,
|
||||
sizeof(context->switch_report))) {
|
||||
context->input_state.motion_sample_count = 0;
|
||||
// Stay on the 15 ms clock across 8 ms USB polling quantization.
|
||||
// Long stalls skip obsolete periods, never replay a motion burst.
|
||||
context->last_imu_report_timer += periods * SWITCH_PRO_IMU_REPORT_TIMER;
|
||||
context->last_report_timer = now;
|
||||
context->last_report_was_reply = false;
|
||||
return true;
|
||||
}
|
||||
} else if (!context->is_initialized) {
|
||||
send_identify(*context);
|
||||
if (tud_hid_n_ready(instance)) {
|
||||
bool result = tud_hid_n_report(
|
||||
instance, 0, context->report_buffer,
|
||||
SWITCH_PRO_ENDPOINT_SIZE);
|
||||
if (result) {
|
||||
context->is_initialized = true;
|
||||
} else {
|
||||
LOG_PRINTF("[HID %u] send_report failed id=0 len=%u\n",
|
||||
instance, SWITCH_PRO_ENDPOINT_SIZE);
|
||||
}
|
||||
}
|
||||
context->last_report_timer = now;
|
||||
// Readiness can change before queuing. Failed transmission must not
|
||||
// advance quaternion integration or expose an unsent timestamp.
|
||||
context->motion_quaternion = previous_quaternion;
|
||||
context->switch_report.timestamp = previous_timestamp;
|
||||
memcpy(context->switch_report.imuData, previous_imu, sizeof(previous_imu));
|
||||
return false;
|
||||
}
|
||||
return regular_report_sent;
|
||||
|
||||
if (!context->is_initialized) {
|
||||
send_identify(*context);
|
||||
if (tud_hid_n_report(instance, 0, context->report_buffer,
|
||||
SWITCH_PRO_ENDPOINT_SIZE)) {
|
||||
context->is_initialized = true;
|
||||
context->last_report_timer = now;
|
||||
} else {
|
||||
LOG_PRINTF("[HID %u] send_report failed id=0 len=%u\n", instance,
|
||||
SWITCH_PRO_ENDPOINT_SIZE);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length,
|
||||
|
|
|
|||
|
|
@ -180,7 +180,8 @@ HAPTICS_GAMEPLAY_TIMING = {
|
|||
"lookback_us": 64000000 / 3000,
|
||||
"command_window_us": 8000,
|
||||
"watchdog_us": 50000,
|
||||
"host_gain": 1.5,
|
||||
"band_gains": {"low": 2.0, "high": 2.0},
|
||||
"response_exponent": 0.8,
|
||||
}
|
||||
HAPTICS_TRANSPORT_PROBE_UNSUPPORTED_HINT = (
|
||||
"Firmware does not support haptics transport profile operation 0x41. "
|
||||
|
|
@ -2320,8 +2321,9 @@ def _print_haptics_experiment(
|
|||
print(
|
||||
"Gameplay: continuous 3 kHz, 64 stereo frames/packet; "
|
||||
"21333.333 us lookback, 8000 us command window, "
|
||||
"50000 us host-effect watchdog; 1.5x gameplay gain, jointly "
|
||||
"headroom-limited. Silence continues without commands."
|
||||
"50000 us host-effect watchdog; balanced 2x gameplay gain with a "
|
||||
"0.8-power response curve, jointly headroom-limited. "
|
||||
"Silence continues without commands."
|
||||
)
|
||||
print(HAPTICS_GAMEPLAY_ARMING_NOTE)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -42,7 +42,11 @@ std::vector<uint8_t> render(SwitchHdRumbleSynth& synth, uint64_t first,
|
|||
}
|
||||
|
||||
double wave(double cycles, uint16_t amplitude = 32768) {
|
||||
return 95.25 * std::sin(kTau * cycles) * amplitude / 32768;
|
||||
return 127.0 * std::sin(kTau * cycles) * std::pow(amplitude / 32768.0, 0.8);
|
||||
}
|
||||
|
||||
double feedback_wave(double cycles, uint16_t amplitude = 32768) {
|
||||
return 63.5 * std::sin(kTau * cycles) * amplitude / 32768;
|
||||
}
|
||||
|
||||
template <typename Function>
|
||||
|
|
@ -110,7 +114,7 @@ void test_physical_frequency_and_channels() {
|
|||
}
|
||||
}
|
||||
expect(std::abs(peak_frequency - frequency) <= 0.5 &&
|
||||
peak_amplitude > 92 && peak_amplitude < 99,
|
||||
peak_amplitude > 123 && peak_amplitude < 132,
|
||||
"DFT peak matches physical frequency including extreme indices");
|
||||
}
|
||||
}
|
||||
|
|
@ -124,10 +128,10 @@ void test_linear_mix_headroom() {
|
|||
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; },
|
||||
expect_wave(pcm, 0, [](size_t n) { return wave(160.0 * n / 3000); },
|
||||
"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;
|
||||
return (wave(160.0 * n / 3000) + wave(320.0 * n / 3000)) / 2;
|
||||
}, "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) {
|
||||
|
|
@ -141,7 +145,15 @@ void test_linear_mix_headroom() {
|
|||
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");
|
||||
}, "joint limiting preserves the two-band amplitude ratio");
|
||||
|
||||
synth.reset(0);
|
||||
synth.push(one_side(0, state(64, 8192, 64, 4096)), 0);
|
||||
expect_wave(render(synth, 0, 150), 0, [](size_t n) {
|
||||
const double peak = 127.0 * std::pow(0.375, 0.8);
|
||||
return peak * (2 * std::sin(kTau * 160.0 * n / 3000) +
|
||||
std::sin(kTau * 320.0 * n / 3000)) / 3;
|
||||
}, "quiet-effect curve preserves band balance instead of independently boosting voices");
|
||||
|
||||
synth.reset(0);
|
||||
synth.push(one_side(0, state(127, 0, 127, 0)), 0);
|
||||
|
|
@ -235,14 +247,14 @@ void test_feedback_returns_to_live_host() {
|
|||
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;
|
||||
return feedback_wave(320.0 * n / 3000);
|
||||
}
|
||||
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;
|
||||
return n >= 16 && n < 30 ? feedback_wave(320.0 * n / 3000) : 0;
|
||||
}, "feedback overrides both sides only for its actual duration");
|
||||
|
||||
synth.reset(0);
|
||||
|
|
@ -254,10 +266,10 @@ void test_feedback_returns_to_live_host() {
|
|||
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;
|
||||
return feedback_wave(160.0 * n / 3000, static_cast<uint16_t>((128u * 32768 + 127) / 255));
|
||||
}
|
||||
const bool feedback = n >= 24 && n < 36;
|
||||
return wave((feedback ? 320.0 : 160.0) * n / 3000) / (feedback ? 1.5 : 1);
|
||||
return feedback ? feedback_wave(320.0 * n / 3000) : wave(160.0 * n / 3000);
|
||||
}, "zero magnitudes and zero duration cancel override without cancelling host");
|
||||
|
||||
synth.reset(0);
|
||||
|
|
@ -265,7 +277,7 @@ void test_feedback_returns_to_live_host() {
|
|||
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;
|
||||
return n < 240 ? feedback_wave(320.0 * n / 3000) : 0;
|
||||
}, "feedback expiry cannot resurrect an expired host effect");
|
||||
}
|
||||
|
||||
|
|
@ -301,7 +313,7 @@ void test_late_commands_and_clock_rollover() {
|
|||
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 feedback_wave(320.0 * n / 3000);
|
||||
}
|
||||
return wave(160.0 * n / 3000, n < 9 ? 32768 : 16384);
|
||||
}, "64-bit microsecond clock rollover preserves order and duration");
|
||||
|
|
@ -330,7 +342,7 @@ void test_stall_and_overflow() {
|
|||
"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,
|
||||
expect(fresh_amplitude > 124 && fresh_amplitude < 131,
|
||||
"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");
|
||||
|
|
|
|||
|
|
@ -714,6 +714,102 @@ void test_uart_parser_is_pure() {
|
|||
"failed UART parse modified its output reference");
|
||||
}
|
||||
|
||||
void test_motion_backpressure_retries_without_advancing_state() {
|
||||
for (bool rejected_transfer : {false, true}) {
|
||||
initialize_contexts();
|
||||
send_feature(0, TOGGLE_IMU, 2);
|
||||
send_feature(1, TOGGLE_IMU, 2);
|
||||
now_ms = 6;
|
||||
switch_pro_task(0);
|
||||
switch_pro_task(1);
|
||||
ControllerState moving{};
|
||||
moving.motion_sample_count = 1;
|
||||
moving.motion_samples[0] = {100, 200, 300, 20000, 0, 0};
|
||||
switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
|
||||
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
|
||||
switch_pro_set_input(1, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
|
||||
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
|
||||
const auto before = get_current_report(0, "missing pre-send report");
|
||||
hid_ready[0] = rejected_transfer;
|
||||
hid_report_succeeds[0] = !rejected_transfer;
|
||||
now_ms = 21;
|
||||
expect(!switch_pro_task(0), "blocked motion transfer was counted as sent");
|
||||
const auto blocked = get_current_report(0, "missing blocked report");
|
||||
expect(blocked.timestamp == before.timestamp &&
|
||||
std::memcmp(blocked.imuData, before.imuData, sizeof(blocked.imuData)) == 0,
|
||||
"blocked motion advanced timestamp or quaternion payload");
|
||||
hid_ready[0] = true;
|
||||
hid_report_succeeds[0] = true;
|
||||
now_ms = 22;
|
||||
expect(switch_pro_task(0), "overdue motion waited another 15 ms after USB became ready");
|
||||
expect(switch_pro_task(1), "reference motion did not send");
|
||||
const auto recovered = copy_switch_report(latest_regular_report(0));
|
||||
const auto reference = copy_switch_report(latest_regular_report(1));
|
||||
expect(recovered.timestamp == reference.timestamp &&
|
||||
std::memcmp(recovered.imuData, reference.imuData, sizeof(reference.imuData)) == 0,
|
||||
"retry integrated an unsent quaternion sample twice");
|
||||
now_ms = 23;
|
||||
expect(!switch_pro_task(0), "retry recovery emitted motion faster than its 15 ms cadence");
|
||||
}
|
||||
}
|
||||
|
||||
void test_control_replies_do_not_postpone_motion() {
|
||||
initialize_contexts();
|
||||
send_feature(0, TOGGLE_IMU, 1);
|
||||
now_ms = 6;
|
||||
switch_pro_task(0);
|
||||
ControllerState moving{};
|
||||
moving.motion_sample_count = 1;
|
||||
moving.motion_samples[0].gyro_z = 1000;
|
||||
switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
|
||||
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
|
||||
now_ms = 21;
|
||||
expect(switch_pro_task(0), "initial motion report missing");
|
||||
send_feature(0, GET_CONTROLLER_STATE, 0);
|
||||
now_ms = 35;
|
||||
expect(!switch_pro_task(0), "control response counted as motion");
|
||||
moving.motion_samples[0].gyro_z = 2000;
|
||||
switch_pro_set_input(0, moving, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD,
|
||||
SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD);
|
||||
now_ms = 36;
|
||||
expect(switch_pro_task(0), "control response postponed the independent motion deadline");
|
||||
const auto current = copy_switch_report(latest_regular_report(0));
|
||||
expect(read_int16_le(current.imuData + 10) == 2000 && current.timestamp == 6,
|
||||
"overdue motion did not use the freshest input");
|
||||
|
||||
initialize_contexts();
|
||||
now_ms = 15;
|
||||
send_feature(0, GET_CONTROLLER_STATE, 0);
|
||||
switch_pro_task(0);
|
||||
now_ms = 16;
|
||||
send_feature(0, GET_CONTROLLER_STATE, 0);
|
||||
expect(switch_pro_task(0), "successive control replies starved overdue motion");
|
||||
now_ms = 22;
|
||||
expect(!switch_pro_task(0), "pending control response counted as motion");
|
||||
expect(sent_reports[sent_report_count - 1].data[0] == REPORT_OUTPUT_21,
|
||||
"motion fairness discarded the pending control reply");
|
||||
}
|
||||
|
||||
void test_motion_cadence_survives_usb_poll_quantization() {
|
||||
initialize_contexts();
|
||||
for (now_ms = 1; now_ms <= 120; ++now_ms) {
|
||||
hid_ready[0] = now_ms % 8 == 0;
|
||||
switch_pro_task(0);
|
||||
}
|
||||
expect(reports_for_instance(0) == 8,
|
||||
"8 ms USB polling stretched the 15 ms motion clock");
|
||||
const auto report = copy_switch_report(latest_regular_report(0));
|
||||
expect(report.timestamp == 24, "motion clock did not represent 24 samples in 120 ms");
|
||||
hid_ready[0] = true;
|
||||
now_ms = 121;
|
||||
expect(!switch_pro_task(0), "motion sent an extra unscheduled sample group");
|
||||
now_ms = 300;
|
||||
expect(switch_pro_task(0), "motion did not recover after a long USB stall");
|
||||
const auto recovered = copy_switch_report(latest_regular_report(0));
|
||||
expect(recovered.timestamp == 60, "motion timer did not skip missing periods");
|
||||
expect(!switch_pro_task(0), "motion replayed a stale catch-up burst");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" absolute_time_t get_absolute_time(void) {
|
||||
|
|
@ -765,6 +861,9 @@ int main() {
|
|||
test_protocol_neutral_trigger_threshold();
|
||||
test_custom_trigger_thresholds_are_isolated();
|
||||
test_uart_parser_is_pure();
|
||||
test_motion_backpressure_retries_without_advancing_state();
|
||||
test_control_replies_do_not_postpone_motion();
|
||||
test_motion_cadence_survives_usb_poll_quantization();
|
||||
if (failures != 0) {
|
||||
std::cerr << failures << " driver context test(s) failed\n";
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -2343,7 +2343,8 @@ def test_haptics_arming_waits_for_firmware_not_usb_ack(
|
|||
"lookback_us": pytest.approx(21333.333333333),
|
||||
"command_window_us": 8000,
|
||||
"watchdog_us": 50000,
|
||||
"host_gain": 1.5,
|
||||
"band_gains": {"low": 2.0, "high": 2.0},
|
||||
"response_exponent": 0.8,
|
||||
}
|
||||
assert row["first_tone_submission_delay_us"] is None
|
||||
else:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue