Files
virtualjaguar-libretro/app/test/remap_test.cpp
Joe MattielloandClaude Opus 5.5 0bbe016f90 feat(app): Ctrl/Cmd+Shift+T toggles TV mode; the TV-menu action is labelled as the TV-mode toggle (#1020) (#1024)
A fixed (not rebindable) Ctrl+Shift+T / Cmd+Shift+T toggles TV mode with or
without a game running (the game keeps running, the in-game menu stays shut).
It is wired into App::handleHotkey, the ImGui View menu and the native View >
TV Mode item, and is ignored while a text field has the keyboard or a binding
is being captured. The C_TVMENU action is relabelled "TV menu / TV mode" and
the cheat sheet lists the shortcut. The headless TV script's `key` verb takes
ctrl+shift+ modifiers; tv_menu_headless and platform_test cover the shortcut.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 20:48:27 -04:00

836 lines
41 KiB
C++

// Headless unit tests for the controller remapping layer (src/remap.*): binding
// serialisation, device snapshot -> Jaguar controls, profile / per-game override
// precedence, persistence round trip, Jaguar controls -> what the core reads
// (including the 12-key keypad), the keypad overlay, bind capture and content
// identity. No SDL_Init, no window, no core.
#include "remap.h"
#include "libretro.h"
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <string>
#include <vector>
using namespace vj;
static int g_fail = 0, g_checks = 0;
#define CHECK(cond) \
do { \
g_checks++; \
if (!(cond)) { \
g_fail++; \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \
} \
} while (0)
static ControlSet only(Ctl c) {
ControlSet s;
s.set(c);
return s;
}
static DeviceSnapshot keyDown(int sc) {
DeviceSnapshot s;
s.key[(size_t)sc] = true;
return s;
}
// ---------------------------------------------------------------------------------
static void testBindingSerialisation() {
Binding all[] = {Binding::key(SDL_SCANCODE_Z), Binding::key(SDL_SCANCODE_KP_7), Binding::key(SDL_SCANCODE_F2),
Binding::button(SDL_GAMEPAD_BUTTON_LEFT_SHOULDER), Binding::button(SDL_GAMEPAD_BUTTON_GUIDE),
Binding::axis(SDL_GAMEPAD_AXIS_RIGHTY, -1), Binding::axis(SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1)};
for (const Binding& b : all) {
Binding back;
std::string s = b.serialize();
CHECK(!s.empty());
CHECK(Binding::parse(s, back));
CHECK(back == b);
CHECK(!b.display().empty());
}
std::vector<Binding> v(all, all + 7);
bool ok = false;
std::vector<Binding> back = parseBindings(serializeBindings(v), &ok);
CHECK(ok);
CHECK(back == v);
CHECK(serializeBindings({}) == "none");
CHECK(parseBindings("none", &ok).empty() && ok);
// Garbage tokens are dropped, the rest survives, and the caller is told.
back = parseBindings("k29,bnotabutton,a*x,k,k99999,k44", &ok);
CHECK(!ok);
CHECK(back.size() == 2);
CHECK(back[0] == Binding::key(29) && back[1] == Binding::key(44));
// Duplicates collapse.
CHECK(parseBindings("k29,k29", &ok).size() == 1);
Ctl c;
CHECK(ctlFromId("k7", c) && c == C_K7);
CHECK(ctlFromId("star", c) && c == C_STAR);
CHECK(!ctlFromId("k10", c));
for (int i = 0; i < C_COUNT; i++) {
CHECK(ctlFromId(ctlId((Ctl)i), c) && c == (Ctl)i);
}
}
// ---------------------------------------------------------------------------------
static void testKeyboardDefaults() {
Profile kb = Profile::builtin(DeviceClass::Keyboard);
kb.stickAsDpad = false;
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_Z)) == only(C_B));
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_X)) == only(C_A));
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_C)) == only(C_C));
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_RETURN)) == only(C_OPTION));
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_RSHIFT)) == only(C_PAUSE));
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_BACKSPACE)) == only(C_PAUSE));
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_UP)) == only(C_UP));
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_F2)) == only(C_OVERLAY));
// Number row and numpad both reach the whole keypad.
for (int d = 0; d <= 9; d++) {
int row = d == 0 ? SDL_SCANCODE_0 : SDL_SCANCODE_1 + (d - 1);
int pad = d == 0 ? SDL_SCANCODE_KP_0 : SDL_SCANCODE_KP_1 + (d - 1);
CHECK(evaluate(kb, keyDown(row)) == only((Ctl)(C_K0 + d)));
CHECK(evaluate(kb, keyDown(pad)) == only((Ctl)(C_K0 + d)));
}
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_MINUS)) == only(C_STAR));
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_EQUALS)) == only(C_HASH));
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_KP_DIVIDE)) == only(C_STAR));
CHECK(evaluate(kb, keyDown(SDL_SCANCODE_KP_MULTIPLY)) == only(C_HASH));
// Chords are independent.
DeviceSnapshot s = keyDown(SDL_SCANCODE_Z);
s.key[SDL_SCANCODE_UP] = true;
s.key[SDL_SCANCODE_5] = true;
ControlSet cs = evaluate(kb, s);
CHECK(cs.test(C_B) && cs.test(C_UP) && cs.test(C_K5) && !cs.test(C_A));
}
static void testGamepadDefaults() {
Profile pad = Profile::builtin(DeviceClass::Gamepad);
pad.stickAsDpad = true;
// Every keypad key is reachable on a standard gamepad, each by its own input and
// without the overlay: press each default binding alone and expect exactly that key.
for (int c = C_K0; c <= C_HASH; c++) {
CHECK(!pad.binds[(size_t)c].empty());
for (const Binding& b : pad.binds[(size_t)c]) {
DeviceSnapshot s;
if (b.kind == Binding::PadButton) s.btn[(size_t)b.code] = true;
else if (b.kind == Binding::PadAxis) s.axis[(size_t)b.code] = (int16_t)(b.dir * 30000);
else CHECK(false);
CHECK(evaluate(pad, s) == only((Ctl)c));
}
}
// Face buttons / meta buttons.
DeviceSnapshot s;
s.btn[SDL_GAMEPAD_BUTTON_SOUTH] = true;
CHECK(evaluate(pad, s) == only(C_B));
s = DeviceSnapshot();
s.btn[SDL_GAMEPAD_BUTTON_EAST] = true;
CHECK(evaluate(pad, s) == only(C_A));
s = DeviceSnapshot();
s.btn[SDL_GAMEPAD_BUTTON_WEST] = true;
CHECK(evaluate(pad, s) == only(C_C));
s = DeviceSnapshot();
s.btn[SDL_GAMEPAD_BUTTON_START] = true;
CHECK(evaluate(pad, s) == only(C_OPTION));
s = DeviceSnapshot();
s.btn[SDL_GAMEPAD_BUTTON_BACK] = true;
CHECK(evaluate(pad, s) == only(C_PAUSE));
s = DeviceSnapshot();
s.btn[SDL_GAMEPAD_BUTTON_MISC1] = true;
CHECK(evaluate(pad, s) == only(C_OVERLAY));
// The right stick resolves to ONE keypad key: the dominant axis.
s = DeviceSnapshot();
s.axis[SDL_GAMEPAD_AXIS_RIGHTX] = -20000;
s.axis[SDL_GAMEPAD_AXIS_RIGHTY] = -30000;
CHECK(evaluate(pad, s) == only(C_K8));
s.axis[SDL_GAMEPAD_AXIS_RIGHTX] = 30000;
s.axis[SDL_GAMEPAD_AXIS_RIGHTY] = -20000;
CHECK(evaluate(pad, s) == only(C_K9));
// Below the threshold nothing happens.
s = DeviceSnapshot();
s.axis[SDL_GAMEPAD_AXIS_RIGHTX] = 15000;
CHECK(!evaluate(pad, s).any());
// Triggers have their own, lower threshold.
s = DeviceSnapshot();
s.axis[SDL_GAMEPAD_AXIS_LEFT_TRIGGER] = 9000;
CHECK(evaluate(pad, s) == only(C_K3));
s.axis[SDL_GAMEPAD_AXIS_LEFT_TRIGGER] = 7000;
CHECK(!evaluate(pad, s).any());
// The left stick drives the d-pad (diagonals allowed) unless switched off.
s = DeviceSnapshot();
s.axis[SDL_GAMEPAD_AXIS_LEFTX] = -30000;
s.axis[SDL_GAMEPAD_AXIS_LEFTY] = 30000;
ControlSet cs = evaluate(pad, s);
CHECK(cs.test(C_LEFT) && cs.test(C_DOWN) && !cs.test(C_UP) && !cs.test(C_RIGHT));
pad.stickAsDpad = false;
CHECK(!evaluate(pad, s).any());
}
// ---------------------------------------------------------------------------------
static std::vector<Binding> one(const Binding& b) { return std::vector<Binding>(1, b); }
static void testPrecedence() {
Config cfg; // in memory only
BindingStore st(cfg);
const std::string G = "crc32-deadbeef", OTHER = "crc32-00000001";
const std::string PAD = "030000005e040000ea02000000007801", PAD2 = "0300000000000000000000000000dead";
const Binding kA = Binding::key(SDL_SCANCODE_A), kB = Binding::key(SDL_SCANCODE_B),
kC = Binding::key(SDL_SCANCODE_C), kD = Binding::key(SDL_SCANCODE_D);
// Nothing stored: the built-in defaults, tagged as such.
Profile p = st.resolve(DeviceClass::Keyboard, "", "");
CHECK(p.source[C_A] == SRC_DEFAULT && p.binds[C_A] == Profile::builtin(DeviceClass::Keyboard).binds[C_A]);
// global / class
st.set(LayerId{"", DeviceClass::Gamepad, ""}, C_A, one(kA));
p = st.resolve(DeviceClass::Gamepad, PAD, "");
CHECK(p.binds[C_A] == one(kA) && p.source[C_A] == SRC_GLOBAL);
CHECK(p.source[C_B] == SRC_DEFAULT); // untouched controls fall through
// The keyboard is a separate class.
CHECK(st.resolve(DeviceClass::Keyboard, "", "").source[C_A] == SRC_DEFAULT);
// global / this controller beats global / class, for that controller only
st.set(LayerId{"", DeviceClass::Gamepad, PAD}, C_A, one(kB));
CHECK(st.resolve(DeviceClass::Gamepad, PAD, "").binds[C_A] == one(kB));
CHECK(st.resolve(DeviceClass::Gamepad, PAD, "").source[C_A] == SRC_GLOBAL_PAD);
CHECK(st.resolve(DeviceClass::Gamepad, PAD2, "").binds[C_A] == one(kA));
// this game / class beats both globals, for that game only
st.set(LayerId{G, DeviceClass::Gamepad, ""}, C_A, one(kC));
CHECK(st.resolve(DeviceClass::Gamepad, PAD, G).binds[C_A] == one(kC));
CHECK(st.resolve(DeviceClass::Gamepad, PAD, G).source[C_A] == SRC_GAME);
CHECK(st.resolve(DeviceClass::Gamepad, PAD, OTHER).binds[C_A] == one(kB));
CHECK(st.resolve(DeviceClass::Gamepad, PAD, "").binds[C_A] == one(kB));
// this game / this controller beats everything
st.set(LayerId{G, DeviceClass::Gamepad, PAD}, C_A, one(kD));
CHECK(st.resolve(DeviceClass::Gamepad, PAD, G).binds[C_A] == one(kD));
CHECK(st.resolve(DeviceClass::Gamepad, PAD, G).source[C_A] == SRC_GAME_PAD);
CHECK(st.resolve(DeviceClass::Gamepad, PAD2, G).binds[C_A] == one(kC)); // other model: game / class
// Clearing a layer's entry falls back to the next layer down.
st.clear(LayerId{G, DeviceClass::Gamepad, PAD}, C_A);
CHECK(st.resolve(DeviceClass::Gamepad, PAD, G).binds[C_A] == one(kC));
st.clear(LayerId{G, DeviceClass::Gamepad, ""}, C_A);
CHECK(st.resolve(DeviceClass::Gamepad, PAD, G).binds[C_A] == one(kB));
st.clear(LayerId{"", DeviceClass::Gamepad, PAD}, C_A);
CHECK(st.resolve(DeviceClass::Gamepad, PAD, G).binds[C_A] == one(kA));
st.clear(LayerId{"", DeviceClass::Gamepad, ""}, C_A);
CHECK(st.resolve(DeviceClass::Gamepad, PAD, G).source[C_A] == SRC_DEFAULT);
// An explicit "unbound" in a higher layer shadows a binding below it (it is not "absent").
st.set(LayerId{"", DeviceClass::Keyboard, ""}, C_UP, one(kA));
st.set(LayerId{G, DeviceClass::Keyboard, ""}, C_UP, {});
CHECK(st.defined(LayerId{G, DeviceClass::Keyboard, ""}, C_UP));
p = st.resolve(DeviceClass::Keyboard, "", G);
CHECK(p.binds[C_UP].empty() && p.source[C_UP] == SRC_GAME);
CHECK(st.resolve(DeviceClass::Keyboard, "", OTHER).binds[C_UP] == one(kA));
// The keyboard ignores controller layers; the keyboard port defaults to 0.
st.set(LayerId{"", DeviceClass::Gamepad, PAD}, C_B, one(kD));
CHECK(st.resolve(DeviceClass::Keyboard, PAD, "").source[C_B] == SRC_DEFAULT);
CHECK(st.keyboardPort() == 0);
st.setKeyboardPort(1);
CHECK(st.keyboardPort() == 1);
st.setKeyboardPort(7);
CHECK(st.keyboardPort() == 0);
// clearLayer drops every control of one layer and no other.
st.set(LayerId{G, DeviceClass::Gamepad, ""}, C_K7, one(kA));
st.set(LayerId{G, DeviceClass::Gamepad, ""}, C_K8, one(kB));
st.clearLayer(LayerId{G, DeviceClass::Gamepad, ""});
CHECK(!st.defined(LayerId{G, DeviceClass::Gamepad, ""}, C_K7));
CHECK(!st.defined(LayerId{G, DeviceClass::Gamepad, ""}, C_K8));
CHECK(st.defined(LayerId{"", DeviceClass::Gamepad, PAD}, C_B));
}
static void testPersistence() {
namespace fs = std::filesystem;
std::error_code ec;
fs::path dir = fs::temp_directory_path(ec) / ("vj-remap-test-" + std::to_string((long long)std::chrono::steady_clock::now().time_since_epoch().count()));
fs::create_directories(dir, ec);
std::string path = (dir / "config.ini").string();
const std::string G = "crc32-1234abcd", PAD = "03000000c82d00000160000000000000";
std::vector<Binding> multi = {Binding::button(SDL_GAMEPAD_BUTTON_LEFT_SHOULDER),
Binding::axis(SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 1), Binding::key(SDL_SCANCODE_F9)};
Profile before;
{
Config cfg;
cfg.setPath(path);
BindingStore st(cfg);
st.set(LayerId{"", DeviceClass::Gamepad, ""}, C_K1, multi);
st.set(LayerId{G, DeviceClass::Gamepad, PAD}, C_HASH, one(Binding::button(SDL_GAMEPAD_BUTTON_TOUCHPAD)));
st.set(LayerId{G, DeviceClass::Keyboard, ""}, C_PAUSE, {});
st.setKeyboardPort(1);
CHECK(cfg.save());
before = st.resolve(DeviceClass::Gamepad, PAD, G);
}
{
Config cfg;
cfg.load(path);
BindingStore st(cfg);
Profile after = st.resolve(DeviceClass::Gamepad, PAD, G);
for (int i = 0; i < C_COUNT; i++) {
CHECK(after.binds[(size_t)i] == before.binds[(size_t)i]);
CHECK(after.source[(size_t)i] == before.source[(size_t)i]);
}
CHECK(after.binds[C_K1] == multi);
CHECK(after.source[C_HASH] == SRC_GAME_PAD);
CHECK(st.resolve(DeviceClass::Keyboard, "", G).binds[C_PAUSE].empty());
CHECK(st.resolve(DeviceClass::Keyboard, "", "").binds[C_PAUSE].size() == 2); // other games keep the default
CHECK(st.keyboardPort() == 1);
// A hand-edited / corrupt value falls through to the layer below instead of unbinding.
cfg.set("input.pad.k2", "bnonsense,k");
CHECK(!st.defined(LayerId{"", DeviceClass::Gamepad, ""}, C_K2));
CHECK(st.resolve(DeviceClass::Gamepad, PAD, "").source[C_K2] == SRC_DEFAULT);
}
fs::remove_all(dir, ec); // our own temp dir only
}
// ---------------------------------------------------------------------------------
// controls -> what the core reads. The expected tables below are transcribed from
// libretro.c (update_input default path), NOT derived from remap.cpp.
// ---------------------------------------------------------------------------------
static void testCoreInput() {
// Pad controls ride the RetroPad bitmask (libretro.c: ret[0] & (1 << RETRO_DEVICE_ID_JOYPAD_x)).
struct { Ctl c; unsigned id; } padMap[] = {
{C_UP, RETRO_DEVICE_ID_JOYPAD_UP}, {C_DOWN, RETRO_DEVICE_ID_JOYPAD_DOWN},
{C_LEFT, RETRO_DEVICE_ID_JOYPAD_LEFT}, {C_RIGHT, RETRO_DEVICE_ID_JOYPAD_RIGHT},
{C_A, RETRO_DEVICE_ID_JOYPAD_A}, {C_B, RETRO_DEVICE_ID_JOYPAD_B}, {C_C, RETRO_DEVICE_ID_JOYPAD_Y},
{C_OPTION, RETRO_DEVICE_ID_JOYPAD_START}, {C_PAUSE, RETRO_DEVICE_ID_JOYPAD_SELECT}};
for (unsigned port = 0; port < 2; port++) {
for (const auto& m : padMap) {
CoreInput ci;
ci.beginFrame();
ci.addControls(port, only(m.c));
CHECK(ci.state(port, RETRO_DEVICE_JOYPAD, 0, m.id) == 1);
CHECK((ci.state(port, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_MASK) & 0xFFFF) == (1 << m.id));
CHECK(ci.state(1 - port, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_MASK) == 0);
CHECK(!ci.keypadSynthesised());
}
}
// Keypad controls become keyboard keys on frontend port 0: port 1 = 0-9 - =, port 2 = p q w e r t y u i o [ ].
const unsigned p1[12] = {RETROK_0, RETROK_1, RETROK_2, RETROK_3, RETROK_4, RETROK_5,
RETROK_6, RETROK_7, RETROK_8, RETROK_9, RETROK_MINUS, RETROK_EQUALS};
const unsigned p2[12] = {'p', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', '[', ']'};
for (unsigned port = 0; port < 2; port++) {
for (int k = 0; k < 12; k++) {
Ctl c = (Ctl)(C_K0 + k);
unsigned want = port == 0 ? p1[k] : p2[k];
CoreInput ci;
ci.beginFrame();
ci.addControls(port, only(c));
CHECK(ci.keypadSynthesised());
CHECK(ci.state(0, RETRO_DEVICE_KEYBOARD, 0, want) == 1);
// Exactly one key down, and the RetroPad is untouched: pressing RetroPad X / L1 / R1 /
// L2 / R2 / L3 / R3 as well would press the same Jaguar key a second time (or an alias
// under the Pro Controller).
int down = 0;
for (unsigned rk = 0; rk < 512; rk++) down += ci.state(0, RETRO_DEVICE_KEYBOARD, 0, rk) ? 1 : 0;
CHECK(down == 1);
CHECK(ci.pad(0) == 0 && ci.pad(1) == 0);
// The core only ever reads the keyboard on port 0.
CHECK(ci.state(1, RETRO_DEVICE_KEYBOARD, 0, want) == 0);
CHECK(CoreInput::keypadKey(port, c) == want);
}
}
// Two players' keypads never share a key, so both can be used at once.
CoreInput both;
both.beginFrame();
both.addControls(0, only(C_K7));
both.addControls(1, only(C_K7));
CHECK(both.state(0, RETRO_DEVICE_KEYBOARD, 0, RETROK_7) == 1);
CHECK(both.state(0, RETRO_DEVICE_KEYBOARD, 0, 'u') == 1);
// The truthful keyboard: other keys pass through (push-to-talk reads them) but the 24 keys the
// core's default path reads as keypad are only ever answered from synthesis.
CoreInput ci;
ci.beginFrame();
ci.addRawKey(RETROK_v);
ci.addRawKey(RETROK_SPACE);
ci.addRawKey(RETROK_5); // physical '5' -- masked
ci.addRawKey('p'); // physical 'p' -- masked
ci.addRawKey(RETROK_KP5); // numpad -- the core only reads it in alt-input mode
CHECK(ci.state(0, RETRO_DEVICE_KEYBOARD, 0, RETROK_v) == 1);
CHECK(ci.state(0, RETRO_DEVICE_KEYBOARD, 0, RETROK_SPACE) == 1);
CHECK(ci.state(0, RETRO_DEVICE_KEYBOARD, 0, RETROK_KP5) == 1);
CHECK(ci.state(0, RETRO_DEVICE_KEYBOARD, 0, RETROK_5) == 0);
CHECK(ci.state(0, RETRO_DEVICE_KEYBOARD, 0, 'p') == 0);
for (unsigned port = 0; port < 2; port++)
for (int k = 0; k < 12; k++) CHECK(CoreInput::isCoreKeypadKey(CoreInput::keypadKey(port, (Ctl)(C_K0 + k))));
CHECK(!CoreInput::isCoreKeypadKey(RETROK_v));
// Rebinding the physical '1' key to Jaguar A: the keyboard profile no longer says K1, so
// pressing '1' must not press keypad 1 -- not even through the truthful key state.
{
Config cfg;
BindingStore st(cfg);
st.set(LayerId{"", DeviceClass::Keyboard, ""}, C_A, one(Binding::key(SDL_SCANCODE_1)));
st.set(LayerId{"", DeviceClass::Keyboard, ""}, C_K1, {});
Profile kb = st.resolve(DeviceClass::Keyboard, "", "");
kb.stickAsDpad = false;
ControlSet cs = evaluate(kb, keyDown(SDL_SCANCODE_1));
CHECK(cs == only(C_A));
CoreInput r;
r.beginFrame();
r.addRawKey(RETROK_1);
r.addControls(0, cs);
CHECK(r.state(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_A) == 1);
CHECK(r.state(0, RETRO_DEVICE_KEYBOARD, 0, RETROK_1) == 0);
}
// Raw RetroPad ports (analog / 6D / driving / Team Tap) see the physical pad unchanged.
CoreInput raw;
raw.beginFrame();
raw.addRawPad(0, (uint16_t)((1u << RETRO_DEVICE_ID_JOYPAD_L3) | (1u << RETRO_DEVICE_ID_JOYPAD_X)));
CHECK(raw.state(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_L3) == 1);
CHECK(raw.state(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_X) == 1);
CHECK(!raw.keypadSynthesised());
// beginFrame clears everything.
raw.beginFrame();
CHECK(raw.pad(0) == 0);
}
// ---------------------------------------------------------------------------------
static void testOverlay() {
CHECK(KeypadOverlay::keyAt(0, 0) == C_K1 && KeypadOverlay::keyAt(0, 2) == C_K3);
CHECK(KeypadOverlay::keyAt(2, 0) == C_K7 && KeypadOverlay::keyAt(2, 2) == C_K9);
CHECK(KeypadOverlay::keyAt(3, 0) == C_STAR && KeypadOverlay::keyAt(3, 1) == C_K0 && KeypadOverlay::keyAt(3, 2) == C_HASH);
CHECK(KeypadOverlay::keyAt(-1, -1) == C_HASH); // wraps
KeypadOverlay ov;
ControlSet none;
// Closed: input passes through, minus the overlay control itself.
ControlSet pass = ov.filter(0, only(C_B));
CHECK(pass == only(C_B));
// The overlay control opens it; the game then sees nothing from any device.
ControlSet in = only(C_OVERLAY);
in.set(C_B);
CHECK(ov.filter(0, in) == none);
CHECK(ov.open);
CHECK(ov.filter(0, none) == none);
CHECK(ov.filter(1, only(C_UP)) == none); // another pad: nav + swallowed
CHECK(ov.row == 3); // wrapped up from row 0
// Navigate to 5 (row 1, col 1) and press it with A.
ov.row = 0;
ov.col = 0;
ov.filter(0, none);
ov.filter(0, only(C_DOWN));
ov.filter(0, none);
ov.filter(0, only(C_RIGHT));
ov.filter(0, none);
CHECK(ov.row == 1 && ov.col == 1);
ov.filter(0, only(C_A));
ov.filter(0, none);
// The press is held for kHoldFrames, released, then the gap passes.
int heldFrames = 0;
for (int f = 0; f < 30; f++) {
ov.tick();
if (ov.held().test(C_K5)) heldFrames++;
CHECK(ov.held().bits == 0 || ov.held() == only(C_K5));
}
CHECK(heldFrames == KeypadOverlay::kHoldFrames);
// Two taps queue and don't merge: a gap of released frames separates them.
ov.tap(C_K1);
ov.tap(C_K1);
int transitions = 0;
bool last = false;
for (int f = 0; f < 40; f++) {
ov.tick();
bool now = ov.held().test(C_K1);
if (now && !last) transitions++;
last = now;
}
CHECK(transitions == 2);
ov.tap(C_UP); // not a keypad key: ignored
for (int f = 0; f < 20; f++) {
ov.tick();
CHECK(!ov.held().any());
}
// C closes it, and the C press that closed it is not leaked to the game.
CHECK(ov.filter(0, only(C_C)) == none);
CHECK(!ov.open);
CHECK(ov.filter(0, only(C_C)) == none); // still held: suppressed
CHECK(ov.filter(0, none) == none);
CHECK(ov.filter(0, only(C_C)) == only(C_C)); // released and pressed again: passes
// The overlay toggles off with its own control, too.
ov.filter(0, none);
ov.filter(0, only(C_OVERLAY));
CHECK(ov.open);
ov.filter(0, none);
ov.filter(0, only(C_OVERLAY));
CHECK(!ov.open);
}
// ---------------------------------------------------------------------------------
static SDL_Event keyEvent(SDL_EventType t, SDL_Scancode sc, bool repeat = false) {
SDL_Event e;
SDL_zero(e);
e.type = t;
e.key.scancode = sc;
e.key.repeat = repeat;
return e;
}
static SDL_Event buttonEvent(SDL_GamepadButton b) {
SDL_Event e;
SDL_zero(e);
e.type = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
e.gbutton.which = 7;
e.gbutton.button = (Uint8)b;
return e;
}
static SDL_Event axisEvent(SDL_JoystickID which, SDL_GamepadAxis a, int16_t v) {
SDL_Event e;
SDL_zero(e);
e.type = SDL_EVENT_GAMEPAD_AXIS_MOTION;
e.gaxis.which = which;
e.gaxis.axis = (Uint8)a;
e.gaxis.value = v;
return e;
}
static void testCapture() {
Binding out;
BindCapture cap;
CHECK(!cap.active());
CHECK(cap.feed(keyEvent(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_Q), out) == BindCapture::Pending); // inactive
cap.begin(DeviceClass::Keyboard);
CHECK(cap.active());
CHECK(cap.feed(keyEvent(SDL_EVENT_KEY_UP, SDL_SCANCODE_RETURN), out) == BindCapture::Pending); // the key that clicked "Bind"
CHECK(cap.feed(keyEvent(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_Q, true), out) == BindCapture::Pending); // auto-repeat
CHECK(cap.feed(buttonEvent(SDL_GAMEPAD_BUTTON_SOUTH), out) == BindCapture::Pending); // wrong device class
CHECK(cap.active());
CHECK(cap.feed(keyEvent(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_Q), out) == BindCapture::Captured);
CHECK(out == Binding::key(SDL_SCANCODE_Q));
CHECK(!cap.active());
cap.begin(DeviceClass::Keyboard);
CHECK(cap.feed(keyEvent(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_ESCAPE), out) == BindCapture::Cancelled);
CHECK(!cap.active());
cap.begin(DeviceClass::Gamepad);
CHECK(cap.feed(keyEvent(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_Q), out) == BindCapture::Pending);
CHECK(cap.feed(buttonEvent(SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER), out) == BindCapture::Captured);
CHECK(out == Binding::button(SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER));
// Axes bind on movement relative to where they rested, and the push direction is recorded.
cap.begin(DeviceClass::Gamepad);
cap.setBaseline(7, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, -32768); // some pads rest negative
CHECK(cap.feed(axisEvent(7, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, -32000), out) == BindCapture::Pending);
CHECK(cap.feed(axisEvent(7, SDL_GAMEPAD_AXIS_LEFTX, 5000), out) == BindCapture::Pending); // jitter
CHECK(cap.feed(axisEvent(7, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, -10000), out) == BindCapture::Captured);
CHECK(out == Binding::axis(SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1));
cap.begin(DeviceClass::Gamepad);
CHECK(cap.feed(axisEvent(7, SDL_GAMEPAD_AXIS_RIGHTY, -30000), out) == BindCapture::Captured);
CHECK(out == Binding::axis(SDL_GAMEPAD_AXIS_RIGHTY, -1));
// A stick already held at full deflection when capture began doesn't bind until it moves further.
cap.begin(DeviceClass::Gamepad);
cap.setBaseline(7, SDL_GAMEPAD_AXIS_RIGHTX, 32000);
CHECK(cap.feed(axisEvent(7, SDL_GAMEPAD_AXIS_RIGHTX, 31000), out) == BindCapture::Pending);
CHECK(cap.feed(axisEvent(7, SDL_GAMEPAD_AXIS_RIGHTX, 0), out) == BindCapture::Captured);
CHECK(out == Binding::axis(SDL_GAMEPAD_AXIS_RIGHTX, -1));
}
// ---------------------------------------------------------------------------------
static void testIdentity() {
const char* check = "123456789";
CHECK(crc32Bytes((const uint8_t*)check, 9) == 0xCBF43926u);
// A plain 1 MiB image is hashed whole.
std::vector<uint8_t> rom(1048576);
for (size_t i = 0; i < rom.size(); i++) rom[i] = (uint8_t)(i * 31u + 7u);
std::string plain = cartIdentity(rom.data(), rom.size());
char buf[32];
std::snprintf(buf, sizeof buf, "crc32-%08x", (unsigned)crc32Bytes(rom.data(), rom.size()));
CHECK(plain == buf);
// A copier-headered image (N MiB + 512, universal marker $04040404 at payload+$400) hashes as the
// payload, so it gets the same identity as the headerless dump -- the key titledb uses.
rom[0x400] = rom[0x401] = rom[0x402] = rom[0x403] = 4;
plain = cartIdentity(rom.data(), rom.size());
std::vector<uint8_t> headered(512, 0xAA);
headered.insert(headered.end(), rom.begin(), rom.end());
CHECK(headered.size() == 1048576 + 512);
CHECK(cartIdentity(headered.data(), headered.size()) == plain);
// ...but the same bytes WITHOUT the marker are not stripped.
headered[512 + 0x400] = 9;
CHECK(cartIdentity(headered.data(), headered.size()) != plain);
CHECK(cartIdentity(nullptr, 0).empty());
// Discs are path-loaded: identified by file stem (no file access happens for these).
CHECK(contentIdentity("/games/Myst (USA) [Disc 1].cue") == "disc-myst-usa-disc-1");
CHECK(contentIdentity("C:\\x\\Battle_Sphere.CHD") == "disc-battle-sphere");
CHECK(contentIdentity("/nonexistent/path/missing.j64").empty());
}
// ---------------------------------------------------------------------------------
// What a build WITHOUT the "~hold" suffix makes of a stored binding list: a copy of
// the old Binding::parse + parseBindings. Documents how an older app reads a profile
// written by a TV-mode one.
static bool legacyParseOne(const std::string& s, Binding& out) {
if (s.size() < 2) return false;
if (s[0] == 'k') {
char* end = nullptr;
long v = std::strtol(s.c_str() + 1, &end, 10);
if (!end || *end != '\0' || v <= 0 || v >= SDL_SCANCODE_COUNT) return false;
out = Binding::key((int)v);
return true;
}
if (s[0] == 'b') {
SDL_GamepadButton b = SDL_GetGamepadButtonFromString(s.c_str() + 1);
if (b == SDL_GAMEPAD_BUTTON_INVALID) return false;
out = Binding::button((int)b);
return true;
}
return false;
}
static std::vector<Binding> legacyParse(const std::string& s) {
std::vector<Binding> out;
size_t pos = 0;
while (pos <= s.size()) {
size_t comma = s.find(',', pos);
std::string tok = s.substr(pos, comma == std::string::npos ? std::string::npos : comma - pos);
Binding b;
if (!tok.empty() && legacyParseOne(tok, b) && std::find(out.begin(), out.end(), b) == out.end()) out.push_back(b);
if (comma == std::string::npos) break;
pos = comma + 1;
}
return out;
}
static void testTvMenuControl() {
CHECK(C_TVMENU == C_HELP + 1 && C_PRO_X == C_TVMENU + 1 && isActionCtl(C_TVMENU)); // the Pro controls follow it
CHECK(std::string(ctlId(C_TVMENU)) == "tvmenu");
CHECK(std::string(ctlLabel(C_TVMENU)) == "TV menu / TV mode");
CHECK(std::string(ctlId(C_HELP)) == "help" && std::string(ctlId(C_OVERLAY)) == "overlay"); // ids unmoved
Ctl back = C_UP;
CHECK(ctlFromId("tvmenu", back) && back == C_TVMENU);
}
static void testHoldBindings() {
Binding g = Binding::button(SDL_GAMEPAD_BUTTON_MISC1).withHold();
CHECK(g.serialize() == "bmisc1~hold");
Binding p;
CHECK(Binding::parse("bmisc1~hold", p) && p == g && p.hold);
CHECK(Binding::parse("k61~hold", p) && p.kind == Binding::Key && p.hold);
CHECK(!Binding::parse("a+lefty~hold", p)); // hold is for keys and buttons only
CHECK(!Binding::parse("~hold", p));
CHECK(g.display() == "Share / Capture (hold)");
CHECK(Binding::button(SDL_GAMEPAD_BUTTON_GUIDE) != Binding::button(SDL_GAMEPAD_BUTTON_GUIDE).withHold());
// A plain binding parses with hold clear.
CHECK(Binding::parse("bmisc1", p) && !p.hold);
// Round trip through a list.
std::vector<Binding> v = {Binding::button(SDL_GAMEPAD_BUTTON_GUIDE), g};
bool ok = false;
CHECK(parseBindings(serializeBindings(v), &ok) == v && ok);
// What a pre-TV build does with the new form: it rejects only the ~hold token.
CHECK(legacyParse("bguide,bmisc1~hold").size() == 1);
CHECK(parseBindings("bguide,bmisc1~hold").size() == 2);
}
static void testTvMenuDefaults() {
Profile withShare = Profile::builtin(DeviceClass::Gamepad, PadCaps{true});
Profile noShare = Profile::builtin(DeviceClass::Gamepad, PadCaps{false});
auto has = [](const Profile& pr, Binding b) {
const std::vector<Binding>& v = pr.binds[C_TVMENU];
return std::find(v.begin(), v.end(), b) != v.end();
};
CHECK(has(withShare, Binding::button(SDL_GAMEPAD_BUTTON_MISC1).withHold()));
CHECK(!has(withShare, Binding::button(SDL_GAMEPAD_BUTTON_GUIDE).withHold())); // Guide keeps plain #
CHECK(has(noShare, Binding::button(SDL_GAMEPAD_BUTTON_GUIDE).withHold()));
CHECK(!has(noShare, Binding::button(SDL_GAMEPAD_BUTTON_MISC1).withHold()));
for (int b : {SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1, SDL_GAMEPAD_BUTTON_LEFT_PADDLE1, SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2,
SDL_GAMEPAD_BUTTON_LEFT_PADDLE2}) {
CHECK(has(withShare, Binding::button(b))); // plain presses
CHECK(has(noShare, Binding::button(b)));
}
CHECK(has(Profile::builtin(DeviceClass::Keyboard), Binding::key(SDL_SCANCODE_F4)));
CHECK(!has(withShare, Binding::button(SDL_GAMEPAD_BUTTON_GUIDE))); // C_TVMENU never gets plain Guide
CHECK(std::find(withShare.binds[C_HASH].begin(), withShare.binds[C_HASH].end(),
Binding::button(SDL_GAMEPAD_BUTTON_GUIDE)) != withShare.binds[C_HASH].end());
// The default argument is "has Share".
CHECK(has(Profile::builtin(DeviceClass::Gamepad), Binding::button(SDL_GAMEPAD_BUTTON_MISC1).withHold()));
// resolve() hands the capabilities to builtin(), and a stored layer still overrides them.
Config cfg;
BindingStore store(cfg);
CHECK(has(store.resolve(DeviceClass::Gamepad, "", "", PadCaps{false}), Binding::button(SDL_GAMEPAD_BUTTON_GUIDE).withHold()));
CHECK(has(store.resolve(DeviceClass::Gamepad, "", ""), Binding::button(SDL_GAMEPAD_BUTTON_MISC1).withHold()));
store.set(LayerId{"", DeviceClass::Gamepad, ""}, C_TVMENU, {Binding::button(SDL_GAMEPAD_BUTTON_START)});
Profile over = store.resolve(DeviceClass::Gamepad, "", "", PadCaps{false});
CHECK(over.binds[C_TVMENU].size() == 1 && over.source[C_TVMENU] == SRC_GLOBAL);
// A shared layer that stored the Share default (the controls screen's "Add" seeds from the
// default list) must not strip a Share-less pad of its Guide-hold: resolve maps MISC1~hold to
// GUIDE~hold for that pad, from any layer, and nothing else.
{
Config cfg2;
BindingStore st2(cfg2);
const Binding share = Binding::button(SDL_GAMEPAD_BUTTON_MISC1).withHold();
const Binding guide = Binding::button(SDL_GAMEPAD_BUTTON_GUIDE).withHold();
const Binding paddle = Binding::button(SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1);
st2.set(LayerId{"", DeviceClass::Gamepad, ""}, C_TVMENU, {share, paddle});
Profile ns = st2.resolve(DeviceClass::Gamepad, "pad-a", "", PadCaps{false});
CHECK(has(ns, guide) && has(ns, paddle) && !has(ns, share) && ns.binds[C_TVMENU].size() == 2);
CHECK(ns.source[C_TVMENU] == SRC_GLOBAL);
Profile ws = st2.resolve(DeviceClass::Gamepad, "pad-a", "", PadCaps{true});
CHECK(has(ws, share) && has(ws, paddle) && !has(ws, guide) && ws.binds[C_TVMENU].size() == 2);
for (const Profile* pr : {&ns, &ws}) {
const std::vector<Binding>& h = pr->binds[C_HASH];
CHECK(std::find(h.begin(), h.end(), Binding::button(SDL_GAMEPAD_BUTTON_GUIDE)) != h.end());
}
// An explicit Guide-hold survives for a Share pad (the mapping only goes MISC1 -> GUIDE),
// and a Share-less pad with both listed gets it once.
st2.set(LayerId{"", DeviceClass::Gamepad, "pad-b"}, C_TVMENU, {guide});
CHECK(has(st2.resolve(DeviceClass::Gamepad, "pad-b", "", PadCaps{true}), guide));
st2.set(LayerId{"", DeviceClass::Gamepad, "pad-b"}, C_TVMENU, {share, guide});
Profile dd = st2.resolve(DeviceClass::Gamepad, "pad-b", "", PadCaps{false});
CHECK(dd.binds[C_TVMENU].size() == 1 && has(dd, guide));
}
// A hold binding is never "down" in evaluate: only the arbiter (a later task) fires a hold.
DeviceSnapshot s;
s.btn[SDL_GAMEPAD_BUTTON_MISC1] = true;
CHECK(!evaluateExtra(withShare, s).test(C_TVMENU));
CHECK(evaluate(withShare, s).test(C_OVERLAY)); // the plain mapping still sees the bit
DeviceSnapshot pad;
pad.btn[SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1] = true;
CHECK(evaluateExtra(withShare, pad).test(C_TVMENU)); // a plain paddle press does
}
// The standard defaults of every control that existed before the Pro Controller ones (ids 0 .. C_PRO_X - 1),
// serialised, FNV-1a 64. Frozen from the tree BEFORE C_PRO_* were added: the Pro work must not move one of them.
static std::string defaultsDump(const Profile& p) {
std::string s;
for (int i = 0; i < C_PRO_X; i++) s += std::string(ctlId((Ctl)i)) + "=" + serializeBindings(p.binds[(size_t)i]) + "\n";
return s;
}
static void testProControls() {
// Appended after C_TVMENU, nothing before them moved (saved profiles keep their meaning).
CHECK(C_PRO_X == 46 && C_PRO_R == C_PRO_X + 4 && C_COUNT == C_PRO_R + 1);
const char* ids[] = {"pro_x", "pro_y", "pro_z", "pro_l", "pro_r"};
const char* labels[] = {"X (Pro)", "Y (Pro)", "Z (Pro)", "L (Pro)", "R (Pro)"};
for (int i = 0; i < 5; i++) {
Ctl c = (Ctl)(C_PRO_X + i), back = C_UP;
CHECK(std::string(ctlId(c)) == ids[i] && std::string(ctlLabel(c)) == labels[i]);
CHECK(ctlFromId(ids[i], back) && back == c && isProCtl(c));
CHECK(!isKeypad(c) && !isAnalogCtl(c) && !isPointerCtl(c) && !isActionCtl(c));
}
CHECK(!isProCtl(C_TVMENU) && !isProCtl(C_K9));
// The standard defaults are byte-identical to before.
std::string all = defaultsDump(Profile::builtin(DeviceClass::Keyboard)) +
defaultsDump(Profile::builtin(DeviceClass::Gamepad, PadCaps{true})) +
defaultsDump(Profile::builtin(DeviceClass::Gamepad, PadCaps{false}));
unsigned long long h = 1469598103934665603ull;
for (unsigned char ch : all) {
h ^= ch;
h *= 1099511628211ull;
}
CHECK(h == 0x39c1525059c2f12dull);
if (h != 0x39c1525059c2f12dull) std::fprintf(stderr, "%s", all.c_str());
// The Pro port defaults, Genesis 6-button style: X Y Z = LB North RB, L R = the triggers ...
const Binding LB = Binding::button(SDL_GAMEPAD_BUTTON_LEFT_SHOULDER), RB = Binding::button(SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER);
const Binding N = Binding::button(SDL_GAMEPAD_BUTTON_NORTH);
const Binding LT = Binding::axis(SDL_GAMEPAD_AXIS_LEFT_TRIGGER, 1), RT = Binding::axis(SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 1);
for (bool share : {true, false}) {
Profile std_ = Profile::builtin(DeviceClass::Gamepad, PadCaps{share});
Profile pro = Profile::builtin(DeviceClass::Gamepad, PadCaps{share}, true);
const std::vector<Binding> want[5] = {{LB}, {N}, {RB}, {LT}, {RT}};
for (int i = 0; i < 5; i++) {
CHECK(pro.binds[(size_t)(C_PRO_X + i)] == want[i]);
CHECK(std_.binds[(size_t)(C_PRO_X + i)] == want[i]); // bound in both; the Router reads them on a Pro port only
}
// ... keypad 0-4 lose their pad buttons there (the overlay, Share, reaches them) ...
for (int c = C_K0; c <= C_K4; c++) CHECK(pro.binds[(size_t)c].empty() && !std_.binds[(size_t)c].empty());
// ... and everything else is the standard layout: A B C, Option, Pause, 5 / 6 on the stick clicks,
// 7 8 9 * on the right stick, # on Guide, the overlay on Share.
for (int c = 0; c < C_COUNT; c++)
if (c < C_K0 || c > C_K4) CHECK(pro.binds[(size_t)c] == std_.binds[(size_t)c]);
CHECK(pro.binds[C_A] == std::vector<Binding>{Binding::button(SDL_GAMEPAD_BUTTON_EAST)});
CHECK(pro.binds[C_B] == std::vector<Binding>{Binding::button(SDL_GAMEPAD_BUTTON_SOUTH)});
CHECK(pro.binds[C_C] == std::vector<Binding>{Binding::button(SDL_GAMEPAD_BUTTON_WEST)});
// One press, one control: no button is both a Pro button and a keypad digit on a Pro port.
DeviceSnapshot s;
s.btn[SDL_GAMEPAD_BUTTON_LEFT_SHOULDER] = true;
s.axis[SDL_GAMEPAD_AXIS_RIGHT_TRIGGER] = 32767;
CHECK(!evaluate(pro, s).any() && evaluateExtra(pro, s).test(C_PRO_X) && evaluateExtra(pro, s).test(C_PRO_R));
CHECK(evaluate(std_, s).test(C_K1) && evaluate(std_, s).test(C_K4));
}
// The keyboard's Pro port defaults are its standard ones (A S D / Q E already are keypad 9 8 7 / 4 6).
Profile kb = Profile::builtin(DeviceClass::Keyboard), kbPro = Profile::builtin(DeviceClass::Keyboard, PadCaps(), true);
for (int c = 0; c < C_COUNT; c++) CHECK(kb.binds[(size_t)c] == kbPro.binds[(size_t)c]);
for (int c = C_PRO_X; c <= C_PRO_R; c++) CHECK(kb.binds[(size_t)c].empty());
// A saved profile from before the Pro controls loads unchanged, on either kind of port; a saved keypad 1 is the
// user's choice and stays theirs on a Pro port too.
Config cfg;
cfg.set("input.pad.k1", Binding::button(SDL_GAMEPAD_BUTTON_WEST).serialize());
cfg.set("input.pad.a", Binding::button(SDL_GAMEPAD_BUTTON_NORTH).serialize());
cfg.set("input.kb.k9", "k4");
BindingStore st(cfg);
Profile p = st.resolve(DeviceClass::Gamepad, "", ""), pp = st.resolve(DeviceClass::Gamepad, "", "", PadCaps(), true);
for (const Profile* r : {&p, &pp}) {
CHECK(r->binds[C_K1] == std::vector<Binding>{Binding::button(SDL_GAMEPAD_BUTTON_WEST)} && r->source[C_K1] == SRC_GLOBAL);
CHECK(r->binds[C_A] == std::vector<Binding>{N} && r->source[C_A] == SRC_GLOBAL);
CHECK(r->binds[C_PRO_X] == std::vector<Binding>{LB} && r->source[C_PRO_X] == SRC_DEFAULT);
}
CHECK(p.binds[C_K0] == std::vector<Binding>{N} && pp.binds[C_K0].empty());
Profile k = st.resolve(DeviceClass::Keyboard, "", "");
CHECK(k.binds[C_K9] == std::vector<Binding>{Binding::key(SDL_SCANCODE_A)} && k.source[C_K9] == SRC_GLOBAL);
// ... and a Pro binding persists under its own id.
st.set(LayerId{"", DeviceClass::Gamepad, ""}, C_PRO_Y, {Binding::button(SDL_GAMEPAD_BUTTON_LEFT_STICK)});
CHECK(cfg.get("input.pad.pro_y") == Binding::button(SDL_GAMEPAD_BUTTON_LEFT_STICK).serialize());
CHECK(st.resolve(DeviceClass::Gamepad, "", "", PadCaps(), true).binds[C_PRO_Y] ==
std::vector<Binding>{Binding::button(SDL_GAMEPAD_BUTTON_LEFT_STICK)});
}
static void testPaddleLabels() {
CHECK(Binding::button(SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1).display() == "Paddle P1");
CHECK(Binding::button(SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2).display() == "Paddle P2");
CHECK(Binding::button(SDL_GAMEPAD_BUTTON_LEFT_PADDLE1).display() == "Paddle P3");
CHECK(Binding::button(SDL_GAMEPAD_BUTTON_LEFT_PADDLE2).display() == "Paddle P4");
}
int main() {
testBindingSerialisation();
testKeyboardDefaults();
testGamepadDefaults();
testPrecedence();
testPersistence();
testCoreInput();
testOverlay();
testCapture();
testIdentity();
testTvMenuControl();
testHoldBindings();
testTvMenuDefaults();
testPaddleLabels();
testProControls();
std::printf("remap_test: %d checks, %d failed\n", g_checks, g_fail);
return g_fail ? 1 : 0;
}