// 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 #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 # include # include typedef SOCKET sock_t; static const sock_t kBadSock = INVALID_SOCKET; static void closeSock(sock_t s) { closesocket(s); } #else # include # include # include # include # include 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& pred, int maxMs = 4000) { const auto t0 = std::chrono::steady_clock::now(); while (!pred()) { if (std::chrono::duration_cast(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 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 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 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 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& args) { std::vector 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 appArgs(const std::string& exe, const std::string& rom, const std::vector& extra) { std::vector 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(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 hostX = {"--netplay", "host", "--netplay-port", p, "--netplay-device", device, "--netplay-expect-link", "30"}; std::vector 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 joinX = {"--netplay", "join", "--netplay-addr", "127.0.0.1", "--netplay-port", p, "--netplay-expect-link", "3"}; std::vector 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(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(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; }