From 59e3c6c35ae6b86a59af492d1e84d37908df1de8 Mon Sep 17 00:00:00 2001 From: chrg6 Date: Thu, 12 Nov 2026 22:27:15 -0500 Subject: [PATCH] Add the encoding and the report schedule for HD rumble on Switch controllers, with their check. runtime/rumble_hd.c turns one of the game's vibration values (a low and a high band, each with a frequency and an amplitude) into the four bytes a linear actuator's rumble report takes, clamps the frequencies to each band's ends and the amplitude to the last safe step, and decides when a report is written: the newest value only, a held one again every 30 ms, the neutral value three times once the game stops. Nothing calls it yet. tests/rumble_hd_check.py builds it alone, checks known values, the clamps and a batch reduced to its reports, and decodes a sweep of values with formulae of its own. Co-Authored-By: Claude Fable 5.2 --- docs/building.md | 8 ++ runtime/rumble_hd.c | 133 ++++++++++++++++++++++++++ runtime/rumble_hd.h | 75 +++++++++++++++ tests/rumble_hd_check.c | 201 +++++++++++++++++++++++++++++++++++++++ tests/rumble_hd_check.py | 118 +++++++++++++++++++++++ 5 files changed, 535 insertions(+) create mode 100644 runtime/rumble_hd.c create mode 100644 runtime/rumble_hd.h create mode 100644 tests/rumble_hd_check.c create mode 100644 tests/rumble_hd_check.py diff --git a/docs/building.md b/docs/building.md index d004534f..2b8839ae 100644 --- a/docs/building.md +++ b/docs/building.md @@ -529,6 +529,14 @@ files. A change to how a gamepad's axes are read (`runtime/input.c` `pad_axis`: an axis counts once it has been seen at rest) passes `python tests\pad_rest_check.py`, which builds that function with stand-ins for SDL. No game files. +A change to HD rumble's encoding or report schedule (`runtime/rumble_hd.c`: a vibration value as the bytes of a Switch +controller's rumble report, and which reports are written when) passes `python tests\rumble_hd_check.py`, which builds +that file alone: known values and the neutral one, the frequency and amplitude clamps (no amplitude step above the +actuators' safe maximum), a report's data for two actuators and for one, and a batch of values reduced to the reports +written (the newest value at once, a held one again every 30 ms, the neutral value three times once the game stops). +The script then decodes a sweep of encoded values with formulae of its own. No game files, no SDL, no controller: what +a controller does with the reports is not covered. + A change to how a mesh pipeline's fragment module gets the inputs no mesh output feeds (`runtime/vk_pipeline.c` `spirv_mesh_outputs`, `spirv_fs_fill_inputs`) passes `python tests\fs_fill_inputs_check.py`, which builds both functions and runs them on mesh and fragment shaders it compiles with glslang, then validates the result. No game diff --git a/runtime/rumble_hd.c b/runtime/rumble_hd.c new file mode 100644 index 00000000..247ae822 --- /dev/null +++ b/runtime/rumble_hd.c @@ -0,0 +1,133 @@ +// HD rumble for controllers with linear actuators: the encoding of a vibration value and the report schedule +// (rumble_hd.h). The byte layout and the step tables are those of the Switch controllers' rumble output report. +// +// One actuator's value is four bytes, for a high band (hf, 9 bits; its amplitude ha, 7 bits) and a low band (lf, +// 7 bits; its amplitude la, 7 bits): +// byte 0 hf bits 0-7 +// byte 1 hf bit 8 in bit 0, ha in bits 1-7 +// byte 2 lf in bits 0-6, la bit 0 in bit 7 +// byte 3 0x40 + la bits 1-6 +// Frequencies are steps of 1/32 octave above 10 Hz: n = round(32 * log2(f / 10)). The low band takes n 0x41..0xBF +// (40.9 to 626.3 Hz) as lf = n - 0x40, the high band n 0x61..0xDF (81.8 to 1252.6 Hz) as hf = (n - 0x60) * 4. +// Amplitudes are steps 0..100 on a scale that is finer where the amplitude is larger (rumble_hd_step_amp), written to +// both bands as ha = la = step. Steps above 100 exist in the format and drive the actuator past what it is built for; +// rumble_hd_amp_step never returns one. +#include +#include + +#include "rumble_hd.h" + +const uint8_t rumble_hd_neutral[RUMBLE_HD_DATA] = {0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40}; + +// The amplitude scale: step 0 is off, step 1 the smallest amplitude there is, steps 2 to 15 a quarter octave apart, +// 16 to 31 a sixteenth, 32 to 100 a thirty-second, ending at 1.0. +#define AMP_STEP1 0.007843f +#define AMP_STEP2 0.011823f + +float rumble_hd_step_amp(int step) { + if (step <= 0) return 0; + if (step == 1) return AMP_STEP1; + if (step < 16) return AMP_STEP2 * powf(2, (float)(step - 2) / 4); + if (step < 32) return powf(2, (float)step / 16) / 17; + if (step > RUMBLE_HD_AMP_STEPS) step = RUMBLE_HD_AMP_STEPS; + return powf(2, (float)step / 32) / 8.7f; +} + +static int clampi(int v, int lo, int hi) { return v < lo ? lo : v > hi ? hi : v; } + +int rumble_hd_amp_step(float a) { + if (!(a >= AMP_STEP1 / 2)) return 0; // also a NaN + if (a >= 1) return RUMBLE_HD_AMP_STEPS; + // The three ranges meet half way (on the log scale) between their neighbouring steps: 1 and 2, 15 and 16, 31 and 32. + if (a < 0.00963f) return 1; + if (a < 0.1150f) return clampi(2 + (int)lroundf(4 * log2f(a / AMP_STEP2)), 2, 15); + if (a < 0.2276f) return clampi((int)lroundf(16 * log2f(a * 17)), 16, 31); + return clampi((int)lroundf(32 * log2f(a * 8.7f)), 32, RUMBLE_HD_AMP_STEPS); +} + +// The frequency step of f Hz within lo..hi (step numbers); a NaN or a frequency at or below 0 is the lowest. +static int freq_step(float f, int lo, int hi) { + if (!(f > 0)) return lo; + float n = 32 * log2f(f / 10); + return n <= (float)lo ? lo : n >= (float)hi ? hi : clampi((int)lroundf(n), lo, hi); +} + +void rumble_hd_encode(const RumbleHdValue *v, float strength, uint8_t out[RUMBLE_HD_SIDE]) { + float s = strength > 0 ? strength : 0; + int la = rumble_hd_amp_step(v->amp_low * s), ha = rumble_hd_amp_step(v->amp_high * s); + if (!la && !ha) { memcpy(out, rumble_hd_neutral, RUMBLE_HD_SIDE); return; } + int lf = freq_step(v->freq_low, 0x41, 0xBF) - 0x40, hf = (freq_step(v->freq_high, 0x61, 0xDF) - 0x60) * 4; + out[0] = (uint8_t)(hf & 0xff); + out[1] = (uint8_t)((hf >> 8) | ha << 1); + out[2] = (uint8_t)(lf | (la & 1) << 7); + out[3] = (uint8_t)(0x40 + (la >> 1)); +} + +void rumble_hd_data(const RumbleHdValue v[2], const int live[2], float strength, int one, uint8_t out[RUMBLE_HD_DATA]) { + memcpy(out, rumble_hd_neutral, RUMBLE_HD_DATA); + float power[2] = {0, 0}; + for (int s = 0; s < 2; s++) { + if (!live[s]) continue; + rumble_hd_encode(&v[s], strength, out + s * RUMBLE_HD_SIDE); + // how strong the side is, for a controller that can play only one: the two bands' amplitudes as energy + float al = v[s].amp_low > 0 ? v[s].amp_low : 0, ah = v[s].amp_high > 0 ? v[s].amp_high : 0; + power[s] = al * al + ah * ah; + } + if (!one) return; + int pick = power[1] > power[0]; + memcpy(out + (1 - pick) * RUMBLE_HD_SIDE, out + pick * RUMBLE_HD_SIDE, RUMBLE_HD_SIDE); +} + +void rumble_hd_report(const uint8_t data[RUMBLE_HD_DATA], unsigned number, uint8_t *out, unsigned n) { + memset(out, 0, n); + out[0] = RUMBLE_HD_REPORT_ID; + out[1] = (uint8_t)(number & 0xf); + memcpy(out + 2, data, RUMBLE_HD_DATA); +} + +static int is_neutral(const uint8_t d[RUMBLE_HD_DATA]) { return !memcmp(d, rumble_hd_neutral, RUMBLE_HD_DATA); } + +// What the controller is to play at `now`: the game's data while it is fresh and the path is on, else the neutral value. +static const uint8_t *target(const RumbleHd *h, int on, uint64_t now) { + return on && h->have && now - h->want_at <= RUMBLE_HD_STALE_NS ? h->want : rumble_hd_neutral; +} + +void rumble_hd_post(RumbleHd *h, const uint8_t data[RUMBLE_HD_DATA], uint64_t now) { + memcpy(h->want, data, RUMBLE_HD_DATA); + h->want_at = now; + h->have = 1; +} + +int rumble_hd_due(const RumbleHd *h, int on, uint64_t now, uint64_t gap_ns, uint8_t out[RUMBLE_HD_DATA]) { + const uint8_t *t = target(h, on, now); + int due; + if (!h->any) due = !is_neutral(t); // a controller that got nothing yet is at rest already + else if (memcmp(t, h->sent, RUMBLE_HD_DATA)) due = now - h->sent_at >= gap_ns; + else if (is_neutral(t)) due = h->neutral_left > 0 && now - h->sent_at >= gap_ns; + else due = now - h->sent_at >= RUMBLE_HD_REFRESH_NS; + if (due) memcpy(out, t, RUMBLE_HD_DATA); + return due; +} + +void rumble_hd_sent(RumbleHd *h, const uint8_t data[RUMBLE_HD_DATA], uint64_t now) { + if (!is_neutral(data)) h->neutral_left = 0; + else if (!h->any || !is_neutral(h->sent)) h->neutral_left = RUMBLE_HD_NEUTRAL_REPORTS - 1; + else if (h->neutral_left > 0) h->neutral_left--; + memcpy(h->sent, data, RUMBLE_HD_DATA); + h->sent_at = now; + h->any = 1; +} + +int rumble_hd_idle(const RumbleHd *h, int on, uint64_t now) { + if (!is_neutral(target(h, on, now))) return 0; + return !h->any || (is_neutral(h->sent) && h->neutral_left == 0); +} + +int rumble_hd_buzzing(const RumbleHd *h) { return h->any && !is_neutral(h->sent); } + +void rumble_hd_forget(RumbleHd *h) { + h->any = 0; + h->neutral_left = 0; + h->sent_at = 0; + memset(h->sent, 0, sizeof h->sent); +} diff --git a/runtime/rumble_hd.h b/runtime/rumble_hd.h new file mode 100644 index 00000000..4f537cbb --- /dev/null +++ b/runtime/rumble_hd.h @@ -0,0 +1,75 @@ +// HD rumble: the game's vibration values for a controller with linear actuators (Switch Pro Controller, Joy-Con), as +// the bytes of the controller's rumble output report, and the decision when a report is written. Such an actuator takes +// a frequency and an amplitude for a low and a high band, which is what the game sends (ov_input.c VibrationValue), so +// nothing is reduced to motor strengths here. Pure: no SDL and no platform calls; input.c (hd_tick) writes what this +// file decides. Checked by tests/rumble_hd_check.py. +#pragma once +#include + +// One actuator's value as the game sends it: amplitudes 0..1, frequencies in Hz. +typedef struct { float amp_low, freq_low, amp_high, freq_high; } RumbleHdValue; + +enum { + RUMBLE_HD_SIDE = 4, // bytes of one actuator's value in a report + RUMBLE_HD_DATA = 8, // ... and of a report's two: the left actuator's, then the right one's + RUMBLE_HD_REPORT_ID = 0x10, // the output report that carries nothing but rumble data + RUMBLE_HD_REPORT = 10, // its bytes that count: the id, the report's number, the data + RUMBLE_HD_AMP_STEPS = 100, // the highest amplitude step written (amplitude 1.0): the steps above it overdrive the actuator + RUMBLE_HD_NEUTRAL_REPORTS = 3, // how many times in a row the neutral value is written when the vibration ends +}; +// The frequencies an actuator takes: the low band's and the high band's ends, in Hz. Others are clamped to them. +#define RUMBLE_HD_LOW_MIN 40.875885f +#define RUMBLE_HD_LOW_MAX 626.286133f +#define RUMBLE_HD_HIGH_MIN 81.75177f +#define RUMBLE_HD_HIGH_MAX 1252.572266f +#define RUMBLE_HD_STALE_NS 250000000ull // values the game has not renewed for this long count as neutral +#define RUMBLE_HD_REFRESH_NS 30000000ull // a value that still holds is written again this often +#define RUMBLE_HD_GAP_NS 15000000ull // the default time between two reports (rumble_hd_due's gap) + +// The value of an actuator at rest (no amplitude, 160 Hz and 320 Hz), twice. +extern const uint8_t rumble_hd_neutral[RUMBLE_HD_DATA]; + +// The amplitude step (0..RUMBLE_HD_AMP_STEPS) nearest to a, and that step's amplitude. A NaN or a value below half of +// the first step is step 0; 1 and everything above it is the last step. +int rumble_hd_amp_step(float a); +float rumble_hd_step_amp(int step); + +// One actuator's value as its four report bytes. strength scales both amplitudes (1 = as the game sent them) before +// they are clamped to 0..1. With both amplitudes at step 0 the bytes are the neutral value's, whatever the frequencies. +void rumble_hd_encode(const RumbleHdValue *v, float strength, uint8_t out[RUMBLE_HD_SIDE]); + +// A report's data from the game's newest value per side (v[0] left, v[1] right). live[s] 0: that side is at rest +// (never sent, stale, or vibration not permitted). one: the controller has a single actuator (one Joy-Con): the +// stronger side's value goes into both halves. +void rumble_hd_data(const RumbleHdValue v[2], const int live[2], float strength, int one, uint8_t out[RUMBLE_HD_DATA]); + +// The output report: id, number (0..15, going round), data, zeros up to n bytes (n >= RUMBLE_HD_REPORT). +void rumble_hd_report(const uint8_t data[RUMBLE_HD_DATA], unsigned number, uint8_t *out, unsigned n); + +// What one controller is to get and what it got. All zero at the start. +typedef struct { + uint8_t want[RUMBLE_HD_DATA]; // the newest data from the game (rumble_hd_post) ... + uint64_t want_at; // ... and when it came + int have; // ... once there is one + uint8_t sent[RUMBLE_HD_DATA]; // the data of the last report written (rumble_hd_sent) ... + uint64_t sent_at; // ... and when + int any; // ... once there is one + int neutral_left; // neutral reports still to write after the last one +} RumbleHd; + +// The game's newest data: it replaces what was not written yet (nothing is queued). +void rumble_hd_post(RumbleHd *h, const uint8_t data[RUMBLE_HD_DATA], uint64_t now); +// 1 when a report is to be written at `now`, with its data in out. on 0: the controller gets the neutral value whatever +// the game sent (the setting is off, the process ends). A changed value waits until gap_ns have passed since the last +// report; a value that still holds is written again every RUMBLE_HD_REFRESH_NS, since the actuators stop by themselves +// when no report comes; data older than RUMBLE_HD_STALE_NS is neutral; the neutral value is written +// RUMBLE_HD_NEUTRAL_REPORTS times, gap_ns apart, and then nothing until the game posts something else. +int rumble_hd_due(const RumbleHd *h, int on, uint64_t now, uint64_t gap_ns, uint8_t out[RUMBLE_HD_DATA]); +// The report with data was written at `now`. +void rumble_hd_sent(RumbleHd *h, const uint8_t data[RUMBLE_HD_DATA], uint64_t now); +// 1 when nothing is to be written until the next rumble_hd_post (or until `on` changes). +int rumble_hd_idle(const RumbleHd *h, int on, uint64_t now); +// 1 while the last report written was not the neutral value: the actuators may still be running. +int rumble_hd_buzzing(const RumbleHd *h); +// Another controller takes the reports: what was written no longer counts, the game's newest data stays. +void rumble_hd_forget(RumbleHd *h); diff --git a/tests/rumble_hd_check.c b/tests/rumble_hd_check.c new file mode 100644 index 00000000..0f198a92 --- /dev/null +++ b/tests/rumble_hd_check.c @@ -0,0 +1,201 @@ +// runtime/rumble_hd.c: a vibration value as the bytes of a linear actuator's rumble report (known values, the clamps, +// the neutral value), a report's data from the two sides, and which reports are written when the game sends a batch. +// Built and run by tests/rumble_hd_check.py; prints each failed check and exits 1 on any. With --sweep it prints +// values and their bytes, one per line, for the script to decode with formulae of its own. +#include +#include +#include + +#include "rumble_hd.h" + +static int fails; +#define CHECK(c, ...) do { if (!(c)) { fails++; printf("FAIL line %d: ", __LINE__); printf(__VA_ARGS__); printf("\n"); } } while (0) + +static int enc_is(float al, float fl, float ah, float fh, float strength, uint8_t b0, uint8_t b1, uint8_t b2, uint8_t b3, + uint8_t got[4]) { + RumbleHdValue v = {al, fl, ah, fh}; + rumble_hd_encode(&v, strength, got); + return got[0] == b0 && got[1] == b1 && got[2] == b2 && got[3] == b3; +} +#define ENC(al, fl, ah, fh, st, b0, b1, b2, b3) do { uint8_t g_[4]; \ + CHECK(enc_is(al, fl, ah, fh, st, b0, b1, b2, b3, g_), "%g@%g Hz %g@%g Hz x%g gave %02x %02x %02x %02x, expected %02x %02x %02x %02x", \ + (double)(al), (double)(fl), (double)(ah), (double)(fh), (double)(st), g_[0], g_[1], g_[2], g_[3], b0, b1, b2, b3); } while (0) + +static void sweep(void) { + static const float amps[] = {0, 0.001f, 0.0038f, 0.004f, 0.006f, 0.0078f, 0.0095f, 0.0098f, 0.012f, 0.02f, 0.05f, 0.09f, + 0.113f, 0.116f, 0.15f, 0.2f, 0.226f, 0.229f, 0.25f, 0.3f, 0.4f, 0.5f, 0.66f, 0.8f, 0.9f, + 0.99f, 1, 1.3f, 4}; + for (size_t a = 0; a < sizeof amps / sizeof *amps; a++) + for (size_t b = 0; b < sizeof amps / sizeof *amps; b += 3) + for (float f = 20; f < 3000; f *= 1.0717f) { + RumbleHdValue v = {amps[a], f, amps[b], f * 1.9f}; + uint8_t o[4]; + rumble_hd_encode(&v, 1, o); + printf("%.9g %.9g %.9g %.9g %u %u %u %u\n", (double)v.amp_low, (double)v.freq_low, (double)v.amp_high, + (double)v.freq_high, o[0], o[1], o[2], o[3]); + } +} + +#define MS 1000000ull + +int main(int argc, char **argv) { + if (argc > 1 && !strcmp(argv[1], "--sweep")) { sweep(); return 0; } + + // ---- known values + // The neutral value, as the game sends it and as the controller expects it. + ENC(0, 160, 0, 320, 1, 0x00, 0x01, 0x40, 0x40); + CHECK(!memcmp(rumble_hd_neutral, "\x00\x01\x40\x40\x00\x01\x40\x40", 8), "the neutral data is not 00 01 40 40 twice"); + // Both bands at 150 Hz and amplitude 0.9: frequency step 125 (low 0x3d, high 0x74), amplitude step 95. + ENC(0.9f, 150, 0.9f, 150, 1, 0x74, 0xbe, 0xbd, 0x6f); + // Full amplitude at the neutral frequencies: amplitude step 100, the last one. + ENC(1, 160, 1, 320, 1, 0x00, 0xc9, 0x40, 0x72); + // Amplitude 0.5 is step 68; one band alone leaves the other at step 0. + ENC(0.5f, 160, 0, 320, 1, 0x00, 0x01, 0x40, 0x62); + ENC(0, 160, 0.5f, 320, 1, 0x00, 0x89, 0x40, 0x40); + // An odd amplitude step puts its low bit on top of the low frequency byte: 0.245 is step 35. + ENC(0.245f, 160, 0.245f, 320, 1, 0x00, 0x47, 0xc0, 0x51); + + // ---- the clamps + // Frequencies outside a band go to its ends: low 1..0x7f, high 4..0x1fc. + ENC(0.5f, 10, 0.5f, 5000, 1, 0xfc, 0x89, 0x01, 0x62); + ENC(0.5f, 5000, 0.5f, 10, 1, 0x04, 0x88, 0x7f, 0x62); + ENC(0.5f, RUMBLE_HD_LOW_MIN, 0.5f, RUMBLE_HD_HIGH_MIN, 1, 0x04, 0x88, 0x01, 0x62); + ENC(0.5f, RUMBLE_HD_LOW_MAX, 0.5f, RUMBLE_HD_HIGH_MAX, 1, 0xfc, 0x89, 0x7f, 0x62); + ENC(0.5f, 0, 0.5f, -3, 1, 0x04, 0x88, 0x01, 0x62); + ENC(0.5f, NAN, 0.5f, NAN, 1, 0x04, 0x88, 0x01, 0x62); + // Amplitudes above 1, by themselves or through the strength, stop at step 100; the strength scales below it. + ENC(7, 160, 7, 320, 1, 0x00, 0xc9, 0x40, 0x72); + ENC(0.5f, 160, 0.5f, 320, 3, 0x00, 0xc9, 0x40, 0x72); + ENC(1, 160, 1, 320, 0.5f, 0x00, 0x89, 0x40, 0x62); + ENC(INFINITY, 160, INFINITY, 320, 1, 0x00, 0xc9, 0x40, 0x72); + // No amplitude is the neutral value whatever the frequencies say: negative, NaN, too small, strength 0. + ENC(0, 55, 0, 1000, 1, 0x00, 0x01, 0x40, 0x40); + ENC(-1, 55, -0.5f, 1000, 1, 0x00, 0x01, 0x40, 0x40); + ENC(NAN, 55, NAN, 1000, 1, 0x00, 0x01, 0x40, 0x40); + ENC(0.003f, 55, 0.001f, 1000, 1, 0x00, 0x01, 0x40, 0x40); + ENC(1, 55, 1, 1000, 0, 0x00, 0x01, 0x40, 0x40); + ENC(1, 55, 1, 1000, -2, 0x00, 0x01, 0x40, 0x40); + + // ---- the amplitude scale + static const struct { int step; float amp; } table[] = {{0, 0}, {1, 0.007843f}, {2, 0.011823f}, {6, 0.023648f}, + {15, 0.112491f}, {16, 0.117471f}, {31, 0.224982f}, {32, 0.229904f}, {64, 0.459808f}, {100, 1.002867f}}; + for (size_t i = 0; i < sizeof table / sizeof *table; i++) { + float a = rumble_hd_step_amp(table[i].step); + CHECK(fabsf(a - table[i].amp) <= table[i].amp * 0.003f, "step %d is %f, the table says %f", table[i].step, (double)a, + (double)table[i].amp); + } + for (int k = 0; k <= RUMBLE_HD_AMP_STEPS; k++) { + CHECK(rumble_hd_amp_step(rumble_hd_step_amp(k)) == k, "step %d's amplitude %f gives step %d", k, + (double)rumble_hd_step_amp(k), rumble_hd_amp_step(rumble_hd_step_amp(k))); + if (k) CHECK(rumble_hd_step_amp(k) > rumble_hd_step_amp(k - 1), "step %d is not above step %d", k, k - 1); + } + int top = 0; + for (float a = -1; a < 20; a += 0.0007f) { + int k = rumble_hd_amp_step(a); + if (k > top) top = k; + CHECK(k >= 0 && k <= RUMBLE_HD_AMP_STEPS, "amplitude %f gives step %d", (double)a, k); + } + CHECK(top == RUMBLE_HD_AMP_STEPS && RUMBLE_HD_AMP_STEPS == 100, "the highest step reached is %d", top); + + // ---- a report's data from the two sides + RumbleHdValue v[2] = {{0.5f, 160, 0, 320}, {0, 160, 0.9f, 150}}; + int both[2] = {1, 1}, left[2] = {1, 0}, none[2] = {0, 0}; + uint8_t d[8]; + rumble_hd_data(v, both, 1, 0, d); + CHECK(!memcmp(d, "\x00\x01\x40\x62\x74\xbe\x40\x40", 8), "two sides: %02x %02x %02x %02x | %02x %02x %02x %02x", d[0], d[1], + d[2], d[3], d[4], d[5], d[6], d[7]); + rumble_hd_data(v, left, 1, 0, d); + CHECK(!memcmp(d, "\x00\x01\x40\x62\x00\x01\x40\x40", 8), "a side at rest is not neutral"); + rumble_hd_data(v, none, 1, 0, d); + CHECK(!memcmp(d, rumble_hd_neutral, 8), "both sides at rest are not neutral"); + rumble_hd_data(v, both, 1, 1, d); // one actuator: the stronger side (the right one, 0.9) in both halves + CHECK(!memcmp(d, "\x74\xbe\x40\x40\x74\xbe\x40\x40", 8), "one actuator: %02x %02x %02x %02x | %02x %02x %02x %02x", d[0], + d[1], d[2], d[3], d[4], d[5], d[6], d[7]); + rumble_hd_data(v, left, 1, 1, d); // ... and the side that is not at rest when the stronger one is + CHECK(!memcmp(d, "\x00\x01\x40\x62\x00\x01\x40\x62", 8), "one actuator, right side at rest"); + rumble_hd_data(v, both, 0, 0, d); + CHECK(!memcmp(d, rumble_hd_neutral, 8), "strength 0 is not neutral"); + + // ---- the output report + uint8_t r[49]; + memset(r, 0xee, sizeof r); + rumble_hd_report((const uint8_t *)"\x74\xbe\xbd\x6f\x00\x01\x40\x40", 0x25, r, sizeof r); + CHECK(r[0] == 0x10 && r[1] == 0x05 && !memcmp(r + 2, "\x74\xbe\xbd\x6f\x00\x01\x40\x40", 8), "report head %02x %02x", r[0], r[1]); + for (int i = 10; i < 49; i++) CHECK(r[i] == 0, "report byte %d is %02x", i, r[i]); + + // ---- from a batch to the reports written: a tick every 5 ms, 15 ms between reports + static const uint8_t A[8] = {1, 2, 3, 4, 5, 6, 7, 8}, B[8] = {9, 9, 9, 9, 9, 9, 9, 9}, C[8] = {3, 3, 3, 3, 3, 3, 3, 3}, + D[8] = {4, 4, 4, 4, 4, 4, 4, 4}, E[8] = {5, 5, 5, 5, 5, 5, 5, 5}; + RumbleHd h = {0}; + uint8_t o[8]; + uint64_t t0 = 1000 * MS; + CHECK(!rumble_hd_due(&h, 1, t0, RUMBLE_HD_GAP_NS, o) && rumble_hd_idle(&h, 1, t0), "a report is due before the game sent anything"); + rumble_hd_post(&h, rumble_hd_neutral, t0); + CHECK(!rumble_hd_due(&h, 1, t0, RUMBLE_HD_GAP_NS, o) && rumble_hd_idle(&h, 1, t0), "the neutral value is written to a controller at rest"); + // three values in one batch: the newest is written, at once + rumble_hd_post(&h, A, t0); + rumble_hd_post(&h, B, t0); + rumble_hd_post(&h, C, t0 + MS); + struct { uint64_t at; uint8_t d[8]; } rep[64]; + int n = 0; + for (uint64_t t = t0 + MS; t <= t0 + 400 * MS; t += 5 * MS) { + if (t == t0 + 6 * MS) rumble_hd_post(&h, D, t); // 5 ms after the first report: too soon to write + if (t == t0 + 11 * MS) rumble_hd_post(&h, E, t); // replaces D before D was written + if (rumble_hd_due(&h, 1, t, RUMBLE_HD_GAP_NS, o) && n < 64) { + rep[n].at = t - t0; + memcpy(rep[n++].d, o, 8); + rumble_hd_sent(&h, o, t); + } + } + CHECK(n >= 2 && rep[0].at == MS && !memcmp(rep[0].d, C, 8), "the first report is not the batch's newest value, at once"); + CHECK(n >= 2 && rep[1].at == 16 * MS && !memcmp(rep[1].d, E, 8), "the second report is not the newest value 15 ms later (at %llu ms)", + n >= 2 ? (unsigned long long)(rep[1].at / MS) : 0ull); + int held = 0, neutral = 0; + uint64_t last_e = 0, first_neutral = 0, last_neutral = 0; + for (int i = 0; i < n; i++) { + CHECK(memcmp(rep[i].d, A, 8) && memcmp(rep[i].d, B, 8) && memcmp(rep[i].d, D, 8), "a replaced value was written at %llu ms", + (unsigned long long)(rep[i].at / MS)); + if (i) CHECK(rep[i].at - rep[i - 1].at >= RUMBLE_HD_GAP_NS, "two reports %llu ms apart", (unsigned long long)((rep[i].at - rep[i - 1].at) / MS)); + if (!memcmp(rep[i].d, E, 8)) { + if (last_e) CHECK(rep[i].at - last_e == RUMBLE_HD_REFRESH_NS, "a held value was written again after %llu ms", + (unsigned long long)((rep[i].at - last_e) / MS)); + last_e = rep[i].at, held++; + CHECK(!neutral, "a value after the neutral one"); + } + if (!memcmp(rep[i].d, rumble_hd_neutral, 8)) { + if (!neutral++) first_neutral = rep[i].at; + last_neutral = rep[i].at; + } + } + // E came at 11 ms and is stale 250 ms later: written at 16 ms and every 30 ms until then + CHECK(held == 9, "the held value was written %d times", held); + CHECK(neutral == RUMBLE_HD_NEUTRAL_REPORTS, "the neutral value was written %d times", neutral); + CHECK(first_neutral > 261 * MS && first_neutral <= 261 * MS + RUMBLE_HD_GAP_NS, "the neutral value came %llu ms in", + (unsigned long long)(first_neutral / MS)); + CHECK(last_neutral - first_neutral == 2 * RUMBLE_HD_GAP_NS, "the neutral reports are not 15 ms apart"); + CHECK(rumble_hd_idle(&h, 1, t0 + 400 * MS) && !rumble_hd_buzzing(&h), "not idle after the neutral reports"); + + // the path turned off while the game sends: the neutral value, whatever the game's data + RumbleHd g = {0}; + rumble_hd_post(&g, A, t0); + CHECK(rumble_hd_due(&g, 1, t0, RUMBLE_HD_GAP_NS, o) && !memcmp(o, A, 8), "a first value is not due at once"); + rumble_hd_sent(&g, o, t0); + CHECK(!rumble_hd_idle(&g, 1, t0 + MS) && rumble_hd_buzzing(&g), "idle while a value is held"); + CHECK(!rumble_hd_due(&g, 0, t0 + 5 * MS, RUMBLE_HD_GAP_NS, o), "off: a report inside the gap"); + CHECK(rumble_hd_due(&g, 0, t0 + 15 * MS, RUMBLE_HD_GAP_NS, o) && !memcmp(o, rumble_hd_neutral, 8), "off: no neutral report"); + CHECK(rumble_hd_due(&g, 0, t0 + 6 * MS, 5 * MS, o) && !memcmp(o, rumble_hd_neutral, 8), "a 5 ms gap is not honoured"); + rumble_hd_sent(&g, o, t0 + 15 * MS); + CHECK(!rumble_hd_buzzing(&g) && !rumble_hd_idle(&g, 0, t0 + 16 * MS), "idle before the neutral value was repeated"); + // back on with the game's data still fresh: it is written again + CHECK(rumble_hd_due(&g, 1, t0 + 30 * MS, RUMBLE_HD_GAP_NS, o) && !memcmp(o, A, 8), "on again: the game's value is not due"); + rumble_hd_sent(&g, o, t0 + 30 * MS); + // another controller: what was written is forgotten, the game's value is due at once + rumble_hd_forget(&g); + CHECK(!rumble_hd_buzzing(&g), "buzzing after forget"); + CHECK(rumble_hd_due(&g, 1, t0 + 31 * MS, RUMBLE_HD_GAP_NS, o) && !memcmp(o, A, 8), "after forget: the value is not due at once"); + CHECK(!rumble_hd_due(&g, 1, t0 + 300 * MS, RUMBLE_HD_GAP_NS, o) && rumble_hd_idle(&g, 1, t0 + 300 * MS), + "after forget: a stale value is written to a controller that got nothing"); + + printf(fails ? "rumble hd: %d failures\n" : "rumble hd: ok\n", fails); + return fails != 0; +} diff --git a/tests/rumble_hd_check.py b/tests/rumble_hd_check.py new file mode 100644 index 00000000..0cf182b4 --- /dev/null +++ b/tests/rumble_hd_check.py @@ -0,0 +1,118 @@ +"""HD rumble for controllers with linear actuators (runtime/rumble_hd.c): builds it with tests/rumble_hd_check.c and +checks + + - known values: the neutral value, full amplitude, single bands, an odd amplitude step; + - the clamps: frequencies to each band's ends, amplitudes to 0..1 with the last step at 100, no amplitude as the + neutral value; + - a report's data from the two sides, for two actuators and for one, and the output report's layout; + - from a batch to the reports written: the newest value at once, replaced values never, a held value again every + 30 ms, the neutral value three times once the game stops, then nothing. + +Then it decodes a sweep of encoded values with formulae of its own (this script's, not the C file's): each frequency +comes back within half a step of the clamped input, each amplitude as the step nearest to it, and no amplitude step is +above 100. + +No game files, no SDL, no controller. Usage: python tests/rumble_hd_check.py [--cc ] +""" +import argparse +import math +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MSYS_BIN = Path(r'C:\msys64\ucrt64\bin') + +LOW = (40.875885, 626.286133) # Hz, the low band's ends +HIGH = (81.75177, 1252.572266) # ... and the high band's + + +def step_amp(k): + """The amplitude of step k, from the published table's rule: step 1 alone, then quarter, sixteenth and + thirty-second octaves.""" + if k == 0: + return 0.0 + if k == 1: + return 0.007843 + if k < 16: + return 0.011823 * 2 ** ((k - 2) / 4) + if k < 32: + return 2 ** (k / 16) / 17 + return 2 ** (k / 32) / 8.7 + + +def decode(b): + """Four report bytes to (amp_low step, freq_low Hz, amp_high step, freq_high Hz).""" + hf = b[0] | (b[1] & 1) << 8 + ha = b[1] >> 1 + lf = b[2] & 0x7f + la = (b[3] - 0x40) * 2 + (b[2] >> 7) + return la, 10 * 2 ** ((lf + 0x40) / 32), ha, 10 * 2 ** ((hf / 4 + 0x60) / 32) + + +def nearest_step(a): + if not a >= 0.007843 / 2: + return 0 + a = min(a, 1.0) + return min(range(1, 101), key=lambda k: abs(math.log(a / step_amp(k)))) + + +def check_sweep(lines): + fails = 0 + half = 2 ** (1 / 64) * 1.0005 # half a frequency step, and the rounding of a float + for line in lines: + f = line.split() + al, fl, ah, fh = (float(x) for x in f[:4]) + b = [int(x) for x in f[4:]] + la, dfl, ha, dfh = decode(b) + what = f'{al}@{fl} Hz {ah}@{fh} Hz -> {" ".join("%02x" % x for x in b)}' + if not (0 <= la <= 100 and 0 <= ha <= 100 and 0x40 <= b[3] <= 0x72): + print(f'FAIL {what}: amplitude steps {la}, {ha} outside 0..100') + fails += 1 + continue + for name, a, got in (('low', al, la), ('high', ah, ha)): + want = nearest_step(a) + # at a boundary between two steps either neighbour is the nearest + if got != want and (got == 0 or want == 0 or + abs(abs(math.log(min(a, 1.0) / step_amp(got))) - + abs(math.log(min(a, 1.0) / step_amp(want)))) > 2e-3): + print(f'FAIL {what}: {name} amplitude is step {got}, the nearest is {want}') + fails += 1 + if la == 0 and ha == 0: + if b != [0x00, 0x01, 0x40, 0x40]: + print(f'FAIL {what}: no amplitude is not the neutral value') + fails += 1 + continue + for name, hz, got, (lo, hi) in (('low', fl, dfl, LOW), ('high', fh, dfh, HIGH)): + want = min(max(hz, lo), hi) + if not want / half <= got <= want * half: + print(f'FAIL {what}: {name} frequency decodes to {got:.2f} Hz, expected {want:.2f}') + fails += 1 + return fails + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--cc', help='the C compiler (default: MSYS2 UCRT64 clang on Windows, clang elsewhere)') + args = ap.parse_args() + win = os.name == 'nt' + cc = args.cc or os.environ.get('CC') or (str(MSYS_BIN / 'clang.exe') if win and MSYS_BIN.exists() else 'clang') + env = dict(os.environ) + if win and MSYS_BIN.exists(): + env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '') + work = ROOT / 'build' / 'test' / 'rumble_hd' + work.mkdir(parents=True, exist_ok=True) + exe = work / ('rumble_hd_check.exe' if win else 'rumble_hd_check') + subprocess.check_call([cc, '-O1', '-Wall', '-Wextra', '-fno-strict-aliasing', '-I', str(ROOT / 'runtime'), + str(ROOT / 'tests' / 'rumble_hd_check.c'), str(ROOT / 'runtime' / 'rumble_hd.c'), + '-o', str(exe)] + ([] if win else ['-lm']), env=env) + rc = subprocess.run([str(exe)], env=env).returncode + out = subprocess.run([str(exe), '--sweep'], env=env, capture_output=True, text=True, check=True).stdout.splitlines() + fails = check_sweep(out) + print(f'rumble hd sweep: {len(out)} values decoded, ' + (f'{fails} failures' if fails else 'ok')) + return 1 if rc or fails or not out else 0 + + +if __name__ == '__main__': + sys.exit(main())