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 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-12 22:27:15 -05:00
parent a79b0a5125
commit 59e3c6c35a
5 changed files with 535 additions and 0 deletions

View File

@@ -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

133
runtime/rumble_hd.c Normal file
View File

@@ -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 <math.h>
#include <string.h>
#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);
}

75
runtime/rumble_hd.h Normal file
View File

@@ -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 <stdint.h>
// 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);

201
tests/rumble_hd_check.c Normal file
View File

@@ -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 <math.h>
#include <stdio.h>
#include <string.h>
#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;
}

118
tests/rumble_hd_check.py Normal file
View File

@@ -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 <clang>]
"""
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())