From a37c01f85605a3e7d927170a42b6d8fc6e2d81d8 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 7 Oct 2026 20:46:01 -0400 Subject: [PATCH 1/2] runner: load recomp-net as a netplay module (RETRO_RUNTIME_NETPLAY_MODULE) --net-module / RETRO_NETPLAY_MODULE names librecomp_net_module; the runner checks ABI, rbengine and every symbol it calls at start, folds the module's wire version into the build fingerprint, and links no recomp-net. Co-Authored-By: Claude Sonnet 5.5 --- CMakeLists.txt | 16 ++++++- docs/CORE_RUNNER.md | 8 ++++ runner/net_module.cpp | 88 ++++++++++++++++++++++++++++++++++ runner/net_module.hpp | 26 ++++++++++ runner/net_module_api.hpp | 43 +++++++++++++++++ runner/net_module_redirect.hpp | 28 +++++++++++ runner/net_session.cpp | 21 ++++++++ runner/net_session.hpp | 1 + runner/runner_main.cpp | 3 ++ 9 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 runner/net_module.cpp create mode 100644 runner/net_module.hpp create mode 100644 runner/net_module_api.hpp create mode 100644 runner/net_module_redirect.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9369550..7b2f121 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -114,13 +114,27 @@ if(RETRO_RUNTIME_TOOLS) runner/host_session.cpp runner/state_keeper.cpp runner/net_session.cpp + runner/net_module.cpp ) # Netplay (runner/net_session.hpp): recomp-net's episode driver and UDP # transport, compiled in from a checkout when one is named. Without it the # runner answers `netplay 0` and refuses --net-*. set(RETRO_RUNTIME_RECOMP_NET_DIR "" CACHE PATH "recomp-net checkout: build retro-core-runner with netplay") - if(RETRO_RUNTIME_RECOMP_NET_DIR) + # With RETRO_RUNTIME_NETPLAY_MODULE the netcode is NOT linked: the runner + # opens a librecomp_net_module at run time (--net-module / RETRO_NETPLAY_MODULE, + # runner/net_module.hpp), so it updates without a new runner. The checkout + # then supplies headers only. + option(RETRO_RUNTIME_NETPLAY_MODULE "retro-core-runner: netplay through a loadable module" OFF) + if(RETRO_RUNTIME_NETPLAY_MODULE) + if(NOT RETRO_RUNTIME_RECOMP_NET_DIR OR NOT EXISTS "${RETRO_RUNTIME_RECOMP_NET_DIR}/include/recomp_net/module.h") + message(FATAL_ERROR "RETRO_RUNTIME_NETPLAY_MODULE needs RETRO_RUNTIME_RECOMP_NET_DIR with include/recomp_net/module.h") + endif() + target_include_directories(retro-core-runner PRIVATE ${RETRO_RUNTIME_RECOMP_NET_DIR}/include) + target_compile_definitions(retro-core-runner PRIVATE RETRO_RUNNER_NETPLAY=1 RETRO_RUNNER_NETPLAY_MODULE=1) + target_link_libraries(retro-core-runner PRIVATE ${CMAKE_DL_LIBS}) + message(STATUS "retro-core-runner: netplay via loadable module (headers ${RETRO_RUNTIME_RECOMP_NET_DIR})") + elseif(RETRO_RUNTIME_RECOMP_NET_DIR) set(RNET_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(RNET_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(RNET_ENABLE_ICE OFF CACHE BOOL "" FORCE) diff --git a/docs/CORE_RUNNER.md b/docs/CORE_RUNNER.md index 617f893..5e1c71c 100644 --- a/docs/CORE_RUNNER.md +++ b/docs/CORE_RUNNER.md @@ -265,6 +265,14 @@ checkout>`; `--version` then says `netplay 1`, otherwise `netplay 0` and every [--net-content LINE ...] ``` +- **As a module** (`-DRETRO_RUNTIME_NETPLAY_MODULE=ON` with + `RETRO_RUNTIME_RECOMP_NET_DIR`, headers only). recomp-net is not linked: the + runner opens `librecomp_net_module` from `--net-module ` or + `RETRO_NETPLAY_MODULE` (`runner/net_module.*`), refusing it at start, by name, + on a missing path, wrong ABI, no rbengine or any missing symbol. The module's + wire version joins the build fingerprint, and `runner_netplay: MODULE ...` + logs which module played. Measured: Pokemon Stadium, two processes on + loopback, 300 frames, identical `state_hash` at tick 280, 0 desyncs. - **Transport.** Seat 0 hosts: with more than two seats it is recomp-net's LAN hub (the host relays every guest's datagrams to the others), with two a direct pair. Every other seat dials the host (`--net-peer`). With diff --git a/runner/net_module.cpp b/runner/net_module.cpp new file mode 100644 index 0000000..2034376 --- /dev/null +++ b/runner/net_module.cpp @@ -0,0 +1,88 @@ +#include "net_module.hpp" + +#if defined(RETRO_RUNNER_NETPLAY_MODULE) + +#include + +#include + +extern "C" { +#include "recomp_net/module.h" +#include "recomp_net/rb_driver.h" +#include "recomp_net/session.h" +} + +#include "net_module_api.hpp" + +namespace retro::runner { + +NetModuleApi g_nm{}; + +namespace { +std::mutex g_mu; +NetModuleInfo g_info; +bool g_loaded = false; +} // namespace + +const NetModuleInfo* net_module_info() { return g_loaded ? &g_info : nullptr; } + +bool net_module_load(const std::string& path, std::string* error) { + std::lock_guard lock(g_mu); + auto fail = [&](const std::string& why) { + if (error) *error = "netplay module " + path + ": " + why; + return false; + }; + if (path.empty()) { + if (error) *error = "netplay unavailable: no netplay module (--net-module or RETRO_NETPLAY_MODULE)"; + return false; + } + if (g_loaded) { + if (g_info.path == path) return true; + return fail("a different module (" + g_info.path + ") is already loaded"); + } + void* h = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); + if (!h) { + const char* e = dlerror(); + return fail(e ? e : "dlopen failed"); + } + auto bail = [&](const std::string& why) { + dlclose(h); + return fail(why); + }; + auto info_fn = reinterpret_cast(dlsym(h, "rnet_module_info")); + if (!info_fn) return bail("has no rnet_module_info (not a netplay module)"); + RNetModuleInfo m{}; + const size_t got = info_fn(&m, sizeof m); + if (const char* why = rnet_module_check(&m, got, RNET_MODULE_FEATURE_RBENGINE)) return bail(why); + + NetModuleApi api{}; + bool ok = true; + std::string missing; +#define RETRO_NM_SYM(n) \ + api.n = reinterpret_cast(dlsym(h, #n)); \ + if (!api.n) { ok = false; missing += " " #n; } + RETRO_NET_MODULE_SYMBOLS(RETRO_NM_SYM) +#undef RETRO_NM_SYM + if (!ok) return bail("missing symbols:" + missing); + + g_nm = api; + g_info = NetModuleInfo{path, m.abi_version, m.abi_minor, m.wire_version, m.features, + m.version_major, m.version_minor, m.version_patch, + (got >= sizeof m && m.build_id) ? m.build_id : "unknown"}; + g_loaded = true; + return true; +} + +} // namespace retro::runner + +#else + +namespace retro::runner { +bool net_module_load(const std::string&, std::string* error) { + if (error) *error = "this runner was not built with the netplay module (RETRO_RUNTIME_NETPLAY_MODULE)"; + return false; +} +const NetModuleInfo* net_module_info() { return nullptr; } +} // namespace retro::runner + +#endif diff --git a/runner/net_module.hpp b/runner/net_module.hpp new file mode 100644 index 0000000..fa072bc --- /dev/null +++ b/runner/net_module.hpp @@ -0,0 +1,26 @@ +// The netplay module (recomp-net include/recomp_net/module.h), opened at run +// time. In a module build (RETRO_RUNNER_NETPLAY_MODULE) net_session.cpp calls +// recomp-net through the pointers resolved here instead of linking it, so the +// netcode is versioned and shipped apart from the runner and the cores. The +// retcomm-launcher owns fetching and verifying the file; this only opens it. +#pragma once + +#include +#include + +namespace retro::runner { + +struct NetModuleInfo { + std::string path; + std::uint32_t abi_version = 0, abi_minor = 0, wire_version = 0, features = 0; + std::uint32_t major = 0, minor = 0, patch = 0; + std::string build_id; +}; + +// Opens `path` once per process and checks it: module ABI, rbengine, and every +// function the runner calls. Returns false with *error saying which check +// failed. No fallback: a runner with no usable module refuses --net-*. +bool net_module_load(const std::string& path, std::string* error); +const NetModuleInfo* net_module_info(); // null until loaded + +} // namespace retro::runner diff --git a/runner/net_module_api.hpp b/runner/net_module_api.hpp new file mode 100644 index 0000000..52600f5 --- /dev/null +++ b/runner/net_module_api.hpp @@ -0,0 +1,43 @@ +// The recomp-net functions the runner calls, as pointers into the loaded +// module. Add a name here when net_session.cpp starts calling a new one: the +// loader refuses a module that lacks any entry, at load, by name. +#pragma once + +extern "C" { +#include "recomp_net/rb_driver.h" +#include "recomp_net/session.h" +} + +#define RETRO_NET_MODULE_SYMBOLS(X) \ + X(rnet_rb_driver_confirmed_through) \ + X(rnet_rb_driver_create) \ + X(rnet_rb_driver_destroy) \ + X(rnet_rb_driver_desync_count) \ + X(rnet_rb_driver_episode_count) \ + X(rnet_rb_driver_finish_frame) \ + X(rnet_rb_driver_poll_admit) \ + X(rnet_rb_driver_quiesce_state) \ + X(rnet_rb_driver_refusal) \ + X(rnet_rb_driver_request_quiesce) \ + X(rnet_rb_driver_rtt_estimate_ms) \ + X(rnet_rb_driver_set_identity) \ + X(rnet_rb_driver_shutdown) \ + X(rnet_rb_driver_start) \ + X(rnet_session_create) \ + X(rnet_session_destroy) \ + X(rnet_session_is_running) \ + X(rnet_session_peer_disconnected) \ + X(rnet_session_pump) \ + X(rnet_session_send_bye) \ + X(rnet_session_start_lan) \ + X(rnet_session_start_lan_hub) \ + X(rnet_session_wait_recv) + +namespace retro::runner { +struct NetModuleApi { +#define RETRO_NM_FIELD(n) decltype(&::n) n; + RETRO_NET_MODULE_SYMBOLS(RETRO_NM_FIELD) +#undef RETRO_NM_FIELD +}; +extern NetModuleApi g_nm; +} // namespace retro::runner diff --git a/runner/net_module_redirect.hpp b/runner/net_module_redirect.hpp new file mode 100644 index 0000000..784f1cf --- /dev/null +++ b/runner/net_module_redirect.hpp @@ -0,0 +1,28 @@ +// Included by net_session.cpp ONLY, after the recomp-net headers: turns each +// call it makes into a call through the loaded module (net_module_api.hpp). +#pragma once +#include "net_module_api.hpp" + +#define rnet_rb_driver_confirmed_through (::retro::runner::g_nm.rnet_rb_driver_confirmed_through) +#define rnet_rb_driver_create (::retro::runner::g_nm.rnet_rb_driver_create) +#define rnet_rb_driver_destroy (::retro::runner::g_nm.rnet_rb_driver_destroy) +#define rnet_rb_driver_desync_count (::retro::runner::g_nm.rnet_rb_driver_desync_count) +#define rnet_rb_driver_episode_count (::retro::runner::g_nm.rnet_rb_driver_episode_count) +#define rnet_rb_driver_finish_frame (::retro::runner::g_nm.rnet_rb_driver_finish_frame) +#define rnet_rb_driver_poll_admit (::retro::runner::g_nm.rnet_rb_driver_poll_admit) +#define rnet_rb_driver_quiesce_state (::retro::runner::g_nm.rnet_rb_driver_quiesce_state) +#define rnet_rb_driver_refusal (::retro::runner::g_nm.rnet_rb_driver_refusal) +#define rnet_rb_driver_request_quiesce (::retro::runner::g_nm.rnet_rb_driver_request_quiesce) +#define rnet_rb_driver_rtt_estimate_ms (::retro::runner::g_nm.rnet_rb_driver_rtt_estimate_ms) +#define rnet_rb_driver_set_identity (::retro::runner::g_nm.rnet_rb_driver_set_identity) +#define rnet_rb_driver_shutdown (::retro::runner::g_nm.rnet_rb_driver_shutdown) +#define rnet_rb_driver_start (::retro::runner::g_nm.rnet_rb_driver_start) +#define rnet_session_create (::retro::runner::g_nm.rnet_session_create) +#define rnet_session_destroy (::retro::runner::g_nm.rnet_session_destroy) +#define rnet_session_is_running (::retro::runner::g_nm.rnet_session_is_running) +#define rnet_session_peer_disconnected (::retro::runner::g_nm.rnet_session_peer_disconnected) +#define rnet_session_pump (::retro::runner::g_nm.rnet_session_pump) +#define rnet_session_send_bye (::retro::runner::g_nm.rnet_session_send_bye) +#define rnet_session_start_lan (::retro::runner::g_nm.rnet_session_start_lan) +#define rnet_session_start_lan_hub (::retro::runner::g_nm.rnet_session_start_lan_hub) +#define rnet_session_wait_recv (::retro::runner::g_nm.rnet_session_wait_recv) diff --git a/runner/net_session.cpp b/runner/net_session.cpp index 8babca6..3101325 100644 --- a/runner/net_session.cpp +++ b/runner/net_session.cpp @@ -10,6 +10,10 @@ extern "C" { #include "recomp_net/rb_driver.h" #include "recomp_net/session.h" } +#if defined(RETRO_RUNNER_NETPLAY_MODULE) +#include "net_module.hpp" +#include "net_module_redirect.hpp" +#endif #endif namespace retro::runner { @@ -205,6 +209,18 @@ bool NetSession::start(const LoadedCore& core, const NetParams& p, std::string* }; Impl& s = *impl_; const rcore_core_api* a = core.api; +#if defined(RETRO_RUNNER_NETPLAY_MODULE) + // The netcode is a separate, versioned file: open and check it before any + // recomp-net call, and say which one a peer-visible log came from. + { + std::string why; + if (!net_module_load(p.module_path, &why)) return fail(why); + const NetModuleInfo* m = net_module_info(); + std::fprintf(stderr, "runner_netplay: MODULE %s recomp-net %u.%u.%u abi %u.%u wire %u build %s\n", + m->path.c_str(), m->major, m->minor, m->patch, m->abi_version, m->abi_minor, + m->wire_version, m->build_id.c_str()); + } +#endif if (!(core.info->capabilities & RCORE_CAP_DETERMINISTIC)) return fail("the core does not declare DETERMINISTIC, so it cannot play netplay"); if (!(core.info->capabilities & RCORE_CAP_ROLLBACK) || !a->rb_snap_save || !a->rb_snap_load || @@ -243,6 +259,11 @@ bool NetSession::start(const LoadedCore& core, const NetParams& p, std::string* // ---- identity: what every peer must hold identically ---- std::uint32_t build_fp = fnv1a(kRunnerNetVersion); build_fp = fnv1a(core.sha256, build_fp); +#if defined(RETRO_RUNNER_NETPLAY_MODULE) + // A module whose wire protocol differs cannot play this match: it joins + // the identity every peer must hold identically (refused at the handshake). + build_fp = fnv1a("wire " + std::to_string(net_module_info()->wire_version), build_fp); +#endif std::uint32_t content_fp = fnv1a("slots " + std::to_string(p.slots) + " occupied " + std::to_string(p.occupied) + " epoch " + std::to_string(p.epoch_s)); diff --git a/runner/net_session.hpp b/runner/net_session.hpp index 1b3d45f..7050b6d 100644 --- a/runner/net_session.hpp +++ b/runner/net_session.hpp @@ -26,6 +26,7 @@ namespace retro::runner { struct NetParams { + std::string module_path; // the netplay module (module builds only) int slot = 0; // this peer's seat int slots = 2; // seats in the match std::uint32_t occupied = 0; // bit i = seat i has a player; 0 = all diff --git a/runner/runner_main.cpp b/runner/runner_main.cpp index 0e51058..4a3f3ff 100644 --- a/runner/runner_main.cpp +++ b/runner/runner_main.cpp @@ -461,6 +461,7 @@ int main(int argc, char** argv) { else if (a == "--list-options") list_options = true; else if (a == "--describe") describe = true; else if (a == "--replay-at") replay_at = num(val()); + else if (a == "--net-module") { netplay = true; net.module_path = val(); } else if (a == "--net-slot") { netplay = true; net.slot = static_cast(num(val())); } else if (a == "--net-slots") { netplay = true; net.slots = static_cast(num(val())); } else if (a == "--net-occupied") { netplay = true; net.occupied = static_cast(num(val())); } @@ -496,6 +497,8 @@ int main(int argc, char** argv) { } } if (core_path.empty()) die("--core is required"); + if (net.module_path.empty()) + if (const char* e = std::getenv("RETRO_NETPLAY_MODULE")) net.module_path = e; if (netplay && (replay_at || load_state)) die("netplay starts from a cold boot: --replay-at and --load-state are refused"); if (netplay && !NetSession::compiled_in()) From 5ce2a7a40724f44d239d5d6053a8fc78940f3ada Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 7 Oct 2026 21:25:53 -0400 Subject: [PATCH 2/2] runner: --version reports netplay_module_abi The recomp-net module ABI the runner loads (0 when netplay is linked in), so a host installs a module with that ABI without opening it; runner_probe reads it. Co-Authored-By: Claude Sonnet 5.5 --- corelink/runner_probe.cpp | 3 +++ corelink/runner_probe.hpp | 4 ++++ docs/CORE_RUNNER.md | 3 +++ runner/net_module.cpp | 2 ++ runner/net_module.hpp | 4 ++++ runner/runner_main.cpp | 4 +++- 6 files changed, 19 insertions(+), 1 deletion(-) diff --git a/corelink/runner_probe.cpp b/corelink/runner_probe.cpp index 60a78a5..7873fce 100644 --- a/corelink/runner_probe.cpp +++ b/corelink/runner_probe.cpp @@ -73,6 +73,9 @@ bool probe_runner(const fs::path& runner, RunnerVersion& out, std::string* error if (fields.count("netplay") && !num("netplay", v.netplay)) { return fail("--version printed an unreadable netplay line"); } + if (fields.count("netplay_module_abi") && !num("netplay_module_abi", v.netplay_module_abi)) { + return fail("--version printed an unreadable netplay_module_abi line"); + } if (fields.count("transfer_pak_seats") && !num("transfer_pak_seats", v.transfer_pak_seats)) { return fail("--version printed an unreadable transfer_pak_seats line"); } diff --git a/corelink/runner_probe.hpp b/corelink/runner_probe.hpp index 3058415..d51e068 100644 --- a/corelink/runner_probe.hpp +++ b/corelink/runner_probe.hpp @@ -32,6 +32,10 @@ struct RunnerVersion { // 1 when the runner was built with recomp-net (--net-*; CORE_RUNNER.md, // "Netplay"); 0 for one without, or from before netplay. std::uint32_t netplay = 0; + // The netplay module ABI (recomp-net module.h) the runner loads at run + // time, with `netplay` 1; 0 when netplay is linked in, absent, or the + // runner predates the line. A host installs the module with this ABI. + std::uint32_t netplay_module_abi = 0; // 1 when the runner answers --describe (a core's declared options and // inputs, no ROM; docs/CORE_RUNNER.md); 0 for a runner from before it. std::uint32_t describe = 0; diff --git a/docs/CORE_RUNNER.md b/docs/CORE_RUNNER.md index 5e1c71c..1b0e23f 100644 --- a/docs/CORE_RUNNER.md +++ b/docs/CORE_RUNNER.md @@ -273,6 +273,9 @@ checkout>`; `--version` then says `netplay 1`, otherwise `netplay 0` and every wire version joins the build fingerprint, and `runner_netplay: MODULE ...` logs which module played. Measured: Pokemon Stadium, two processes on loopback, 300 frames, identical `state_hash` at tick 280, 0 desyncs. + `--version` prints `netplay_module_abi N` (0 when netplay is linked in or + absent): the recomp-net module ABI this runner loads, so a host installs a + module with that ABI. - **Transport.** Seat 0 hosts: with more than two seats it is recomp-net's LAN hub (the host relays every guest's datagrams to the others), with two a direct pair. Every other seat dials the host (`--net-peer`). With diff --git a/runner/net_module.cpp b/runner/net_module.cpp index 2034376..9a4a19f 100644 --- a/runner/net_module.cpp +++ b/runner/net_module.cpp @@ -25,6 +25,7 @@ bool g_loaded = false; } // namespace const NetModuleInfo* net_module_info() { return g_loaded ? &g_info : nullptr; } +std::uint32_t net_module_abi_required() { return RNET_MODULE_ABI_VERSION; } bool net_module_load(const std::string& path, std::string* error) { std::lock_guard lock(g_mu); @@ -83,6 +84,7 @@ bool net_module_load(const std::string&, std::string* error) { return false; } const NetModuleInfo* net_module_info() { return nullptr; } +std::uint32_t net_module_abi_required() { return 0; } } // namespace retro::runner #endif diff --git a/runner/net_module.hpp b/runner/net_module.hpp index fa072bc..c6cb0e7 100644 --- a/runner/net_module.hpp +++ b/runner/net_module.hpp @@ -22,5 +22,9 @@ struct NetModuleInfo { // failed. No fallback: a runner with no usable module refuses --net-*. bool net_module_load(const std::string& path, std::string* error); const NetModuleInfo* net_module_info(); // null until loaded +// The module ABI this runner was built for (recomp-net RNET_MODULE_ABI_VERSION), +// or 0 when netplay is linked in or absent. Reported by --version so a host +// can pick a module whose ABI matches without opening it. +std::uint32_t net_module_abi_required(); } // namespace retro::runner diff --git a/runner/runner_main.cpp b/runner/runner_main.cpp index 4a3f3ff..8536d18 100644 --- a/runner/runner_main.cpp +++ b/runner/runner_main.cpp @@ -57,6 +57,7 @@ #include "core_library.hpp" #include "core_manifest.hpp" #include "host_session.hpp" +#include "net_module.hpp" #include "net_session.hpp" #include "link_protocol.hpp" #include "runner_link.hpp" @@ -299,11 +300,12 @@ void print_version() { "describe 1\n" "transfer_pak_seats %zu\n" "netplay %d\n" + "netplay_module_abi %u\n" "accessory_data 1\n", RETRO_RUNTIME_VERSION, RETRO_RUNTIME_VERSION, RETRO_RUNTIME_COMMIT, retro::corelink::kProtocolMajor, retro::corelink::kProtocolMinor, RCORE_ABI_MAJOR, RCORE_DRAFT_REVISION, gl, retro::runner::kTransferPakSeats, - NetSession::compiled_in() ? 1 : 0); + NetSession::compiled_in() ? 1 : 0, retro::runner::net_module_abi_required()); } // One --describe field: \ TAB LF CR escaped, so a record is one line; a NULL