mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-07 12:47:58 -10:00
807 lines
33 KiB
C++
807 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 pair-shared EXE ROM [THREAD] two copies on ONE data folder (instance.h); THREAD = on runs both with the emulation thread
|
|
// 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, const std::string& thread = "off") {
|
|
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();
|
|
std::vector<std::string> hostX = {"--data-dir", d, "--netplay", "host", "--netplay-port", p, "--netplay-expect-link", "30"};
|
|
std::vector<std::string> joinX = {"--data-dir", d, "--netplay", "join", "--netplay-addr", "127.0.0.1",
|
|
"--netplay-port", p, "--netplay-expect-link", "30"};
|
|
if (thread == "on") {
|
|
hostX.insert(hostX.end(), {"--emu-thread", "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);
|
|
{
|
|
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]] | pair-shared EXE ROM [THREAD] | 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], argc >= 5 ? argv[4] : "off");
|
|
} 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;
|
|
}
|