Files
virtualjaguar-libretro/app/test/devices_e2e.cpp
85cd3f1642 feat(app): debugger halt and GDB stub with the emulation thread on (#967 phase 3b) (#1068)
* feat(app): bounded halt pump on DebugCore; the UI mirrors the halt

DebugCore owns the halt: pump() serves the debug lane, waits at most kPumpWait (4 ms) on a debug-lane-only wait and
returns VJ_DBG_RESUME_NONE to GDBHalt's own loop, which keeps the socket service and the halt timeout.  The UI writes
its verdict tagged with the halt serial it saw, so a verdict for a dead halt never resumes the next one; frameEnded()
closes a halt the core left on gdb's say-so.  DebugSession mirrors HaltState once per UI frame (syncHalt) and fires
onHaltUi / onResumeUi on serial edges; attach/detach/breakNow are ordered through the lane and a pending detach.
Inline keeps the nested UI frame.

Refs #967

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

* fix(app): a STEP the core never re-halts must not leave the UI mirror halted

pump() returns STEP without bumping the halt serial, so the bar stays up while the core steps.  If the frame ends
before the core re-halts (the stepped instruction ended it, or the target had stopped), frameEnded() found nothing
halted and the mirror stayed halted for good; inline, stepEmulation() is gated on halted(), so the frame that would
re-halt never ran and F9 / Detach wrote verdicts nothing took.  DebugCore now remembers a STEP in flight (cleared by
the next halt entry or setIface) and frameEnded() closes it like a foreign resume.

Refs #967

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

* fix(app): verdicts and detach only against the core's live halt

After a STEP verdict the halt serial is unchanged but the core is running until it re-halts, so DebugSession::detach()
offered a DETACH for a serial the re-halt then dropped, left detachPending_ set (attach / Break refused) and, with
hardcore turning on in that window, left the core attached.  resume / step / detachAndResume now require the core's
halt to be live (parked, same serial); detach and detachAndResume fall back to the lane detach otherwise, and that post
also releases the pump if it runs inside a live halt.  haltedFrame() syncs the mirror first so the first nested frame
meets the current halt; the inline resume hook keeps headless audio unpaused as before.

Refs #967

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

* feat(app): the emulation thread pumps the halt; quitting ends it

Threaded, the pump's wait is App::haltWait (serve the debug lane for at most 4 ms, refill the views when a request is
pending); frameEnded() runs after every retro_run so a halt gdb ended is closed on our side (inline: from syncDebug
outside a frame).  beginQuit() sets quitting_, wakes the pump and waits at most 200 ms for the detach the pump answers
with, before the auto-save, the SRAM flush and the 3 s unload; closing the window inside an inline halt takes the same
path.  haltedFrame runs only inline.  The halt bar says "HALTED by gdb" for a halt the app did not ask for; a halt at
the CPU window's own temporary breakpoint (run to cursor, step over) is ours (DebugSession::ownsBreakAt).

Refs #967

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

* feat(app): calls and frame advances give up when a halt begins; content changes detach first

EmuThread::call and advance take a give-up predicate: a call queued behind a frame that halts returns instead of parking
the UI behind the busy spinner; advance registers its budget on the debug lane and waits with the same predicate.  Every
UI-thread call passes one (App::emuCall).  Threaded, open / close / reset / disc detach the debugger first and then
proceed; save / load state and boot refuse with a toast; frame-lane posts wait for Continue and the halt bar and the
Core options tab say so.  Inline keeps refusing (the call would run inside retro_run).  RetroAchievements replies ride
the debug lane, so none is ever cancelled.

Refs #967

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

* fix(app): a given-up disc follow-up retries; close reports a re-halt; deterministic give-up and re-drain tests

followDiscSwap advances seenDiscSwaps_ only when its call ran, so a halt no longer loses the RA / cheats follow-up.
closeContent says so when its detach predicate gave up.  The netplay headless step stops re-budgeting a frame whose
advance gave up.  testCallGiveUpLosesToStart covers the compare-and-swap-lost branch of call(), and
testAdvanceBudgetDoesNotOvertakeFramePost covers the loop's second frame-lane drain; the 300-iteration smoke loop is gone.

Refs #967

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

* feat(app): the halt and the GDB stub work on the emulation thread; a silent host behind every halt; hardcore locks the stub

The phase 1 limits are gone: Break / Step / breakpoints work threaded and virtualjaguar_gdb_stub is a plain option again.
Whenever the stub is enabled the app attaches its host on the emulation thread inside the load (before the first frame,
so a gdb_wait boot halt is seen) and again after any frame it is missing, so every gdb halt flows through the app's
pump: the halt bar shows it, calls give up, quitting detaches.  RetroAchievements hardcore locks the stub to disabled at
load, and a hardcore flip with the stub open reloads the game so the lock reaches the core (phase 3 plan, decisions 2
and 3).

Refs #967

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

* fix(app): B4 review: pre-frame silent host, gdb halts survive headless inline, Detach clears breakpoints

- emuRunOneFrame re-attaches the silent gdb host before the frame, so a gdb halt right after a Detach is hosted
- headless inline haltedFrame releases only app halts and quit; gdb's halt keeps the core's socket loop serving
- Detach drops the session breakpoint list with the core's table
- Settings > Emulation thread hint reflects that the halt and stub work threaded
- locked-option warning is generic; a hardcore restart waits out an inline halt

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

* test(app): the halt and the GDB stub end to end on the emulation thread

debug_e2e_threaded drives break / step / continue / detach / hardcore / quit from a UI thread that never blocks over
50 ms, pins the option post that applies after Continue, the save state that gives up when a halt begins, a posted lane
detach run inside a live halt, the 3 s join, and a gdb_wait boot halt with no client (Detach, quit).  gdb_threaded_halt
finds a headless run halted at boot from a gdb client, reads memory, continues, and quits the app mid-halt, threaded and
inline, plus an inline hardcore variant: SIGINT / SIGTERM end a headless run cleanly (installed after SDL_Init, headless
only).  The headless inline loop now closes a gdb halt after each frame (frameEnded + syncHalt), without which the
hardcore reload retry stays blocked.

Refs #967

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

* fix(app): B5 review: the cancelled save is proven absent after frames run; free ports; signal handler resets

debug_e2e_threaded waits for frames after the halt ends before asserting the cancelled save left no file (it is held while
halted, so the old check was vacuous) and saves to a different file; the gdb_wait sections probe a free port; the test
also fails on ThreadSanitizer.  The signal handler restores SIG_DFL after storing the flag (a second signal kills a wedged
run) and g_signalQuit is asserted lock-free.  gdb_threaded_halt.py's watchdog kills live children; a missing Python3 is
reported at configure time.

Refs #967

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

* docs(app): the halt and the GDB stub on the emulation thread

Halt model, Detach, hardcore lock and reload, gdb_wait, headless signal quit,
testing; README, testing.md, design section 6 (2026-10-22 as-shipped notes),
plan 3b task list.  Includes the MSVC fix: debug_e2e_threaded includes
<ws2tcpip.h> for socklen_t.

Refs #967

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

* fix(app): quitting while a gdb client holds a halt no longer hangs

The pump answered a quit with DETACH, which drops the app's host inside the core.
A gdb client's breakpoint that is still armed (a hot loop) then fires again at
once, with the socket open and no host: the core parks in GDBHalt() for the
client, and the quit's flush, autosave and unload wait on it forever.  The pump
now answers CONTINUE and the host stays attached (unloading drops it).  Every
wait in the quit epilogue is bounded (flush 1 s, autosave 1.5 s, skipped while
the core is still halted); the unload keeps its 3 s backstop.

Also: re-attach the silent gdb host before each frame (inline stepEmulation,
headless inline loops), comment/doc fixes, EmuThread::stop precondition, and the
three gdb tests' PASS regexes lose their ^ anchor.

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

* fix(app): a hardcore restart that gives up stays pending and retries

Achievements::pump cleared resetPending_ before the restart and ignored its result. Threaded, the
restart can give up when the core halts again (a gdb client with a breakpoint still armed): hardcore
was then active without a clean restart and the open GDB stub bypassed the hardcore lock.

The restart now stays pending until it takes. reset() returns whether it ran. A give-up with the game
still loaded (and, for the reload, the stub still open) retries at the next pump; a restart that
failed to load the game, or closed the stub, is dropped. The cheat reset is posted once and the
restart is counted once (--ra-expect-reset), the give-up is logged once.

Tests: VJ_TEST_RESTART_GIVEUPS=N makes the first N restarts report a give-up (a headless run cannot
stage the re-halt); gdb_hardcore_flip_giveup_* (reload path) and ra_hardcore_reset_giveup_* (reset
path), inline and threaded.

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

* fix(app): a signalled threaded --neuro headless run still prints the Neuro report

The signal-quit block returns before the TIMING/report section; report first (it only
touches UI-thread state) so a SIGTERM'd --neuro run behaves like a finished one.

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

* test(app): gdb_* tests take their port from the OS at run time (#967)

The six tests with hard-coded ports 23457-23463 (gdb_enabled, gdb_enabled_threaded,
gdb_hardcore_flip_*, gdb_hardcore_flip_giveup_*) all failed on the hosted Windows
ARM64 runner with 'failed to open port': every one of the distinct ports was
refused, which points at a reserved/excluded range, not a collision (the Python
gdb tests, which already pick a free port, passed there).

tests/with_free_port.py binds 127.0.0.1:0, reads the port back, substitutes {PORT}
in the command and, with --config-ini, writes the saved stub setting for the
hardcore tests.  The assertions and ctest regexes are unchanged.  Registered only
with Python3 (STATUS message otherwise).  RESOURCE_LOCK vj_gdb_port on every test
that binds a stub port closes the bind-0 TOCTOU window.

Refs #967

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

* fix(app): start Winsock before the core loads so the GDB stub can bind on Windows (#967)

Every gdb_* test that opens the stub failed on hosted Windows x64 with
"[GDB] stub enabled but failed to open port N", on fixed and on OS-chosen
free ports alike. The core's GDBSockOpen (src/debug/gdbsock.c) calls socket()
without WSAStartup, because its libretro host (RetroArch) starts Winsock. The
app never did on that path (neuro and netplay only start it for themselves),
so socket() failed with WSANOTINITIALISED and the stub stayed disabled. The
feature was therefore broken for users on Windows too, not only in tests.

platform::initNetworking() now runs WSAStartup once, right after SDL_Init and
before any core load, headless runs included. A no-op on macOS and Linux.
Winsock is reference counted, so neuro's and netplay's own WSAStartup stay
harmless. Also corrects the comments that blamed reserved port ranges.

Refs #967

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

* test(app): with_free_port.py hands the app explicit stdout/stderr (#967)

The Packages / Windows x64 job builds the GUI-subsystem exe (VJ_APP_WIN32_GUI). There, gdb_enabled, gdb_enabled_threaded and the gdb_hardcore_flip* tests missed their PASS regexes. Popen(cmd) left the child's standard handles to inheritance, so Windows started the GUI-subsystem app with no stdout. console_attach.cpp then attached the runner's console, and the output never reached ctest's pipe. The wrapper now passes stdin=DEVNULL and stdout/stderr explicitly, so the output stays on the pipe. Tests that ctest launches directly already get redirected handles and were unaffected.

Refs #1075

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: dry <dry@x>
2026-10-23 02:36:04 -04:00

758 lines
35 KiB
C++

// End-to-end device test: synthetic HOST input -> the app's Router -> the REAL core (statically
// linked, through the retro_* API via CoreHost) -> what the emulated console sees. For every device
// libretro.c supports: the mouse and rotary quadrature state, the analog / driving / 6D values a
// game would decode off $F14000, the paddle ADC at $F17C00, the light gun's aim point, the Team Tap
// sockets' matrix slots, the Pro Controller (keyboard layout, and the device: a gamepad's Pro buttons
// through the core's own Pro path), and the locked core options.
//
// Every device group has a NEGATIVE CONTROL: the same device, the same frames, but no host input (the
// mapping has nothing to translate), which must read the rest value. A group that passes both
// ways would pass for the wrong reason; the PR also quotes a run with the Router stubbed out.
//
// usage: vj_devices_e2e <rom.j64>
#include "core_host.h"
#include "devices.h"
#include "hold_arbiter.h"
#include "router.h"
#include "libretro.h"
extern "C" {
#include "inputdev.h"
#include "joystick.h"
#include "paddle.h"
}
#include <cstdio>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <string>
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)
#define CHECKF(cond, ...) \
do { \
g_checks++; \
if (!(cond)) { \
g_fail++; \
std::fprintf(stderr, "FAIL %s:%d %s -- ", __FILE__, __LINE__, #cond); \
std::fprintf(stderr, __VA_ARGS__); \
std::fprintf(stderr, "\n"); \
} \
} while (0)
static CoreInput g_in;
static Router g_router;
static RouterConfig g_cfg;
static Profile g_kbp, g_padp, g_padPro;
static std::array<bool, 2> g_pro{{false, false}}; // App::proPorts: the option lock reads it
static CoreHost* g_host;
static std::string g_rom;
static DeviceSnapshot keys(std::initializer_list<int> sc) {
DeviceSnapshot s;
for (int k : sc) s.key[(size_t)k] = true;
return s;
}
// One emulated frame: route the host input, then let the core read it.
static void frame(const DeviceSnapshot& kb, const PointerInput& ptr = PointerInput(),
const std::vector<DeviceSnapshot>& pads = {}) {
HostSource k;
k.isKeyboard = true;
k.profile = &g_kbp;
k.snap = kb;
std::vector<HostSource> ps;
for (const DeviceSnapshot& s : pads) {
HostSource h;
h.profile = &g_padp;
h.proProfile = &g_padPro;
h.snap = s;
ps.push_back(h);
}
g_in.beginFrame();
g_router.route(g_cfg, ps, &k, ptr, nullptr, g_in);
g_host->runFrame();
}
static void frames(int n, const DeviceSnapshot& kb, const PointerInput& ptr = PointerInput(),
const std::vector<DeviceSnapshot>& pads = {}) {
for (int i = 0; i < n; i++) frame(kb, ptr, pads);
}
// Fresh content, the devices claimed with the core exactly as applyPortDevices() does. `proLock` = false
// leaves a Pro port's device option at auto (the negative control: what the core does without it).
static bool load(DevKind p1, DevKind p2, bool proLock = true) {
std::string err;
if (!g_host->loadContent(g_rom, err)) {
std::fprintf(stderr, "load failed: %s\n", err.c_str());
return false;
}
g_pro = {{proLock && p1 == DevKind::ProPad, proLock && p2 == DevKind::ProPad}};
g_host->refreshLocks();
g_host->setControllerDevice(0, coreClaimFor(devLibretroId(p1)));
g_host->setControllerDevice(1, coreClaimFor(devLibretroId(p2)));
g_router = Router();
g_cfg = RouterConfig();
g_cfg.kind = {{p1, p2}};
g_cfg.routes.kb[0] = true;
g_cfg.routes.kb[1] = false;
g_cfg.mousePort = resolveMousePort(g_cfg.kind, -1);
g_cfg.pic = PicRect{0, 0, 640, 480};
frames(3, DeviceSnapshot()); // geometry published, devices settled
return true;
}
// ---- bus-side decoding, as a game does it (test/tools/*_decode_test.c) -------------------------------------------
static const uint16_t row_word_p1[4] = {0x81FE, 0x81FD, 0x81FB, 0x81F7};
static const uint16_t row_word_p2[4] = {0x817F, 0x81BF, 0x81DF, 0x81EF};
static const int j_base[2] = {8, 12};
static const int c_bit[2] = {0, 2};
static const int b_bit[2] = {1, 3};
static void scanRow(int port, int row, uint16_t* joy, uint16_t* but) {
JoystickWriteWord(0, port ? row_word_p2[row] : row_word_p1[row]);
*joy = JoystickReadWord(0);
*but = JoystickReadWord(2);
}
static int nib(uint16_t joy, int port) { return (joy >> j_base[port]) & 0x0F; }
static int bitv(uint16_t v, int b) { return (v >> b) & 1; }
// TR10 recipe for the 2-bank analog device: scan two banks, bank 0 is the one whose row-0 C column is low.
static bool readAnalog(int port, int* x, int* y, uint16_t* butRow0 = nullptr) {
uint16_t joy[8], but[8];
for (int b = 0; b < 2; b++)
for (int r = 0; r < 4; r++) scanRow(port, r, &joy[4 * b + r], &but[4 * b + r]);
int base = -1;
for (int b = 0; b < 2; b++)
if (bitv(but[4 * b], c_bit[port]) == 0) {
base = 4 * b;
break;
}
if (base < 0) return false;
*x = nib(joy[base + 0], port) | (nib(joy[base + 1], port) << 4);
*y = nib(joy[base + 2], port) | (nib(joy[base + 3], port) << 4);
if (butRow0) *butRow0 = but[base];
return true;
}
enum { AX_X, AX_Y, AX_Z, AX_TX, AX_TY, AX_TZ };
// TR10 p.22: which DOF and nibble each (bank, row) carries.
static const uint8_t kMap6D[3][4] = {{AX_X, AX_Y, AX_Z, AX_X | 8}, {AX_TX, AX_TY, AX_TZ, AX_Y | 8}, {AX_TX | 8, AX_TY | 8, AX_TZ | 8, AX_Z | 8}};
static bool read6D(int port, int out[6]) {
uint16_t joy[12], but[12];
for (int b = 0; b < 3; b++)
for (int r = 0; r < 4; r++) scanRow(port, r, &joy[4 * b + r], &but[4 * b + r]);
int bank0 = -1;
for (int b = 0; b < 3; b++)
if (bitv(but[4 * b], c_bit[port]) == 0) {
bank0 = 4 * b;
break;
}
if (bank0 < 0) return false;
for (int i = 0; i < 6; i++) out[i] = 0;
for (int b = 0; b < 3; b++)
for (int r = 0; r < 4; r++) {
uint8_t cell = kMap6D[b][r];
int v = nib(joy[(bank0 + 4 * b) % 12 + r], port);
out[cell & 7] |= (cell & 8) ? v << 4 : v;
}
return true;
}
// InputDevStateSave layout (inputdev.c): per port 20 bytes -- x{backlog i32, frac i32, phase}, y{...}, btn_left, btn_right.
struct QuadState {
int32_t xb, yb;
int left, right;
};
static QuadState quad(int port) {
uint8_t buf[256] = {0};
InputDevStateSave(buf);
const uint8_t* p = buf + 20 * port;
QuadState q;
std::memcpy(&q.xb, p, 4);
std::memcpy(&q.yb, p + 9, 4);
q.left = p[18];
q.right = p[19];
return q;
}
// ---------------------------------------------------------------------------------
static void testMouse() {
std::printf("-- ST mouse on port 2\n");
if (!load(DevKind::Pad, DevKind::MouseST)) return;
CHECK(InputDevGetType(1) == INPUTDEV_MOUSE_ST);
g_cfg.routes.kb[0] = false;
g_cfg.routes.kb[1] = true;
// Negative control: nothing moved, nothing queued, no button.
frames(2, DeviceSnapshot());
QuadState q = quad(1);
CHECK(q.xb == 0 && q.yb == 0 && !q.left && !q.right);
uint16_t joy, but;
scanRow(1, 0, &joy, &but);
CHECK(bitv(but, 3) == 1); // $F14002 bit 3 high: LMB not held
// Host motion +40 / -25 and the left button -> positive X backlog, negative Y backlog, LMB on the bus.
PointerInput p;
p.dx = 40;
p.dy = -25;
p.buttons = 1;
frame(DeviceSnapshot(), p);
q = quad(1);
CHECKF(q.xb >= 20 && q.xb <= 40, "x backlog %d", q.xb);
CHECKF(q.yb <= -12 && q.yb >= -25, "y backlog %d", q.yb);
CHECK(q.left == 1 && q.right == 0);
scanRow(1, 0, &joy, &but);
CHECK(bitv(but, 3) == 0); // LMB reads as an asserted (low) bit at $F14002 (inputdev.c mouse overlay)
// Right button from the keyboard (X is the default MB_R binding) and a key-driven nudge.
frame(keys({SDL_SCANCODE_X}));
q = quad(1);
CHECK(q.right == 1);
}
static void testRotary() {
std::printf("-- rotary on port 1 and port 2\n");
for (int port = 0; port < 2; port++) {
DevKind k1 = port == 0 ? DevKind::Rotary : DevKind::Pad, k2 = port == 1 ? DevKind::Rotary : DevKind::Pad;
if (!load(k1, k2)) return;
CHECK(InputDevGetType(port) == INPUTDEV_ROTARY);
g_cfg.routes.kb[0] = port == 0;
g_cfg.routes.kb[1] = port == 1;
g_cfg.mousePort = port;
frames(2, DeviceSnapshot());
QuadState q = quad(port);
CHECK(q.xb == 0); // negative control
// Mouse right = clockwise = the core's NEGATIVE backlog (inputdev.c: "Mapping host +X to clockwise ... Hence the negation").
PointerInput p;
p.dx = 30;
p.dy = 17; // ignored: one wheel
frame(DeviceSnapshot(), p);
q = quad(port);
CHECKF(q.xb <= -15 && q.xb >= -30, "port %d x backlog %d", port + 1, q.xb);
CHECK(q.yb == 0);
p.dx = -30;
frame(DeviceSnapshot(), p);
q = quad(port);
CHECKF(q.xb > -15, "counter-clockwise motion cancels the clockwise backlog: %d", q.xb);
}
// Two keys (Left/Right arrows): sustained rotation queues in the right direction.
if (!load(DevKind::Rotary, DevKind::Pad)) return;
g_cfg.settings.rotarySpeed = 4.0f;
frames(10, keys({SDL_SCANCODE_LEFT}));
QuadState q = quad(0);
CHECKF(q.xb >= 10, "left key -> counter-clockwise (positive) backlog %d", q.xb);
// The rotary keeps its buttons on the pad and loses only U/D/L/R (libretro.c, "Withhold only the four direction slots").
frame(keys({SDL_SCANCODE_Z, SDL_SCANCODE_UP}));
CHECK(joypad0Buttons[BUTTON_B] != 0);
CHECK(joypad0Buttons[BUTTON_U] == 0);
}
static void testAnalog() {
std::printf("-- analog / driving\n");
if (!load(DevKind::Analog, DevKind::Pad)) return;
CHECK(InputDevGetType(0) == INPUTDEV_ANALOG);
int x = -1, y = -1;
// Negative control: nothing deflected, the device stays inert (reads as a pad, no bank flag).
frames(2, DeviceSnapshot());
CHECK(!readAnalog(0, &x, &y));
// Stick right + forward (W = the stick's up = libretro -Y = TR10 +Y), A button held.
uint16_t but = 0;
frames(2, keys({SDL_SCANCODE_D, SDL_SCANCODE_W, SDL_SCANCODE_X}));
CHECK(readAnalog(0, &x, &y, &but));
CHECKF(x == 255 && y == 255, "analog X=%d Y=%d", x, y);
CHECK(bitv(but, b_bit[0]) == 0); // A -> row 0 B column low
frames(2, keys({SDL_SCANCODE_A, SDL_SCANCODE_S}));
CHECK(readAnalog(0, &x, &y));
CHECKF(x == 1 && y == 1, "analog full left/back X=%d Y=%d", x, y);
// A gamepad stick is proportional: 20000 of 32767 past a 10% dead zone.
DeviceSnapshot g;
g.axis[SDL_GAMEPAD_AXIS_LEFTX] = 20000;
g_cfg.routes.pad[0] = 0;
g_cfg.routes.kb[0] = false;
frames(2, DeviceSnapshot(), PointerInput(), {g});
CHECK(readAnalog(0, &x, &y));
CHECKF(x > 190 && x < 210 && y >= 127 && y <= 129, "analog proportional X=%d Y=%d", x, y);
g_cfg.routes.pad[0] = -1;
g_cfg.routes.kb[0] = true;
// Driving, on port 2: steering = stick X, accelerator = R2, brake = L2.
if (!load(DevKind::Pad, DevKind::Driving)) return;
CHECK(InputDevGetType(1) == INPUTDEV_DRIVING);
g_cfg.routes.kb[0] = false;
g_cfg.routes.kb[1] = true;
frames(2, DeviceSnapshot());
CHECK(!readAnalog(1, &x, &y));
frames(2, keys({SDL_SCANCODE_D, SDL_SCANCODE_E}));
CHECK(readAnalog(1, &x, &y));
CHECKF(x == 255 && y == 255, "driving steer right + accelerate X=%d Y=%d", x, y);
frames(2, keys({SDL_SCANCODE_Q}));
CHECK(readAnalog(1, &x, &y));
CHECKF(y == 1, "driving brake Y=%d", y);
}
static void testSixD() {
std::printf("-- 6D\n");
if (!load(DevKind::SixD, DevKind::Pad)) return;
CHECK(InputDevGetType(0) == INPUTDEV_6D);
int ax[6];
frames(2, DeviceSnapshot());
CHECK(!read6D(0, ax)); // negative control: inert until an axis deflects
struct { int sc; int axis; int want; const char* what; } t[] = {
{SDL_SCANCODE_A, AX_X, 255, "stick left -> X (TR10 +X is left)"},
{SDL_SCANCODE_W, AX_Y, 255, "stick up -> Y (TR10 +Y is up)"},
{SDL_SCANCODE_E, AX_Z, 1, "R2 -> Z fore (thrust), TR10 +Z is toward the user"},
{SDL_SCANCODE_J, AX_TX, 1, "stick 2 left -> TX (not inverted)"},
{SDL_SCANCODE_O, AX_TY, 255, "R shoulder -> TY (roll)"},
{SDL_SCANCODE_I, AX_TZ, 1, "stick 2 up -> TZ (not inverted)"},
};
for (const auto& c : t) {
frames(2, keys({c.sc}));
bool ok = read6D(0, ax);
CHECKF(ok, "%s: no bank flag", c.what);
if (!ok) continue;
CHECKF(ax[c.axis] == c.want, "%s: got %d want %d", c.what, ax[c.axis], c.want);
for (int i = 0; i < 6; i++)
if (i != c.axis) CHECKF(ax[i] == 128, "%s: axis %d left rest (%d)", c.what, i, ax[i]);
}
}
static void testPaddle() {
std::printf("-- paddle ADC\n");
if (!load(DevKind::Paddle, DevKind::Paddle)) return;
CHECK(InputDevGetType(0) == INPUTDEV_PADDLE && InputDevGetType(1) == INPUTDEV_PADDLE);
auto sample = [](int ch) {
PaddleWriteWord(0, (uint16_t)(ch | 4)); // BattleSphere's Timer 1 ISR: select channel, start conversion
return (int)(PaddleReadWord(0) & 0xFF);
};
g_cfg.routes.kb[0] = true;
g_cfg.routes.kb[1] = true;
frames(2, DeviceSnapshot());
CHECKF(sample(0) == 128 && sample(1) == 128 && sample(2) == 128, "centre: %d %d %d", sample(0), sample(1), sample(2)); // negative control
frames(2, keys({SDL_SCANCODE_D, SDL_SCANCODE_S}));
int c0 = sample(0), c1 = sample(1), c2 = sample(2), c3 = sample(3);
CHECKF(c0 == 255 && c1 == 255, "port 1 stick right/down: %d %d", c0, c1); // +Y is down: the ADC rises (paddle.h, no inversion)
CHECKF(c2 == 255 && c3 == 255, "port 2 channels follow the same keys: %d %d", c2, c3);
frames(2, keys({SDL_SCANCODE_A, SDL_SCANCODE_W}));
CHECKF(sample(0) == 1 && sample(1) == 1, "stick left/up: %d %d", sample(0), sample(1));
// Port 2 alone (BattleSphere's "2nd Controller: Analog Stick"): the keyboard plays port 2 only.
g_cfg.routes.kb[0] = false;
g_cfg.routes.kb[1] = true;
frames(2, keys({SDL_SCANCODE_D}));
CHECKF(sample(2) == 255 && sample(0) == 128, "port 2 only: ch2=%d ch0=%d", sample(2), sample(0));
}
static void testLightgun() {
std::printf("-- light gun\n");
if (!load(DevKind::LightGun, DevKind::Pad)) return;
CHECK(InputDevGetType(0) == INPUTDEV_LIGHTGUN);
const Frame& f = g_host->frame();
const int W = (int)f.width, H = (int)f.height;
CHECKF(W > 100 && H > 100, "frame %dx%d", W, H);
int32_t col = -1, row = -1;
// Negative control: the pointer is not over the picture -> no aim latched.
PointerInput p;
frames(2, DeviceSnapshot(), p);
CHECK(InputDevLightgunAim(&col, &row) == 0);
// The picture shown 1:1 at an offset, pointer on a known pixel.
g_cfg.pic = PicRect{50, 30, (float)W, (float)H};
g_cfg.mousePort = 0;
p.havePos = true;
p.x = 50 + 100.5f;
p.y = 30 + 60.5f;
frames(2, DeviceSnapshot(), p);
CHECK(InputDevLightgunAim(&col, &row) == 1);
CHECKF(col == 100 && row == 60, "aim %d,%d want 100,60", col, row);
// The same pixel with the picture scaled 3x into a bigger window (integer scaling / HiDPI): same aim.
g_cfg.pic = PicRect{10, 5, 3.0f * W, 3.0f * H};
p.x = 10 + 3 * 100.5f;
p.y = 5 + 3 * 60.5f;
frames(2, DeviceSnapshot(), p);
CHECK(InputDevLightgunAim(&col, &row) == 1);
CHECKF(col == 100 && row == 60, "3x aim %d,%d want 100,60", col, row);
// ... and a letterboxed picture: the bar is off-screen, the picture's far corner is its last pixel.
g_cfg.pic = PicRect{0, 40, (float)W, (float)H};
p.x = 5;
p.y = 10;
frames(2, DeviceSnapshot(), p);
CHECK(InputDevLightgunAim(&col, &row) == 0);
p.x = W - 0.5f;
p.y = 40 + H - 0.5f;
frames(2, DeviceSnapshot(), p);
CHECK(InputDevLightgunAim(&col, &row) == 1);
CHECKF(col == W - 1 && row == H - 1, "corner aim %d,%d want %d,%d", col, row, W - 1, H - 1);
// Trigger = left click -> the Jaguar's B; reload (right click) = off-screen + trigger.
g_cfg.pic = PicRect{0, 0, (float)W, (float)H};
p.x = W / 2.0f;
p.y = H / 2.0f;
p.buttons = 1;
frames(2, DeviceSnapshot(), p);
CHECK(joypad0Buttons[BUTTON_B] != 0 && InputDevLightgunAim(&col, &row) == 1);
p.buttons = 2;
frames(2, DeviceSnapshot(), p);
CHECK(joypad0Buttons[BUTTON_B] != 0 && InputDevLightgunAim(&col, &row) == 0);
p.buttons = 0;
frames(2, DeviceSnapshot(), p);
CHECK(joypad0Buttons[BUTTON_B] == 0);
}
static void testTeamTap() {
std::printf("-- Team Tap\n");
DeviceSnapshot idle, b, a, k0, k7;
b.btn[SDL_GAMEPAD_BUTTON_SOUTH] = true; // Jaguar B
a.btn[SDL_GAMEPAD_BUTTON_EAST] = true; // Jaguar A
k0.btn[SDL_GAMEPAD_BUTTON_NORTH] = true; // keypad 0 -> RetroPad X on a tap socket (teamtap_map[])
k7.axis[SDL_GAMEPAD_AXIS_RIGHTX] = -30000; // keypad 7: no RetroPad button there
for (int tapPort = 0; tapPort < 2; tapPort++) {
if (!load(tapPort == 0 ? DevKind::TeamTap : DevKind::Pad, tapPort == 1 ? DevKind::TeamTap : DevKind::Pad)) return;
CHECK(JoystickGetTeamTap(tapPort) && !JoystickGetTeamTap(1 - tapPort));
std::array<SlotSource, kSlots> cfg; // all automatic
g_cfg.routes = assignSlots(cfg, activeSlots(tapPort == 0, tapPort == 1), {"a", "b", "c", "d", "e"}, 0);
g_cfg.routes.kb[0] = false;
uint8_t* tp = tapPort == 0 ? joypad0Buttons : joypad1Buttons;
uint8_t* op = tapPort == 0 ? joypad1Buttons : joypad0Buttons;
// Negative control: five idle controllers press nothing anywhere.
frames(2, DeviceSnapshot(), PointerInput(), {idle, idle, idle, idle, idle});
int any = 0;
for (int i = 0; i < JOYPAD_BUTTON_SLOTS; i++) any += tp[i] || op[i];
CHECKF(any == 0, "tap on port %d idle: %d slots set", tapPort + 1, any);
// Controllers 1 and 2 are players 1 and 2; 3, 4 and 5 take the tap's sockets 1, 2 and 3.
frames(2, DeviceSnapshot(), PointerInput(), {idle, idle, b, a, k0});
CHECKF(tp[21 * 1 + BUTTON_B] != 0, "tap port %d socket 1 B", tapPort + 1);
CHECKF(tp[21 * 2 + BUTTON_A] != 0, "tap port %d socket 2 A", tapPort + 1);
CHECKF(tp[21 * 3 + BUTTON_0] != 0, "tap port %d socket 3 keypad 0", tapPort + 1);
int stray = 0;
for (int i = 0; i < JOYPAD_BUTTON_SLOTS; i++) stray += op[i] != 0;
CHECKF(stray == 0, "the other port saw %d slots", stray);
// Controllers 1 and 2 play socket 0 of each port.
frames(2, DeviceSnapshot(), PointerInput(), {b, a, idle, idle, idle});
CHECK(joypad0Buttons[BUTTON_B] != 0 && joypad1Buttons[BUTTON_A] != 0);
// Keypad 7 has no RetroPad button on a tap socket: unreachable (a core limitation).
frames(2, DeviceSnapshot(), PointerInput(), {idle, idle, k7, k7, k7});
int sockets = 0;
for (int i = 21; i < JOYPAD_BUTTON_SLOTS; i++) sockets += tp[i] != 0;
CHECK(sockets == 0);
}
// An explicit assignment: socket 1 of port 2 is played by the SECOND of two identical controllers.
if (!load(DevKind::Pad, DevKind::TeamTap)) return;
std::array<SlotSource, kSlots> ex;
ex[5].kind = SlotSource::Pad;
ex[5].guid = "same";
ex[5].instance = 1;
g_cfg.routes = assignSlots(ex, activeSlots(false, true), {"same", "same"}, 0);
g_cfg.routes.kb[0] = false;
CHECK(g_cfg.routes.pad[5] == 1 && g_cfg.routes.pad[0] == 0);
frames(2, DeviceSnapshot(), PointerInput(), {idle, b});
CHECK(joypad1Buttons[21 * 1 + BUTTON_B] != 0 && joypad0Buttons[BUTTON_B] == 0);
}
static void testProKeyboard() {
std::printf("-- Jaguar Pro Controller on the keyboard\n");
if (!load(DevKind::Pad, DevKind::Pad)) return;
// The core's own aliases (libretro.c pro_controller[]): X -> keypad 9, Y (L2) -> 8, Z (R2) -> 7, L -> 4, R -> 6.
struct { int sc; int slot; const char* what; } t[] = {
{SDL_SCANCODE_A, BUTTON_9, "Pro X"}, {SDL_SCANCODE_S, BUTTON_8, "Pro Y"}, {SDL_SCANCODE_D, BUTTON_7, "Pro Z"},
{SDL_SCANCODE_Q, BUTTON_4, "Pro left shoulder"}, {SDL_SCANCODE_E, BUTTON_6, "Pro right shoulder"},
{SDL_SCANCODE_Z, BUTTON_B, "B"}, {SDL_SCANCODE_X, BUTTON_A, "A"}, {SDL_SCANCODE_C, BUTTON_C, "C"},
};
frames(2, DeviceSnapshot());
int none = 0;
for (int i = 0; i < 21; i++) none += joypad0Buttons[i] != 0;
CHECK(none == 0); // negative control
for (const auto& c : t) {
frames(2, keys({c.sc}));
int n = 0, first = -1;
for (int i = 0; i < 21; i++)
if (joypad0Buttons[i]) {
n++;
if (first < 0) first = i;
}
CHECKF(n == 1 && first == c.slot, "%s: %d slot(s), first %d, want %d", c.what, n, first, c.slot);
}
}
// ---- the Pro Controller device: Settings > Input's "Pro Controller (6-button)" ------------------------------------
// A gamepad's LB / North / RB / LT / RT -> the Router's Pro X / Y / Z / L / R -> RetroPad X / L2 / R2 / L / R -> the
// core's Pro path (pro_controller[], switched on only by virtualjaguar_pN_device = pad_pro, which the app's option
// lock hands it) -> keypad 9 / 8 / 7 / 4 / 6 in the matrix. Read back from the core's own button state.
// How many of the twelve keypad slots (joystick.h: BUTTON_s .. BUTTON_3 are slots 4-15) are lit; `first` = the lowest.
static int litDigits(const uint8_t* b, int* first) {
int n = 0;
*first = -1;
for (int i = BUTTON_s; i <= BUTTON_3; i++)
if (b[i]) {
n++;
if (*first < 0) *first = i;
}
return n;
}
static void testProPad() {
// pro: the Pro path. std_: a standard pad (the app's keypad synthesis). noOpt: the RetroPad bit the Router
// sends on a Pro port, read by the core's NON-Pro mapping (X L R L2 R2 = keypad 0 1 2 3 4).
struct { int btn; int axis; int pro; int std_; int noOpt; const char* what; } t[] = {
{SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, -1, BUTTON_9, BUTTON_1, BUTTON_0, "LB = Pro X"},
{SDL_GAMEPAD_BUTTON_NORTH, -1, BUTTON_8, BUTTON_0, BUTTON_3, "North = Pro Y"},
{SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, -1, BUTTON_7, BUTTON_2, BUTTON_4, "RB = Pro Z"},
{-1, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, BUTTON_4, BUTTON_3, BUTTON_1, "LT = Pro L"},
{-1, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, BUTTON_6, BUTTON_4, BUTTON_2, "RT = Pro R"},
};
// mode 0: a Pro port. 1: a standard pad (today's mapping, unchanged). 2: a Pro port whose option never reached
// the core (negative control: RetroPad X / L / R / L2 / R2 are then keypad 0-4, so the lock is what turns it on).
for (unsigned port = 0; port < 2; port++) {
for (int mode = 0; mode < 3; mode++) {
std::printf("-- gamepad on port %u: %s\n", port + 1, mode == 0 ? "Pro Controller" : mode == 1 ? "Standard Joypad"
: "Pro Controller, option not applied");
DevKind k = mode == 1 ? DevKind::Pad : DevKind::ProPad;
if (!load(port == 0 ? k : DevKind::Pad, port == 1 ? k : DevKind::Pad, mode != 2)) return;
g_cfg.routes.kb[0] = false;
g_cfg.routes.pad[port] = 0;
const uint8_t* b = port == 0 ? joypad0Buttons : joypad1Buttons;
const uint8_t* other = port == 0 ? joypad1Buttons : joypad0Buttons;
int first = -1;
frames(2, DeviceSnapshot(), PointerInput(), {DeviceSnapshot()});
CHECKF(litDigits(b, &first) == 0, "nothing pressed: keypad %d lit", first); // negative control
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; // past kTriggerThreshold
frames(2, DeviceSnapshot(), PointerInput(), {s});
int n = litDigits(b, &first), on = 0;
litDigits(other, &on);
const int want = mode == 0 ? c.pro : mode == 1 ? c.std_ : c.noOpt;
CHECKF(n == 1 && first == want && on < 0, "%s (mode %d): %d digit(s), first %d, want %d, other port %d",
c.what, mode, n, first, want, on);
}
// The rest of the pad is the pad: East = A, and keypad 5 (L3) still works on a Pro port.
DeviceSnapshot s;
s.btn[SDL_GAMEPAD_BUTTON_EAST] = s.btn[SDL_GAMEPAD_BUTTON_LEFT_STICK] = true;
frames(2, DeviceSnapshot(), PointerInput(), {s});
CHECK(b[BUTTON_A] != 0 && litDigits(b, &first) == 1 && first == BUTTON_5);
}
}
// Settings > Input switching a running game between the two, as applyPortDevices() does it (no reload): the new
// lock value reaches the core through GET_VARIABLE_UPDATE before the next frame reads the pad.
std::printf("-- Standard Joypad -> Pro Controller -> Standard Joypad, live\n");
if (!load(DevKind::Pad, DevKind::Pad)) return;
g_cfg.routes.kb[0] = false;
g_cfg.routes.pad[0] = 0;
DeviceSnapshot lb;
lb.btn[SDL_GAMEPAD_BUTTON_LEFT_SHOULDER] = true;
int first = -1;
frames(2, DeviceSnapshot(), PointerInput(), {lb});
CHECK(litDigits(joypad0Buttons, &first) == 1 && first == BUTTON_1);
g_pro = {{true, false}};
g_host->refreshLocks();
g_host->setControllerDevice(0, coreClaimFor(devLibretroId(DevKind::ProPad)));
g_cfg.kind = {{DevKind::ProPad, DevKind::Pad}};
frame(DeviceSnapshot(), PointerInput(), {lb}); // ONE frame: the switch is in effect on the very next retro_run
CHECK(litDigits(joypad0Buttons, &first) == 1 && first == BUTTON_9);
g_pro = {{false, false}};
g_host->refreshLocks();
g_host->setControllerDevice(0, RETRO_DEVICE_JOYPAD);
g_cfg.kind = {{DevKind::Pad, DevKind::Pad}};
frame(DeviceSnapshot(), PointerInput(), {lb});
CHECK(litDigits(joypad0Buttons, &first) == 1 && first == BUTTON_1);
for (const OptionDef& d : g_host->optionDefs())
if (d.key == "virtualjaguar_p1_device") CHECK(g_host->optionValue(d) == "auto");
}
// ---- TV-mode dual-use buttons: HoldArbiter + ReleaseGuard + Router + the real core (#991) --------------------------
// One pad (device 1) on a controller with no Share / Capture button, so Guide is both the keypad's # and the TV
// menu's held button. Per frame the order is poll()'s: HoldArbiter::apply -> ReleaseGuard::apply -> Router::route
// -> HoldArbiter::frame. The profile is this test's own; the shared g_padp stays what every other case ran with.
static const uint32_t kPadDev = 1;
static void holdFrame(HoldArbiter& arb, ReleaseGuard& guard, const Profile& prof, DeviceSnapshot snap) {
arb.apply(kPadDev, snap);
guard.apply(kPadDev, snap);
HostSource h;
h.profile = &prof;
h.snap = snap;
HostSource k;
k.isKeyboard = true;
k.profile = &g_kbp;
g_in.beginFrame();
g_router.route(g_cfg, {h}, &k, PointerInput(), nullptr, g_in);
g_host->runFrame();
arb.frame();
}
static std::vector<Binding> holdBindings(const Profile& p) {
std::vector<Binding> v;
for (int c = 0; c < C_COUNT; c++)
for (const Binding& b : p.binds[c])
if (b.hold) v.push_back(b);
return v;
}
static void testHoldButtons() {
std::printf("-- Guide: tap = keypad #, hold = menu, release guard\n");
if (!load(DevKind::Pad, DevKind::Pad)) return;
g_host->sendKeyEvent(false, 0, 0, 0); // the keypad announcement, as finishLoad() makes
g_cfg.routes.kb[0] = false;
g_cfg.routes.pad[0] = 0;
const Profile prof = Profile::builtin(DeviceClass::Gamepad, PadCaps{false});
const Binding G = Binding::button(SDL_GAMEPAD_BUTTON_GUIDE);
DeviceSnapshot none, guideDown, eastDown;
guideDown.btn[SDL_GAMEPAD_BUTTON_GUIDE] = true;
eastDown.btn[SDL_GAMEPAD_BUTTON_EAST] = true;
auto hash = [&]() { return joypad0Buttons[BUTTON_d] != 0; };
auto east = [&]() { return joypad0Buttons[BUTTON_A] != 0; };
CHECK(holdBindings(prof).size() == 1); // Guide is the hold input of this pad
// A tap: nothing while it is down, then # for exactly kPulseFrames emulated frames.
{
HoldArbiter arb;
ReleaseGuard guard;
arb.setHoldInputs(kPadDev, holdBindings(prof));
for (int f = 0; f < 3; f++) { holdFrame(arb, guard, prof, none); CHECK(!hash()); } // negative control
arb.press(kPadDev, G, 0);
for (int f = 0; f < 10; f++) { holdFrame(arb, guard, prof, guideDown); CHECK(!hash()); }
arb.release(kPadDev, G, 200);
int on = 0;
for (int f = 0; f < 8; f++) { holdFrame(arb, guard, prof, none); on += hash(); }
CHECKF(on == HoldArbiter::kPulseFrames, "# pulse lasted %d frames", on);
}
// A hold: fires once, and # never reaches the game, not even after the button comes up.
{
HoldArbiter arb;
ReleaseGuard guard;
arb.setHoldInputs(kPadDev, holdBindings(prof));
arb.press(kPadDev, G, 0);
CHECK(arb.takeFires(600) == 1);
int on = 0;
for (int f = 0; f < 40; f++) { holdFrame(arb, guard, prof, guideDown); on += hash(); }
arb.release(kPadDev, G, 2000);
for (int f = 0; f < 8; f++) { holdFrame(arb, guard, prof, none); on += hash(); }
CHECKF(on == 0, "# reached the core %d times during a hold", on);
}
// The guard: East was already down when the menu closed; it must not be a fresh A press for the game.
{
HoldArbiter arb;
ReleaseGuard guard;
arb.setHoldInputs(kPadDev, holdBindings(prof));
holdFrame(arb, guard, prof, eastDown);
CHECK(east()); // negative control: unguarded, East is A
holdFrame(arb, guard, prof, none);
guard.arm(kPadDev, eastDown);
int a = 0;
for (int f = 0; f < 10; f++) { holdFrame(arb, guard, prof, eastDown); a += east(); }
CHECKF(a == 0, "A reached the game %d times while guarded", a);
holdFrame(arb, guard, prof, none);
CHECK(!guard.armed());
holdFrame(arb, guard, prof, eastDown);
CHECK(east());
}
// held_guide_across_handoff_never_reaches_core: Guide went down before the menu closed onto the game. The hand-off
// as InputManager::armReleaseGuard() does it: the guard takes the RAW state, then the arbiter forgets the hold. Its
// release is then neither a tap (the negative control is the tap case above: the same release WITHOUT the hand-off
// pulses #) nor a hold.
{
HoldArbiter arb;
ReleaseGuard guard;
arb.setHoldInputs(kPadDev, holdBindings(prof));
arb.press(kPadDev, G, 0);
guard.arm(kPadDev, guideDown);
arb.clear();
int on = 0;
for (int f = 0; f < 10; f++) { holdFrame(arb, guard, prof, guideDown); on += hash(); }
arb.release(kPadDev, G, 200); // a no-op for the arbiter
for (int f = 0; f < 8; f++) { holdFrame(arb, guard, prof, none); on += hash(); }
CHECKF(on == 0, "# reached the core %d times for a Guide held across the hand-off", on);
CHECK(arb.takeFires(5000) == 0);
CHECK(!guard.armed());
}
}
static void testLockedOptions() {
std::printf("-- locked core options\n");
if (!load(DevKind::Pad, DevKind::Pad)) return;
int locked = 0;
for (const OptionDef& d : g_host->optionDefs()) {
std::string v;
bool want = lockedCoreOption(d.key, v);
CHECKF(g_host->optionLocked(d.key) == want, "%s", d.key.c_str());
if (want) {
locked++;
CHECKF(!g_host->optionVisible(d.key), "%s still visible", d.key.c_str());
}
if (d.key == "virtualjaguar_alt_inputs") CHECK(g_host->optionValue(d) == "disabled");
}
CHECKF(locked >= 50, "only %d locked", locked);
// A saved / session 'enabled' cannot reach the core: the keypad fallbacks the app relies on keep working.
g_host->setOptionValue("virtualjaguar_alt_inputs", "enabled");
g_host->overrideOption("virtualjaguar_p1_numpad_to_kb", "keypad");
frames(3, keys({SDL_SCANCODE_7}));
CHECK(joypad0Buttons[BUTTON_7] != 0);
for (const OptionDef& d : g_host->optionDefs())
if (d.key == "virtualjaguar_alt_inputs") CHECK(g_host->optionValue(d) == "disabled");
// ... and an --option naming a locked key is REPORTED (it would otherwise silently run the forced value);
// a stale saved value only warns.
std::vector<std::string> bad = g_host->checkOverrides(true);
CHECKF(bad.size() == 1 && bad[0].find("locked by the app") != std::string::npos, "%zu problems", bad.size());
std::vector<std::string> warn = g_host->checkOverrides(false);
CHECKF(warn.size() == 1 && warn[0].find("saved value is ignored") != std::string::npos, "%zu warnings", warn.size());
g_host->overrideOption("virtualjaguar_p1_numpad_to_kb", "disabled"); // the forced value itself is fine
CHECK(g_host->checkOverrides(true).empty());
}
int main(int argc, char** argv) {
if (argc < 2) {
std::fprintf(stderr, "usage: vj_devices_e2e <rom.j64>\n");
return 2;
}
g_rom = argv[1];
namespace fs = std::filesystem;
std::error_code ec;
fs::path tmp = fs::temp_directory_path(ec) / ("vj-devices-e2e-" + std::to_string((long long)std::time(nullptr)));
fs::create_directories(tmp / "saves", ec);
fs::create_directories(tmp / "system", ec);
std::string sys = (tmp / "system").string() + "/", sav = (tmp / "saves").string() + "/";
g_kbp = Profile::builtin(DeviceClass::Keyboard);
g_kbp.stickAsDpad = false;
g_padp = Profile::builtin(DeviceClass::Gamepad);
g_padPro = Profile::builtin(DeviceClass::Gamepad, PadCaps(), true);
CoreHost& host = CoreHost::instance();
g_host = &host;
host.setDirectories(sys, sav);
host.optionLock = [](const std::string& k, std::string& v) { return lockedCoreOptionFor(k, v, g_pro); }; // as App::wireHost() does
host.announceKeyboard = true;
host.inputPoll = []() {};
host.inputState = [](unsigned p, unsigned d, unsigned i, unsigned id) { return g_in.state(p, d, i, id); };
testMouse();
testRotary();
testAnalog();
testSixD();
testPaddle();
testLightgun();
testTeamTap();
testProKeyboard();
testProPad();
testHoldButtons();
testLockedOptions();
host.unloadContent();
fs::remove_all(tmp, ec); // our own temp dir only
std::printf("devices_e2e: %d checks, %d failed\n", g_checks, g_fail);
return g_fail ? 1 : 0;
}