Files
SpeedBreakerProject--speedb…/tests/wheel_map_test.cpp
T
2026-10-22 14:02:14 +00:00

881 lines
52 KiB
C++

// Tests for hid/wheel_map.h: a USB steering wheel as the Xbox 360 pad. Which joystick is a wheel,
// which of its axes is the wheel and which are its pedals, the steering curve (range, deadzone and
// response), a pedal from where it rests to as far as it is pressed, the buttons and the paddles,
// NFSMW_WHEEL_BUTTONS, and the force-feedback levels. Header only; from the repo root:
// clang++ -std=c++20 -O2 -Iruntime tests/wheel_map_test.cpp -o build/wheel_map_test && build/wheel_map_test
#include <hid/wheel_map.h>
#include <array>
#include <cmath>
#include <cstdio>
#include <initializer_list>
#include <iterator>
#include <random>
#include <set>
#include <string>
#include <vector>
static int g_failures = 0;
#define CHECK(cond, ...) do { if (!(cond)) { g_failures++; printf(" FAIL %s:%d: %s: ", __FILE__, __LINE__, #cond); printf(__VA_ARGS__); printf("\n"); } } while (0)
using namespace hid::wheelmap;
// Steer works on the turn as a fraction of full lock; these cases are
// written as raw axis readings, as the device gives them.
static float SteerRaw(int16_t raw, int range, int full, float deadzone, float response)
{
return Steer(float(raw) / float(kAxisMax), range, full, deadzone, response);
}
static bool Near(float a, float b, float slack = 0.001f)
{
return std::fabs(a - b) <= slack;
}
// A G29's axes as they rest, measured on the hardware: the wheel centred on
// axis 0, and its three pedals held at +32767 by their springs.
static constexpr int16_t kG29Rest[] = { 0, 32767, 32767, 32767 };
// Resolve takes a contiguous run of bools (std::vector<bool> is not one).
template <size_t N>
static std::array<bool, N> Held(std::initializer_list<int> which)
{
std::array<bool, N> held{};
for (int b : which)
if (b >= 0 && size_t(b) < N)
held[size_t(b)] = true;
return held;
}
// Settings > Wheel > Calibrate, frame by frame: a wheel's axes, buttons and hat as SDL reads them, fed to the
// Calibration as wheel.cpp feeds it. A G29 by default: the wheel on axis 0, the clutch 1, the throttle 2, the brake 3,
// pedals resting at +32767; Cross (0) is A and Circle (2) is B.
struct Rig
{
Calibration cal;
std::vector<int16_t> axes{ 0, 32767, 32767, 32767 };
std::vector<bool> seen = std::vector<bool>(4, true);
std::array<bool, 25> buttons{};
uint8_t hat = hat::Centred;
int a = 0, b = 2;
Calibration::Event last = Calibration::Event::None;
void Start(int steerAxis = 0) { cal.Start(axes, seen, buttons, hat, steerAxis); }
Calibration::Event Frame()
{
last = cal.Update(axes, seen, buttons, hat, a, b);
return last;
}
// An axis moved to `to` over a few frames (each one a frame the calibration sees).
Calibration::Event Move(int axis, int to, int frames = 6)
{
const int from = axes[size_t(axis)];
Calibration::Event e = Calibration::Event::None;
for (int f = 1; f <= frames && e == Calibration::Event::None; f++)
{
axes[size_t(axis)] = int16_t(from + (to - from) * f / frames);
e = Frame();
}
return e;
}
Calibration::Event Down(int button) { buttons[size_t(button)] = true; return Frame(); }
Calibration::Event Up(int button) { buttons[size_t(button)] = false; return Frame(); }
Calibration::Event Tap(int button) { const auto e = Down(button); const auto f = Up(button); return e != Calibration::Event::None ? e : f; }
Calibration::Event Hat(uint8_t bits) { hat = bits; const auto e = Frame(); hat = hat::Centred; Frame(); return e; }
};
int main()
{
// The table: one fallback, last, and every other model a wheel we name,
// with its own USB ids, an axis to steer with and buttons.
CHECK(kModelCount >= 2, "%zu models", kModelCount);
std::set<uint32_t> ids;
for (size_t i = 0; i < kModelCount; i++)
{
const Model& m = kModels[i];
const bool last = i == kModelCount - 1;
CHECK((m.vendor == 0 && m.product == 0) == last, "model %zu: only the last is the fallback", i);
CHECK(m.name != nullptr && m.axes.steer >= 0 && !m.buttons.empty(), "model %zu: named, steers, has buttons", i);
// Where its pedals rest: an end of the axis, or 0 for not known.
CHECK((m.pedalRest != m.pedalFull) == (m.vendor != 0),
"model %zu: the wheels we know give both ends of their pedals, the fallback neither", i);
if (m.pedalRest != m.pedalFull)
CHECK((m.pedalRest > 0) != (m.pedalFull > 0), "model %zu: its pedals run from one end of the axis to the other", i);
CHECK(m.fullTurnDegrees >= 0 && m.fullTurnDegrees <= 1080, "model %zu: %d degrees", i, m.fullTurnDegrees);
if (!last)
CHECK(ids.insert(uint32_t(m.vendor) << 16 | m.product).second, "model %zu: its own USB ids", i);
// A pedal is never the wheel's own axis, and no two roles share one.
const int axes[] = { m.axes.steer, m.axes.throttle, m.axes.brake, m.axes.clutch };
for (size_t a = 0; a < 4; a++)
for (size_t b = a + 1; b < 4; b++)
CHECK(axes[a] < 0 || axes[a] != axes[b], "model %zu: axis %d is one role", i, axes[a]);
}
// The G29 is the wheel this was written for, and the one its numbers
// were measured on: the wheel is axis 0, the pedals are throttle 2,
// brake 3 and clutch 1 (not the order they stand in), they rest at
// +32767, it turns 900 degrees, and its buttons are the Logitech set.
for (uint16_t product : { 0xC24F, 0xC260 })
{
const Model& g29 = ModelFor(0x046D, product);
CHECK(g29.vendor == 0x046D && g29.product == product, "the G29 (%04x) is known", product);
CHECK(g29.axes.steer == 0 && g29.axes.throttle == 2 && g29.axes.brake == 3 && g29.axes.clutch == 1,
"the G29's measured axes (%d, %d, %d, %d)", g29.axes.steer, g29.axes.throttle, g29.axes.brake, g29.axes.clutch);
CHECK(g29.pedalRest == kAxisMax && g29.pedalFull == -32768, "the G29's pedals run +32767 to -32768");
CHECK(g29.fullTurnDegrees == 900, "the G29 turns 900 degrees");
CHECK(g29.buttons.data() == kLogitechButtons, "the G29 has the Logitech buttons");
}
// The models SDL 3.4.16's own Logitech driver reads (SDL_hidapi_lg4ff.c: HandleState sends the wheel as
// axis 0 and each pedal byte, 255 resting, as byte * 257 - 32768 on the axis numbers below; OpenJoystick
// gives the G29, G27 and G25 four axes and the Driving Force GT and Pro three). On a Mac this is the
// layout these wheels arrive in, so a model has to match it: before any axis reports, its axes are used as
// they are, and Detect only keeps them if they rest where pedals do.
struct SdlLayout { uint16_t product; int axes, gas, brake, clutch; };
for (const SdlLayout& d : { SdlLayout{ 0xC24F, 4, 2, 3, 1 }, SdlLayout{ 0xC29B, 4, 2, 3, 1 }, SdlLayout{ 0xC299, 4, 2, 3, 1 },
SdlLayout{ 0xC29A, 3, 1, 2, -1 }, SdlLayout{ 0xC298, 3, 1, 2, -1 } })
{
const Model& m = ModelFor(0x046D, d.product);
CHECK(&m != &Fallback(), "%04x is a wheel we know", d.product);
CHECK(m.axes.steer == 0 && m.axes.throttle == d.gas && m.axes.brake == d.brake && m.axes.clutch == d.clutch,
"%04x: the model's axes (%d, %d, %d, %d) are SDL's (0, %d, %d, %d)", d.product, m.axes.steer, m.axes.throttle,
m.axes.brake, m.axes.clutch, d.gas, d.brake, d.clutch);
CHECK(m.axes.throttle < d.axes && m.axes.brake < d.axes && m.axes.clutch < d.axes, "%04x: every pedal is on an axis it has", d.product);
std::vector<int16_t> rest(size_t(d.axes), int16_t(kAxisMax));
rest[0] = 0;
const Axes at = Detect(rest, m.axes);
CHECK(at.throttle == d.gas && at.brake == d.brake && at.clutch == d.clutch, "%04x at rest: throttle %d, brake %d, clutch %d",
d.product, at.throttle, at.brake, at.clutch);
CHECK(m.pedalRest == kAxisMax && m.pedalFull == -32768, "%04x: pedals rest at +32767 and floor at -32768, as SDL sends them", d.product);
}
CHECK(&ModelFor(0x046D, 0x0001) == &Fallback(), "another Logitech device is not a wheel we know");
CHECK(&ModelFor(0, 0) == &Fallback(), "no USB ids: the fallback");
// Which joystick is a wheel. SDL's own word, our table, or the name;
// but never something without an axis to steer with and a button.
CHECK(LooksLikeWheel(true, 0x1234, 0x5678, "Something", 4, 12), "SDL says it is a wheel");
CHECK(LooksLikeWheel(false, 0x046D, 0xC24F, "", 4, 25), "the G29 by its USB ids, unnamed");
CHECK(LooksLikeWheel(false, 0, 0, "Thrustmaster T300RS", 4, 13), "an unknown wheel by its name");
CHECK(LooksLikeWheel(false, 0, 0, "LOGITECH G923 Racing Wheel", 4, 13), "the name, whatever its case");
CHECK(!LooksLikeWheel(false, 0x045E, 0x028E, "Xbox 360 Controller", 6, 11), "a controller is not a wheel");
CHECK(!LooksLikeWheel(true, 0x046D, 0xC24F, "Logitech G29", 1, 1), "one axis: not a wheel, whatever it says");
CHECK(!LooksLikeWheel(true, 0x046D, 0xC24F, "Logitech G29", 4, 0), "no buttons: nor is this");
// Where the axes rest tells the wheel (centred) from the pedals (held at
// one end): a G29 comes out as its model says.
Axes axes = Detect(kG29Rest, ModelFor(0x046D, 0xC24F).axes);
CHECK(axes.steer == 0 && axes.throttle == 2 && axes.brake == 3 && axes.clutch == 1, "the G29's axes: %d %d %d %d",
axes.steer, axes.throttle, axes.brake, axes.clutch);
// A wheel we don't know, with the same resting places, still steers and
// brakes: the centred axis is the wheel, the rest are pedals in order.
axes = Detect(kG29Rest, Fallback().axes);
CHECK(axes.steer == 0 && axes.throttle == 1 && axes.brake == 2 && axes.clutch == 3, "an unknown wheel's axes");
// A model that names the wrong axes loses to where they rest: here the
// wheel is axis 2, which the model called a pedal. Its pedal layout
// can't hold, so it is dropped whole and the axes left over go to the
// pedals in the order the device reports them, rather than the throttle
// keeping the model's axis while the brake takes what is left.
constexpr int16_t kOddRest[] = { -32768, 32767, 0, 32767 };
axes = Detect(kOddRest, { 0, 1, 2, 3 });
CHECK(axes.steer == 2, "the centred axis is the wheel, not the one the model named: %d", axes.steer);
CHECK(axes.throttle == 0 && axes.brake == 1 && axes.clutch == 3, "the rest in the device's order: %d, %d, %d",
axes.throttle, axes.brake, axes.clutch);
// Two pedals and no clutch: nothing is invented for a role the model
// hasn't got, and a pedal is never the wheel's axis.
constexpr int16_t kTwoPedals[] = { 0, 32767, 32767 };
axes = Detect(kTwoPedals, { 0, 1, 2, -1 });
CHECK(axes.steer == 0 && axes.throttle == 1 && axes.brake == 2 && axes.clutch == -1, "a wheel with two pedals");
constexpr int16_t kOnePedal[] = { 0, 32767 };
axes = Detect(kOnePedal, Fallback().axes);
CHECK(axes.steer == 0 && axes.throttle == 1 && axes.brake == -1 && axes.clutch == -1, "a wheel with one pedal");
// A wheel held off centre as the game starts is still the wheel (half
// way is the line), and a pedal already pressed is still a pedal: here
// axis 1, which on a G29 is the clutch, is held to the floor while the
// other two rest, and every role still lands where the model says.
constexpr int16_t kOffCentreRest[] = { 12000, -32768, 32767, 32767 };
axes = Detect(kOffCentreRest, ModelFor(0x046D, 0xC24F).axes);
CHECK(axes.steer == 0 && axes.throttle == 2 && axes.brake == 3 && axes.clutch == 1,
"a wheel off centre, the clutch held down: %d, %d, %d, %d", axes.steer, axes.throttle, axes.brake, axes.clutch);
// Preferred axes that name one axis for two pedals (wheel.cpp passes a stored calibration in as
// `preferred`, and a calibration can go wrong) don't hold: no axis ever gets two roles.
axes = Detect(kG29Rest, { 0, 2, 2, 1 });
CHECK(axes.throttle != axes.brake && axes.throttle >= 0 && axes.brake >= 0,
"throttle and brake both named axis 2: they end up apart (%d, %d)", axes.throttle, axes.brake);
// ...whatever the device and whatever is preferred: every role on its own axis, the wheel never a pedal,
// and a wheel found whenever an axis rests centred. A fixed seed, so a failure repeats.
{
std::mt19937 rng(29);
auto pick = [&](int lo, int hi) { return std::uniform_int_distribution<int>(lo, hi)(rng); };
int bad = 0;
for (int trial = 0; trial < 50000 && bad < 3; trial++)
{
std::vector<int16_t> rest(size_t(pick(0, 8)));
bool centred = false;
for (int16_t& v : rest)
{
const int kind = pick(0, 3);
v = int16_t(kind == 0 ? pick(-32768, 32767) : kind == 1 ? pick(-3000, 3000) : kind == 2 ? 32767 : -32768);
centred = centred || RestsCentred(v);
}
const Axes pref = { pick(-1, 9), pick(-1, 9), pick(-1, 9), pick(-1, 9) };
const Axes got = Detect(rest, pref);
const int roles[] = { got.steer, got.throttle, got.brake, got.clutch };
bool ok = (got.steer >= 0) == centred;
for (int a = 0; a < 4; a++)
{
ok = ok && roles[a] >= -1 && roles[a] < int(rest.size());
for (int b = a + 1; b < 4; b++)
ok = ok && (roles[a] < 0 || roles[a] != roles[b]);
}
if (!ok)
{
bad++;
CHECK(false, "Detect, %zu axes, preferred %d %d %d %d: %d %d %d %d", rest.size(), pref.steer, pref.throttle,
pref.brake, pref.clutch, got.steer, got.throttle, got.brake, got.clutch);
}
}
}
// Steering. The whole turn, straight through: the ends and the middle.
CHECK(Near(SteerRaw(0, 0, 900, 0.0f, 1.0f), 0.0f), "centred is straight ahead");
CHECK(Near(SteerRaw(32767, 0, 900, 0.0f, 1.0f), 1.0f), "full right");
CHECK(Near(SteerRaw(-32768, 0, 900, 0.0f, 1.0f), -1.0f), "full left (the extra count clamps)");
CHECK(Near(SteerRaw(16384, 0, 900, 0.0f, 1.0f), 0.5f, 0.002f), "half way round");
// A range smaller than the wheel's turn reaches full lock sooner: 450 of
// a G29's 900 degrees means half a turn is already full lock.
CHECK(Near(SteerRaw(16384, 450, 900, 0.0f, 1.0f), 1.0f), "450 of 900: half the turn is full lock");
CHECK(Near(SteerRaw(8192, 450, 900, 0.0f, 1.0f), 0.5f, 0.002f), "...and a quarter of it is half lock");
CHECK(Near(SteerRaw(-16384, 450, 900, 0.0f, 1.0f), -1.0f), "the same the other way");
CHECK(Near(SteerRaw(32767, 450, 900, 0.0f, 1.0f), 1.0f), "past full lock is full lock");
// A range at or above the wheel's turn, or a wheel whose turn we don't
// know, uses the whole turn.
CHECK(Near(SteerRaw(16384, 900, 900, 0.0f, 1.0f), 0.5f, 0.002f), "900 of 900 is the whole turn");
CHECK(Near(SteerRaw(16384, 540, 0, 0.0f, 1.0f), 0.5f, 0.002f), "a wheel whose turn is unknown: the whole axis");
// The deadzone: nothing inside it, and what is outside still reaches
// full lock.
CHECK(Near(SteerRaw(3000, 0, 900, 0.1f, 1.0f), 0.0f), "inside the deadzone: straight ahead");
CHECK(Near(SteerRaw(-3000, 0, 900, 0.1f, 1.0f), 0.0f), "...either way");
CHECK(SteerRaw(4000, 0, 900, 0.1f, 1.0f) > 0.0f, "just outside it: steering");
CHECK(Near(SteerRaw(32767, 0, 900, 0.1f, 1.0f), 1.0f), "the deadzone doesn't cost full lock");
// Response: above 1 is gentler near the centre, below 1 sharper, and
// full lock stays full lock either way.
CHECK(SteerRaw(16384, 0, 900, 0.0f, 2.0f) < SteerRaw(16384, 0, 900, 0.0f, 1.0f), "response 2: gentler near the centre");
CHECK(SteerRaw(16384, 0, 900, 0.0f, 0.5f) > SteerRaw(16384, 0, 900, 0.0f, 1.0f), "response 0.5: sharper");
for (float response : { 0.5f, 1.0f, 2.0f })
CHECK(Near(SteerRaw(32767, 0, 900, 0.05f, response), 1.0f), "response %.1f still reaches full lock", double(response));
// Nothing a hand-edited settings.toml holds can send it out of range.
for (int raw : { -32768, -1, 0, 1, 32767 })
for (float deadzone : { -1.0f, 0.0f, 0.5f, 2.0f })
for (float response : { -1.0f, 0.0f, 1.0f, 3.0f })
{
const float v = SteerRaw(int16_t(raw), -5, 900, deadzone, response);
CHECK(v >= -1.0f && v <= 1.0f && v == v, "raw %d, deadzone %.1f, response %.1f: %f", raw, double(deadzone),
double(response), double(v));
}
// A pedal is the span from where it rests to where it reads floored,
// measured at both ends. A G29's rest at +32767 and run to -32768.
Pedal pedal;
pedal.Start(32767, -32768);
CHECK(pedal.ready, "both ends given: it reads");
CHECK(Near(pedal.Value(32767), 0.0f), "a pedal at rest is up");
CHECK(Near(pedal.Value(0), 0.5f, 0.01f), "half way down");
CHECK(Near(pedal.Value(-32768), 1.0f), "on the floor");
// One wired the other way round works the same.
pedal.Start(-32768, 32767);
CHECK(Near(pedal.Value(-32768), 0.0f) && Near(pedal.Value(32767), 1.0f), "a pedal that runs the other way");
// Only movement towards the pressed end counts: the other way is a
// pedal on its way back up, which is not a pedal being pressed.
pedal.Start(0, 32767);
CHECK(Near(pedal.Value(0), 0.0f) && Near(pedal.Value(32767), 1.0f), "a pedal from the middle");
CHECK(Near(pedal.Value(-32768), 0.0f), "...and the other way is still up, not full");
// A pedal that stops short of the end of its axis still reads as
// floored, because the span says how far it really goes. (A G29's
// brake stops around -31740 when it is pressed hard.)
pedal.Start(32767, -31740);
CHECK(Near(pedal.Value(-31740), 1.0f), "a pedal that stops short still reaches the floor");
CHECK(Near(pedal.Value(-32768), 1.0f), "...and past it is still the floor");
CHECK(pedal.Value(0) > 0.4f && pedal.Value(0) < 0.6f, "half way is still half way");
// Two pedals sharing one axis, one taking it positive and the other
// negative: each reads its own half and ignores the other's. (Some
// wheels report a combined pedal axis this way.)
Pedal shareUp, shareDown;
shareUp.Start(0, 32767);
shareDown.Start(0, -32768);
CHECK(Near(shareUp.Value(32767), 1.0f) && Near(shareDown.Value(32767), 0.0f), "the positive half is one pedal's");
CHECK(Near(shareDown.Value(-32768), 1.0f) && Near(shareUp.Value(-32768), 0.0f), "the negative half is the other's");
CHECK(Near(shareUp.Value(0), 0.0f) && Near(shareDown.Value(0), 0.0f), "at rest, neither is pressed");
// At rest it is 0, not -0 (which the menu and the log printed as "-0%").
for (const Pedal& p : { shareUp, shareDown, pedal })
for (int raw : { -32768, -1, 0, 1, 32767 })
CHECK(!std::signbit(p.Value(int16_t(raw))), "no -0 at %d", raw);
// A pedal nobody has placed reads nothing, however far its axis is from
// zero: SDL reports an axis only when it moves, so an untouched pedal
// resting at +32767 would otherwise read as floored.
const Pedal untouched;
CHECK(!untouched.ready, "a fresh pedal knows nothing");
for (int raw : { -32768, 0, 32767 })
CHECK(untouched.Value(int16_t(raw)) == 0.0f, "an unplaced pedal reads nothing at %d", raw);
Pedal degenerate;
degenerate.Start(5000, 5000);
CHECK(!degenerate.ready && degenerate.Value(0) == 0.0f, "two ends the same is no span at all");
// An axis read the other way round (Invert Steering).
CHECK(Flip(0) == 0, "centred stays centred");
CHECK(Flip(32767) == -32767 && Flip(-32767) == 32767, "the ends swap");
CHECK(Flip(-32768) == kAxisMax, "the extra count clamps rather than wrapping to itself");
CHECK(Flip(Flip(12345)) == 12345, "twice is where it started");
// Whatever it reads, the answer is a fraction.
pedal.Start(32767, -32768);
for (int raw : { -32768, -1000, 0, 1000, 32767 })
{
const float v = pedal.Value(int16_t(raw));
CHECK(v >= 0.0f && v <= 1.0f, "pedal at %d: %f", raw, double(v));
}
// The wheel's own axis as two measured ends: the centre falls halfway
// between them, each side is measured against its own end, and which
// way round the wheel is needs nothing else said.
Steering wheel;
CHECK(!Steering{}.ready && Steering{}.left == -kAxisMax && Steering{}.right == kAxisMax, "a default Steering spans the axis, unmeasured");
wheel.Start(-32768, 32767);
CHECK(wheel.ready, "two ends given");
CHECK(Near(wheel.Fraction(0), 0.0f, 0.01f), "the middle is straight ahead");
CHECK(Near(wheel.Fraction(32767), 1.0f, 0.01f), "full right");
CHECK(Near(wheel.Fraction(-32768), -1.0f, 0.01f), "full left");
// A wheel whose axis runs the other way: the ends come out swapped, and
// that alone turns it round.
wheel.Start(32767, -32768);
CHECK(Near(wheel.Fraction(32767), -1.0f, 0.01f), "the positive end is full left on a backwards wheel");
CHECK(Near(wheel.Fraction(-32768), 1.0f, 0.01f), "...and the negative end is full right");
// A wheel that reaches neither end of its axis, and sits off centre:
// each side still reaches full lock.
wheel.Start(-20000, 10000);
CHECK(Near(wheel.Fraction(-20000), -1.0f, 0.01f) && Near(wheel.Fraction(10000), 1.0f, 0.01f), "a short, lopsided wheel");
CHECK(Near(wheel.Fraction(-5000), 0.0f, 0.01f), "its own centre is straight ahead");
CHECK(wheel.Fraction(32767) <= 1.0f && wheel.Fraction(-32768) >= -1.0f, "past its ends is still full lock");
// Two ends the same is no span: straight ahead, whatever it reads.
Steering flat;
flat.Start(1000, 1000);
CHECK(!flat.ready && Near(flat.Fraction(32767), 0.0f), "no span, no steering");
// The buttons the player assigns sit on top of the model's, and a
// button given to one control is taken off whatever else had it.
{
std::array<int, kControlCount> assigned{};
assigned.fill(-1);
assigned[size_t(Control::A)] = 7; // A is the lever the model calls LB
const std::vector<Binding> mine = WithAssigned(kLogitechButtons, assigned);
CHECK(Resolve(Held<25>({ 7 }), hat::Centred, mine).buttons == button::A, "button 7 is now A");
CHECK(Resolve(Held<25>({ 0 }), hat::Centred, mine).buttons == 0, "...and the model's A no longer answers");
CHECK(Resolve(Held<25>({ 9 }), hat::Centred, mine).buttons == button::Start, "the rest of the model stands");
// Nothing assigned leaves the model exactly as it was.
assigned.fill(-1);
CHECK(WithAssigned(kLogitechButtons, assigned).size() == std::size(kLogitechButtons), "no assignments, no change");
// A paddle can be reassigned too, and keeps its own role.
assigned[size_t(Control::ShiftUp)] = 2;
const std::vector<Binding> shift = WithAssigned(kLogitechButtons, assigned);
CHECK(Resolve(Held<25>({ 2 }), hat::Centred, shift).rightY > 0, "button 2 now shifts up");
CHECK(Resolve(Held<25>({ 4 }), hat::Centred, shift).rightY == 0, "...and the model's paddle no longer does");
}
// ---- Calibrate (wheel_map.h's Calibration), as a player does it on a G29 ----
{
using Event = Calibration::Event;
Rig r;
r.Start();
CHECK(r.cal.Running() && r.cal.Current() == Step::SteerLeft, "it starts at the wheel's left end");
// Full left, then letting go (the wheel centres itself) before A: the end it reached is what is kept.
r.Move(0, -32768);
r.Move(0, 0);
CHECK(r.cal.Watch().found && r.cal.Watch().full == -32768, "full left is the end reached: %d", int(r.cal.Watch().full));
CHECK(r.Tap(0) == Event::Kept && r.cal.LastFound().axis == 0 && r.cal.LastFound().full == -32768 && r.cal.LastFound().rest == 0,
"kept: axis 0, from 0 to -32768 (not the centre it was let back to)");
// Full right begins with the wheel held at full left (A pressed while holding it): coming back to the centre
// is not the right end, so A does nothing yet...
r.axes[0] = -32768;
r.Frame();
r.Move(0, 0);
CHECK(!r.cal.Watch().found && r.Tap(0) == Event::None && r.cal.Current() == Step::SteerRight,
"back to the centre from full left is not full right");
// ...until it turns right.
r.Move(0, 32767);
CHECK(r.Tap(0) == Event::Kept && r.cal.LastFound().full == 32767, "full right: +32767");
r.Move(0, 0);
// The throttle, held while A keeps it.
r.Move(2, -32768);
CHECK(r.Tap(0) == Event::Kept && r.cal.LastFound().axis == 2 && r.cal.LastFound().rest == 32767 &&
r.cal.LastFound().full == -32768, "the throttle: axis 2, +32767 to -32768");
// The brake: the throttle is let up during this step (a full sweep of its axis) and then the brake pressed.
// The throttle's axis is measured from its own rest and only the other way, so its release counts for nothing.
r.Move(2, 32767);
CHECK(!r.cal.Watch().found, "letting the throttle up is not the brake");
r.Move(3, -32768);
CHECK(r.Tap(0) == Event::Kept && r.cal.LastFound().axis == 3, "the brake is axis 3, not the throttle's 2 (%d)",
r.cal.LastFound().axis);
// The clutch, the brake let up meanwhile; turning the wheel (its own axis) never counts for a pedal.
r.Move(3, 32767);
r.Move(0, -32768);
r.Move(0, 0);
CHECK(!r.cal.Watch().found, "the wheel's axis is never a pedal's");
r.Move(1, -20000);
CHECK(r.Tap(0) == Event::Kept && r.cal.LastFound().axis == 1 && r.cal.LastFound().full == -20000, "the clutch, half way: axis 1");
r.Move(1, 32767);
// A: Cross is still down from keeping the clutch... no, Tap let it go. Press Cross: it is the candidate; press it
// again and it is kept. Cross can be A.
CHECK(r.cal.Current() == Step::ButtonA, "the buttons follow");
CHECK(r.Tap(0) == Event::None && r.cal.Watch().button == 0, "Cross pressed for A: the candidate");
CHECK(r.Tap(0) == Event::Kept && r.cal.LastFound().button == 0, "Cross again: A is Cross");
// B: Circle pressed and let go, then A. Circle doesn't stop a button step, and the press is kept through its release.
CHECK(r.Tap(2) == Event::None && r.cal.Running() && r.cal.Watch().button == 2, "Circle for B: kept through its release, no stop");
CHECK(r.Tap(0) == Event::Kept && r.cal.LastFound().button == 2, "A keeps Circle as B");
// X: Square, another button replaces it, then that one again keeps it (on its press, so it is still held
// as the Y step begins).
r.Tap(1);
r.Tap(10);
CHECK(r.cal.Watch().button == 10, "another button replaces the candidate");
CHECK(r.Down(10) == Event::Kept && r.cal.LastFound().button == 10 && r.cal.Current() == Step::ButtonY,
"...and pressing it again keeps it");
// A button held as a step begins is not a press until it is let go and pressed again.
r.Frame();
CHECK(!r.cal.Watch().found, "a button held as the step begins is no press");
r.Up(10);
CHECK(!r.cal.Watch().found, "nor is letting it go");
r.Down(10);
CHECK(r.cal.Watch().button == 10, "pressed again, it is");
r.Up(10);
// The D-pad: right skips (nothing kept), left goes back, down stops.
CHECK(r.Hat(hat::Right) == Event::Skipped && r.cal.Current() == Step::ButtonLB, "D-pad right skips Y");
CHECK(r.Hat(hat::Left) == Event::Back && r.cal.Current() == Step::ButtonY, "D-pad left goes back to Y");
for (int i = 0; i < 20 && r.cal.Running(); i++)
r.Hat(hat::Right);
CHECK(!r.cal.Running(), "skipping past the last step ends it");
}
{
using Event = Calibration::Event;
// A wheel without a clutch (a Driving Force GT: three axes): the clutch step is skipped with the D-pad.
Rig r;
r.axes = { 0, 32767, 32767 };
r.seen = std::vector<bool>(3, true);
r.Start();
r.Move(0, -32768); r.Tap(0); r.Move(0, 32767); r.Tap(0); r.Move(0, 0);
r.Move(1, -32768); r.Tap(0); r.Move(1, 32767);
r.Move(2, -32768); r.Tap(0); r.Move(2, 32767);
CHECK(r.cal.Current() == Step::Clutch, "at the clutch");
CHECK(r.Tap(0) == Event::None, "A with no clutch pressed: nothing to keep");
CHECK(r.Hat(hat::Right) == Event::Skipped && r.cal.Current() == Step::ButtonA, "D-pad right skips the clutch");
// B stops an axis step; D-pad down stops a button step.
Rig s;
s.Start();
CHECK(s.Tap(2) == Event::Stopped && !s.cal.Running(), "B stops an axis step");
Rig t;
t.Start();
for (int i = 0; i < 5; i++)
t.Hat(hat::Right);
CHECK(t.cal.Current() == Step::ButtonA && t.Hat(hat::Down) == Event::Stopped, "D-pad down stops a button step");
}
{
using Event = Calibration::Event;
// Two pedals on one axis, one each way (a combined pedal axis resting in the middle): each finds its own half,
// and letting the first up is not the second.
Rig r;
r.axes = { 0, 0 };
r.seen = std::vector<bool>(2, true);
r.Start();
r.Move(0, -32768); r.Tap(0); r.Move(0, 32767); r.Tap(0); r.Move(0, 0);
r.Move(1, 32767);
CHECK(r.Tap(0) == Event::Kept && r.cal.LastFound().axis == 1 && r.cal.LastFound().rest == 0 && r.cal.LastFound().full == 32767,
"throttle: axis 1, 0 to +32767");
r.Move(1, 0);
CHECK(!r.cal.Watch().found, "the throttle back to the middle is not the brake");
r.Move(1, -32768);
CHECK(r.Tap(0) == Event::Kept && r.cal.LastFound().axis == 1 && r.cal.LastFound().rest == 0 &&
r.cal.LastFound().full == -32768, "brake: the same axis, 0 to -32768");
}
{
using Event = Calibration::Event;
// An axis the device hasn't reported reads 0, which says nothing: measured from SDL's first report of it
// (where it was before it moved), not from that 0.
Rig r;
r.axes = { 0, 0, 0, 0 };
r.seen = { true, false, false, false };
r.Start();
r.Move(0, -32768); r.Tap(0); r.Move(0, 32767); r.Tap(0); r.Move(0, 0);
r.cal.FirstValue(2, 32767);
r.seen[2] = true;
r.axes[2] = 32767;
r.Move(2, -32768);
CHECK(r.Tap(0) == Event::Kept && r.cal.LastFound().rest == 32767, "rest from the first report: %d", int(r.cal.LastFound().rest));
// Keep from the keyboard with nothing found: nothing happens, the step carries on.
CHECK(r.cal.Keep(r.axes) == Event::None && r.cal.Current() == Step::Brake, "A with nothing found: nothing");
// Jitter doesn't count: an axis has to go a third of its travel.
r.seen[3] = true;
r.cal.FirstValue(3, 32767);
r.axes[3] = 32767;
r.Move(3, 32767 - kCalibrateMoved + 100);
CHECK(!r.cal.Watch().found, "less than a third of the travel is not a press");
}
// Idle with a wheel (v0.1.7's refresh-rate notice waits for 3 s of idle controls): a wheel resting off its
// centre with a little jitter is idle, as a stick in its deadzone is; turning it, even slowly, is not.
{
WheelStill still;
int counted = 0;
for (int frame = 0; frame < 600; frame++) // 10 s at rest, 11% off centre (a G29 opened at 3623), +-0.1% jitter
counted += still.Turned(0.11f + 0.001f * float((frame * 7) % 3 - 1));
CHECK(counted == 0, "a resting wheel off its centre is idle (counted %d times)", counted);
CHECK(still.Turned(0.2f), "a turn of 9%% of full lock is use");
int slow = 0;
for (int frame = 0; frame < 60; frame++) // 0.5% of lock a frame: a slow turn
slow += still.Turned(0.2f + 0.005f * float(frame));
CHECK(slow >= 10, "a slow turn keeps counting as use (%d of 60 frames)", slow);
WheelStill fresh;
CHECK(!fresh.Turned(0.5f), "where it first is says nothing");
}
// Every control has a name and its own place in the order.
for (size_t i = 0; i < kControlCount; i++)
CHECK(kControls[i].name != nullptr && size_t(kControls[i].control) == i, "control %zu is itself", i);
// The buttons. A G29's, by position: Cross is A, Circle is B, Square is
// X, Triangle is Y; Share and Options are Back and Start.
auto press = [](std::initializer_list<int> which) {
return Resolve(Held<25>(which), hat::Centred, kLogitechButtons);
};
CHECK(press({ 0 }).buttons == button::A, "Cross is A");
CHECK(press({ 1 }).buttons == button::X, "Square is X");
CHECK(press({ 2 }).buttons == button::B, "Circle is B");
CHECK(press({ 3 }).buttons == button::Y, "Triangle is Y");
CHECK(press({ 8 }).buttons == button::Back, "Share is Back");
CHECK(press({ 9 }).buttons == button::Start, "Options is Start");
CHECK(press({ 8, 9 }).buttons == (button::Back | button::Start), "Share + Options is the settings chord");
CHECK(press({ 6 }).buttons == button::RightShoulder && press({ 7 }).buttons == button::LeftShoulder, "the levers are the shoulders");
CHECK(press({ 10 }).buttons == button::RightThumb && press({ 11 }).buttons == button::LeftThumb, "the stick clicks");
// The paddles shift gears, which the game does with the right stick;
// both at once is no change, as holding both keys of a pair is.
CHECK(press({ 4 }).rightY > 0 && press({ 4 }).buttons == 0, "the right paddle pushes the stick up");
CHECK(press({ 5 }).rightY < 0 && press({ 5 }).buttons == 0, "the left one pulls it down");
CHECK(press({ 4, 5 }).rightY == 0, "both paddles: no gear change");
CHECK(press({}).buttons == 0 && press({}).rightY == 0, "nothing held, nothing pressed");
// A button the wheel reports but nothing is bound to does nothing, and a
// binding for a button the wheel hasn't got is ignored rather than read
// off the end.
CHECK(press({ 20 }).buttons == 0, "an unbound button does nothing");
CHECK(Resolve(Held<2>({ 0, 1 }), hat::Centred, kLogitechButtons).buttons == (button::A | button::X),
"a wheel with two buttons: those two, and no reading past them");
// The hat is the D-pad, corners included.
const std::array<bool, 25> none{};
CHECK(Resolve(none, hat::Up, kLogitechButtons).buttons == button::DpadUp, "the hat is the D-pad");
CHECK(Resolve(none, uint8_t(hat::Left | hat::Down), kLogitechButtons).buttons == (button::DpadLeft | button::DpadDown),
"its corners too");
// NFSMW_WHEEL_BUTTONS: what it names changes, the rest of the model's
// bindings stay, NONE takes one out, and nonsense is reported without
// throwing the others away.
std::string error = "x";
std::vector<Binding> changed = ParseBindings("0=B,2=A", kLogitechButtons, error);
CHECK(error.empty(), "a line that reads: \"%s\"", error.c_str());
CHECK(Resolve(Held<25>({ 0 }), hat::Centred, changed).buttons == button::B, "button 0 is now B");
CHECK(Resolve(Held<25>({ 9 }), hat::Centred, changed).buttons == button::Start, "...and the model's Start is untouched");
changed = ParseBindings("9=NONE", kLogitechButtons, error);
CHECK(error.empty() && Resolve(Held<25>({ 9 }), hat::Centred, changed).buttons == 0, "NONE takes a button out");
changed = ParseBindings(" 4=START , nonsense , 5=WHAT ", kLogitechButtons, error);
CHECK(error == "nonsense", "the first item it can't read: \"%s\"", error.c_str());
CHECK(Resolve(Held<25>({ 4 }), hat::Centred, changed).buttons == button::Start, "the items that do read still count");
CHECK(ParseBindings("", kLogitechButtons, error).size() == std::size(kLogitechButtons) && error.empty(), "an empty list changes nothing");
// A button number past 255 is refused, however long (it used to overflow an int on the way: UBSan).
for (const char* text : { "256=A", "99999999999=A", "4294967296=A", "000000000000000000004=A" })
{
changed = ParseBindings(text, kLogitechButtons, error);
bool past = false;
for (const Binding& b : changed)
past = past || b.index < 0 || b.index > 255;
const bool leadingZeros = text[0] == '0';
CHECK(!past && (leadingZeros ? error.empty() : error == text), "\"%s\": refused (error \"%s\")", text, error.c_str());
}
CHECK(Resolve(Held<25>({ 4 }), hat::Centred, ParseBindings("000000000000000000004=A", kLogitechButtons, error)).buttons == button::A,
"leading zeros are still button 4");
// Every name is one of the pad's controls, and NameOf says which.
for (const auto& [name, binding] : kNames)
if (name != "NONE")
CHECK(NameOf(binding) == name, "%s names itself", std::string(name).c_str());
// Force feedback: the stronger motor the game asks for, at the share the
// setting allows, and nothing at all at 0.
CHECK(Near(RumbleStrength(65535, 0, 100), 1.0f), "a full rumble at 100%%");
CHECK(Near(RumbleStrength(0, 65535, 100), 1.0f), "either motor");
CHECK(Near(RumbleStrength(65535, 65535, 50), 0.5f), "half strength");
CHECK(Near(RumbleStrength(65535, 0, 0), 0.0f), "0%%: nothing");
CHECK(Near(RumbleStrength(0, 0, 100), 0.0f), "no request: nothing");
for (int percent : { -10, 0, 50, 100, 1000 })
{
const float v = RumbleStrength(40000, 20000, percent);
CHECK(v >= 0.0f && v <= 1.0f, "%d%%: %f", percent, double(v));
CHECK(SpringLevel(percent) <= 0x7FFF, "%d%%: the spring's coefficient fits", percent);
}
CHECK(SpringLevel(0) == 0 && SpringSaturation(0) == 0, "no centring force is no spring");
CHECK(SpringLevel(100) == 0x7FFF, "full centring force is the strongest spring");
CHECK(SpringLevel(50) > 0 && SpringLevel(50) < SpringLevel(100), "and half is in between");
// The game's own steering deadzone (round 2 of the G29 test: "I had to
// turn the wheel more than I should have to before the car would
// steer"). GameSteer is the game as measured in a race; a wheel's stick
// is stepped past it, so the car follows the wheel from the first step.
{
// hid/input.cpp's ToShort: the fraction a stick reaches the game as.
auto toShort = [](float v) { return int16_t(std::clamp(v * 32767.0f, -32768.0f, 32767.0f)); };
CHECK(GameStickUnits(0) == 0 && GameStickUnits(32767) == 1000 && GameStickUnits(-32768) == -1000,
"the guest's int: centre and both ends");
CHECK(GameStickUnits(6553) == 200 && GameStickUnits(6586) == 201 && GameStickUnits(-6586) == -201,
"and the steps either side of the deadzone's edge");
// What the race measured (the stick swept in 1% steps, the car's steering read back).
const float measured[][2] = { { 0.06f, 0.0f }, { 0.12f, 0.0f }, { 0.20f, 0.0f }, { 0.21f, 0.0125f }, { 0.25f, 0.0625f },
{ 0.30f, 0.125f }, { 0.40f, 0.25f }, { 0.60f, 0.5f }, { 0.80f, 0.75f }, { 1.0f, 1.0f } };
for (const auto& [x, y] : measured)
{
CHECK(Near(GameSteer(toShort(x)), y, 0.0015f), "a pad's stick at %.2f steers %.4f (got %.4f)", double(x), double(y),
double(GameSteer(toShort(x))));
CHECK(Near(GameSteer(toShort(-x)), -y, 0.0015f), "and as far the other way at -%.2f", double(x));
}
// A wheel: the car steers as far as the wheel says, from the first step.
float before = -2.0f;
int worst = 0;
for (int i = -10000; i <= 10000; i++)
{
const float w = float(i) / 10000.0f;
const float y = GameSteer(toShort(StickPastGameDeadzone(w)));
CHECK(std::abs(y - w) <= 0.5f / float(kGameSteerSteps) + 1e-4f, "wheel %+.4f: the car steers %+.5f", double(w), double(y));
CHECK(y >= before, "wheel %+.4f: never less than a smaller turn's (%+.5f < %+.5f)", double(w), double(y), double(before));
CHECK((y == 0.0f) == (std::abs(w) * float(kGameSteerSteps) < 0.5f), "wheel %+.4f: straight only within half a step",
double(w));
before = y;
if (i > 0 && GameSteer(toShort(w)) == 0.0f)
worst = i;
}
CHECK(worst >= 2000 && worst <= 2010, "without it, a wheel steered nothing up to 20%% of its turn (to %d/10000)", worst);
CHECK(Near(GameSteer(toShort(StickPastGameDeadzone(0.15f))), 0.15f, 0.001f) && GameSteer(toShort(0.15f)) == 0.0f,
"15%% of the turn: the car turns, where a pad's stick at 15%% doesn't");
CHECK(StickPastGameDeadzone(0.0f) == 0.0f && toShort(StickPastGameDeadzone(1.0f)) == 32767 &&
toShort(StickPastGameDeadzone(-1.0f)) == -32768,
"straight is a centred stick; full lock is the stick's end");
}
// The centring force (round 4 of the G29 test: "a consistent click every
// second" with the wheel held round a corner, none with it straight).
// Builds up to 286 said the autocentre level again every second while no
// knock played, and SDL says it by letting the spring go and taking it up
// again. Now: once at the open, once at the device's first report, and
// on a change of the setting. Never on a timer.
{
const float frame = 1000.0f / 60.0f;
Centring c;
CHECK(c.Due(50, false) && c.said == 50, "the open says it");
CHECK(!c.Due(50, false), "and nothing more until something happens");
// The wheel held at 30% of its turn for 10 s: its axis reports every
// frame (deviceReported from the first), no knock plays, the setting
// stays at 50%.
int sends = 0;
for (int f = 0; f < 600; f++)
sends += c.Due(50, true);
CHECK(sends == 1, "10 s held off centre: said once (SDL's driver cleared it at the first report), not %d times", sends);
// The old rule over the same 10 s, for the record: once a second.
int old = 0;
float sinceSaid = 0.0f;
for (int f = 0; f < 600; f++)
if ((sinceSaid += frame) >= 1000.0f)
{
old++;
sinceSaid = 0.0f;
}
CHECK(old >= 9, "the old rule said it %d times in those 10 s", old);
CHECK(c.Due(70, true) && c.said == 70, "a change of the setting says it");
CHECK(!c.Due(70, true), "once");
CHECK(c.Due(-5, true) && c.said == 0 && !c.Due(0, true), "out of range is clamped, and said once");
CHECK(c.sends == 4, "four times in all: open, first report, two changes (%d)", c.sends);
Centring reopened;
CHECK(reopened.Due(0, true) && !reopened.Due(0, true), "a wheel opened after its first report: said once");
}
// Knocks (rounds 2 and 3 of the G29 test: "a click whenever feedback
// should occur", then the clicking back while driving). The game knocks
// with one frame of full rumble; the wheel must get a thump that lasts,
// never a restart, and never a jump in strength from one frame to the
// next: a rise takes kKnockAttackMs, a fall kKnockReleaseMs.
{
using Action = Knock::Action;
const float frame = 1000.0f / 60.0f;
// Every frame of a run: the rules every knock keeps.
struct Checked
{
Knock k;
int starts = 0, stops = 0, changes = 0;
float playedMs = 0.0f, peak = 0.0f;
void Frame(float target, float ms, const char* what, int f)
{
const bool was = k.playing;
const float before = k.level;
const Action a = k.Step(target, ms);
starts += a == Action::Start;
stops += a == Action::Stop;
changes += a == Action::Change;
CHECK(a != Action::Start || !was, "%s, frame %d: never started again while it plays", what, f);
CHECK(a != Action::Change || was, "%s, frame %d: a change only while it plays", what, f);
CHECK(k.level <= before + ms / float(kKnockAttackMs) + 1e-6f, "%s, frame %d: rises no faster than the attack", what, f);
CHECK(k.level >= before - ms / float(kKnockReleaseMs) - 1e-6f, "%s, frame %d: falls no faster than the release", what, f);
CHECK(k.playing == (k.level > 0.0f), "%s, frame %d: plays exactly while there is something to play", what, f);
if (k.playing)
playedMs += ms;
peak = std::max(peak, k.level);
}
};
// One frame of 32767/32767 at Force Feedback 100% (0.5), as the game
// sends for a wall hit, then nothing.
Checked one;
for (int f = 0; f < 60; f++)
one.Frame(f == 1 ? RumbleStrength(32767, 32767, 100) : 0.0f, frame, "one knock", f);
CHECK(one.starts == 1 && one.stops == 1, "one frame of rumble is one knock (%d starts, %d stops)", one.starts, one.stops);
CHECK(Near(one.peak, 0.5f), "it comes in whole within a frame (attack %d ms): %f", kKnockAttackMs, double(one.peak));
CHECK(one.playedMs >= 0.5f * float(kKnockReleaseMs) - frame, "and it lasts: %.0f ms", double(one.playedMs));
CHECK(one.changes >= 3, "let go of in steps (%d), not dropped", one.changes);
// The game's own rumble from a drag race (the virtual G29's log, 86.08 to
// 86.75 s; one value a frame): a ramp down, two one-frame knocks, and
// the small values between.
static constexpr uint16_t kRecorded[][2] = { { 3160, 0 }, { 2883, 0 }, { 2624, 0 }, { 32767, 32767 }, { 2159, 0 },
{ 1949, 0 }, { 1755, 0 }, { 1572, 0 }, { 1401, 0 }, { 1242, 0 }, { 774, 0 }, { 1011, 0 }, { 495, 0 }, { 756, 0 },
{ 361, 0 }, { 640, 0 }, { 295, 0 }, { 575, 0 }, { 166, 0 }, { 469, 0 }, { 81, 0 }, { 46, 0 }, { 0, 0 }, { 311, 0 },
{ 49151, 49151 }, { 223, 0 }, { 0, 0 }, { 136, 0 }, { 0, 0 }, { 133, 0 }, { 0, 0 }, { 113, 0 }, { 0, 0 }, { 96, 0 },
{ 0, 0 }, { 79, 0 }, { 0, 0 }, { 64, 0 }, { 0, 0 }, { 8, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 },
{ 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 } };
for (int percent : { 100, 50 })
{
Checked r;
for (size_t f = 0; f < std::size(kRecorded); f++)
r.Frame(RumbleStrength(kRecorded[f][0], kRecorded[f][1], percent), frame, percent == 100 ? "drag 100%" : "drag 50%", int(f));
CHECK(r.starts >= 1 && r.starts == r.stops && !r.k.playing, "%d%%: %d starts, %d stops", percent, r.starts, r.stops);
CHECK(r.peak > 0.0f && r.peak <= 0.75f * float(percent) / 100.0f + 1e-6f, "%d%%: no stronger than the strongest knock (%f)",
percent, double(r.peak));
}
// Round 3's session, as the game plays it around the settings menu:
// scraping a wall (the game's rumble swinging 25-100% about three
// times a second, a frame of jitter on top), the menu opening (the
// game pauses: its rumble runs down to 0 and stays there), Force
// Feedback stepped from 100% to 45% and back to 50% while the menu is
// open, Vibration off and on, the menu closing, and the scrape again.
// The wheel's knock comes back exactly as it was, at the new
// strength: let go of when the game stops, started from nothing when
// it starts again, never restarted or jumped in between.
{
Checked m;
int percent = 100;
bool vibration = true;
int f = 0;
auto scrape = [&](int frames) {
for (int i = 0; i < frames; i++, f++)
{
const double t = double(f) / 60.0;
double v = 0.62 + 0.38 * std::sin(t * 2.0 * 3.14159265 * 3.0) + ((f % 3) == 0 ? 0.04 : -0.02);
const uint16_t low = uint16_t(std::clamp(v, 0.0, 1.0) * 65535.0);
m.Frame(vibration ? RumbleStrength(low, 0, percent) : 0.0f, frame, "menu run", f);
}
};
auto quiet = [&](int frames) {
for (int i = 0; i < frames; i++, f++)
m.Frame(vibration ? RumbleStrength(0, 0, percent) : 0.0f, frame, "menu run", f);
};
scrape(180);
const float before = m.peak;
CHECK(m.starts == 1 && m.k.playing, "the scrape is one knock while it lasts (%d starts)", m.starts);
quiet(30); // the menu opens; the game's rumble runs down
CHECK(!m.k.playing && m.stops == 1, "let go of while the game is paused");
for (int step = 0; step < 11; step++) // Force Feedback 100% -> 45% under the menu
{
percent -= 5;
quiet(12);
}
percent = 50;
vibration = false;
quiet(20);
vibration = true;
quiet(20);
CHECK(m.starts == 1 && !m.k.playing, "nothing plays under the menu, whatever the settings do");
m.peak = 0.0f;
scrape(180); // the menu closes; scraping again
CHECK(m.starts == 2 && m.k.playing, "the next scrape starts it again, from nothing");
CHECK(Near(m.peak, 0.5f * before, 0.01f), "at half the strength now (%f, was %f)", double(m.peak), double(before));
percent = 100; // Force Feedback back up while it plays: a rise, at the attack
scrape(60);
CHECK(m.starts == 2, "a setting changed while it plays changes it in place");
}
// Nitrous (the G29 retest of build 287): the game asks for 3.5-4.4 s
// of rumble at 75-100%, which shook the rim the whole time. Past
// kKnockSustainMs it is a long rumble: eased, at the release, to
// kKnockSustainScale of it, and held there. A crash's knock (a few
// frames at full) is untouched, before and after.
{
Checked n;
float lateMax = 0.0f;
for (int f = 0; f < 240; f++) // 4 s
{
const double t = double(f) / 60.0;
const uint16_t v = uint16_t((0.875 + 0.125 * std::sin(t * 2.0 * 3.14159265 * 2.0)) * 65535.0);
n.Frame(RumbleStrength(v, v, 100), frame, "nitrous", f);
if (f * frame > float(kKnockSustainMs + kKnockReleaseMs) + frame)
lateMax = std::max(lateMax, n.k.level);
}
CHECK(n.starts == 1 && n.k.sustained, "nitrous is one long rumble (%d starts)", n.starts);
CHECK(n.peak > 0.75f, "it starts as a knock (%f)", double(n.peak));
CHECK(lateMax > 0.0f && lateMax <= kKnockSustainScale + 1e-6f, "then it is light: at most %.0f%% (%f)",
double(kKnockSustainScale) * 100.0, double(lateMax));
for (int f = 0; f < 30; f++)
n.Frame(0.0f, frame, "nitrous ends", f);
CHECK(!n.k.playing && !n.k.sustained && n.stops == 1, "let go of when it ends");
// A crash after it: a few frames of full rumble, as strong as ever.
Checked c;
for (int f = 0; f < 60; f++)
c.Frame(f >= 1 && f <= 6 ? 1.0f : 0.0f, frame, "crash", f);
Checked c2 = n;
c2.peak = 0.0f;
for (int f = 0; f < 60; f++)
c2.Frame(f >= 1 && f <= 6 ? 1.0f : 0.0f, frame, "crash after nitrous", f);
CHECK(Near(c.peak, 1.0f) && !c.k.sustained, "a crash knock is full strength (%f)", double(c.peak));
CHECK(Near(c2.peak, c.peak) && c2.starts == 2, "and the same after a long rumble (%f)", double(c2.peak));
CHECK(c.playedMs >= 6.0f * frame + float(kKnockReleaseMs) - frame, "and it lasts as before: %.0f ms", double(c.playedMs));
}
// A steady request is followed within a step, and holds still.
Knock s;
CHECK(s.Step(0.0f, frame) == Action::None && !s.playing, "nothing asked: nothing");
CHECK(s.Step(0.40f, frame) == Action::Start && Near(s.level, 0.40f), "a rumble starts it");
CHECK(s.Step(0.41f, frame) == Action::Change && Near(s.sent, 0.41f), "a stronger one is followed");
CHECK(s.Step(0.41f, frame) == Action::None, "the same: nothing to say");
CHECK(s.Step(1.0f, frame) == Action::Change && s.level < 1.0f, "a knock on top rises at the attack, in place");
CHECK(Knock::Magnitude(1.0f) == int16_t(std::lround(kKnockGain * 32767.0f)) && Knock::Magnitude(0.0f) == 0 &&
Knock::Magnitude(2.0f) == Knock::Magnitude(1.0f),
"a full rumble is kKnockGain of the sine's largest magnitude");
CHECK(std::abs(Knock::Magnitude(RumbleStrength(65535, 65535, 50)) * 2 - Knock::Magnitude(1.0f)) <= 1,
"Force Feedback 50%% is half the swing");
}
// Usable at compile time (constexpr), as hid/key_map.h's rules are.
static_assert([] { Pedal p; p.Start(32767, -32768); return p.Value(32767) == 0.0f && p.Value(-32768) == 1.0f; }(),
"constexpr pedal");
static_assert(Resolve(std::array<bool, 4>{ true, false, false, false }, hat::Up, kLogitechButtons).buttons ==
(button::A | button::DpadUp), "constexpr buttons");
printf("%s (%d failures)\n", g_failures ? "FAILED" : "passed", g_failures);
return g_failures ? 1 : 0;
}