Files
virtualjaguar-libretro/app/test/devices_test.cpp
Joe MattielloandClaude Opus 5.5 337e595810 fix(app): Pro Controller (6-button) device with its own X Y Z L R controls (#1019)
* fix(app): add the Pro Controller (6-button) device with its own X Y Z L R controls

Settings > Input now offers "Pro Controller (6-button)" on both Jaguar ports.
The core has no RETRO_DEVICE id for it, only virtualjaguar_pN_device =
pad_pro, and the app locks that option to auto, so the core's
pro_controller[] flag could never turn on from the app. The option lock now
takes the Pro ports into account (lockedCoreOptionFor), and
CoreHost::refreshLocks() hands a changed value to the core through
GET_VARIABLE_UPDATE. retro_run() runs check_variables() before
update_input(), so a switch takes effect on the next frame. The app stores
a private id for the device in the joypad class and gives the core a
standard pad for it.

New controls X / Y / Z / L / R (Pro) are appended after C_TVMENU, so saved
ids keep their meaning. On a Pro port the Router presses them as RetroPad
X / L2 / R2 / L / R, and the core's Pro path turns those into keypad
9 / 8 / 7 / 4 / 6. They are never synthesised as keypad digits.

Gamepad defaults on a Pro port, Genesis 6-button style: A / B / C = East /
South / West, X / Y / Z = Left Shoulder / North / Right Shoulder, L / R =
the triggers. Keypad 0-4 have no pad button there and are reached through
the keypad overlay (Share). 5 / 6 stay on the stick clicks, 7 / 8 / 9 / *
on the right stick and # on Guide. The standard-pad defaults are unchanged:
a new remap_unit digest, frozen from the tree before this change, covers
every control that already existed. The keyboard has no Pro bindings,
because A S D / Q E already press keypad 9 8 7 / 4 6, the same wires.

The cheat sheet, the controls overlay and the remap table show the Pro
buttons for a Pro port.

Closes #1017

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(app): keep the Pro Controller lock on the emulation thread's owner

After #1000 the option state CoreHost keeps for the core (locked_, the
session values) belongs to the emulation thread, and the UI reads a
published snapshot of it. Three changes follow:

- refreshLocks() queues itself through `post` when it is called off the
  owner. On the owner it bumps the option serial and publishes, so the
  UI's copy of the locked values follows.
- App::proPorts is atomic, because the option lock reads it on the
  emulation thread.
- applyPortDevices() works out each port's kind from the lock's own
  value, not the UI snapshot, which lags until the queued refresh has
  run and been pinned.

With --emu-thread on, limitFeaturesForEmuThread() replaces the option
lock. Its lock called the 2-argument lockedCoreOption(), so pad_pro
never reached the core in that mode. It now uses lockedCoreOptionFor()
with the Pro ports, as wireHost()'s lock does.

Tests:
- core_host_thread: a lock value moved from the UI thread reaches the
  core before the next frame's input (RetroPad X reads as keypad 9 under
  pad_pro, keypad 0 without), the UI snapshot shows it after a pin, and
  no owner violations occur.
- pro_controller_option_on / _off: the app itself hands the core pad_pro
  for a config naming the Pro Controller, with the emulation thread on
  and off. The _on test fails when limitFeaturesForEmuThread() keeps the
  old lock.

Refs #1017

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 19:25:04 -04:00

728 lines
40 KiB
C++

// Device layer, no core: kinds and ids, per-port and per-game persistence, player-slot assignment
// (Team Tap), light-gun geometry, mouse-capture policy, locked core options, the Router's
// translation (host input -> what libretro.c would be asked for), the Pro Controller keyboard
// layout, and the controls cheat sheet (content follows the bindings; auto-show rule).
#include "cheatsheet.h"
#include "devices.h"
#include "libretro.h"
#include "remap.h"
#include "router.h"
#include <cstdio>
#include <ctime>
#include <filesystem>
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 DeviceSnapshot keys(std::initializer_list<int> sc) {
DeviceSnapshot s;
for (int k : sc) s.key[(size_t)k] = true;
return s;
}
// ---------------------------------------------------------------------------------
static void testKinds() {
// The ids libretro.c advertises: RETRO_DEVICE_SUBCLASS(base, n) = ((n + 1) << 8) | base.
CHECK(devLibretroId(DevKind::TeamTap) == ((1u << 8) | RETRO_DEVICE_JOYPAD));
CHECK(devLibretroId(DevKind::MouseST) == ((1u << 8) | RETRO_DEVICE_MOUSE));
CHECK(devLibretroId(DevKind::Rotary) == ((4u << 8) | RETRO_DEVICE_MOUSE));
CHECK(devLibretroId(DevKind::Analog) == ((1u << 8) | RETRO_DEVICE_ANALOG));
CHECK(devLibretroId(DevKind::SixD) == ((4u << 8) | RETRO_DEVICE_ANALOG));
CHECK(devLibretroId(DevKind::LightGun) == RETRO_DEVICE_LIGHTGUN);
for (int i = 0; i < kDevKinds; i++) {
DevKind k;
CHECK(devKindFromLibretro(devLibretroId((DevKind)i), k) && k == (DevKind)i);
CHECK(devKindFromOption(devKindId((DevKind)i)) == (DevKind)i || (DevKind)i == DevKind::Pad);
CHECK(*devKindLabel((DevKind)i) && *devKindHelp((DevKind)i));
}
CHECK(devKindFromOption("auto") == DevKind::Pad && devKindFromOption("pad") == DevKind::Pad);
// The Pro Controller: the core's option value, an app-only id in the joypad class that the core is never handed.
CHECK(devKindFromOption("pad_pro") == DevKind::ProPad && std::string(devKindId(DevKind::ProPad)) == "pad_pro");
CHECK(std::string(devKindLabel(DevKind::ProPad)) == "Pro Controller (6-button)");
CHECK((devLibretroId(DevKind::ProPad) & RETRO_DEVICE_MASK) == RETRO_DEVICE_JOYPAD);
for (int i = 0; i < kDevKinds; i++)
if ((DevKind)i != DevKind::ProPad) CHECK(devLibretroId((DevKind)i) != devLibretroId(DevKind::ProPad));
CHECK(coreClaimFor(devLibretroId(DevKind::ProPad)) == RETRO_DEVICE_JOYPAD);
CHECK(coreClaimFor(devLibretroId(DevKind::Rotary)) == devLibretroId(DevKind::Rotary));
CHECK(effectiveDevKind(-1, "pad_pro") == DevKind::ProPad); // JOYPAD claim + the option
CHECK(effectiveDevKind((int)devLibretroId(DevKind::ProPad), "auto") == DevKind::ProPad);
CHECK(effectiveDevKind((int)devLibretroId(DevKind::Rotary), "pad_pro") == DevKind::Rotary); // a claim outranks it
CHECK(portOffers(0, devLibretroId(DevKind::ProPad)) && portOffers(1, devLibretroId(DevKind::ProPad)));
CHECK(!portOffers(2, devLibretroId(DevKind::ProPad)));
// The app's claim outranks the option (libretro.c: "an explicit frontend selection outranks the core option").
CHECK(effectiveDevKind(-1, "rotary") == DevKind::Rotary);
CHECK(effectiveDevKind((int)devLibretroId(DevKind::LightGun), "rotary") == DevKind::LightGun);
CHECK(effectiveDevKind((int)RETRO_DEVICE_JOYPAD, "analog") == DevKind::Analog); // JOYPAD releases the claim
CHECK(effectiveDevKind((int)RETRO_DEVICE_MOUSE, "") == DevKind::MouseST); // "a plain mouse means the ST wiring"
// SET_CONTROLLER_INFO: a mouse is port 2 only, the gun port 1 only.
CHECK(!portOffers(0, devLibretroId(DevKind::MouseST)) && portOffers(1, devLibretroId(DevKind::MouseST)));
CHECK(portOffers(0, devLibretroId(DevKind::LightGun)) && !portOffers(1, devLibretroId(DevKind::LightGun)));
CHECK(portOffers(0, devLibretroId(DevKind::Rotary)) && portOffers(1, devLibretroId(DevKind::Rotary)));
CHECK(portOffers(0, devLibretroId(DevKind::TeamTap)) && portOffers(1, devLibretroId(DevKind::TeamTap)));
CHECK(!portOffers(2, RETRO_DEVICE_JOYPAD));
}
static void testPortStore() {
Config cfg;
const std::string G = "crc32-deadbeef";
CHECK(resolvePortDevice(cfg, 0, "") == -1 && resolvePortDevice(cfg, 0, G) == -1);
cfg.setInt("port.1", 77); // the key the first Settings tab wrote
CHECK(resolvePortDevice(cfg, 1, "") == 77);
setPortDevice(cfg, 1, "", (int)devLibretroId(DevKind::MouseST));
CHECK(resolvePortDevice(cfg, 1, "") == (int)devLibretroId(DevKind::MouseST));
CHECK(!cfg.has("port.1")); // migrated
CHECK(resolvePortDevice(cfg, 1, G) == (int)devLibretroId(DevKind::MouseST)); // a game inherits the global choice
setPortDevice(cfg, 1, G, (int)devLibretroId(DevKind::Rotary));
CHECK(resolvePortDevice(cfg, 1, G) == (int)devLibretroId(DevKind::Rotary));
CHECK(resolvePortDevice(cfg, 1, "crc32-00000000") == (int)devLibretroId(DevKind::MouseST)); // other games keep the global one
CHECK(resolvePortDevice(cfg, 0, G) == -1);
clearPortDevice(cfg, 1, G);
CHECK(resolvePortDevice(cfg, 1, G) == (int)devLibretroId(DevKind::MouseST));
// Round trip through the config file.
namespace fs = std::filesystem;
std::error_code ec;
fs::path dir = fs::temp_directory_path(ec) / ("vj-devices-test-" + std::to_string((long long)std::time(nullptr)));
fs::create_directories(dir, ec);
std::string path = (dir / "c.ini").string();
setPortDevice(cfg, 0, G, (int)devLibretroId(DevKind::LightGun));
cfg.setPath(path);
CHECK(cfg.save());
Config back;
back.load(path);
CHECK(resolvePortDevice(back, 0, G) == (int)devLibretroId(DevKind::LightGun));
CHECK(resolvePortDevice(back, 1, "") == (int)devLibretroId(DevKind::MouseST));
fs::remove_all(dir, ec); // our own temp dir only
}
static void testSlots() {
SlotSource s;
CHECK(parseSlot("auto", s) && s.kind == SlotSource::Auto);
CHECK(parseSlot("none", s) && s.kind == SlotSource::None);
CHECK(parseSlot("kb", s) && s.kind == SlotSource::Keyboard);
CHECK(parseSlot("pad:0300aabb#1", s) && s.kind == SlotSource::Pad && s.guid == "0300aabb" && s.instance == 1);
CHECK(!parseSlot("pad:#1", s) && !parseSlot("pad:x#99", s) && !parseSlot("bogus", s));
SlotSource p;
p.kind = SlotSource::Pad;
p.guid = "g1";
p.instance = 2;
CHECK(parseSlot(serializeSlot(p), s) && s == p);
CHECK(slotJaguarPort(0) == 0 && slotJaguarPort(1) == 1 && slotJaguarPort(2) == 0 && slotJaguarPort(4) == 0 &&
slotJaguarPort(5) == 1 && slotJaguarPort(7) == 1);
CHECK(slotSocket(0) == 0 && slotSocket(1) == 0 && slotSocket(2) == 1 && slotSocket(4) == 3 && slotSocket(5) == 1 &&
slotSocket(7) == 3);
std::array<SlotSource, kSlots> cfg; // all auto
auto act0 = activeSlots(false, false);
CHECK(act0[0] && act0[1] && !act0[2] && !act0[5]);
// Two controllers, no tap: players 1 and 2, exactly as before. The keyboard sits on player 1.
Routes r = assignSlots(cfg, act0, {"A", "B", "C"}, 0);
CHECK(r.pad[0] == 0 && r.pad[1] == 1 && r.pad[2] == -1); // the third has no slot to go to
CHECK(r.kb[0] && !r.kb[1]);
// A tap on Jaguar port 1: further controllers fill ITS sockets (slots 2-4).
Routes t = assignSlots(cfg, activeSlots(true, false), {"A", "B", "C", "D", "E"}, 0);
CHECK(t.pad[0] == 0 && t.pad[1] == 1 && t.pad[2] == 2 && t.pad[3] == 3 && t.pad[4] == 4 && t.pad[5] == -1);
// A tap on port 2 only.
Routes t2 = assignSlots(cfg, activeSlots(false, true), {"A", "B", "C", "D"}, 0);
CHECK(t2.pad[5] == 2 && t2.pad[6] == 3 && t2.pad[2] == -1);
// Explicit: the second identical controller plays socket 3; "none" leaves a slot empty.
cfg[4].kind = SlotSource::Pad;
cfg[4].guid = "X";
cfg[4].instance = 1;
cfg[1].kind = SlotSource::None;
Routes e = assignSlots(cfg, activeSlots(true, false), {"X", "Y", "X", "Z"}, 0);
CHECK(e.pad[4] == 2); // the second "X"
CHECK(e.pad[1] == -1); // nobody
CHECK(e.pad[0] == 0 && e.pad[2] == 1 && e.pad[3] == 3); // the rest, in plug order, skipping the claimed one
// A controller that is not plugged in leaves its slot empty instead of stealing another.
cfg[4].guid = "missing";
Routes m = assignSlots(cfg, activeSlots(true, false), {"X"}, 0);
CHECK(m.pad[4] == -1 && m.pad[0] == 0);
// The keyboard: explicit slot wins over the default, and an inactive slot is never driven.
std::array<SlotSource, kSlots> k;
k[1].kind = SlotSource::Keyboard;
Routes kr = assignSlots(k, act0, {}, 0);
CHECK(!kr.kb[0] && kr.kb[1]);
Routes kd = assignSlots(std::array<SlotSource, kSlots>(), act0, {}, 1);
CHECK(kd.kb[1] && !kd.kb[0]);
CHECK(!assignSlots(std::array<SlotSource, kSlots>(), act0, {"A"}, 3).kb[3]);
// Persistence and per-game precedence.
Config cfgs;
const std::string G = "crc32-1";
CHECK(resolveSlot(cfgs, 2, G).kind == SlotSource::Auto && !slotDefined(cfgs, 2, ""));
SlotSource pk;
pk.kind = SlotSource::Keyboard;
setSlot(cfgs, 2, "", pk);
CHECK(resolveSlot(cfgs, 2, G).kind == SlotSource::Keyboard);
SlotSource pn;
pn.kind = SlotSource::None;
setSlot(cfgs, 2, G, pn);
CHECK(resolveSlot(cfgs, 2, G).kind == SlotSource::None && resolveSlot(cfgs, 2, "").kind == SlotSource::Keyboard);
clearSlot(cfgs, 2, G);
CHECK(resolveSlot(cfgs, 2, G).kind == SlotSource::Keyboard);
}
static void testSettingsAndGeometry() {
Config cfg;
const std::string G = "g";
DeviceSettings d = DeviceSettings::load(cfg, G);
CHECK(d.mouseSpeed == 1.0f && d.mousePort == -1 && d.deadzonePct == 10);
DeviceSettings::set(cfg, "", "mouse_speed", "2.5");
DeviceSettings::set(cfg, G, "rotary_speed", "0.5");
DeviceSettings::set(cfg, "", "mouse_port", "9"); // out of range -> clamped
d = DeviceSettings::load(cfg, G);
CHECK(d.mouseSpeed == 2.5f && d.rotarySpeed == 0.5f && d.mousePort == 1);
d = DeviceSettings::load(cfg, "other");
CHECK(d.rotarySpeed == 1.0f);
// The core's own inverse (libretro.c, light gun): col = ((gx + 0x8000) * w) / 0x10000.
auto col = [](int32_t gx, int w) { return ((gx + 0x8000) * w) / 0x10000; };
PicRect r{100, 50, 640, 480};
int32_t gx, gy;
CHECK(gunCoords(r, 100, 50, gx, gy) && col(gx, 320) == 0 && col(gy, 240) == 0); // top-left pixel
CHECK(gunCoords(r, 100 + 639.9f, 50 + 479.9f, gx, gy) && col(gx, 320) == 319 && col(gy, 240) == 239); // bottom-right pixel
CHECK(gunCoords(r, 420, 290, gx, gy) && col(gx, 320) == 160 && col(gy, 240) == 120); // centre
// A 3x integer-scaled picture in a bigger window: the offset and the scale both count.
PicRect big{20, 10, 960, 720};
CHECK(gunCoords(big, 20 + 3 * 100 + 1, 10 + 3 * 50 + 1, gx, gy) && col(gx, 320) == 100 && col(gy, 240) == 50);
// Outside the picture (letterbox bars, menu bar) is off-screen.
CHECK(!gunCoords(r, 99, 100, gx, gy) && !gunCoords(r, 300, 49, gx, gy));
CHECK(!gunCoords(r, 100 + 640, 100, gx, gy) && !gunCoords(r, 300, 50 + 480, gx, gy));
CHECK(!gunCoords(PicRect(), 1, 1, gx, gy));
float px, py;
gunToPointer(r, 0, 0, px, py);
CHECK(px > 100 + 319 && px < 100 + 321 && py > 50 + 239 && py < 50 + 241);
CaptureInputs c;
c.deviceWantsMouse = c.windowFocused = c.gameRunning = true;
CHECK(wantRelativeMouse(c));
{ CaptureInputs x = c; x.deviceWantsMouse = false; CHECK(!wantRelativeMouse(x)); }
{ CaptureInputs x = c; x.windowFocused = false; CHECK(!wantRelativeMouse(x)); }
{ CaptureInputs x = c; x.gameRunning = false; CHECK(!wantRelativeMouse(x)); }
{ CaptureInputs x = c; x.uiOpen = true; CHECK(!wantRelativeMouse(x)); } // never trap the cursor behind a menu
{ CaptureInputs x = c; x.userReleased = true; CHECK(!wantRelativeMouse(x)); }
std::array<DevKind, 2> k = {{DevKind::Pad, DevKind::Rotary}};
CHECK(resolveMousePort(k, -1) == 1 && resolveMousePort(k, 0) == 1 && resolveMousePort(k, 1) == 1);
k = {{DevKind::LightGun, DevKind::MouseST}};
CHECK(resolveMousePort(k, -1) == 0 && resolveMousePort(k, 1) == 1);
k = {{DevKind::Pad, DevKind::Analog}};
CHECK(resolveMousePort(k, -1) == -1);
// Core options the app owns.
std::string v;
CHECK(lockedCoreOption("virtualjaguar_alt_inputs", v) && v == "disabled");
CHECK(lockedCoreOption("virtualjaguar_p1_numpad_to_kb", v) && v == "disabled");
CHECK(lockedCoreOption("virtualjaguar_p2_retropad_analog_rr", v) && v.empty());
CHECK(lockedCoreOption("virtualjaguar_p2_device", v) && v == "auto");
// A Pro Controller port: the device option is still the app's, now pad_pro, and only on that port.
CHECK(lockedCoreOptionFor("virtualjaguar_p2_device", v, {{false, true}}) && v == "pad_pro");
CHECK(lockedCoreOptionFor("virtualjaguar_p1_device", v, {{false, true}}) && v == "auto");
CHECK(lockedCoreOptionFor("virtualjaguar_p1_device", v, {{true, false}}) && v == "pad_pro");
CHECK(lockedCoreOptionFor("virtualjaguar_alt_inputs", v, {{true, true}}) && v == "disabled");
CHECK(!lockedCoreOptionFor("virtualjaguar_pal", v, {{true, true}}));
CHECK(!lockedCoreOption("virtualjaguar_mouse_sensitivity", v) && !lockedCoreOption("virtualjaguar_rotary_deadzone", v));
CHECK(!lockedCoreOption("virtualjaguar_pal", v));
}
// ---------------------------------------------------------------------------------
// Router (no core): what libretro.c would be asked for.
// ---------------------------------------------------------------------------------
struct Rig {
Profile kbp = Profile::builtin(DeviceClass::Keyboard);
Profile padp = Profile::builtin(DeviceClass::Gamepad);
Profile padPro = Profile::builtin(DeviceClass::Gamepad, PadCaps(), true);
Router router;
CoreInput in;
RouterConfig cfg;
Rig() {
cfg.routes.kb[0] = true;
cfg.pic = PicRect{0, 0, 640, 480};
}
void frame(const DeviceSnapshot& kb, const PointerInput& ptr = PointerInput(),
const std::vector<DeviceSnapshot>& pads = {}) {
HostSource k;
k.isKeyboard = true;
k.profile = &kbp;
k.snap = kb;
std::vector<HostSource> ps;
for (const DeviceSnapshot& s : pads) {
HostSource h;
h.profile = &padp;
h.proProfile = &padPro;
h.snap = s;
ps.push_back(h);
}
in.beginFrame();
router.route(cfg, ps, &k, ptr, nullptr, in);
}
};
static void testRouter() {
// ---- ST mouse on port 2 -----------------------------------------------------------------
{
Rig r;
r.cfg.kind = {{DevKind::Pad, DevKind::MouseST}};
r.cfg.routes.kb[0] = false;
r.cfg.routes.kb[1] = true;
r.cfg.mousePort = 1;
PointerInput p;
p.dx = 7;
p.dy = -3;
p.buttons = 1;
r.frame(DeviceSnapshot(), p);
CHECK(r.in.state(1, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_X) == 7);
CHECK(r.in.state(1, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_Y) == -3);
CHECK(r.in.state(1, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_LEFT) == 1);
CHECK(r.in.state(1, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_RIGHT) == 0);
CHECK(r.in.state(0, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_X) == 0); // port 1 untouched
// The speed setting scales it, fractions are carried.
r.cfg.settings.mouseSpeed = 0.5f;
p.dx = 3;
p.dy = 0;
r.frame(DeviceSnapshot(), p);
CHECK(r.in.state(1, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_X) == 1); // 1.5 -> 1, carry 0.5
r.frame(DeviceSnapshot(), p);
CHECK(r.in.state(1, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_X) == 2); // 1.5 + 0.5 -> 2
// The keyboard's pointer keys move it too (default: the arrows), and Z / X are the buttons.
r.cfg.settings.mouseSpeed = 1.0f;
r.frame(keys({SDL_SCANCODE_RIGHT, SDL_SCANCODE_Z}));
CHECK(r.in.state(1, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_X) > 0);
CHECK(r.in.state(1, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_LEFT) == 1);
r.frame(keys({SDL_SCANCODE_X}));
CHECK(r.in.state(1, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_RIGHT) == 1);
// Nothing pressed: nothing moves (the negative control for all of the above).
r.frame(DeviceSnapshot());
CHECK(r.in.state(1, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_X) == 0);
CHECK(r.in.state(1, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_LEFT) == 0);
}
// ---- rotary: mouse X, or two keys, or a stick -------------------------------------------------
{
Rig r;
r.cfg.kind = {{DevKind::Rotary, DevKind::Pad}};
r.cfg.mousePort = 0;
PointerInput p;
p.dx = 5;
p.dy = 9; // a rotary is one wheel: Y is ignored
r.frame(DeviceSnapshot(), p);
CHECK(r.in.state(0, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_X) == 5);
CHECK(r.in.state(0, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_Y) == 0);
// Two keys: one count per frame at speed 1; the sign follows the key.
for (int i = 0; i < 4; i++) r.frame(keys({SDL_SCANCODE_RIGHT}));
CHECK(r.in.state(0, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_X) == 1);
r.frame(keys({SDL_SCANCODE_LEFT}));
CHECK(r.in.state(0, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_X) == -1);
// The sensitivity setting: speed 2.5 emits 2, 3, 2 ... (fractions carried), never loses a count.
r.cfg.settings.rotarySpeed = 2.5f;
int total = 0;
for (int i = 0; i < 4; i++) {
r.frame(keys({SDL_SCANCODE_RIGHT}));
total += r.in.state(0, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_X);
}
CHECK(total == 10);
// A gamepad stick: proportional.
r.cfg.settings.rotarySpeed = 4.0f;
DeviceSnapshot half;
half.axis[SDL_GAMEPAD_AXIS_RIGHTX] = 32767;
r.cfg.routes.pad[0] = 0;
r.cfg.routes.kb[0] = false;
r.frame(DeviceSnapshot(), PointerInput(), {half});
CHECK(r.in.state(0, RETRO_DEVICE_MOUSE, 0, RETRO_DEVICE_ID_MOUSE_X) == 4);
// The rotary keeps its buttons and keypad on the pad: Z (B) still reaches the RetroPad.
r.cfg.routes.pad[0] = -1;
r.cfg.routes.kb[0] = true;
r.cfg.settings.rotarySpeed = 1.0f;
r.frame(keys({SDL_SCANCODE_Z}));
CHECK(r.in.state(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_B) == 1);
}
// ---- analog / driving / 6D / paddle ----------------------------------------------------------------
{
Rig r;
r.cfg.kind = {{DevKind::Analog, DevKind::Pad}};
r.frame(keys({SDL_SCANCODE_D, SDL_SCANCODE_W})); // stick right + up
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_LEFT, RETRO_DEVICE_ID_ANALOG_X) == 32767);
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_LEFT, RETRO_DEVICE_ID_ANALOG_Y) == -32767); // up = -Y
r.frame(keys({SDL_SCANCODE_A, SDL_SCANCODE_S}));
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_LEFT, RETRO_DEVICE_ID_ANALOG_X) == -32767);
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_LEFT, RETRO_DEVICE_ID_ANALOG_Y) == 32767);
// D is the RetroPad's X.
r.frame(keys({SDL_SCANCODE_V}));
CHECK(r.in.state(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_X) == 1);
// Rest.
r.frame(DeviceSnapshot());
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_LEFT, RETRO_DEVICE_ID_ANALOG_X) == 0);
CHECK(r.in.state(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_X) == 0);
// A gamepad stick is proportional, with the dead zone cut out and the rest rescaled.
DeviceSnapshot g;
g.axis[SDL_GAMEPAD_AXIS_LEFTX] = 3000; // inside the 10% dead zone
r.cfg.routes.pad[0] = 0;
r.cfg.routes.kb[0] = false;
r.frame(DeviceSnapshot(), PointerInput(), {g});
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_LEFT, RETRO_DEVICE_ID_ANALOG_X) == 0);
g.axis[SDL_GAMEPAD_AXIS_LEFTX] = 20000;
r.frame(DeviceSnapshot(), PointerInput(), {g});
int x = r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_LEFT, RETRO_DEVICE_ID_ANALOG_X);
CHECK(x > 15000 && x < 20000);
g.axis[SDL_GAMEPAD_AXIS_LEFTX] = 32767;
r.frame(DeviceSnapshot(), PointerInput(), {g});
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_LEFT, RETRO_DEVICE_ID_ANALOG_X) == 32767);
}
{
Rig r; // driving: steering on stick X, R2 accelerator, L2 brake (libretro.c, the driving skin)
r.cfg.kind = {{DevKind::Driving, DevKind::Pad}};
r.frame(keys({SDL_SCANCODE_E}));
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_BUTTON, RETRO_DEVICE_ID_JOYPAD_R2) == 32767);
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_BUTTON, RETRO_DEVICE_ID_JOYPAD_L2) == 0);
r.frame(keys({SDL_SCANCODE_Q}));
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_BUTTON, RETRO_DEVICE_ID_JOYPAD_L2) == 32767);
DeviceSnapshot g;
g.axis[SDL_GAMEPAD_AXIS_RIGHT_TRIGGER] = 32767;
r.cfg.routes.pad[0] = 0;
r.cfg.routes.kb[0] = false;
r.frame(DeviceSnapshot(), PointerInput(), {g});
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_BUTTON, RETRO_DEVICE_ID_JOYPAD_R2) == 32767);
}
{
Rig r; // 6D: both sticks and four analog buttons; E / F are the stick clicks
r.cfg.kind = {{DevKind::SixD, DevKind::Pad}};
r.frame(keys({SDL_SCANCODE_J, SDL_SCANCODE_O, SDL_SCANCODE_U, SDL_SCANCODE_B, SDL_SCANCODE_N}));
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_RIGHT, RETRO_DEVICE_ID_ANALOG_X) == -32767);
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_BUTTON, RETRO_DEVICE_ID_JOYPAD_R) == 32767);
CHECK(r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_BUTTON, RETRO_DEVICE_ID_JOYPAD_L) == 32767);
CHECK(r.in.state(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_L3) == 1);
CHECK(r.in.state(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_R3) == 1);
}
{
Rig r; // paddle: left stick only; the pad stays an ordinary pad (V is NOT the RetroPad X here)
r.cfg.kind = {{DevKind::Pad, DevKind::Paddle}};
r.cfg.routes.kb[0] = false;
r.cfg.routes.kb[1] = true;
r.frame(keys({SDL_SCANCODE_D, SDL_SCANCODE_V, SDL_SCANCODE_Z}));
CHECK(r.in.state(1, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_LEFT, RETRO_DEVICE_ID_ANALOG_X) == 32767);
CHECK(r.in.state(1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_X) == 0);
CHECK(r.in.state(1, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_B) == 1);
}
// ---- light gun --------------------------------------------------------------------------------------------
{
Rig r;
r.cfg.kind = {{DevKind::LightGun, DevKind::Pad}};
r.cfg.mousePort = 0;
PointerInput p;
p.havePos = true;
p.x = 320;
p.y = 240;
r.frame(DeviceSnapshot(), p);
int gx = r.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_SCREEN_X);
int gy = r.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_SCREEN_Y);
CHECK(gx == 0 && gy == 0); // the exact middle of the picture is 0,0
CHECK(r.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_IS_OFFSCREEN) == 0);
CHECK(r.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_TRIGGER) == 0);
p.buttons = 1;
r.frame(DeviceSnapshot(), p);
CHECK(r.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_TRIGGER) == 1);
// Right click reloads: off-screen + trigger.
p.buttons = 2;
r.frame(DeviceSnapshot(), p);
CHECK(r.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_IS_OFFSCREEN) == 1);
CHECK(r.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_TRIGGER) == 1);
// The pointer on the menu bar (no position) is off-screen.
p.buttons = 0;
p.havePos = false;
r.frame(DeviceSnapshot(), p);
CHECK(r.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_IS_OFFSCREEN) == 1);
// A stick drives a cursor (when the mouse is not moving): it starts in the middle and walks.
Rig s;
s.cfg.kind = {{DevKind::LightGun, DevKind::Pad}};
s.cfg.mousePort = 0;
s.cfg.routes.kb[0] = true;
s.frame(keys({SDL_SCANCODE_RIGHT}));
int sx = s.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_SCREEN_X);
CHECK(sx > 0);
CHECK(s.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_IS_OFFSCREEN) == 0);
s.frame(keys({SDL_SCANCODE_R})); // reload key
CHECK(s.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_IS_OFFSCREEN) == 1);
CHECK(s.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_TRIGGER) == 1);
// gun_source = mouse only ignores the keys.
Rig m;
m.cfg.kind = {{DevKind::LightGun, DevKind::Pad}};
m.cfg.mousePort = 0;
m.cfg.settings.gunSource = 1;
PointerInput q;
q.havePos = true;
q.x = 640 * 0.75f;
q.y = 240;
m.frame(keys({SDL_SCANCODE_LEFT}), q);
int mx = m.in.state(0, RETRO_DEVICE_LIGHTGUN, 0, RETRO_DEVICE_ID_LIGHTGUN_SCREEN_X);
CHECK(mx > 16000 && mx < 16500);
}
// ---- Team Tap sockets ---------------------------------------------------------------------------------------
{
Rig r;
r.cfg.kind = {{DevKind::TeamTap, DevKind::Pad}};
r.cfg.routes = Routes();
r.cfg.routes.pad[0] = 0;
r.cfg.routes.pad[2] = 1;
r.cfg.routes.pad[3] = 2;
DeviceSnapshot b, a, k0, k7;
b.btn[SDL_GAMEPAD_BUTTON_SOUTH] = true; // B
a.btn[SDL_GAMEPAD_BUTTON_EAST] = true; // A
k0.btn[SDL_GAMEPAD_BUTTON_NORTH] = true; // keypad 0
k7.axis[SDL_GAMEPAD_AXIS_RIGHTX] = -30000; // keypad 7: no RetroPad button on a tap socket
r.frame(DeviceSnapshot(), PointerInput(), {b, a, k0});
CHECK(r.in.state(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_B) == 1); // socket 0
CHECK(r.in.state(2, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_A) == 1); // socket 1 = frontend port 3
CHECK(r.in.state(2, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_B) == 0);
CHECK(r.in.state(3, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_X) == 1); // keypad 0 -> X (teamtap_map)
r.frame(DeviceSnapshot(), PointerInput(), {k7, k7, k7});
for (unsigned port = 2; port < 5; port++) CHECK(r.in.state(port, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_MASK) == 0);
CHECK(CoreInput::tapPadId(C_K6) == RETRO_DEVICE_ID_JOYPAD_R3 && CoreInput::tapPadId(C_K7) == -1 &&
CoreInput::tapPadId(C_HASH) == -1);
}
}
static void testStickDpad() {
// A gamepad's left stick must not also press the D-pad where it is the analog axis.
CHECK(stickDrivesDpad(DevKind::Pad, true) && stickDrivesDpad(DevKind::TeamTap, true) && stickDrivesDpad(DevKind::Rotary, true));
CHECK(!stickDrivesDpad(DevKind::Pad, false));
for (DevKind k : {DevKind::Analog, DevKind::Driving, DevKind::SixD, DevKind::Paddle}) CHECK(!stickDrivesDpad(k, true));
DeviceSnapshot up;
up.axis[SDL_GAMEPAD_AXIS_LEFTY] = -30000;
for (DevKind k : {DevKind::Pad, DevKind::Driving}) {
Rig r;
r.padp.stickAsDpad = stickDrivesDpad(k, true); // what InputManager::poll() does
r.cfg.kind = {{k, DevKind::Pad}};
r.cfg.routes.pad[0] = 0;
r.cfg.routes.kb[0] = false;
r.frame(DeviceSnapshot(), PointerInput(), {up});
bool up_ = r.in.state(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_UP) == 1;
int ay = r.in.state(0, RETRO_DEVICE_ANALOG, RETRO_DEVICE_INDEX_ANALOG_LEFT, RETRO_DEVICE_ID_ANALOG_Y);
if (k == DevKind::Pad) CHECK(up_ && ay == 0); // a plain pad: the stick is the D-pad
else CHECK(!up_ && ay < -20000); // driving: analog only, no gear shift
}
}
static void testProPad() {
// A gamepad on a Pro Controller port: LB / North / RB / LT / RT are the Pro's X / Y / Z / L / R, pressed as the
// RetroPad X / L2 / R2 / L / R the core's Pro path reads -- never as synthesised keypad keys.
struct { int btn; int axis; int retro; Ctl keypadOnStd; } t[] = {
{SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, -1, RETRO_DEVICE_ID_JOYPAD_X, C_K1},
{SDL_GAMEPAD_BUTTON_NORTH, -1, RETRO_DEVICE_ID_JOYPAD_L2, C_K0},
{SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, -1, RETRO_DEVICE_ID_JOYPAD_R2, C_K2},
{-1, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, RETRO_DEVICE_ID_JOYPAD_L, C_K3},
{-1, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, RETRO_DEVICE_ID_JOYPAD_R, C_K4},
};
for (unsigned port = 0; port < 2; port++) {
for (const auto& c : t) {
DeviceSnapshot s;
if (c.btn >= 0) s.btn[(size_t)c.btn] = true;
else s.axis[(size_t)c.axis] = 32767;
for (bool pro : {true, false}) {
Rig r;
r.cfg.kind = {{DevKind::Pad, DevKind::Pad}};
r.cfg.kind[port] = pro ? DevKind::ProPad : DevKind::Pad;
r.cfg.routes.kb[0] = false;
r.cfg.routes.pad[port] = 0;
r.frame(DeviceSnapshot(), PointerInput(), {s});
int keys = 0;
for (int k = C_K0; k <= C_HASH; k++) keys += r.in.key(CoreInput::keypadKey(port, (Ctl)k));
if (pro) {
CHECK(r.in.pad(port) == (int16_t)(1 << c.retro) && keys == 0);
} else { // a standard pad: today's keypad digit, as a synthesised key, and no RetroPad bit
CHECK(r.in.pad(port) == 0 && keys == 1 && r.in.key(CoreInput::keypadKey(port, c.keypadOnStd)));
}
CHECK(r.in.pad(1 - port) == 0);
}
}
}
// The rest of the pad is untouched on a Pro port: A B C, keypad 5 / 6 on the stick clicks, 7 on the right stick.
Rig r;
r.cfg.kind = {{DevKind::ProPad, DevKind::Pad}};
r.cfg.routes.kb[0] = false;
r.cfg.routes.pad[0] = 0;
DeviceSnapshot s;
s.btn[SDL_GAMEPAD_BUTTON_EAST] = s.btn[SDL_GAMEPAD_BUTTON_LEFT_STICK] = true;
s.axis[SDL_GAMEPAD_AXIS_RIGHTX] = -32767;
r.frame(DeviceSnapshot(), PointerInput(), {s});
CHECK(r.in.pad(0) == (int16_t)(1 << RETRO_DEVICE_ID_JOYPAD_A));
CHECK(r.in.key(CoreInput::keypadKey(0, C_K5)) && r.in.key(CoreInput::keypadKey(0, C_K7)));
// The keyboard on a Pro port keeps A S D / Q E as keypad 9 8 7 / 4 6 (the same wires) and presses no RetroPad bit.
Rig k;
k.cfg.kind = {{DevKind::ProPad, DevKind::Pad}};
k.frame(keys({SDL_SCANCODE_A}));
CHECK(k.in.pad(0) == 0 && k.in.key(CoreInput::keypadKey(0, C_K9)));
}
static void testProKeyboard() {
// The Pro Controller's X Y Z are keypad 9 8 7, its shoulders 4 6 (libretro.c pro_controller[]); the
// keyboard profile carries them on A S D / Q E.
struct { int sc; Ctl c; } map[] = {{SDL_SCANCODE_A, C_K9}, {SDL_SCANCODE_S, C_K8}, {SDL_SCANCODE_D, C_K7},
{SDL_SCANCODE_Q, C_K4}, {SDL_SCANCODE_E, C_K6}};
Profile kb = Profile::builtin(DeviceClass::Keyboard);
for (const auto& m : map) {
ControlSet cs = evaluate(kb, keys({m.sc}));
CHECK(cs.test(m.c));
Rig r;
r.frame(keys({m.sc}));
CHECK(r.in.key(CoreInput::keypadKey(0, m.c)));
}
// ... and the three bottom-row buttons stay where they were.
CHECK(evaluate(kb, keys({SDL_SCANCODE_Z})).test(C_B) && evaluate(kb, keys({SDL_SCANCODE_X})).test(C_A) &&
evaluate(kb, keys({SDL_SCANCODE_C})).test(C_C));
}
// ---------------------------------------------------------------------------------
static void testCheatSheet() {
Config cfg;
BindingStore st(cfg);
const std::string G = "crc32-cafe";
auto prof = [&](const std::string& game) { return st.resolve(DeviceClass::Keyboard, "", game); };
Sheet base = buildSheet(prof(""), DevKind::Pad, SheetSource::Keyboard, "");
std::string t0 = base.text();
CHECK(t0.find("Player 1: Standard Joypad - keyboard") != std::string::npos);
CHECK(t0.find("B: Z") != std::string::npos); // the actual binding
CHECK(t0.find("Keypad 7 (Pro: Z): ") != std::string::npos && t0.find(", D\n") != std::string::npos); // Pro Z on D
CHECK(t0.find("Controls overlay: F3") != std::string::npos);
CHECK(t0.find("Keypad overlay: F2") != std::string::npos);
// A global rebind changes the text ...
st.set(LayerId{"", DeviceClass::Keyboard, ""}, C_B, {Binding::key(SDL_SCANCODE_M), Binding::key(SDL_SCANCODE_N)});
std::string t1 = buildSheet(prof(""), DevKind::Pad, SheetSource::Keyboard, "").text();
CHECK(t1 != t0 && t1.find("B: M, N") != std::string::npos && t1.find("B: Z") == std::string::npos);
// ... a per-game override applies to that game only ...
st.set(LayerId{G, DeviceClass::Keyboard, ""}, C_A, {Binding::key(SDL_SCANCODE_G)});
std::string tg = buildSheet(prof(G), DevKind::Pad, SheetSource::Keyboard, "").text();
CHECK(tg.find("A: G") != std::string::npos && t1.find("A: X") != std::string::npos);
CHECK(buildSheet(prof("crc32-other"), DevKind::Pad, SheetSource::Keyboard, "").text() == t1);
// ... and unbinding shows as such.
st.set(LayerId{"", DeviceClass::Keyboard, ""}, C_PAUSE, {});
CHECK(buildSheet(prof(""), DevKind::Pad, SheetSource::Keyboard, "").text().find("Pause: (unbound)") != std::string::npos);
// The sheet follows the device in Player 1's port.
Profile p = prof("");
for (int i = 0; i < kDevKinds; i++) {
Sheet s = buildSheet(p, (DevKind)i, SheetSource::Keyboard, "");
CHECK(s.title.find(devKindLabel((DevKind)i)) != std::string::npos);
CHECK(s.lines.size() > 6);
}
CHECK(buildSheet(p, DevKind::LightGun, SheetSource::Keyboard, "").text().find("Reload") != std::string::npos);
CHECK(buildSheet(p, DevKind::Driving, SheetSource::Keyboard, "").text().find("Accelerator: E") != std::string::npos);
CHECK(buildSheet(p, DevKind::Rotary, SheetSource::Keyboard, "").text().find("Spin clockwise: Right") != std::string::npos);
CHECK(buildSheet(p, DevKind::SixD, SheetSource::Keyboard, "").text().find("Rezero") != std::string::npos);
// A Pro Controller port: the Pro buttons get their own section; the keyboard's are its keypad keys.
std::string pk = buildSheet(p, DevKind::ProPad, SheetSource::Keyboard, "").text();
CHECK(pk.find("Player 1: Pro Controller (6-button) - keyboard") != std::string::npos);
CHECK(pk.find("\nPro buttons") != std::string::npos);
CHECK(pk.find("X (Pro): 9, Keypad 9, A (keypad 9)") != std::string::npos);
CHECK(pk.find("R (Pro): 6, Keypad 6, E (keypad 6)") != std::string::npos);
CHECK(pk.find("Keypad 0-4") == std::string::npos);
// A gamepad sheet shows the pad's bindings and its name.
Profile gp = Profile::builtin(DeviceClass::Gamepad);
std::string pg = buildSheet(gp, DevKind::Pad, SheetSource::Gamepad, "Test Pad").text();
CHECK(pg.find("gamepad") == std::string::npos && pg.find("Test Pad") != std::string::npos);
CHECK(pg.find("B: South") != std::string::npos);
CHECK(pg.find("Controls overlay: Touchpad") != std::string::npos);
CHECK(pg.find("(Pro)") == std::string::npos); // a standard pad's sheet has no Pro rows
std::string pp = buildSheet(Profile::builtin(DeviceClass::Gamepad, PadCaps(), true), DevKind::ProPad,
SheetSource::Gamepad, "Test Pad").text();
CHECK(pp.find("X (Pro): Left Shoulder") != std::string::npos && pp.find("Y (Pro): North") != std::string::npos);
CHECK(pp.find("Z (Pro): Right Shoulder") != std::string::npos && pp.find("L (Pro): Left Trigger") != std::string::npos);
CHECK(pp.find("R (Pro): Right Trigger") != std::string::npos);
CHECK(pp.find("Keypad 1: (unbound)") != std::string::npos);
CHECK(pp.find("Keypad 0-4: no pad button on a Pro Controller port: use the keypad overlay (Share / Capture, or View > Keypad overlay)") != std::string::npos);
CHECK(pp.find("Keypad 7 (Pro: Z): Right Stick Left") != std::string::npos);
// ---- auto-show ---------------------------------------------------------------------------------------------------
AutoShowInput in;
in.contentLoaded = true;
CHECK(!in.enabled); // the setting is OFF by default
CHECK(autoShowDecision(in) == AutoShow::Hint); // keyboard player: a toast, not the sheet
in.hintShown = true;
CHECK(autoShowDecision(in) == AutoShow::No); // ... once per session
in.hintShown = false;
in.padOnPlayer1 = true;
CHECK(autoShowDecision(in) == AutoShow::No); // a gamepad player is not nagged either
in.padOnPlayer1 = false;
in.contentLoaded = false;
CHECK(autoShowDecision(in) == AutoShow::No); // nothing running -> nothing
in.contentLoaded = true;
// The player turned the setting on: the old behaviour.
in.enabled = true;
CHECK(autoShowDecision(in) == AutoShow::Keyboard); // no gamepad on Player 1 -> shown
in.hintShown = true;
CHECK(autoShowDecision(in) == AutoShow::Keyboard); // (the hint flag is irrelevant then)
in.padOnPlayer1 = true;
CHECK(autoShowDecision(in) == AutoShow::Gamepad); // first use of this controller
in.padSeenBefore = true;
CHECK(autoShowDecision(in) == AutoShow::No); // a known controller -> not shown
in.padOnPlayer1 = false;
in.contentLoaded = false;
CHECK(autoShowDecision(in) == AutoShow::No);
}
static void testOverlayPolicy() {
// Auto-shown: hides on a click, a tap, Esc, the toggle, or its timeout.
const OverlayEvent hiders[] = {OverlayEvent::Click, OverlayEvent::Tap, OverlayEvent::Escape};
for (OverlayEvent ev : hiders) {
ControlsOverlayState s;
CHECK(!s.show && !s.autoShown);
CHECK(!overlayEvent(s, ev)); // hidden: nothing to dismiss, nothing swallowed
overlayAutoShow(s, 100.0);
CHECK(s.show && s.autoShown && s.hideAt == 100.0 + kAutoShowSeconds);
CHECK(overlayEvent(s, ev) && !s.show); // dismissed, and the caller swallows the click
CHECK(!overlayTick(s, 1000.0) && !s.show);
}
{
ControlsOverlayState s;
overlayAutoShow(s, 10.0);
CHECK(!overlayTick(s, 10.0 + kAutoShowSeconds - 0.1) && s.show);
CHECK(overlayTick(s, 10.0 + kAutoShowSeconds) && !s.show); // timeout
overlayAutoShow(s, 20.0);
CHECK(!overlayEvent(s, OverlayEvent::Toggle) && !s.show); // F3 hides it
}
// Opened by the player (F3): stays until hidden - no timeout - and the same events hide it.
for (OverlayEvent ev : hiders) {
ControlsOverlayState s;
CHECK(!overlayEvent(s, OverlayEvent::Toggle) && s.show && !s.autoShown);
CHECK(!overlayTick(s, 1e9) && s.show); // no timeout for a manual open
CHECK(overlayEvent(s, ev) && !s.show);
}
{
// F3 over an auto-shown overlay hides it; F3 again opens it for good.
ControlsOverlayState s;
overlayAutoShow(s, 0.0);
overlayEvent(s, OverlayEvent::Toggle);
CHECK(!s.show);
overlayEvent(s, OverlayEvent::Toggle);
CHECK(s.show && !s.autoShown && !overlayTick(s, 1e9));
}
}
int main() {
// SDL's scancode name table does not need SDL_Init.
testKinds();
testPortStore();
testSlots();
testSettingsAndGeometry();
testRouter();
testStickDpad();
testProKeyboard();
testProPad();
testCheatSheet();
testOverlayPolicy();
std::printf("devices_unit: %d checks, %d failed\n", g_checks, g_fail);
return g_fail ? 1 : 0;
}