From fddd561f9ea2bd8848e12f7eca9d39e862be3c7d Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 14 Oct 2026 20:52:34 -0700 Subject: [PATCH] Settle netplay execution profiles before core init --- CMakeLists.txt | 22 +++++ docs/CORE_ABI.md | 1 + docs/CORE_RUNNER.md | 14 +++ include/rcore/rcore.h | 27 +++++- runner/net_module_api.hpp | 4 + runner/net_module_redirect.hpp | 5 ++ runner/net_session.cpp | 154 +++++++++++++++++++++++++-------- runner/net_session.hpp | 4 + runner/runner_link.cpp | 2 + runner/runner_main.cpp | 1 + tests/net_profiles_test.cpp | 104 ++++++++++++++++++++++ 11 files changed, 303 insertions(+), 35 deletions(-) create mode 100644 tests/net_profiles_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b669937..23bd180 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -127,6 +127,10 @@ if(RETRO_RUNTIME_TOOLS) # 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_RECOMP_NET_DIR AND NOT EXISTS + "${RETRO_RUNTIME_RECOMP_NET_DIR}/include/recomp_net/startup_profiles.h") + message(FATAL_ERROR "Netplay requires recomp-net's startup_profiles.h API (module ABI minor 1); update RETRO_RUNTIME_RECOMP_NET_DIR") + endif() 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") @@ -144,6 +148,24 @@ if(RETRO_RUNTIME_TOOLS) target_compile_definitions(retro-core-runner PRIVATE RETRO_RUNNER_NETPLAY=1) message(STATUS "retro-core-runner: netplay from ${RETRO_RUNTIME_RECOMP_NET_DIR}") endif() + set(RETRO_RUNTIME_TEST_NETPLAY_MODULE "" CACHE FILEPATH + "Optional built recomp-net module for the startup negotiation integration test") + if(RETRO_RUNTIME_RECOMP_NET_DIR AND + (NOT RETRO_RUNTIME_NETPLAY_MODULE OR RETRO_RUNTIME_TEST_NETPLAY_MODULE)) + add_executable(retro-net-profiles-test tests/net_profiles_test.cpp + runner/net_session.cpp runner/net_module.cpp) + target_include_directories(retro-net-profiles-test PRIVATE ${RETRO_RUNTIME_RECOMP_NET_DIR}/include) + target_compile_definitions(retro-net-profiles-test PRIVATE RETRO_RUNNER_NETPLAY=1) + target_link_libraries(retro-net-profiles-test PRIVATE retro_core_support Threads::Threads) + if(RETRO_RUNTIME_NETPLAY_MODULE) + target_compile_definitions(retro-net-profiles-test PRIVATE RETRO_RUNNER_NETPLAY_MODULE=1) + target_link_libraries(retro-net-profiles-test PRIVATE ${CMAKE_DL_LIBS}) + add_test(NAME runner_startup_profiles COMMAND retro-net-profiles-test ${RETRO_RUNTIME_TEST_NETPLAY_MODULE}) + else() + target_link_libraries(retro-net-profiles-test PRIVATE recomp_net) + add_test(NAME runner_startup_profiles COMMAND retro-net-profiles-test) + endif() + endif() # Threads: the savestate keeper hashes content off the core thread. find_package(Threads REQUIRED) target_link_libraries(retro-core-runner PRIVATE retro_core_support retro_corelink retro_state diff --git a/docs/CORE_ABI.md b/docs/CORE_ABI.md index 4023d28..fe6eeeb 100644 --- a/docs/CORE_ABI.md +++ b/docs/CORE_ABI.md @@ -12,6 +12,7 @@ only the shape of the contract and why. | Rev | Date | Change | |---|---|---| | 1 | 2026-09-23 | First draft. | +| 10 | 2026-10-14 | Optional pre-init `execution_profiles()` capability/qualification query; appended init `execution_profile` index, valid with `RCORE_INIT_PROFILE_AGREED`. The runner exchanges offers/ACKs before init/guest boot, folds the common profile and canonical capability transcript into existing netplay identity, and keeps selection fixed through replay/reset/rollback. Old runners retain the core's conservative startup default. | | 9 | 2026-10-14 | Optional host `presentation_size(ctx, width, height)`: the available drawable physical pixel area before letterboxing, stable for a granted frame. Returns 0 and zeros when unavailable. Core checks host `struct_size` before reading the appended callback. Presentation policy only; never guest input or serialized simulation state. | | 8 | 2026-10-08 | `rcore_frame::game_frame`: the pictures the GAME has finished, as distinct from the frames the machine ran (an N64 game drawing at 30 fps runs 60 VI fields a second). 0 = the core cannot tell them apart. For a host's readout only (link 2.2 carries it to the hub; `OVERLAY.md`), never pacing or netplay. n64lle fills it from `n64_vi_swap_count()`, the game's VI_ORIGIN changes. | | 7 | 2026-10-01 | `accessory_poll` / `accessory_notify` on the host table and `RCORE_CAP_ACCESSORY_DATA`: bytes between the host and a plugged accessory whose input is not a pad, host->core drained at the frame boundary and stable for the frame, core->host for the host's UI. The first such accessory is `n64.vru`, the Voice Recognition Unit (n64lle PR #65 gave it a debug-port seam; Alex's ruling 2026-10-01: a seat's pak dropdown selects "VRU Microphone" and the hub owns the recognizer). | diff --git a/docs/CORE_RUNNER.md b/docs/CORE_RUNNER.md index dbba84a..66af6da 100644 --- a/docs/CORE_RUNNER.md +++ b/docs/CORE_RUNNER.md @@ -252,6 +252,20 @@ values and 16 inputs, every record with its field count. ## Netplay (headless, 2026-09-29) +**Startup execution profiles (2026-10-14, rcore draft 10).** A core with +`execution_profiles()` advertises actual CPU/backend capabilities and +qualified profiles before init. The runner completes recomp-net's immutable +startup offer/ACK barrier before init/load, supplies the agreed bit index +with `RCORE_INIT_PROFILE_AGREED`, and appends the common profile and canonical +capability transcript to existing identity. Every occupied seat must agree; +missing legacy support times out, never silently downgrades. No per-frame +capability probing, HLE/LLE handoff, live renegotiation or UI is added. Old +cores retain their startup behavior; old runners leave the new core's safe +default in force. A core/package/build check is never removed by this feature. +Requires recomp-net's startup_profiles.h API (module ABI minor 1 for module +builds). The generic fixture tests the real UDP barrier and clean one-peer +stop; native CPU qualification remains the engine's responsibility. + `runner/net_session.*` binds recomp-net's `rb_driver` once, for every core (rev 4; the core's slots map onto `RNetRbHost` as CORE_ABI.md §Netplay tables). Built when CMake is given `-DRETRO_RUNTIME_RECOMP_NET_DIR=valid = 1; } static void publish_unused(rnet_u32, const RNetInputSample*, int, void*) {} + + bool open_wire(const NetParams& p, const RNetProfileCaps* caps, std::string* error) { + RNetConfig rc; + rnet_config_init_defaults(&rc); + rc.slot_count = static_cast(p.slots); + rc.local_slot = static_cast(p.slot); + rc.input_delay = static_cast(p.delay); + rc.session_id = p.session_id; + rc.occupied_mask = p.occupied; + RNetHostVTable hv{}; + hv.sample_local = sample_local; + hv.publish = publish_unused; + hv.ctx = this; + session = rnet_session_create(&rc, &hv); + if (!session) { if (error) *error = "invalid session configuration"; return false; } + if (caps && rnet_session_profiles_begin(session, + fnv1a("startup-profiles-v1\n" + profile_qualification), caps) != 0) { + if (error) *error = "invalid execution profile offer"; + return false; + } + int result; + if (p.slot == 0 && p.slots > 2 && !p.via_relay) + result = rnet_session_start_lan_hub(session, p.bind.c_str()); + else + result = rnet_session_start_lan(session, p.bind.c_str(), p.peer.c_str()); + if (result == 0) return true; + if (error) *error = "could not open the UDP transport on " + p.bind; + return false; + } }; NetSession::NetSession() : impl_(std::make_unique()) {} NetSession::~NetSession() { shutdown(); } bool NetSession::compiled_in() { return true; } +bool NetSession::prepare(const LoadedCore& core, const NetParams& p, + rcore_init_params& init, std::string* error) { + Impl& s = *impl_; + auto fail = [&](const std::string& why) { if (error) *error = why; return false; }; + if (s.session) return fail("execution profile startup was already prepared"); + if (!RCORE_HAS(core.api, rcore_core_api, execution_profiles) || + !core.api->execution_profiles) return true; + const auto* offer = core.api->execution_profiles(); + if (!offer) return true; + if (init.struct_size < sizeof(init) || !(init.flags & RCORE_INIT_NETPLAY)) + return fail("execution profile agreement requires current netplay init params"); + if (!(core.info->capabilities & RCORE_CAP_DETERMINISTIC)) + return fail("the core does not declare DETERMINISTIC"); + if (p.slots < 2 || p.slots > static_cast(RCORE_MAX_SEATS) || + p.slot < 0 || p.slot >= p.slots) return fail("invalid startup seat"); + if (offer->struct_size < sizeof(*offer) || !offer->qualification || + !*offer->qualification || !(offer->qualified_profiles & 1u)) + return fail("the core advertised an invalid execution profile table"); +#if defined(RETRO_RUNNER_NETPLAY_MODULE) + if (!net_module_load(p.module_path, error)) return false; +#endif + s.p = p; + s.core = &core; + s.slot = p.slot; + s.slots = p.slots; + s.profile_qualification = offer->qualification; + RNetProfileCaps caps{offer->cpu_features, offer->backends, + offer->qualified_profiles, offer->architecture}; + if (!s.open_wire(p, &caps, error)) return false; + const auto started = steady_ms(); + for (;;) { + rnet_session_pump(s.session); + const int status = rnet_session_profiles_status(s.session, &s.profile, + &s.profile_transcript); + if (status < 0) return fail("execution profile agreement refused (namespace, offer or acknowledgement differs)"); + if (status == 1) break; + if (rnet_session_peer_disconnected(s.session, 0)) return fail("a peer left during execution profile startup"); + if (steady_ms() - started > 30000u) + return fail("execution profile startup timed out; every occupied peer must support the barrier"); + rnet_session_wait_recv(s.session, 5); + } + s.profile_agreed = true; + init.flags |= RCORE_INIT_PROFILE_AGREED; + init.execution_profile = s.profile; + for (int i = 0; i < p.slots; ++i) { + RNetProfileCaps peer{}; + if (rnet_session_profiles_peer(s.session, i, &peer)) + std::fprintf(stderr, "runner_netplay: CAP seat=%d arch=%u cpu=%08x backends=%08x profiles=%08x\n", + i, peer.architecture, peer.cpu_features, peer.backends, peer.profiles); + } + std::fprintf(stderr, "runner_netplay: PROFILE %s:%u transcript=%08x (locked before init)\n", + s.profile_qualification.c_str(), s.profile, s.profile_transcript); + return true; +} + bool NetSession::start(const LoadedCore& core, const NetParams& p, std::string* error) { auto fail = [&](const std::string& m) { if (error) *error = m; @@ -209,6 +297,15 @@ bool NetSession::start(const LoadedCore& core, const NetParams& p, std::string* }; Impl& s = *impl_; const rcore_core_api* a = core.api; + if (s.drv) return fail("a netplay execution is already active"); + if (s.profile_agreed && (s.core != &core || s.p.slot != p.slot || s.p.slots != p.slots || + s.p.occupied != p.occupied || s.p.delay != p.delay || s.p.prediction != p.prediction || + s.p.session_id != p.session_id || s.p.bind != p.bind || s.p.peer != p.peer || + s.p.via_relay != p.via_relay || s.p.epoch_s != p.epoch_s)) + return fail("netplay configuration changed after startup profile agreement"); + if (!s.profile_agreed && RCORE_HAS(a, rcore_core_api, execution_profiles) && + a->execution_profiles && a->execution_profiles()) + return fail("execution profiles must be prepared before core init"); #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. @@ -271,42 +368,18 @@ bool NetSession::start(const LoadedCore& core, const NetParams& p, std::string* content_fp = fnv1a(l + "\n", content_fp); std::fprintf(stderr, "runner_netplay: IDENT content %s\n", l.c_str()); } + if (s.profile_agreed) { + const std::string row = "execution_profile " + s.profile_qualification + ":" + + std::to_string(s.profile) + " transcript " + std::to_string(s.profile_transcript); + content_fp = fnv1a(row + "\n", content_fp); + std::fprintf(stderr, "runner_netplay: IDENT content %s\n", row.c_str()); + } std::fprintf(stderr, "runner_netplay: IDENT build=%08x content=%08x\n", build_fp, content_fp); // ---- the wire ---- - RNetConfig rc; - rnet_config_init_defaults(&rc); - rc.slot_count = static_cast(p.slots); - rc.local_slot = static_cast(p.slot); - rc.input_delay = static_cast(p.delay); - rc.session_id = p.session_id; - rc.occupied_mask = p.occupied; - RNetHostVTable hv{}; - hv.sample_local = Impl::sample_local; - hv.publish = Impl::publish_unused; - hv.ctx = &s; - s.session = rnet_session_create(&rc, &hv); - if (!s.session) return fail("recomp-net refused the session configuration"); - int rc_start; - std::string how; - if (p.via_relay) { - rc_start = rnet_session_start_lan(s.session, p.bind.c_str(), p.peer.c_str()); - how = "via the relay " + p.peer; - } else if (p.slot == 0 && p.slots > 2) { - rc_start = rnet_session_start_lan_hub(s.session, p.bind.c_str()); - how = "host, relaying for " + std::to_string(p.slots - 1) + " guests on " + p.bind; - } else if (p.slot == 0) { - rc_start = rnet_session_start_lan(s.session, p.bind.c_str(), ""); - how = "host on " + p.bind; - } else { - rc_start = rnet_session_start_lan(s.session, p.bind.c_str(), p.peer.c_str()); - how = "guest, dialling the host " + p.peer; - } - if (rc_start != 0) { - rnet_session_destroy(s.session); - s.session = nullptr; - return fail("could not open the UDP transport (" + how + ")"); - } + if (!s.session && !s.open_wire(p, nullptr, error)) return false; + const std::string how = p.via_relay ? "via relay " + p.peer : + p.slot == 0 ? "host on " + p.bind : "guest to " + p.peer; // ---- the driver ---- s.drv = rnet_rb_driver_create(); @@ -385,6 +458,15 @@ NetSession::Admit NetSession::poll() { Impl& s = *impl_; if (!s.session || !s.drv || !s.ended.empty()) return Admit::Stall; rnet_session_pump(s.session); + if (s.profile_agreed && rnet_session_profiles_status(s.session, nullptr, nullptr) != 1) { + s.ended = "execution profile agreement violated; live switching is forbidden"; + return Admit::Stall; + } + // Drain received control first: a final QUIESCE acknowledgement and BYE + // can arrive in one pump. The driver can finish an already agreed stop + // with the wire down; reporting BYE first turned that into a false error. + const auto admitted = rnet_rb_driver_poll_admit(s.drv); + if (drained()) return Admit::Stall; const bool running = rnet_session_is_running(s.session) != 0; if (running) s.was_running = true; if (!running && !s.was_running && steady_ms() - s.started_ms > 30000) { @@ -400,7 +482,7 @@ NetSession::Admit NetSession::poll() { s.ended = "a player left the match (no packets for 5 s)"; return Admit::Stall; } - switch (rnet_rb_driver_poll_admit(s.drv)) { + switch (admitted) { case RNET_RB_ADMIT_LIVE: return Admit::Live; case RNET_RB_ADMIT_REPLAY: return Admit::Replay; default: ++s.stats.stalls; return Admit::Stall; @@ -472,6 +554,10 @@ struct NetSession::Impl { NetSession::NetSession() : impl_(std::make_unique()) {} NetSession::~NetSession() = default; bool NetSession::compiled_in() { return false; } +bool NetSession::prepare(const LoadedCore&, const NetParams&, rcore_init_params&, std::string* error) { + if (error) *error = "this runner was built without recomp-net"; + return false; +} bool NetSession::start(const LoadedCore&, const NetParams&, std::string* error) { if (error) *error = "this runner was built without recomp-net (RETRO_RUNTIME_RECOMP_NET_DIR)"; return false; diff --git a/runner/net_session.hpp b/runner/net_session.hpp index 7050b6d..ce7686d 100644 --- a/runner/net_session.hpp +++ b/runner/net_session.hpp @@ -56,6 +56,10 @@ class NetSession { // True when this runner was built with recomp-net. static bool compiled_in(); + // Before core init/boot: exchange qualified profiles, lock the agreement + // into init params. Legacy cores keep their existing startup behavior. + bool prepare(const LoadedCore& core, const NetParams& p, + rcore_init_params& init, std::string* error); // Opens the transport and starts the driver. The core must be loaded and // at the boundary frame 1 runs from. False with *error saying why (a core // without ROLLBACK, a bad address, the driver's refusal). diff --git a/runner/runner_link.cpp b/runner/runner_link.cpp index 623d518..bc37a53 100644 --- a/runner/runner_link.cpp +++ b/runner/runner_link.cpp @@ -321,6 +321,8 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li ip.flags = (a.strict ? RCORE_INIT_STRICT : 0u) | (a.netplay ? RCORE_INIT_NETPLAY : 0u); ip.cache_dir = cache.c_str(); NetSession ns; + if (a.netplay && !ns.prepare(core, a.net, ip, &err)) + return exiting(sock, 2, "netplay: " + err); std::uint64_t net_tick = 0; if (a.netplay) { session.set_input_source([&](std::uint32_t seat, rcore_pad& pad) { pad = ns.seat_pad(seat); }); diff --git a/runner/runner_main.cpp b/runner/runner_main.cpp index 3410eb4..3ad0245 100644 --- a/runner/runner_main.cpp +++ b/runner/runner_main.cpp @@ -713,6 +713,7 @@ int main(int argc, char** argv) { ip.system_dir = nullptr; ip.cache_dir = cache.c_str(); NetSession ns; + if (netplay && !ns.prepare(core, net, ip, &err)) die("netplay: " + err); std::uint64_t net_tick = 0; if (netplay) { // Settled before the core runs: rows from the session, the clock from diff --git a/tests/net_profiles_test.cpp b/tests/net_profiles_test.cpp new file mode 100644 index 0000000..5875a8d --- /dev/null +++ b/tests/net_profiles_test.cpp @@ -0,0 +1,104 @@ +#include "net_session.hpp" +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#define getpid _getpid +#else +#include +#endif + +using namespace retro::runner; +thread_local rcore_execution_profiles offer{}; +static std::string module_path; +static const rcore_execution_profiles* profiles() { return &offer; } +static rcore_result save(std::uint32_t) { return RCORE_OK; } +static std::uint32_t has(std::uint32_t) { return 1; } +static std::uint32_t oldest(std::uint32_t* out) { *out = 0; return 1; } +static void drop(std::uint32_t) {} +static std::uint32_t depth() { return 40; } +static rcore_result frame() { return RCORE_OK; } +static std::uint64_t hash() { return 0x12345678; } + +static bool run(unsigned sequence, unsigned expected, bool force_portable) { + std::array peers; + std::atomic ready{0}, done{0}, failures{0}; + const unsigned port = 30000 + (static_cast(getpid()) % 4000) * 4 + sequence * 2; + for (unsigned seat = 0; seat < 2; ++seat) peers[seat] = std::thread([&, seat] { + offer = {sizeof offer, seat + 1, 10u + seat, 20u + seat, + force_portable && seat == 1 ? 1u : 15u, 0u, "test.fixed-profiles.v1"}; + rcore_core_info info{}; + info.capabilities = RCORE_CAP_DETERMINISTIC | RCORE_CAP_ROLLBACK; + rcore_core_api api{}; + api.struct_size = sizeof api; + api.execution_profiles = profiles; + api.rb_snap_save = save; api.rb_snap_load = save; api.rb_snap_has = has; + api.rb_snap_oldest = oldest; api.rb_snap_drop_after = drop; + api.rb_snap_depth = depth; api.run_frame_resim = frame; api.state_hash = hash; + LoadedCore core; + core.api = &api; + core.info = &info; + core.sha256 = "same-core-build"; + NetParams p; + p.module_path = module_path; + p.slot = static_cast(seat); + p.session_id = sequence + 1; + p.bind = "127.0.0.1:" + std::to_string(port + seat); + p.peer = seat ? "127.0.0.1:" + std::to_string(port) : ""; + rcore_init_params init{}; + init.struct_size = sizeof init; + init.flags = RCORE_INIT_NETPLAY; + NetSession session; + ++ready; + while (ready != 2) std::this_thread::yield(); + std::string why; + if (!session.prepare(core, p, init, &why) || + !(init.flags & RCORE_INIT_PROFILE_AGREED) || init.execution_profile != expected) { + std::fprintf(stderr, "prepare seat %u failed: %s, selected %u\n", seat, why.c_str(), init.execution_profile); + ++failures; + } + // Keep the transport alive until both have accepted their init params. + // No core init/load/frame or runtime profile switch occurs in this test. + ++done; + while (done != 2) std::this_thread::yield(); + NetParams changed = p; + ++changed.session_id; + if (session.start(core, changed, &why)) { + std::fprintf(stderr, "changed startup configuration was accepted\n"); + ++failures; + return; + } + if (!session.start(core, p, &why)) { + std::fprintf(stderr, "start failed: %s\n", why.c_str()); + ++failures; + return; + } + const auto started = std::chrono::steady_clock::now(); + unsigned live = 0; + while (!session.drained() && session.ended().empty() && + std::chrono::steady_clock::now() - started < std::chrono::seconds(5)) { + const auto admitted = session.poll(); + if (admitted != NetSession::Admit::Stall) { + session.finish(admitted); + if (admitted == NetSession::Admit::Live && ++live == 20 && seat == 0) + session.request_quiesce(); + } else session.wait(1); + } + if (!session.drained() || !session.ended().empty()) { + std::fprintf(stderr, "clean stop failed on seat %u: %s\n", seat, session.ended().c_str()); + ++failures; + } + }); + for (auto& peer : peers) peer.join(); + return failures == 0; +} + +int main(int argc, char** argv) { + if (argc > 1) module_path = argv[1]; + const bool ok = run(1, 3, false) && run(2, 0, true); + std::puts(ok ? "runner startup profiles: PASS" : "runner startup profiles: FAIL"); + return ok ? 0 : 1; +}