mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-11 11:31:25 -10:00
* docs(app): emulation thread phase 2 plan (#967) Achievements and netplay back on with the emulation thread (core-event stream, rc_client_do_frame on the emulation thread, netplay as a UI-thread object fed by the stream), the cheat batch as a regular command, the 250 ms busy spinner, and the Phase 1 open items (MinGW waits, limits after a failed start, the vsync-off nap). Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): EmuSnapshot in its own file, with the core-event stream (#967) CoreEventQueue carries log lines, vj_events and option writes from the emulation thread to the UI thread in order; log lines are bounded, events and option writes never dropped. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): core log lines, events and owner option writes reach the UI thread through the stream (#967) CoreHost::onUiEvent gets every log line and vj_event on the UI thread (pinSnapshot), with an epoch that rides the command queue; option writes made on the emulation thread inside a call() are applied to the UI's mirror and the config there too, never on the emulation thread. A content switch drops queued lines and events. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): netplay works with the emulation thread on (#967) Netplay stays a UI-thread object: the core's link lines and events reach it through CoreHost::onUiEvent, filtered by an epoch bumped at every start/stop/bounce, and the endpoint bounce completes after the core has read "disabled" for 2 emulated frames (3 threaded), counted with frameCount(). Threaded twins of netplay_pair (both sides, voice modem, a mixed threaded/inline pair) and of netplay_no_partner, which must exit 1, never 3. Co-Authored-By: Claude Opus 5.5 <[email protected]> * refactor(app): the cheat list reaches the core as one regular command (#967) CheatManager::apply() builds one CheatBatch (reset, then every set) and hands it to commit, which the app posts to the emulation thread; the three sinks and the UI-thread batch collector are gone. Co-Authored-By: Claude Opus 5.5 <[email protected]> * refactor(app): RetroAchievements safe on two threads (#967) rc_client events are copied into AchEvents and applied in pump(); core RAM, the memory map and the cheat resets are reached on the emulation thread (call/post); read_memory off it answers address checks from the map without touching RAM; the rich-presence line is computed with the frame and published; the mock server is locked. Inline runs behave as before. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): high-resolution short waits for the emulation thread on Windows (#967) The MinGW runner paced threaded fast-forward at one frame per ~15.6 ms (63.4 vs 96.6 frames/s); develop's newest completed MinGW runs (x64 and ARM64) pass emu_thread_unit, but ctest hides a passing test's output so the timer note is not visible there, and no MinGW run on develop has yet carried the 1 ms precise-wait check. preciseSleepNs uses a high-resolution waitable timer, and every app job now prints emu_thread_unit's timer report. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(app): the precise-sleep check uses the shortest wake (#967) A starved host still has some on-time wakes, while timers that are coarse by platform (every wake ~15.6 ms on MinGW) have none, so the check asserts on the shortest of 20 preciseSleepNs(1 ms) waits; the note prints mean and min. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): RetroAchievements works with the emulation thread on (#967) rc_client_do_frame (and with it rc_client_idle) runs after each frame on the emulation thread; the UI pump posts the idle while paused. A threaded headless RA run steps one frame at a time in the inline order, so every ra_* test has a _threaded twin that lands its steps on the same frames. Only the debugger tools and the GDB stub stay off. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): precise_sleep.h includes a lean windows.h (#967) emu_commands.h reaches ~24 MinGW translation units through app.h; WIN32_LEAN_AND_MEAN keeps legacy winsock and macro collisions out of them (the waitable-timer and Sleep calls need nothing outside it). A WaitForSingleObject that does not return WAIT_OBJECT_0 now falls back to Sleep(1), so a failed wait cannot make waitUntil spin. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): quiesce the emulation thread before RetroAchievements shuts down (#967) App::shutdown destroyed the rc_client while a free-running emulation thread could still be inside rc_client_do_frame or a posted emuIdle: it now unloads and stops the thread first, then ach.shutdown() (still before video.shutdown(), images.stop() needs the GL context). AchHttp::ensureWorker runs inside request()'s locked block, and shutdown() moves the worker out under the lock and joins outside it, so request() is safe from the UI and the emulation thread (new two-thread case in ach_http_test). onContentLoaded and onReset resync framesSeen_ so frames that ran before the load are not counted as the new game's. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(app): a call() that takes over 250 ms shows a busy spinner (#967) EmuThread::call() runs a wait hook on the waiting thread from 250 ms on; the app's hook pumps OS events and swaps a spinner drawn with scissored clears -- no ImGui frame, no call(), no read of a field only a call() writes (CoreHost counts those while the UI waits). Headless, it logs once per call. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): a failed emulation-thread start lifts the feature limits; vsync off naps to the next refresh (#967) Phase 1 final-review minors 2 and 3. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs(app): emulation thread phase 2 -- achievements, netplay, the busy spinner (#967) Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): drop the previous game's achievement trackers and indicators after a content switch (#967) Events carry the generation they were queued in; onContentLoaded and onContentClosed bump it, and drainEvents drops the stale tracker, challenge, progress, attempt started/failed and reset events (no HIDE would ever come for them). A real unlock, completions, scoreboards and the connection state are kept. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): drive rc_client's mutating calls from the emulation thread only (#967) App::reset runs ach.onReset() inside the emulation-thread call (rc_client_reset raises events); applyHardcore, setEnabled(false), logout and the pause check (rc_client_can_pause advances the pause-decay state) go through coreCall, so none of them runs while rc_client_do_frame does. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): the vsync-off present loop runs on a display-period deadline grid and handles input while it naps (#967) The nap was anchored at the swap end, so the period was refresh + build + swap (~54-57 Hz on a 60 Hz display) and no input was published during it. LateLatch::advanceGridNs / gridNapNs keep a deadline grid; App::presentNap waits for events as latchWait does and hands them to the emulation thread at once. (SDL 3.4 has no SDL_WaitEventTimeoutNS.) Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): the emulation-thread setting hint names only what is still switched off (#967) RetroAchievements and netplay work with the thread on; the Tools (debugger) menu and the GDB stub do not. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(app): pin that a reload drops the previous game's queued core log lines (#967) The block had no onUiEvent, so it only proved option survival. It now queues a log line inside the same call as the reload, and asserts it is not delivered while the option write still is. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): final-review minors -- ci report step, inline pin, display mode, RAII guards, stale docs (#967) ci: the timer-report step is informational (continue-on-error). CoreHost::pinSnapshot applies queued option events when the UI thread is the owner (thread stopped / inline). The display period is re-read on SDL_EVENT_DISPLAY_CURRENT_MODE_CHANGED. busyFrame's UI-waiting flag and call()'s wait-hook flag are RAII. netplay.h and cheats.h comments, docs/agent/testing.md (TSan recipe) and the ach_event_unit label are brought up to date. The off-thread read_memory answer notes the rcheevos v12.5 assumption. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(app): serialize achievement replies and synchronize scope capture (#967) Run HTTP and mock rc_client replies on the core owner; guard scope visibility and its sample ring. Add fail-first ownership/concurrency regressions to the sanitizer suite and prevent Windows GL-loader macros from breaking the busy spinner. * fix(app): synchronize achievement UI client access (#967) --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
800 lines
33 KiB
C++
800 lines
33 KiB
C++
// Netplay tests (the link-cable window's engine; see netplay.h). No window, no GL, no internet.
|
|
//
|
|
// vj_netplay_test unit ROM parsers, then the REAL core in this process: the settings the
|
|
// window makes reach the core (loopback / host / join, device,
|
|
// a port outside the option's list, changing the endpoint while
|
|
// connected), and the log tap reports link UP / DOWN.
|
|
// vj_netplay_test pair EXE ROM [DEVICE [HOST JOIN]] (HOST / JOIN: --emu-thread on|off for that child, default off)
|
|
// two app processes on localhost, one Host and one Join through
|
|
// the command-line twin of the window; both must report the link UP.
|
|
// Does not use UDP broadcast: the client is given the address.
|
|
// vj_netplay_test control EXE ROM [THREAD] the same with nobody to talk to: must FAIL (so the check can fail).
|
|
// vj_netplay_test discovery EXE ROM a host and a client that must FIND it by LAN discovery and join
|
|
// it. Exit 77 (ctest: skipped) when this machine does not deliver
|
|
// UDP broadcast to itself -- CI runners often do not.
|
|
//
|
|
// Ports are picked at run time from 20000-29999 (below every OS's ephemeral range, and probed free):
|
|
// a hard-coded port, or an ephemeral one, collides with a concurrent run.
|
|
#include "core_host.h"
|
|
#include "instance.h"
|
|
#include "netplay.h"
|
|
#include "paths.h"
|
|
|
|
extern "C" {
|
|
#include "jlink_discover.h" // the core's beacon codec (JLinkDiscEncode): to play a LAN host without broadcast
|
|
}
|
|
|
|
#include <SDL3/SDL.h>
|
|
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <functional>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#ifdef _WIN32
|
|
# include <winsock2.h>
|
|
# include <ws2tcpip.h>
|
|
typedef SOCKET sock_t;
|
|
static const sock_t kBadSock = INVALID_SOCKET;
|
|
static void closeSock(sock_t s) { closesocket(s); }
|
|
#else
|
|
# include <arpa/inet.h>
|
|
# include <fcntl.h>
|
|
# include <netinet/in.h>
|
|
# include <sys/socket.h>
|
|
# include <unistd.h>
|
|
typedef int sock_t;
|
|
static const sock_t kBadSock = -1;
|
|
static void closeSock(sock_t s) { close(s); }
|
|
#endif
|
|
|
|
using namespace vj;
|
|
namespace fs = std::filesystem;
|
|
|
|
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 CHECK_EQ(a, b) \
|
|
do { \
|
|
g_checks++; \
|
|
std::string va_ = (a), vb_ = (b); \
|
|
if (va_ != vb_) { \
|
|
g_fail++; \
|
|
std::fprintf(stderr, "FAIL %s:%d %s\n got '%s'\n expected '%s'\n", __FILE__, __LINE__, #a, \
|
|
va_.c_str(), vb_.c_str()); \
|
|
} \
|
|
} while (0)
|
|
|
|
// ---- sockets ----------------------------------------------------------------------------------
|
|
static void netInit() {
|
|
#ifdef _WIN32
|
|
WSADATA w;
|
|
WSAStartup(MAKEWORD(2, 2), &w);
|
|
#endif
|
|
}
|
|
|
|
static void setNonBlocking(sock_t s) {
|
|
#ifdef _WIN32
|
|
u_long nb = 1;
|
|
ioctlsocket(s, FIONBIO, &nb);
|
|
#else
|
|
fcntl(s, F_SETFL, fcntl(s, F_GETFL, 0) | O_NONBLOCK);
|
|
#endif
|
|
}
|
|
|
|
static sockaddr_in loopbackAddr(int port) {
|
|
sockaddr_in sa;
|
|
std::memset(&sa, 0, sizeof sa);
|
|
sa.sin_family = AF_INET;
|
|
sa.sin_port = htons((unsigned short)port);
|
|
sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
|
return sa;
|
|
}
|
|
|
|
static bool bindsOn(int type, int port) {
|
|
sock_t s = socket(AF_INET, type, 0);
|
|
if (s == kBadSock) return false;
|
|
sockaddr_in sa = loopbackAddr(port);
|
|
sa.sin_addr.s_addr = htonl(INADDR_ANY);
|
|
bool ok = bind(s, (sockaddr*)&sa, sizeof sa) == 0;
|
|
closeSock(s);
|
|
return ok;
|
|
}
|
|
|
|
// A port that is free for TCP and UDP right now. 20000-29999: below the ephemeral ranges.
|
|
static int pickFreePort(int avoid = 0) {
|
|
unsigned long long seed = SDL_GetPerformanceCounter();
|
|
for (int i = 0; i < 200; i++) {
|
|
int port = 20000 + (int)((seed + (unsigned long long)i * 7919ull) % 10000ull);
|
|
if (port == avoid) continue;
|
|
if (bindsOn(SOCK_STREAM, port) && bindsOn(SOCK_DGRAM, port)) return port;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static sock_t listenOn(int port) {
|
|
sock_t s = socket(AF_INET, SOCK_STREAM, 0);
|
|
if (s == kBadSock) return kBadSock;
|
|
sockaddr_in sa = loopbackAddr(port);
|
|
if (bind(s, (sockaddr*)&sa, sizeof sa) != 0 || listen(s, 4) != 0) {
|
|
closeSock(s);
|
|
return kBadSock;
|
|
}
|
|
setNonBlocking(s);
|
|
return s;
|
|
}
|
|
|
|
static sock_t connectTo(int port) {
|
|
sock_t s = socket(AF_INET, SOCK_STREAM, 0);
|
|
if (s == kBadSock) return kBadSock;
|
|
sockaddr_in sa = loopbackAddr(port);
|
|
if (connect(s, (sockaddr*)&sa, sizeof sa) != 0) {
|
|
closeSock(s);
|
|
return kBadSock;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// ---- helpers ----------------------------------------------------------------------------------
|
|
static std::string optionOf(CoreHost& h, const char* key) {
|
|
for (const OptionDef& d : h.optionDefs())
|
|
if (d.key == key) return h.optionValue(d);
|
|
return "(no such option)";
|
|
}
|
|
|
|
static void frames(CoreHost& h, Netplay& np, int n) {
|
|
for (int i = 0; i < n; i++) {
|
|
h.runFrame();
|
|
np.afterFrame();
|
|
}
|
|
}
|
|
|
|
// Run until `pred` holds, a frame at a time, for at most `maxMs` of wall time (TCP needs real time).
|
|
static bool runUntil(CoreHost& h, Netplay& np, const std::function<bool()>& pred, int maxMs = 4000) {
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
while (!pred()) {
|
|
if (std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - t0).count() > maxMs)
|
|
return false;
|
|
h.runFrame();
|
|
np.afterFrame();
|
|
SDL_Delay(1);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// The process environment (what the core's getenv() sees) and SDL's own copy of it: SDL_CreateProcess
|
|
// hands the child SDL's copy, which is taken once and would not see a later setenv().
|
|
static void setEnv(const char* k, const std::string& v) {
|
|
#ifdef _WIN32
|
|
_putenv_s(k, v.c_str());
|
|
#else
|
|
setenv(k, v.c_str(), 1);
|
|
#endif
|
|
SDL_SetEnvironmentVariable(SDL_GetEnvironment(), k, v.c_str(), true);
|
|
}
|
|
|
|
// ---- pure parsers -----------------------------------------------------------------------------
|
|
static void testParsers() {
|
|
// The exact format jlink.c's JLinkStatsPoll() writes (libretro.c prefixes "[NETLINK] ").
|
|
const std::string line =
|
|
"[NETLINK] link stats 5s: hops/s=18 hop=1.2ms wait/hop=0.4ms slipped=11% (10/90) timeouts=1 "
|
|
"budget_cut=2 budget_us=9000 (emu=4200us margin_adj=+0us) wait/frame=500us idle_wait=3/7";
|
|
LinkStats s;
|
|
CHECK(Netplay::parseStatsLine(line, s));
|
|
CHECK(s.valid);
|
|
CHECK(s.windowSec == 5);
|
|
CHECK(s.hopsPerSec == 18);
|
|
CHECK(s.hopMs > 1.19 && s.hopMs < 1.21);
|
|
CHECK(s.waitPerHopMs > 0.39 && s.waitPerHopMs < 0.41);
|
|
CHECK(s.slippedPct == 11 && s.slipped == 10 && s.hops == 90);
|
|
CHECK(s.timeouts == 1 && s.budgetCut == 2 && s.budgetUs == 9000);
|
|
CHECK(s.idleHits == 3 && s.idleWaits == 7);
|
|
CHECK(s.raw.rfind("link stats 5s", 0) == 0);
|
|
|
|
LinkStats bad;
|
|
CHECK(!Netplay::parseStatsLine("[NETLINK] link UP (tcp_server, device=jaglink)", bad));
|
|
CHECK(!Netplay::parseStatsLine("link stats 5s: nothing useful", bad));
|
|
CHECK(!bad.valid);
|
|
|
|
// The host picker: presets stay, discovered peers become rows; a differing device is tagged.
|
|
OptionDef host;
|
|
host.key = "virtualjaguar_netlink_host";
|
|
host.values = {{"127.0.0.1", "127.0.0.1 (localhost)"},
|
|
{"192.168.1.42", "studio - 192.168.1.42"},
|
|
{"10.0.0.7", "10.0.0.7 - 10.0.0.7 (Voice Modem)"},
|
|
{"127.0.0.1", "devbox - 127.0.0.1"}, // a peer on this very machine
|
|
{"jaghub.local", "jaghub.local (host machine named 'jaghub' on the LAN)"},
|
|
{"vj_netlink.txt", "From file"}};
|
|
OptionDef other;
|
|
other.key = "virtualjaguar_netlink_port";
|
|
other.values = {{"42171", ""}};
|
|
std::vector<DiscoveredHost> found = Netplay::hostsFromOptions({other, host});
|
|
CHECK(found.size() == 3);
|
|
if (found.size() == 3) {
|
|
CHECK_EQ(found[0].address, "192.168.1.42");
|
|
CHECK_EQ(found[0].name, "studio");
|
|
CHECK_EQ(found[0].device, "");
|
|
CHECK_EQ(found[1].address, "10.0.0.7");
|
|
CHECK_EQ(found[1].device, "Voice Modem");
|
|
CHECK_EQ(found[1].name, "");
|
|
CHECK_EQ(found[2].address, "127.0.0.1");
|
|
CHECK_EQ(found[2].name, "devbox");
|
|
}
|
|
CHECK(Netplay::hostsFromOptions({other}).empty());
|
|
|
|
OptionDef r;
|
|
r.info = "Restart required: applied when content is loaded. Something.";
|
|
CHECK(Netplay::optionNeedsRestart(r));
|
|
OptionDef live;
|
|
live.info = "Takes effect immediately.";
|
|
live.desc = "Network Link";
|
|
CHECK(!Netplay::optionNeedsRestart(live));
|
|
OptionDef suffix;
|
|
suffix.desc = "Cart BIOS Type (Restart)";
|
|
CHECK(Netplay::optionNeedsRestart(suffix));
|
|
|
|
CHECK(Netplay::portIsOptionValue(42171) && Netplay::portIsOptionValue(42174));
|
|
CHECK(!Netplay::portIsOptionValue(42170) && !Netplay::portIsOptionValue(42175) && !Netplay::portIsOptionValue(23456));
|
|
|
|
// Local addresses: IPv4 dotted quads, never loopback or link-local.
|
|
std::vector<LanAddress> lan = Netplay::lanAddresses();
|
|
for (const LanAddress& a : lan) {
|
|
unsigned p[4] = {0, 0, 0, 0};
|
|
CHECK(std::sscanf(a.ip.c_str(), "%u.%u.%u.%u", &p[0], &p[1], &p[2], &p[3]) == 4);
|
|
CHECK(p[0] != 127 && !(p[0] == 169 && p[1] == 254));
|
|
}
|
|
std::printf("netplay: %zu LAN address(es) on this machine\n", lan.size());
|
|
}
|
|
|
|
// ---- the real core ------------------------------------------------------------------------------
|
|
// #967 phase 2: the app's tap is CoreHost::onUiEvent. What the core said before the last start()/stop() is stale:
|
|
// a LINK_UP from the previous session must not mark the new one up.
|
|
static void testEpochFilter() {
|
|
vj::CoreHost& host = vj::CoreHost::instance();
|
|
vj::Netplay np(host);
|
|
np.setAddressViaOption(true);
|
|
vj::NetSettings s;
|
|
s.role = vj::NetRole::Host;
|
|
std::string err;
|
|
CHECK(np.start(s, err)); // nothing loaded: applied at once, with a new epoch
|
|
const uint32_t now = host.eventEpoch();
|
|
vj::CoreEvent up;
|
|
up.kind = vj::CoreEvent::Kind::Event;
|
|
up.type = VJ_EVT_LINK_UP;
|
|
up.text = "tcp_server";
|
|
up.epoch = now - 1; // said before this start
|
|
np.onUiEvent(up);
|
|
CHECK(np.link() == vj::LinkState::Waiting);
|
|
up.epoch = now;
|
|
np.onUiEvent(up);
|
|
CHECK(np.link() == vj::LinkState::Up);
|
|
CHECK(np.linkUpEvents() == 1);
|
|
vj::CoreEvent line;
|
|
line.kind = vj::CoreEvent::Kind::Log;
|
|
line.text = "[NETLINK] failed to open the port"; // a line with no event: read even with the event channel on
|
|
line.epoch = now;
|
|
np.onUiEvent(line);
|
|
CHECK(!np.notices().empty());
|
|
np.stop();
|
|
}
|
|
|
|
static void testCore(const std::string& rom) {
|
|
std::error_code ec;
|
|
fs::path tmp = fs::temp_directory_path(ec) / ("vj-netplay-test-" + std::to_string((long long)std::time(nullptr)));
|
|
fs::create_directories(tmp / "saves", ec);
|
|
fs::create_directories(tmp / "system", ec);
|
|
const std::string sys = (tmp / "system").string() + "/", sav = (tmp / "saves").string() + "/";
|
|
|
|
// Keep the core's LAN discovery (and wire-speed negotiation) on a port of ours.
|
|
int discPort = pickFreePort();
|
|
CHECK(discPort != 0);
|
|
setEnv("VJ_DISC_PORT", std::to_string(discPort));
|
|
|
|
CoreHost& host = CoreHost::instance();
|
|
host.setDirectories(sys, sav);
|
|
host.setLogVerbose(std::getenv("VJ_TEST_VERBOSE") != nullptr); // the core's INFO lines, for debugging
|
|
host.inputPoll = []() {};
|
|
Netplay np(host);
|
|
np.setSystemDir(sys);
|
|
host.onLog = [&np](int level, const char* text) { np.onCoreLog(level, text); };
|
|
host.onEvent = [&np](const vj_event& e) { np.onCoreEvent(e); };
|
|
|
|
std::string err;
|
|
if (!host.loadContent(rom, err)) {
|
|
std::fprintf(stderr, "FAIL load failed: %s\n", err.c_str());
|
|
g_fail++;
|
|
return;
|
|
}
|
|
frames(host, np, 5);
|
|
CHECK(np.link() == LinkState::Off);
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink"), "auto"); // the default: nothing configured, nothing listening
|
|
|
|
// ---- the core's private event channel ------------------------------------------------------------
|
|
// (a) the app answered HELLO; with the log tap CUT, the link still comes up: LINK_UP arrives as an event.
|
|
CHECK(host.eventsActive());
|
|
{
|
|
const auto onLog = host.onLog;
|
|
host.onLog = nullptr;
|
|
NetSettings s;
|
|
s.role = NetRole::Loopback;
|
|
CHECK(np.start(s, err));
|
|
CHECK(runUntil(host, np, [&] { return np.link() == LinkState::Up; }));
|
|
CHECK(np.linkUpEvents() >= 1);
|
|
CHECK_EQ(np.linkDevice(), "jaglink");
|
|
CHECK(host.eventCount() >= 1);
|
|
np.stop();
|
|
frames(host, np, 3);
|
|
CHECK(np.link() == LinkState::Off);
|
|
host.onLog = onLog;
|
|
}
|
|
// (b) a frontend that does not answer HELLO (RetroArch's position): the core sends nothing, and the
|
|
// log text carries the link UP, as it did before the channel existed.
|
|
{
|
|
host.announceExtensions = false;
|
|
CHECK(host.loadContent(rom, err));
|
|
CHECK(!host.eventsActive());
|
|
const uint64_t before = host.eventCount();
|
|
const unsigned upBefore = np.linkUpEvents();
|
|
frames(host, np, 5);
|
|
NetSettings s;
|
|
s.role = NetRole::Loopback;
|
|
CHECK(np.start(s, err));
|
|
CHECK(runUntil(host, np, [&] { return np.link() == LinkState::Up; }));
|
|
CHECK_EQ(np.linkDevice(), "jaglink");
|
|
CHECK(np.linkUpEvents() == upBefore); // it was the log, not an event
|
|
CHECK(host.eventCount() == before); // not one event while HELLO was refused
|
|
np.stop();
|
|
frames(host, np, 3);
|
|
host.announceExtensions = true;
|
|
CHECK(host.loadContent(rom, err));
|
|
CHECK(host.eventsActive());
|
|
frames(host, np, 5);
|
|
}
|
|
|
|
// ---- loopback: the cheapest proof that the log tap sees the core's link UP ------------------
|
|
{
|
|
NetSettings s;
|
|
s.role = NetRole::Loopback;
|
|
std::vector<std::string> restart;
|
|
CHECK(np.start(s, err, &restart));
|
|
CHECK(restart.empty()); // the link options are live: nothing to offer a restart for
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink"), "loopback");
|
|
CHECK(runUntil(host, np, [&] { return np.link() == LinkState::Up; }));
|
|
CHECK_EQ(np.linkDevice(), "jaglink");
|
|
}
|
|
|
|
// ---- host on a port the option's list does not offer: the option takes any port -----------------
|
|
const int hostPort = pickFreePort(discPort);
|
|
CHECK(hostPort != 0 && !Netplay::portIsOptionValue(hostPort));
|
|
{
|
|
NetSettings s;
|
|
s.role = NetRole::Host;
|
|
s.port = hostPort;
|
|
s.device = NetDevice::VoiceModem;
|
|
CHECK(np.start(s, err));
|
|
frames(host, np, 5);
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink"), "tcp_server");
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_uart_device"), "voicemodem");
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink_port"), std::to_string(hostPort)); // the option itself...
|
|
CHECK(std::getenv("VJ_NETLINK_PORT") == nullptr || std::getenv("VJ_NETLINK_PORT")[0] == 0); // ...no env hack
|
|
CHECK(np.link() == LinkState::Waiting);
|
|
// ...and it really listens there: a partner connecting brings the link up as a Voice Modem.
|
|
sock_t partner = connectTo(hostPort);
|
|
CHECK(partner != kBadSock);
|
|
CHECK(runUntil(host, np, [&] { return np.link() == LinkState::Up; }));
|
|
CHECK_EQ(np.linkDevice(), "voicemodem");
|
|
if (partner != kBadSock) closeSock(partner);
|
|
CHECK(runUntil(host, np, [&] { return np.link() == LinkState::Lost; }));
|
|
CHECK(np.statusText().find("disconnected") != std::string::npos);
|
|
}
|
|
|
|
// ---- moving the host to another port while hosting: bounces through "disabled" ---------------
|
|
{
|
|
const int port2 = pickFreePort(discPort);
|
|
NetSettings s = np.settings();
|
|
s.port = port2;
|
|
s.device = NetDevice::JagLink;
|
|
CHECK(np.start(s, err));
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink"), "disabled"); // the first step
|
|
frames(host, np, 3);
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink"), "tcp_server");
|
|
frames(host, np, 3);
|
|
sock_t partner = connectTo(port2);
|
|
CHECK(partner != kBadSock);
|
|
CHECK(runUntil(host, np, [&] { return np.link() == LinkState::Up; }));
|
|
CHECK_EQ(np.linkDevice(), "jaglink");
|
|
if (partner != kBadSock) closeSock(partner);
|
|
}
|
|
|
|
// An endpoint change while content runs: "disabled" for at least 2 emulated frames, then the new endpoint --
|
|
// counted in frames, whenever afterFrame() is called.
|
|
{
|
|
NetSettings s2 = np.settings();
|
|
s2.port = s2.port + 1;
|
|
std::string err2;
|
|
CHECK(np.start(s2, err2));
|
|
CHECK(host.optionValueFor("virtualjaguar_netlink", "") == "disabled");
|
|
np.afterFrame(); // no frame has run yet
|
|
np.afterFrame();
|
|
CHECK(host.optionValueFor("virtualjaguar_netlink", "") == "disabled");
|
|
host.runFrame();
|
|
np.afterFrame();
|
|
CHECK(host.optionValueFor("virtualjaguar_netlink", "") == "disabled");
|
|
host.runFrame();
|
|
np.afterFrame();
|
|
CHECK(host.optionValueFor("virtualjaguar_netlink", "") != "disabled");
|
|
}
|
|
|
|
// ---- join: a typed address goes straight into the core's host option; the port too ------------
|
|
// A name works as well as a dotted quad (the core resolves it), and no vj_netlink.txt is written.
|
|
for (const char* addr : {"127.0.0.1", "localhost"}) {
|
|
const int port3 = pickFreePort(discPort);
|
|
sock_t srv = listenOn(port3);
|
|
CHECK(srv != kBadSock);
|
|
NetSettings s;
|
|
s.role = NetRole::Join;
|
|
s.address = addr;
|
|
s.port = port3;
|
|
CHECK(np.start(s, err));
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink"), "disabled"); // a switch of mode bounces too
|
|
frames(host, np, 3);
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink"), "tcp_client");
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink_host"), addr);
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink_port"), std::to_string(port3));
|
|
CHECK(!fs::exists(sys + "vj_netlink.txt"));
|
|
sock_t peer = kBadSock;
|
|
bool up = runUntil(host, np, [&] {
|
|
if (peer == kBadSock) peer = accept(srv, nullptr, nullptr);
|
|
return np.link() == LinkState::Up;
|
|
});
|
|
CHECK(up);
|
|
if (!up) std::fprintf(stderr, " join status (%s): %s\n", addr, np.statusText().c_str());
|
|
CHECK(peer != kBadSock);
|
|
CHECK(np.statusText().find(addr) != std::string::npos);
|
|
if (peer != kBadSock) closeSock(peer);
|
|
closeSock(srv);
|
|
}
|
|
|
|
// ---- join through the fallback: the core's own vj_netlink.txt route ("From file") ----------------
|
|
{
|
|
np.setAddressViaOption(false);
|
|
const int port4 = pickFreePort(discPort);
|
|
sock_t srv = listenOn(port4);
|
|
CHECK(srv != kBadSock);
|
|
NetSettings s;
|
|
s.role = NetRole::Join;
|
|
s.address = "127.0.0.1";
|
|
s.port = port4;
|
|
CHECK(np.start(s, err));
|
|
frames(host, np, 5);
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink"), "tcp_client");
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink_host"), "vj_netlink.txt");
|
|
{
|
|
std::ifstream f(sys + "vj_netlink.txt");
|
|
std::string line;
|
|
std::getline(f, line);
|
|
CHECK_EQ(line, "127.0.0.1");
|
|
}
|
|
sock_t peer = kBadSock;
|
|
bool up = runUntil(host, np, [&] {
|
|
if (peer == kBadSock) peer = accept(srv, nullptr, nullptr);
|
|
return np.link() == LinkState::Up;
|
|
});
|
|
CHECK(up);
|
|
CHECK(peer != kBadSock);
|
|
if (peer != kBadSock) closeSock(peer);
|
|
closeSock(srv);
|
|
np.setAddressViaOption(true);
|
|
}
|
|
|
|
// ---- looking for hosts: the core's discovery list reaches Netplay::discovered() ---------------------
|
|
// No broadcast needed: the core listens on a UDP port, so a beacon sent straight to it is a host
|
|
// "seen on the LAN". This is the real SET_CORE_OPTIONS_V2 re-push path, end to end.
|
|
{
|
|
NetSettings s;
|
|
s.role = NetRole::Join; // no address: just look
|
|
s.port = pickFreePort(discPort);
|
|
CHECK(np.start(s, err));
|
|
frames(host, np, 5);
|
|
CHECK(np.discovered().empty());
|
|
sock_t udp = socket(AF_INET, SOCK_DGRAM, 0);
|
|
CHECK(udp != kBadSock);
|
|
uint8_t pkt[JLINK_DISC_PKT_LEN];
|
|
size_t n = JLinkDiscEncode(pkt, sizeof pkt, JLINK_DISC_DEV_VOICEMODEM, 42171, "teststation");
|
|
CHECK(n == JLINK_DISC_PKT_LEN);
|
|
sockaddr_in to = loopbackAddr(discPort);
|
|
unsigned pushes = host.optionPushes();
|
|
auto lastSend = std::chrono::steady_clock::time_point();
|
|
bool seen = runUntil(host, np, [&] {
|
|
auto now = std::chrono::steady_clock::now();
|
|
if (udp != kBadSock && now - lastSend > std::chrono::milliseconds(200)) {
|
|
sendto(udp, (const char*)pkt, (int)n, 0, (sockaddr*)&to, sizeof to);
|
|
lastSend = now;
|
|
}
|
|
return !np.discovered().empty();
|
|
}, 6000);
|
|
CHECK(seen);
|
|
CHECK(host.optionPushes() > pushes); // the core re-pushed its options
|
|
std::vector<DiscoveredHost> found = np.discovered();
|
|
CHECK(found.size() == 1);
|
|
if (found.size() == 1) {
|
|
CHECK_EQ(found[0].address, "127.0.0.1");
|
|
CHECK_EQ(found[0].name, "teststation");
|
|
CHECK_EQ(found[0].device, "Voice Modem"); // we are a JagLink: the core tags the mismatch
|
|
}
|
|
if (udp != kBadSock) closeSock(udp);
|
|
}
|
|
|
|
// ---- bad input is refused, not applied -----------------------------------------------------------
|
|
{
|
|
NetSettings s;
|
|
s.role = NetRole::Join;
|
|
s.address = "10.0.0.1:42171"; // a port belongs in the Port box
|
|
CHECK(!np.start(s, err) && !err.empty());
|
|
s.address = "two words";
|
|
CHECK(!np.start(s, err));
|
|
s.address = "10.0.0.1";
|
|
s.port = 0;
|
|
CHECK(!np.start(s, err));
|
|
}
|
|
|
|
// ---- disconnect hands the options back to what the user had --------------------------------------
|
|
np.stop();
|
|
CHECK(np.link() == LinkState::Off && !np.configured());
|
|
frames(host, np, 5);
|
|
CHECK_EQ(optionOf(host, "virtualjaguar_netlink"), "auto");
|
|
CHECK(std::getenv("VJ_NETLINK_PORT") == nullptr || std::getenv("VJ_NETLINK_PORT")[0] == 0);
|
|
|
|
host.onLog = nullptr;
|
|
host.unloadContent();
|
|
fs::remove_all(tmp, ec); // our own temp dir only
|
|
}
|
|
|
|
// ---- two processes ------------------------------------------------------------------------------
|
|
struct Child {
|
|
SDL_Process* p = nullptr;
|
|
int code = -1;
|
|
bool done = false;
|
|
};
|
|
|
|
static Child spawn(const std::vector<std::string>& args) {
|
|
std::vector<const char*> argv;
|
|
for (const std::string& a : args) argv.push_back(a.c_str());
|
|
argv.push_back(nullptr);
|
|
Child c;
|
|
c.p = SDL_CreateProcess(argv.data(), false); // stdout/stderr are inherited: they land in the ctest log
|
|
if (!c.p) std::fprintf(stderr, "FAIL could not start %s: %s\n", args[0].c_str(), SDL_GetError());
|
|
return c;
|
|
}
|
|
|
|
static bool poll(Child& c) {
|
|
if (!c.p || c.done) return true;
|
|
int code = 0;
|
|
if (SDL_WaitProcess(c.p, false, &code)) {
|
|
c.done = true;
|
|
c.code = code;
|
|
}
|
|
return c.done;
|
|
}
|
|
|
|
static void reap(Child& c) {
|
|
if (!c.p) return;
|
|
if (!c.done) {
|
|
SDL_KillProcess(c.p, true);
|
|
int code = 0;
|
|
SDL_WaitProcess(c.p, true, &code);
|
|
c.code = -2;
|
|
c.done = true;
|
|
}
|
|
SDL_DestroyProcess(c.p);
|
|
c.p = nullptr;
|
|
}
|
|
|
|
static std::vector<std::string> appArgs(const std::string& exe, const std::string& rom, const std::vector<std::string>& extra) {
|
|
std::vector<std::string> a = {exe, "--headless", "--frames", "300"};
|
|
a.insert(a.end(), extra.begin(), extra.end());
|
|
a.push_back(rom);
|
|
return a;
|
|
}
|
|
|
|
// Waits for both; false on timeout.
|
|
static bool waitBoth(Child& a, Child& b, int seconds) {
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
while (!(poll(a) && poll(b))) {
|
|
if (std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count() > seconds) return false;
|
|
SDL_Delay(50);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static int runPair(const std::string& exe, const std::string& rom, const std::string& device,
|
|
const std::string& hostThread = "off", const std::string& joinThread = "off") {
|
|
const int port = pickFreePort();
|
|
const int disc = pickFreePort(port);
|
|
if (!port || !disc) {
|
|
std::fprintf(stderr, "FAIL no free port\n");
|
|
return 1;
|
|
}
|
|
setEnv("VJ_DISC_PORT", std::to_string(disc)); // inherited by both children
|
|
const std::string p = std::to_string(port);
|
|
std::vector<std::string> hostX = {"--netplay", "host", "--netplay-port", p, "--netplay-device", device,
|
|
"--netplay-expect-link", "30"};
|
|
std::vector<std::string> joinX = {"--netplay", "join", "--netplay-addr", "127.0.0.1", "--netplay-port", p,
|
|
"--netplay-device", device, "--netplay-expect-link", "30"};
|
|
if (hostThread == "on") hostX.insert(hostX.end(), {"--emu-thread", "on"});
|
|
if (joinThread == "on") joinX.insert(joinX.end(), {"--emu-thread", "on"});
|
|
Child host = spawn(appArgs(exe, rom, hostX));
|
|
SDL_Delay(300);
|
|
Child join = spawn(appArgs(exe, rom, joinX));
|
|
bool finished = waitBoth(host, join, 90);
|
|
reap(host);
|
|
reap(join);
|
|
CHECK(finished);
|
|
CHECK(host.code == 0);
|
|
CHECK(join.code == 0);
|
|
return g_fail ? 1 : 0;
|
|
}
|
|
|
|
// Two copies of the app on ONE data folder. The test takes instance 1 itself, so both children are
|
|
// secondaries (2 and 3): they must coexist, link up, and leave the shared files alone.
|
|
static int runPairShared(const std::string& exe, const std::string& rom) {
|
|
namespace fs = std::filesystem;
|
|
const int port = pickFreePort();
|
|
const int disc = pickFreePort(port);
|
|
if (!port || !disc) {
|
|
std::fprintf(stderr, "FAIL no free port\n");
|
|
return 1;
|
|
}
|
|
setEnv("VJ_DISC_PORT", std::to_string(disc));
|
|
std::error_code ec;
|
|
const fs::path dir = fs::temp_directory_path(ec) / ("vj-netplay-shared-" + std::to_string(port));
|
|
fs::remove_all(dir, ec);
|
|
fs::create_directories(dir);
|
|
const fs::path cfg = dir / "config.ini";
|
|
const std::string seed = "# Virtual Jaguar settings\nvideo.integer=1\nra.token=not-a-real-token\n";
|
|
{ std::ofstream f(cfg, std::ios::binary); f << seed; }
|
|
|
|
vj::InstanceLock mine;
|
|
CHECK(mine.acquire(dir.string() + "/") == 1);
|
|
|
|
const std::string p = std::to_string(port);
|
|
const std::string d = dir.string();
|
|
Child host = spawn(appArgs(exe, rom, {"--data-dir", d, "--netplay", "host", "--netplay-port", p, "--netplay-expect-link", "30"}));
|
|
SDL_Delay(300);
|
|
Child join = spawn(appArgs(exe, rom, {"--data-dir", d, "--netplay", "join", "--netplay-addr", "127.0.0.1",
|
|
"--netplay-port", p, "--netplay-expect-link", "30"}));
|
|
bool finished = waitBoth(host, join, 90);
|
|
reap(host);
|
|
reap(join);
|
|
CHECK(finished);
|
|
CHECK(host.code == 0);
|
|
CHECK(join.code == 0);
|
|
{
|
|
std::ifstream f(cfg, std::ios::binary);
|
|
std::stringstream ss;
|
|
ss << f.rdbuf();
|
|
CHECK(ss.str() == seed); // neither copy rewrote the shared settings
|
|
}
|
|
CHECK(fs::exists(dir / "instance-2.lock"));
|
|
CHECK(fs::exists(dir / "instance-3.lock"));
|
|
// Each copy kept its game saves in its own folder (headless names them per instance and load).
|
|
int perInstance = 0;
|
|
for (const auto& e : fs::directory_iterator(dir)) {
|
|
const std::string n = e.path().filename().string();
|
|
if (n.rfind("saves-i2-", 0) == 0 || n.rfind("saves-i3-", 0) == 0) perInstance++;
|
|
CHECK(n.rfind("saves0", 0) != 0); // nothing went into the first copy's names
|
|
}
|
|
CHECK(perInstance == 2);
|
|
CHECK(!fs::exists(dir / "system" / "vj_netlink.txt")); // the address travelled by environment
|
|
mine.release();
|
|
fs::remove_all(dir, ec); // our own temp dir only
|
|
return g_fail ? 1 : 0;
|
|
}
|
|
|
|
static int runControl(const std::string& exe, const std::string& rom, const std::string& thread = "off") {
|
|
const int port = pickFreePort();
|
|
const int disc = pickFreePort(port);
|
|
setEnv("VJ_DISC_PORT", std::to_string(disc));
|
|
const std::string p = std::to_string(port);
|
|
// Nobody is listening on the port, and nobody joins the host: neither may claim a link.
|
|
std::vector<std::string> joinX = {"--netplay", "join", "--netplay-addr", "127.0.0.1", "--netplay-port", p,
|
|
"--netplay-expect-link", "3"};
|
|
std::vector<std::string> hostX = {"--netplay", "host", "--netplay-port", std::to_string(pickFreePort(port)),
|
|
"--netplay-expect-link", "3"};
|
|
if (thread == "on") {
|
|
joinX.insert(joinX.end(), {"--emu-thread", "on"});
|
|
hostX.insert(hostX.end(), {"--emu-thread", "on"});
|
|
}
|
|
Child join = spawn(appArgs(exe, rom, joinX));
|
|
Child host = spawn(appArgs(exe, rom, hostX));
|
|
bool finished = waitBoth(host, join, 60);
|
|
reap(host);
|
|
reap(join);
|
|
CHECK(finished);
|
|
CHECK(join.code != 0 && join.code != -2); // exited by itself, with a failure
|
|
CHECK(host.code != 0 && host.code != -2);
|
|
if (thread == "on") {
|
|
CHECK(join.code == 1); // 3 = the emulation thread did not stop within 3 s (a wedged quit)
|
|
CHECK(host.code == 1);
|
|
}
|
|
return g_fail ? 1 : 0;
|
|
}
|
|
|
|
static int runDiscovery(const std::string& exe, const std::string& rom) {
|
|
const int port = pickFreePort();
|
|
const int disc = pickFreePort(port);
|
|
setEnv("VJ_DISC_PORT", std::to_string(disc));
|
|
const std::string p = std::to_string(port);
|
|
Child host = spawn(appArgs(exe, rom, {"--netplay", "host", "--netplay-port", p, "--netplay-expect-link", "40"}));
|
|
SDL_Delay(300);
|
|
Child join = spawn(appArgs(exe, rom, {"--netplay", "join-discovered", "--netplay-port", p, "--netplay-expect-link",
|
|
"30", "--netplay-expect-discovery", "10"}));
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
while (!poll(join)) {
|
|
if (std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count() > 90) break;
|
|
SDL_Delay(50);
|
|
}
|
|
const int jc = join.done ? join.code : -2;
|
|
if (jc == 77) {
|
|
reap(host);
|
|
reap(join);
|
|
std::printf("netplay discovery: SKIP (no host was discovered; UDP broadcast not delivered here)\n");
|
|
return 77;
|
|
}
|
|
// The host leaves once its partner has been seen.
|
|
const auto t1 = std::chrono::steady_clock::now();
|
|
while (!poll(host)) {
|
|
if (std::chrono::duration<double>(std::chrono::steady_clock::now() - t1).count() > 60) break;
|
|
SDL_Delay(50);
|
|
}
|
|
reap(host);
|
|
reap(join);
|
|
CHECK(jc == 0);
|
|
CHECK(host.code == 0);
|
|
return g_fail ? 1 : 0;
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
if (argc < 2) {
|
|
std::fprintf(stderr, "usage: vj_netplay_test unit ROM | pair EXE ROM [DEVICE [HOST JOIN]] | control EXE ROM [THREAD] | discovery EXE ROM\n");
|
|
return 2;
|
|
}
|
|
netInit();
|
|
const std::string mode = argv[1];
|
|
int rc = 0;
|
|
if (mode == "unit" && argc >= 3) {
|
|
testParsers();
|
|
testEpochFilter();
|
|
testCore(argv[2]);
|
|
rc = g_fail ? 1 : 0;
|
|
} else if (mode == "pair" && argc >= 4) {
|
|
rc = runPair(argv[2], argv[3], argc >= 5 ? argv[4] : "jaglink", argc >= 6 ? argv[5] : "off", argc >= 7 ? argv[6] : "off");
|
|
} else if (mode == "pair-shared" && argc >= 4) {
|
|
rc = runPairShared(argv[2], argv[3]);
|
|
} else if (mode == "control" && argc >= 4) {
|
|
rc = runControl(argv[2], argv[3], argc >= 5 ? argv[4] : "off");
|
|
} else if (mode == "discovery" && argc >= 4) {
|
|
rc = runDiscovery(argv[2], argv[3]);
|
|
} else {
|
|
std::fprintf(stderr, "bad arguments\n");
|
|
return 2;
|
|
}
|
|
std::printf("netplay_test %s: %d checks, %d failed\n", mode.c_str(), g_checks, g_fail);
|
|
return rc;
|
|
}
|