From e41abaf219b099f2f92195c77ada92c110cc9f8f Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Sat, 26 Sep 2026 21:39:12 -0400 Subject: [PATCH 1/2] overlay, savestates: retro_overlay, the envelope, link 1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retro_overlay (overlay/): what a host draws over a running core, the same for every core. FPS (top left, emulated frames over the time they span), >> TURBO (top right), the volume meter (right edge), toasts, and the save-state browser: twelve slots with thumbnails, input read by pad position, requests the host carries to the runner. No SDL, GL or clock; place() puts every layer in the same spot on every host. The font, boxes and browser are the ones psxrecomp, snesrecomp and n64lle already share. retro_state (state/): the savestate envelope CORE_ABI.md specifies, and check_state(), its load rule in order, naming both values of the first mismatch. The thumbnail is inside the envelope. Link 1.1: SaveState / LoadState / StateDone, between frames. The runner's StateKeeper writes and checks the envelope, hashing the content on a background thread so the first save does not wait. --load-state checks an envelope and still takes a bare state. as_msg now ignores a longer packet's tail and zero-fills down to a pre-append size: the minor rule, fixed before this first minor bump as LINK_TRANSPORTS.md §10 required. Tests: retro-overlay-test (FPS method, layers, place(), the browser, InputGuard, every load-rule mismatch, corruption, truncation); the fake core gains a 12-byte savestate; link_savestates saves after frame 4, loads after frame 8 and sees picture 6, is refused on other content, and loads headless. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 44 +++- corelink/core_link.cpp | 50 +++++ corelink/core_link.hpp | 33 ++- corelink/link_io.hpp | 17 +- corelink/link_protocol.hpp | 42 +++- corelink/link_test_main.cpp | 51 ++++- overlay/osd.cpp | 161 +++++++++++++ overlay/overlay.hpp | 257 +++++++++++++++++++++ overlay/overlay_font.cpp | 107 +++++++++ overlay/overlay_raster.cpp | 115 ++++++++++ overlay/overlay_raster.hpp | 56 +++++ overlay/savestate_menu.cpp | 437 ++++++++++++++++++++++++++++++++++++ runner/runner_link.cpp | 58 ++++- runner/runner_link.hpp | 1 + runner/runner_main.cpp | 21 +- runner/sha256.cpp | 21 +- runner/sha256.hpp | 4 + runner/state_keeper.cpp | 143 ++++++++++++ runner/state_keeper.hpp | 68 ++++++ state/state_envelope.cpp | 358 +++++++++++++++++++++++++++++ state/state_envelope.hpp | 103 +++++++++ tests/overlay_test.cpp | 418 ++++++++++++++++++++++++++++++++++ tests/rcore_fake_core.c | 38 +++- tests/state_test.cmake | 50 +++++ 24 files changed, 2617 insertions(+), 36 deletions(-) create mode 100644 overlay/osd.cpp create mode 100644 overlay/overlay.hpp create mode 100644 overlay/overlay_font.cpp create mode 100644 overlay/overlay_raster.cpp create mode 100644 overlay/overlay_raster.hpp create mode 100644 overlay/savestate_menu.cpp create mode 100644 runner/state_keeper.cpp create mode 100644 runner/state_keeper.hpp create mode 100644 state/state_envelope.cpp create mode 100644 state/state_envelope.hpp create mode 100644 tests/overlay_test.cpp create mode 100644 tests/state_test.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index a73e239..a5c7ec8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,10 @@ # retro_core_support STATIC a core's sidecar manifest reader and loader # (hosts read sidecars; the runner loads cores) # retro_corelink STATIC the host side of the link +# retro_state STATIC the savestate envelope (the runner writes and +# checks it; hosts read its header to list slots) +# retro_overlay STATIC what a host draws over a running core: FPS, +# TURBO, volume, toasts, the save-state browser # retro-core-runner exe the child process that runs a core # retro-core-link-test exe drives a core through the link, as a host does # rcore_fake_core shared the smallest core, for tests (never installed) @@ -76,6 +80,17 @@ if(WIN32) target_link_libraries(retro_corelink PUBLIC advapi32 bcrypt shell32) endif() +add_library(retro_state STATIC state/state_envelope.cpp) +target_include_directories(retro_state PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/state) +target_link_libraries(retro_state PUBLIC retro_core_support) + +# No SDL, no GL, no clock: a host passes the time in and draws the images +# (docs/OVERLAY.md). +add_library(retro_overlay STATIC overlay/overlay_font.cpp overlay/overlay_raster.cpp + overlay/osd.cpp overlay/savestate_menu.cpp) +target_include_directories(retro_overlay PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/overlay) +target_link_libraries(retro_overlay PUBLIC retro_rcore PRIVATE retro_state) + if(RETRO_RUNTIME_TOOLS) set(_sdl "${RETRO_RUNTIME_SDL3_TARGET}") if(NOT _sdl) @@ -97,8 +112,12 @@ if(RETRO_RUNTIME_TOOLS) runner/runner_main.cpp runner/runner_link.cpp runner/host_session.cpp + runner/state_keeper.cpp ) - target_link_libraries(retro-core-runner PRIVATE retro_core_support retro_corelink) + # 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 + Threads::Threads) target_include_directories(retro-core-runner PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated) if(_sdl) target_link_libraries(retro-core-runner PRIVATE ${_sdl}) @@ -112,13 +131,13 @@ if(RETRO_RUNTIME_TOOLS) target_link_libraries(retro-core-link-test PRIVATE retro_corelink) # The smallest core: silent, states 50/1 after load(), one 64x48 frame - # per run_frame. Its sidecar is written beside it. + # per run_frame, a 12-byte savestate. Its sidecar is written beside it. add_library(rcore_fake_core SHARED tests/rcore_fake_core.c) target_link_libraries(rcore_fake_core PRIVATE retro_rcore) set_target_properties(rcore_fake_core PROPERTIES PREFIX "" OUTPUT_NAME fake_core C_VISIBILITY_PRESET hidden) file(GENERATE OUTPUT $/fake_core.rcore.toml CONTENT -"# Written by CMake for the rcore_fake_core test fixture.\n\n[core]\nabi_major = 0\ndraft_revision = 5\nid = \"fake\"\nversion = \"1.0\"\nlibrary = \"$\"\nplatforms = [\"test\"]\ncapabilities = [\"run_frame\"]\n\n[build]\nengine_commit = \"\"\nengine_dirty = false\ntoolchain = \"\"\ngenerated_utc = \"\"\n") +"# Written by CMake for the rcore_fake_core test fixture.\n\n[core]\nabi_major = 0\ndraft_revision = 5\nid = \"fake\"\nversion = \"1.0\"\nlibrary = \"$\"\nplatforms = [\"test\"]\ncapabilities = [\"run_frame\", \"savestate\"]\n\n[build]\nengine_commit = \"\"\nengine_dirty = false\ntoolchain = \"\"\ngenerated_utc = \"\"\n") # The same core built as a generic one: GAME_PACKAGE, load() requires the # fixture package (tests/fake_package.txt). Its sidecar has no [title]: a @@ -129,7 +148,7 @@ if(RETRO_RUNTIME_TOOLS) set_target_properties(rcore_fake_pkg_core PROPERTIES PREFIX "" OUTPUT_NAME fake_pkg_core C_VISIBILITY_PRESET hidden) file(GENERATE OUTPUT $/fake_pkg_core.rcore.toml CONTENT -"# Written by CMake for the rcore_fake_pkg_core test fixture.\n\n[core]\nabi_major = 0\ndraft_revision = 5\nid = \"fake_pkg\"\nversion = \"1.0\"\nlibrary = \"$\"\nplatforms = [\"test\"]\ncapabilities = [\"run_frame\", \"game_package\"]\n\n[build]\nengine_commit = \"\"\nengine_dirty = false\ntoolchain = \"\"\ngenerated_utc = \"\"\n") +"# Written by CMake for the rcore_fake_pkg_core test fixture.\n\n[core]\nabi_major = 0\ndraft_revision = 5\nid = \"fake_pkg\"\nversion = \"1.0\"\nlibrary = \"$\"\nplatforms = [\"test\"]\ncapabilities = [\"run_frame\", \"savestate\", \"game_package\"]\n\n[build]\nengine_commit = \"\"\nengine_dirty = false\ntoolchain = \"\"\ngenerated_utc = \"\"\n") enable_testing() # Headless: the runner loads the fake core, checks its sidecar, runs 10 @@ -168,6 +187,23 @@ if(RETRO_RUNTIME_TOOLS) -DROM=${CMAKE_CURRENT_SOURCE_DIR}/LICENSE -DOUT=${CMAKE_CURRENT_BINARY_DIR}/test-crash -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/link_crash_test.cmake) + # Savestates: saved and loaded over the link, refused by the load rule, + # and read by headless --load-state (tests/state_test.cmake). + add_test(NAME link_savestates + COMMAND ${CMAKE_COMMAND} + "-DEMULATOR=${CMAKE_CROSSCOMPILING_EMULATOR}" + -DLINK_TEST=$ + -DRUNNER=$ + -DCORE=$ + -DROM=${CMAKE_CURRENT_SOURCE_DIR}/LICENSE + -DOTHER_ROM=${CMAKE_CURRENT_SOURCE_DIR}/README.md + -DOUT=${CMAKE_CURRENT_BINARY_DIR}/test-states + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/state_test.cmake) + # The overlay and the envelope, without a window or a core. + add_executable(retro-overlay-test tests/overlay_test.cpp) + target_link_libraries(retro-overlay-test PRIVATE retro_overlay retro_state) + add_test(NAME overlay COMMAND retro-overlay-test ${CMAKE_CURRENT_BINARY_DIR}/test-overlay) + set_tests_properties(overlay PROPERTIES PASS_REGULAR_EXPRESSION "overlay-test: ok") # Game packages: fake_pkg_core with the fixture package, headless and over # the link; and the refusals (no --package for a package core, --package # for a plain one, [title] in a package core's sidecar). diff --git a/corelink/core_link.cpp b/corelink/core_link.cpp index babffba..d7d7ee1 100644 --- a/corelink/core_link.cpp +++ b/corelink/core_link.cpp @@ -188,6 +188,18 @@ void CoreLink::handle_packet(const std::vector& buf, if (as_msg(buf, m)) exit_reason_ = m.reason; break; } + case Msg::StateDone: { + StateDoneMsg m{}; + if (!as_msg(buf, m) || !state_pending_) break; + m.detail[sizeof m.detail - 1] = '\0'; + StateResult r = state_request_; + r.ok = m.ok != 0; + r.bytes = m.bytes; + r.detail = m.detail; + state_result_ = r; + state_pending_ = false; + break; + } default: break; } @@ -205,6 +217,37 @@ bool CoreLink::grant(const rcore_pad pads[RCORE_MAX_SEATS]) { return true; } +bool CoreLink::states_supported() const { + return state_ == LinkState::Ready && identity_.protocol_minor >= 1 && + (identity_.capabilities & RCORE_CAP_SAVESTATE); +} + +bool CoreLink::request_state(Msg type, const fs::path& path) { + if (!can_request_state()) return false; + StateRequestMsg m{}; + m.h.type = type; + m.frame_number = done_; + const std::string p = path_utf8(path); + if (p.size() >= sizeof m.path) return false; + std::memcpy(m.path, p.c_str(), p.size() + 1); + if (!send_msg(channel_, m)) return false; + state_pending_ = true; + state_request_ = StateResult{}; + state_request_.save = type == Msg::SaveState; + state_request_.path = path; + state_result_.reset(); + return true; +} + +bool CoreLink::request_save_state(const fs::path& path) { return request_state(Msg::SaveState, path); } +bool CoreLink::request_load_state(const fs::path& path) { return request_state(Msg::LoadState, path); } + +std::optional CoreLink::take_state_result() { + std::optional r; + r.swap(state_result_); + return r; +} + bool CoreLink::take_frame() { if (!shm_) return false; if (!(shm_->middle.load(std::memory_order_acquire) & kFresh)) return false; @@ -275,6 +318,13 @@ void CoreLink::on_ended(int code) { exit_code_ = code; state_ = LinkState::Ended; outstanding_ = 0; + if (state_pending_) { + StateResult r = state_request_; + r.ok = false; + r.detail = "the runner ended before it answered"; + state_result_ = r; + state_pending_ = false; + } persist_saves(); // the hub's mappings survive the runner } diff --git a/corelink/core_link.hpp b/corelink/core_link.hpp index 9e2fe5f..06b0ace 100644 --- a/corelink/core_link.hpp +++ b/corelink/core_link.hpp @@ -52,6 +52,15 @@ struct CoreIdentity { std::uint32_t protocol_minor = 0; }; +// How a SaveState / LoadState went (link 1.1). +struct StateResult { + bool save = false; // SaveState; else LoadState + bool ok = false; + fs::path path; + std::uint64_t bytes = 0; + std::string detail; // the runner's reason when !ok +}; + struct LinkLog { std::uint32_t level; std::string text; @@ -86,12 +95,28 @@ class CoreLink { const CoreIdentity& identity() const { return identity_; } // One frame, with every seat's pad. Only one grant is outstanding at a - // time; can_grant() says whether the previous one has finished. - bool can_grant() const { return state_ == LinkState::Ready && outstanding_ == 0; } + // time; can_grant() says whether the previous one has finished. Nothing is + // granted while a savestate request is outstanding. + bool can_grant() const { + return state_ == LinkState::Ready && outstanding_ == 0 && !state_pending_; + } bool grant(const rcore_pad pads[RCORE_MAX_SEATS]); std::uint64_t frames_granted() const { return granted_; } std::uint64_t frames_done() const { return done_; } + // Savestates (link 1.1), between frames. The runner writes and checks the + // envelope at `path` (docs/CORE_ABI.md, "Savestates"). False without + // sending anything when the session cannot: a 1.0 runner, a core without + // CAP_SAVESTATE, a grant or another request still outstanding. + bool states_supported() const; + bool can_request_state() const { return states_supported() && can_grant(); } + bool request_save_state(const fs::path& path); + bool request_load_state(const fs::path& path); + bool state_pending() const { return state_pending_; } + // The answer, once, when it has arrived. A runner that ends first + // answers with a failure naming that. + std::optional take_state_result(); + // Swaps in the newest picture if there is one the hub has not taken. bool take_frame(); // The picture last taken (nullptr before the first one). @@ -123,6 +148,7 @@ class CoreLink { private: void handle_packet(const std::vector& buf, std::vector& handles); + bool request_state(Msg type, const fs::path& path); void on_ended(int code); void reap(int timeout_ms); @@ -146,6 +172,9 @@ class CoreLink { int outstanding_ = 0; std::uint32_t front_ = 0; // the triple buffer's initial front slot bool have_frame_ = false; + bool state_pending_ = false; + StateResult state_request_; + std::optional state_result_; int exit_code_ = 0; std::string exit_reason_; }; diff --git a/corelink/link_io.hpp b/corelink/link_io.hpp index 8329ee7..fafd0e1 100644 --- a/corelink/link_io.hpp +++ b/corelink/link_io.hpp @@ -25,11 +25,20 @@ inline bool packet_type(const std::vector& buf, Msg& out) { return true; } -// Copies a packet into a message struct when the size matches exactly. +// Copies a packet into a message struct. The minor-version rule +// (link_protocol.hpp): a newer peer may have appended fields, so a longer +// packet is read and its tail ignored; an older peer may not have sent fields +// this side appended, so a packet down to `min_size` -- the message's size +// before its first appended field -- is read and the rest zeroed. No message +// has grown yet, so every reader passes sizeof(M). +// +// A 1.0 peer still demands the exact 1.0 size. A message that grows must +// therefore be sent at its old size to a session speaking an older minor. template -bool as_msg(const std::vector& buf, M& out) { - if (buf.size() != sizeof(M)) return false; - std::memcpy(&out, buf.data(), sizeof(M)); +bool as_msg(const std::vector& buf, M& out, std::size_t min_size = sizeof(M)) { + if (buf.size() < min_size || min_size < sizeof(MsgHeader)) return false; + std::memset(&out, 0, sizeof(M)); + std::memcpy(&out, buf.data(), buf.size() < sizeof(M) ? buf.size() : sizeof(M)); return true; } diff --git a/corelink/link_protocol.hpp b/corelink/link_protocol.hpp index c7f8d30..4c97fa1 100644 --- a/corelink/link_protocol.hpp +++ b/corelink/link_protocol.hpp @@ -32,7 +32,7 @@ namespace retro::corelink { constexpr std::uint32_t kProtocolMajor = 1; -constexpr std::uint32_t kProtocolMinor = 0; +constexpr std::uint32_t kProtocolMinor = 1; // 1.1: savestates (SaveState / LoadState / StateDone) constexpr char kMagic[8] = {'R', 'C', 'L', 'I', 'N', 'K', '1', '\0'}; // Frame slots big enough for any console this contract hosts at 1x: the @@ -116,10 +116,13 @@ enum class Msg : std::uint32_t { Log = 5, // a log() line at WARN or above (all lines go to core.log) Event = 6, // a report(): BRIDGE / DISPATCH_MISS / FAULT Exiting = 7, // the runner is stopping on purpose; the reason follows + StateDone = 8, // 1.1: how the last SaveState / LoadState went // hub -> runner SavesFilled = 64, // every region filled from its file; the seats at power-on Grant = 65, // run exactly one frame, with these pads Quit = 66, // unload, deinit, exit 0 + SaveState = 67, // 1.1: serialize now, into an envelope at this path + LoadState = 68, // 1.1: check the envelope at this path, then unserialize }; struct MsgHeader { @@ -201,9 +204,29 @@ struct EmptyMsg { MsgHeader h; }; -constexpr std::size_t kMaxMsgSize = sizeof(SaveRegionsMsg) > sizeof(LogMsg) - ? sizeof(SaveRegionsMsg) - : sizeof(LogMsg); +// 1.1. Savestates, between frames: the hub sends one only while no grant is +// outstanding, and grants nothing until StateDone. The RUNNER writes and +// checks the envelope (state/state_envelope.hpp, docs/CORE_ABI.md +// "Savestates"): it holds every identity the load rule compares. The path is +// the hub's choice, UTF-8. +struct StateRequestMsg { + MsgHeader h; // SaveState or LoadState + std::uint64_t frame_number; // frames done so far, recorded in the envelope + char path[1024]; +}; + +struct StateDoneMsg { + MsgHeader h; + Msg request; // SaveState or LoadState + std::int32_t ok; // 1 = written / loaded; 0 = refused or failed, see detail + std::uint64_t bytes; // the core's state size + char detail[512]; // why not, naming both values (the load rule's words) +}; + +constexpr std::size_t kMaxMsgSize = sizeof(SaveRegionsMsg); // the largest message +static_assert(kMaxMsgSize >= sizeof(LogMsg) && kMaxMsgSize >= sizeof(StateRequestMsg) && + kMaxMsgSize >= sizeof(StateDoneMsg) && kMaxMsgSize >= sizeof(EventMsg), + "kMaxMsgSize must hold every message"); // ---- the wire layout, pinned ------------------------------------------------ // @@ -292,6 +315,17 @@ static_assert(offsetof(GrantMsg, pads) == 16, "GrantMsg::pads"); static_assert(sizeof(EmptyMsg) == 8 && alignof(EmptyMsg) == 4, "EmptyMsg"); static_assert(offsetof(EmptyMsg, h) == 0, "EmptyMsg::h"); static_assert(sizeof(rcore_pad) == 28 && alignof(rcore_pad) == 4, "rcore_pad"); +// 1.1 +static_assert(sizeof(StateRequestMsg) == 1040 && alignof(StateRequestMsg) == 8, "StateRequestMsg"); +static_assert(offsetof(StateRequestMsg, h) == 0, "StateRequestMsg::h"); +static_assert(offsetof(StateRequestMsg, frame_number) == 8, "StateRequestMsg::frame_number"); +static_assert(offsetof(StateRequestMsg, path) == 16, "StateRequestMsg::path"); +static_assert(sizeof(StateDoneMsg) == 536 && alignof(StateDoneMsg) == 8, "StateDoneMsg"); +static_assert(offsetof(StateDoneMsg, h) == 0, "StateDoneMsg::h"); +static_assert(offsetof(StateDoneMsg, request) == 8, "StateDoneMsg::request"); +static_assert(offsetof(StateDoneMsg, ok) == 12, "StateDoneMsg::ok"); +static_assert(offsetof(StateDoneMsg, bytes) == 16, "StateDoneMsg::bytes"); +static_assert(offsetof(StateDoneMsg, detail) == 24, "StateDoneMsg::detail"); } // namespace retro::corelink diff --git a/corelink/link_test_main.cpp b/corelink/link_test_main.cpp index 74a48d1..9186a5a 100644 --- a/corelink/link_test_main.cpp +++ b/corelink/link_test_main.cpp @@ -3,9 +3,14 @@ // artifacts are byte-identical to `retro-core-runner` headless and to // n64lle's rcore_probe on the same core and scenario (docs/CORE_LINK.md). // -// Same flags as headless mode (a subset: no --replay-at, which needs the -// savestate envelope; --package included), plus --runner . +// Same flags as headless mode (a subset: no --replay-at; --package included), +// plus --runner , and savestates the way a host's +// menu takes them (link 1.1): +// --state-save-at K:PATH after frame K, save an envelope to PATH +// --state-load-at K:PATH after frame K, load the envelope at PATH +// Each prints one `state:` line with the runner's answer. The last picture's +// first byte is printed (`picture:`): the fake core paints frame k as k & 0xff, +// so it shows where a load landed. // Outputs in --out: core.log, events.tsv and state_hash.tsv come from the // runner's session dir, which is --out; shot.ppm is the last picture taken // from shared memory; summary.txt is grepped from core.log. @@ -17,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +72,18 @@ int main(int argc, char** argv) { std::uint64_t frames = 60; bool seat0 = true; std::vector> script; + struct StateOp { + std::uint64_t at; + bool save; + std::string path; + }; + std::vector state_ops; + auto state_op = [&](const std::string& v, bool save) { + const auto colon = v.find(':'); + if (colon == std::string::npos) die("--state-save-at / --state-load-at K:PATH"); + state_ops.push_back({std::strtoull(v.substr(0, colon).c_str(), nullptr, 10), save, + v.substr(colon + 1)}); + }; for (int i = 1; i < argc; ++i) { const std::string a = args[i]; auto val = [&]() -> std::string { @@ -120,6 +138,10 @@ int main(int argc, char** argv) { if (comma == std::string::npos) break; start = comma + 1; } + } else if (a == "--state-save-at") { + state_op(val(), true); + } else if (a == "--state-load-at") { + state_op(val(), false); } else if (a == "--replay-at") { die("--replay-at: not over the link yet (needs the savestate envelope)"); } else { @@ -171,6 +193,26 @@ int main(int argc, char** argv) { std::int16_t sink[4096]; while (link.drain_audio(sink, 2048)) { } + for (const StateOp& op : state_ops) { + if (op.at != k || !ok) continue; + const bool sent = op.save ? link.request_save_state(utf8_path(op.path)) + : link.request_load_state(utf8_path(op.path)); + if (!sent) { + std::printf("state: %s at frame %llu not sent (link %u.%u, core %s savestate)\n", + op.save ? "save" : "load", static_cast(k), + kProtocolMajor, link.identity().protocol_minor, + (link.identity().capabilities & RCORE_CAP_SAVESTATE) ? "declares" + : "lacks"); + continue; + } + std::optional r; + while (!(r = link.take_state_result())) link.pump(100); + std::printf("state: %s at frame %llu %s%s%s\n", op.save ? "save" : "load", + static_cast(k), r->ok ? "ok" : "failed", + r->ok ? (" (" + std::to_string(r->bytes) + " bytes)").c_str() : ": ", + r->ok ? "" : r->detail.c_str()); + if (link.state() != LinkState::Ready) ok = false; + } } for (const LinkLog& l : link.logs) { if (l.level <= RCORE_LOG_WARN) std::fprintf(stderr, "core[%u]: %s\n", l.level, l.text.c_str()); @@ -211,6 +253,9 @@ int main(int argc, char** argv) { } } } + if (const FrameInfo* f = link.frame_info(); f && f->width && link.frame_pixels()) { + std::printf("picture: first byte %u\n", unsigned(link.frame_pixels()[0])); + } std::printf("link-test: %llu frame(s) granted and done, runner exit %d, %u fault(s)\n", static_cast(link.frames_done()), link.exit_code(), faults); return (ok && !faults) ? 0 : 1; diff --git a/overlay/osd.cpp b/overlay/osd.cpp new file mode 100644 index 0000000..f469bdf --- /dev/null +++ b/overlay/osd.cpp @@ -0,0 +1,161 @@ +// The OSD: FPS readout, TURBO marker, volume meter, toasts. +// +// Carried from snesrecomp's snes_osd.c (itself psxrecomp's host_osd.c): the +// same font at 2x, the same boxes, the same volume meter, the same 64-frame +// FPS mean counted on emulated frames. What changed is where the pieces sit +// -- TURBO has its own corner instead of trailing the FPS line -- and that +// the time comes from the host instead of SDL. + +#include "overlay.hpp" +#include "overlay_raster.hpp" + +#include +#include +#include + +namespace retro::overlay { + +namespace { + +using namespace raster; + +constexpr int kScale = 2; // OSD text is the font at 2x +constexpr int kPad = 2 * kScale; // inside every box +constexpr int kRowGap = 1 * kScale; +constexpr std::uint64_t kNsPerMs = 1000000ull; +constexpr std::uint64_t kFpsGapNs = 500 * kNsPerMs; // longer than this is a break +constexpr std::uint64_t kVolumeShowNs = 1500 * kNsPerMs; + +// The volume meter, in font pixels (x kScale in the image). +constexpr int kVolBarW = 8; +constexpr int kVolBarH = 64; +constexpr int kVolPanelW = 2 * 2 + 4 * kGlyph; // "100%" fits +constexpr int kVolPanelH = 2 * 2 + kVolBarH + 3 + kGlyph + 1; + +} // namespace + +Osd::Osd() { + // The marker never changes: draw it once. + const std::string t = ">> TURBO"; + turbo_img_.resize(std::uint32_t(kPad * 2 + text_width(t, kScale)), + std::uint32_t(kPad * 2 + kGlyph * kScale), color::kPanel); + text(turbo_img_, kPad, kPad, t, color::kGold, kScale); +} + +void Osd::set_fps_visible(bool on) { + if (on == fps_visible_) return; + fps_visible_ = on; + // Start from a clean window: an average built partly from before the + // readout opened reports frames the player never watched. + restart_fps(); +} + +void Osd::restart_fps() { pos_ = count_ = 0; } + +void Osd::note_frame(std::uint64_t now_ns) { + if (count_) { + const std::uint64_t last = stamps_[(pos_ + kFpsWindow - 1) % kFpsWindow]; + if (now_ns < last || now_ns - last > kFpsGapNs) restart_fps(); + } + stamps_[pos_] = now_ns; + pos_ = (pos_ + 1) % kFpsWindow; + if (count_ < kFpsWindow) ++count_; +} + +double Osd::fps() const { + if (count_ < 2) return 0.0; + const std::uint64_t newest = stamps_[(pos_ + kFpsWindow - 1) % kFpsWindow]; + const std::uint64_t oldest = stamps_[(pos_ + kFpsWindow - count_) % kFpsWindow]; + return newest > oldest ? double(count_ - 1) * 1e9 / double(newest - oldest) : 0.0; +} + +void Osd::set_turbo(bool on) { + if (on == turbo_) return; + turbo_ = on; + restart_fps(); // the reading is of the speed the player is now asking for +} + +void Osd::show_volume(int percent, std::uint64_t now_ns) { + volume_ = std::clamp(percent, 0, 100); + volume_until_ns_ = now_ns + kVolumeShowNs; +} + +void Osd::toast(const std::string& text_in, std::uint64_t now_ns, std::uint32_t duration_ms) { + if (text_in.empty()) return; + toast_ = text_in.substr(0, 64); + toast_until_ns_ = now_ns + std::uint64_t(duration_ms ? duration_ms : 2000) * kNsPerMs; +} + +void Osd::rasterize_status() { + // status_drawn_ holds "\n"; either may be empty. + const auto nl = status_drawn_.find('\n'); + const std::string fps_line = status_drawn_.substr(0, nl); + const std::string toast_line = nl == std::string::npos ? "" : status_drawn_.substr(nl + 1); + const int rows = (fps_line.empty() ? 0 : 1) + (toast_line.empty() ? 0 : 1); + const std::size_t widest = std::max(fps_line.size(), toast_line.size()); + status_.resize(std::uint32_t(kPad * 2 + int(widest) * kGlyph * kScale), + std::uint32_t(kPad * 2 + rows * kGlyph * kScale + (rows - 1) * kRowGap), + color::kPanel); + int y = kPad; + if (!fps_line.empty()) { + text(status_, kPad, y, fps_line, color::kText, kScale); + y += (kGlyph + 1) * kScale; + } + if (!toast_line.empty()) text(status_, kPad, y, toast_line, color::kText, kScale); + ++status_rev_; +} + +void Osd::rasterize_volume() { + constexpr int S = kScale; + volume_img_.resize(kVolPanelW * S, kVolPanelH * S, color::kPanel); + const int bar_x = (kVolPanelW - kVolBarW) / 2 * S; + const int bar_y = 2 * S; + // Trough, the level from the bottom, and a tick either side every 25%. + fill(volume_img_, bar_x, bar_y, kVolBarW * S, kVolBarH * S, color::kTrough); + const int level = (kVolBarH * volume_ + 50) / 100; + fill(volume_img_, bar_x, bar_y + (kVolBarH - level) * S, kVolBarW * S, level * S, + volume_ ? color::kText : color::kMuted); + for (int q = 1; q < 4; ++q) { + const int ty = bar_y + (kVolBarH * q / 4) * S; + fill(volume_img_, bar_x - S, ty, S, S, color::kTick); + fill(volume_img_, bar_x + kVolBarW * S, ty, S, S, color::kTick); + } + char pct[8]; + std::snprintf(pct, sizeof pct, "%d%%", volume_); + const int text_x = (kVolPanelW - int(std::string(pct).size()) * kGlyph) / 2; + text(volume_img_, text_x * S, (2 + kVolBarH + 3) * S, pct, color::kText, S); + volume_drawn_ = volume_; + ++volume_rev_; +} + +const std::vector& Osd::layers(std::uint64_t now_ns) { + layers_.clear(); + + std::string fps_line; + if (fps_visible_) { + char buf[32]; + std::snprintf(buf, sizeof buf, "%d FPS", int(std::lround(fps()))); + fps_line = buf; + } + if (!toast_.empty() && now_ns >= toast_until_ns_) toast_.clear(); + const std::string want = toast_.empty() ? fps_line : fps_line + "\n" + toast_; + if (!want.empty()) { + if (want != status_drawn_) { + status_drawn_ = want; + rasterize_status(); + } + layers_.push_back({kLayerStatus, &status_, status_rev_, Anchor::TopLeft}); + } else { + status_drawn_.clear(); + } + + if (turbo_) layers_.push_back({kLayerTurbo, &turbo_img_, 1, Anchor::TopRight}); + + if (volume_ >= 0 && now_ns < volume_until_ns_) { + if (volume_ != volume_drawn_) rasterize_volume(); + layers_.push_back({kLayerVolume, &volume_img_, volume_rev_, Anchor::RightMiddle}); + } + return layers_; +} + +} // namespace retro::overlay diff --git a/overlay/overlay.hpp b/overlay/overlay.hpp new file mode 100644 index 0000000..e6b5ea2 --- /dev/null +++ b/overlay/overlay.hpp @@ -0,0 +1,257 @@ +#pragma once + +// retro_overlay -- what a host draws over a running core: the FPS readout +// (top left), the TURBO marker (top right), the volume meter (right edge), +// toasts, and the save-state browser (centre). docs/OVERLAY.md is the design. +// +// It lives here, in the runtime, and not in a core or a host, so that it looks +// and behaves the same whichever core is running and whichever host is +// drawing. Cores never draw it (a core owns no window: rcore.h); hosts never +// re-implement it. A host feeds it events and draws the images it hands back. +// +// It depends on nothing but the C++ library and rcore.h: no SDL, no GL, no +// clock. The host passes the time in and composites the images however it +// draws anything else -- one texture per Layer::id, re-uploaded when +// Layer::revision changes, drawn at place(), nearest-filtered. +// +// Nothing here touches the machine. The overlay is composited after the core +// has produced its picture; it is not in the frame the core submitted, not in +// a savestate and not in anything a netplay peer sees. + +#include "rcore/rcore.h" + +#include +#include +#include +#include + +namespace retro::overlay { + +namespace fs = std::filesystem; + +// 0xAARRGGBB. +using Argb = std::uint32_t; + +// RGBA8, bytes R,G,B,A in memory order (the layout of an RCORE_PIXEL_RGBA8 +// frame), rows packed, straight alpha. +struct Image { + std::uint32_t width = 0, height = 0; + std::vector rgba; + void resize(std::uint32_t w, std::uint32_t h, Argb fill); +}; + +// Where a layer sits in the WINDOW. Overlay is host chrome: it does not scale +// or move with the game's letterboxed picture. +enum class Anchor : std::uint32_t { + TopLeft, // FPS readout and toasts + TopRight, // TURBO + RightMiddle, // volume meter + Center, // the save-state browser +}; + +enum LayerId : std::uint32_t { + kLayerStatus = 1, // FPS + toasts + kLayerTurbo = 2, + kLayerVolume = 3, + kLayerSavestates = 4, +}; + +struct Layer { + std::uint32_t id = 0; // LayerId: one texture per id is enough + const Image* image = nullptr; + std::uint64_t revision = 0; // changes whenever the pixels do + Anchor anchor = Anchor::TopLeft; +}; + +struct Rect { + float x = 0, y = 0, w = 0, h = 0; +}; + +// Where to draw `layer` in a window `win_w` x `win_h` (in whatever units the +// host draws in). Every host places layers with this, so the overlay sits in +// the same place everywhere: +// - corner and edge layers draw at an integer scale that grows with the +// window (1 below 1800 units tall, 2 below 3600, ...), inset 8 units per +// step of scale from the edges they are anchored to; +// - the save-state browser draws at the largest integer scale that fits 90% +// of the window (or shrinks to fit a window smaller than it). +Rect place(const Layer& layer, float win_w, float win_h); + +// ---- OSD: FPS, TURBO, volume, toasts ------------------------------------------ + +class Osd { +public: + Osd(); + + // FPS readout, top left. The host's "show FPS" setting and its hotkey + // both set this; the OSD only draws what it is told. + void set_fps_visible(bool on); + bool fps_visible() const { return fps_visible_; } + + // Once per EMULATED frame -- each frame the core finished -- not per + // present. Under turbo the core runs several frames per present, and the + // readout should say how fast the machine is running, which is what a + // player holding turbo is asking. Frames over the time they span, for the + // last 64 -- not a mean of 1/dt, which reads high whenever frames arrive + // unevenly. A gap of more than half a second starts it afresh rather than + // averaging the gap in. + void note_frame(std::uint64_t now_ns); + double fps() const; + // Start the reading afresh: a host calls it when play resumes after a + // pause or a menu. Turbo starting or stopping does it too. + void restart_fps(); + + // TURBO marker, top right, while the host runs the core faster than its + // own rate. + void set_turbo(bool on); + bool turbo() const { return turbo_; } + + // Volume meter, right edge, for 1.5 s after the player changes the volume. + void show_volume(int percent, std::uint64_t now_ns); + + // A timed line under the FPS readout: something that happened. + void toast(const std::string& text, std::uint64_t now_ns, std::uint32_t duration_ms = 2000); + + // What is showing at `now_ns`, rasterized where it changed. Valid until + // the next call on this object. + const std::vector& layers(std::uint64_t now_ns); + +private: + void rasterize_status(); + void rasterize_volume(); + + bool fps_visible_ = false; + bool turbo_ = false; + + static constexpr int kFpsWindow = 64; + std::uint64_t stamps_[kFpsWindow]{}; // when each of the last frames finished + int pos_ = 0, count_ = 0; + + std::string toast_; + std::uint64_t toast_until_ns_ = 0; + + int volume_ = -1, volume_drawn_ = -1; + std::uint64_t volume_until_ns_ = 0; + + std::string status_drawn_; + Image status_, turbo_img_, volume_img_; + std::uint64_t status_rev_ = 0, volume_rev_ = 0; + std::vector layers_; +}; + +// ---- Save-state browser -------------------------------------------------------- + +// Twelve slots of a title, each an envelope file (state/state_envelope.hpp) +// the RUNNER writes and checks: this class lists them, draws the browser and +// turns the player's input into requests. The host carries a request out -- +// over its link to the runner -- and reports back with finish(). +// +// Input, the same for every core: +// open / close SELECT + R1 on any pad (the gesture psxrecomp, snesrecomp +// and n64lle share), or the host's hotkey (open()) +// choose a slot D-pad or left stick (held: repeats), keys Up / Down, +// 1-9, 0, -, = jump to slots 1-12 +// load SOUTH face button, Enter +// save NORTH face button, S +// back EAST face button, Start, Escape, Backspace +// Face buttons are read by POSITION, from the physical pad, and the legend +// draws them as positions -- never as one console's letters. +class SavestateMenu { +public: + static constexpr int kSlots = 12; + + SavestateMenu(); + + // Slots are `dir`/slot01.rstate .. slot12.rstate. `core_id` and + // `core_sha256` are the running core's (the link's Hello): a slot saved by + // another core or another build is marked in the list. That mark is a + // hint; the runner's load rule is the judge. + void configure(const fs::path& dir, const std::string& core_id, const std::string& core_sha256); + // The header's right-hand text: how to open and close it (e.g. + // "SELECT+R1 OR F7"). + void set_hint(const std::string& hint); + fs::path slot_path(int slot) const; // slot 0..11 + + bool is_open() const { return open_; } + void open(); + void close(); + + // The physical pads, positionally (RCORE_PAD_* bits: SOUTH is the bottom + // face button whatever it is labelled), OR-ed over every pad the host + // has, and the left stick's Y (rcore sign: positive = up). Call once per + // host frame whether or not the browser is open. Returns true when this + // call opened it (the SELECT + R1 chord). + bool poll_pad(std::uint32_t buttons, std::int16_t stick_y, std::uint64_t now_ms); + + enum class Key { Up, Down, Load, Save, Back }; + void key(Key k); + void jump(int slot); // 0..11 + + struct Request { + enum Kind { None, Save, Load } kind = None; + int slot = -1; + fs::path path; + }; + // What the player asked for since the last call; None if nothing. While + // one is outstanding (until finish()) the browser shows it and takes no + // other request. + Request take_request(); + bool pending() const { return pending_.kind != Request::None; } + // How the outstanding request went. On a refusal `detail` is the runner's + // reason and the browser shows it; a successful load closes the browser. + void finish(bool ok, const std::string& detail); + + // A line for the OSD once the browser has something to announce ("Slot 3 + // loaded"); empty when there is none. + std::string take_toast(); + + // The browser while open, else nullptr. + const Layer* layer(); + +private: + struct Slot { + bool exists = false, readable = false; + std::int64_t saved_unix = 0; + std::uint64_t frame = 0; + std::string mark; // "OTHER CORE", "OTHER BUILD", "UNREADABLE" + std::vector thumb; // kThumbWidth x kThumbHeight RGBA8, or empty + }; + void scan(); + void scan_slot(int i); + void move(int delta); + void request(Request::Kind kind); + void rasterize(); + + fs::path dir_; + std::string core_id_, core_sha_, hint_ = "SELECT+R1"; + bool open_ = false; + int selected_ = 0; + Slot slots_[kSlots]; + Request pending_, ready_; + std::string status_, detail_, toast_; + + std::uint32_t prev_buttons_ = 0; + int repeat_dir_ = 0; + std::uint64_t repeat_next_ms_ = 0; + + bool dirty_ = true; + Image panel_; + std::uint64_t rev_ = 0; + Layer layer_; +}; + +// ---- Input guard ----------------------------------------------------------------- + +// Buttons held when an overlay closed stay out of the game until released: +// the chord that closed the browser, or the Enter that loaded a slot, must not +// also press Start in the game. Applied to the pads a host grants. +class InputGuard { +public: + void arm(const rcore_pad* pads, std::size_t count); + void apply(rcore_pad* pads, std::size_t count); + +private: + std::uint32_t mask_ = 0; +}; + +} // namespace retro::overlay diff --git a/overlay/overlay_font.cpp b/overlay/overlay_font.cpp new file mode 100644 index 0000000..9d17cee --- /dev/null +++ b/overlay/overlay_font.cpp @@ -0,0 +1,107 @@ +#include "overlay_raster.hpp" + +namespace retro::overlay::raster { + +// Public-domain 8x8 ASCII 32..126, row bitmasks with the LSB the left pixel, +// from the font8x8_basic set (https://github.com/d7samurai/font8x8). The same +// table psxrecomp's host_osd.c, snesrecomp's snes_osd.c and n64lle's save-state +// browser carry, so this overlay reads as theirs did. +const std::uint8_t kFont8x8[95][8] = { + {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, /* */ + {0x18,0x3C,0x3C,0x18,0x18,0x00,0x18,0x00}, /* ! */ + {0x36,0x36,0x00,0x00,0x00,0x00,0x00,0x00}, /* " */ + {0x36,0x36,0x7F,0x36,0x7F,0x36,0x36,0x00}, /* # */ + {0x0C,0x3E,0x03,0x1E,0x30,0x1F,0x0C,0x00}, /* $ */ + {0x00,0x63,0x33,0x18,0x0C,0x66,0x63,0x00}, /* % */ + {0x1C,0x36,0x1C,0x6E,0x3B,0x33,0x6E,0x00}, /* & */ + {0x06,0x06,0x03,0x00,0x00,0x00,0x00,0x00}, /* ' */ + {0x18,0x0C,0x06,0x06,0x06,0x0C,0x18,0x00}, /* ( */ + {0x06,0x0C,0x18,0x18,0x18,0x0C,0x06,0x00}, /* ) */ + {0x00,0x66,0x3C,0xFF,0x3C,0x66,0x00,0x00}, /* * */ + {0x00,0x0C,0x0C,0x3F,0x0C,0x0C,0x00,0x00}, /* + */ + {0x00,0x00,0x00,0x00,0x00,0x0C,0x0C,0x06}, /* , */ + {0x00,0x00,0x00,0x3F,0x00,0x00,0x00,0x00}, /* - */ + {0x00,0x00,0x00,0x00,0x00,0x0C,0x0C,0x00}, /* . */ + {0x60,0x30,0x18,0x0C,0x06,0x03,0x01,0x00}, /* / */ + {0x3E,0x63,0x73,0x7B,0x6F,0x67,0x3E,0x00}, /* 0 */ + {0x0C,0x0E,0x0C,0x0C,0x0C,0x0C,0x3F,0x00}, /* 1 */ + {0x1E,0x33,0x30,0x1C,0x06,0x33,0x3F,0x00}, /* 2 */ + {0x1E,0x33,0x30,0x1C,0x30,0x33,0x1E,0x00}, /* 3 */ + {0x38,0x3C,0x36,0x33,0x7F,0x30,0x78,0x00}, /* 4 */ + {0x3F,0x03,0x1F,0x30,0x30,0x33,0x1E,0x00}, /* 5 */ + {0x1C,0x06,0x03,0x1F,0x33,0x33,0x1E,0x00}, /* 6 */ + {0x3F,0x33,0x30,0x18,0x0C,0x0C,0x0C,0x00}, /* 7 */ + {0x1E,0x33,0x33,0x1E,0x33,0x33,0x1E,0x00}, /* 8 */ + {0x1E,0x33,0x33,0x3E,0x30,0x18,0x0E,0x00}, /* 9 */ + {0x00,0x0C,0x0C,0x00,0x00,0x0C,0x0C,0x00}, /* : */ + {0x00,0x0C,0x0C,0x00,0x00,0x0C,0x0C,0x06}, /* ; */ + {0x18,0x0C,0x06,0x03,0x06,0x0C,0x18,0x00}, /* < */ + {0x00,0x00,0x3F,0x00,0x00,0x3F,0x00,0x00}, /* = */ + {0x06,0x0C,0x18,0x30,0x18,0x0C,0x06,0x00}, /* > */ + {0x1E,0x33,0x30,0x18,0x0C,0x00,0x0C,0x00}, /* ? */ + {0x3E,0x63,0x7B,0x7B,0x7B,0x03,0x1E,0x00}, /* @ */ + {0x0C,0x1E,0x33,0x33,0x3F,0x33,0x33,0x00}, /* A */ + {0x3F,0x66,0x66,0x3E,0x66,0x66,0x3F,0x00}, /* B */ + {0x3C,0x66,0x03,0x03,0x03,0x66,0x3C,0x00}, /* C */ + {0x1F,0x36,0x66,0x66,0x66,0x36,0x1F,0x00}, /* D */ + {0x7F,0x06,0x06,0x3E,0x06,0x06,0x7F,0x00}, /* E */ + {0x7F,0x06,0x06,0x3E,0x06,0x06,0x06,0x00}, /* F */ + {0x3C,0x66,0x03,0x03,0x73,0x66,0x7C,0x00}, /* G */ + {0x33,0x33,0x33,0x3F,0x33,0x33,0x33,0x00}, /* H */ + {0x1E,0x0C,0x0C,0x0C,0x0C,0x0C,0x1E,0x00}, /* I */ + {0x78,0x30,0x30,0x30,0x33,0x33,0x1E,0x00}, /* J */ + {0x67,0x66,0x36,0x1E,0x36,0x66,0x67,0x00}, /* K */ + {0x06,0x06,0x06,0x06,0x06,0x06,0x7F,0x00}, /* L */ + {0x63,0x77,0x7F,0x7F,0x6B,0x63,0x63,0x00}, /* M */ + {0x63,0x67,0x6F,0x7B,0x73,0x63,0x63,0x00}, /* N */ + {0x1C,0x36,0x63,0x63,0x63,0x36,0x1C,0x00}, /* O */ + {0x3F,0x66,0x66,0x3E,0x06,0x06,0x06,0x00}, /* P */ + {0x1E,0x33,0x33,0x33,0x3B,0x1E,0x38,0x00}, /* Q */ + {0x3F,0x66,0x66,0x3E,0x36,0x66,0x67,0x00}, /* R */ + {0x1E,0x33,0x07,0x0E,0x38,0x33,0x1E,0x00}, /* S */ + {0x3F,0x2D,0x0C,0x0C,0x0C,0x0C,0x1E,0x00}, /* T */ + {0x33,0x33,0x33,0x33,0x33,0x33,0x3F,0x00}, /* U */ + {0x33,0x33,0x33,0x33,0x33,0x1E,0x0C,0x00}, /* V */ + {0x63,0x63,0x63,0x6B,0x7F,0x77,0x63,0x00}, /* W */ + {0x63,0x63,0x36,0x1C,0x1C,0x36,0x63,0x00}, /* X */ + {0x33,0x33,0x33,0x1E,0x0C,0x0C,0x1E,0x00}, /* Y */ + {0x7F,0x63,0x31,0x18,0x4C,0x66,0x7F,0x00}, /* Z */ + {0x1E,0x06,0x06,0x06,0x06,0x06,0x1E,0x00}, /* [ */ + {0x03,0x06,0x0C,0x18,0x30,0x60,0x40,0x00}, /* \ */ + {0x1E,0x18,0x18,0x18,0x18,0x18,0x1E,0x00}, /* ] */ + {0x08,0x1C,0x36,0x63,0x00,0x00,0x00,0x00}, /* ^ */ + {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xFF}, /* _ */ + {0x0C,0x0C,0x18,0x00,0x00,0x00,0x00,0x00}, /* ` */ + {0x00,0x00,0x1E,0x30,0x3E,0x33,0x6E,0x00}, /* a */ + {0x07,0x06,0x06,0x3E,0x66,0x66,0x3B,0x00}, /* b */ + {0x00,0x00,0x1E,0x33,0x03,0x33,0x1E,0x00}, /* c */ + {0x38,0x30,0x30,0x3e,0x33,0x33,0x6E,0x00}, /* d */ + {0x00,0x00,0x1E,0x33,0x3f,0x03,0x1E,0x00}, /* e */ + {0x1C,0x36,0x06,0x0f,0x06,0x06,0x0f,0x00}, /* f */ + {0x00,0x00,0x6E,0x33,0x33,0x3E,0x30,0x1F}, /* g */ + {0x07,0x06,0x36,0x6E,0x66,0x66,0x67,0x00}, /* h */ + {0x0C,0x00,0x0E,0x0C,0x0C,0x0C,0x1E,0x00}, /* i */ + {0x30,0x00,0x30,0x30,0x30,0x33,0x33,0x1E}, /* j */ + {0x07,0x06,0x66,0x36,0x1E,0x36,0x67,0x00}, /* k */ + {0x0E,0x0C,0x0C,0x0C,0x0C,0x0C,0x1E,0x00}, /* l */ + {0x00,0x00,0x33,0x7F,0x7F,0x6B,0x63,0x00}, /* m */ + {0x00,0x00,0x1F,0x33,0x33,0x33,0x33,0x00}, /* n */ + {0x00,0x00,0x1E,0x33,0x33,0x33,0x1E,0x00}, /* o */ + {0x00,0x00,0x3B,0x66,0x66,0x3E,0x06,0x0F}, /* p */ + {0x00,0x00,0x6E,0x33,0x33,0x3E,0x30,0x78}, /* q */ + {0x00,0x00,0x3B,0x6E,0x66,0x06,0x0F,0x00}, /* r */ + {0x00,0x00,0x3E,0x03,0x1E,0x30,0x1F,0x00}, /* s */ + {0x08,0x0C,0x3E,0x0C,0x0C,0x2C,0x18,0x00}, /* t */ + {0x00,0x00,0x33,0x33,0x33,0x33,0x6E,0x00}, /* u */ + {0x00,0x00,0x33,0x33,0x33,0x1E,0x0C,0x00}, /* v */ + {0x00,0x00,0x63,0x6B,0x7F,0x7F,0x36,0x00}, /* w */ + {0x00,0x00,0x63,0x36,0x1C,0x36,0x63,0x00}, /* x */ + {0x00,0x00,0x33,0x33,0x33,0x3E,0x30,0x1F}, /* y */ + {0x00,0x00,0x3F,0x19,0x0C,0x26,0x3F,0x00}, /* z */ + {0x38,0x0C,0x0C,0x07,0x0C,0x0C,0x38,0x00}, /* { */ + {0x18,0x18,0x18,0x00,0x18,0x18,0x18,0x00}, /* | */ + {0x07,0x0C,0x0C,0x38,0x0C,0x0C,0x07,0x00}, /* } */ + {0x6E,0x3B,0x00,0x00,0x00,0x00,0x00,0x00}, /* ~ */ +}; + +} // namespace retro::overlay::raster diff --git a/overlay/overlay_raster.cpp b/overlay/overlay_raster.cpp new file mode 100644 index 0000000..1eaea9b --- /dev/null +++ b/overlay/overlay_raster.cpp @@ -0,0 +1,115 @@ +#include "overlay_raster.hpp" + +#include + +namespace retro::overlay { + +namespace { + +void put(Image& img, int x, int y, Argb c) { + if (x < 0 || y < 0 || std::uint32_t(x) >= img.width || std::uint32_t(y) >= img.height) return; + std::uint8_t* p = img.rgba.data() + (std::size_t(y) * img.width + std::size_t(x)) * 4; + p[0] = static_cast(c >> 16); + p[1] = static_cast(c >> 8); + p[2] = static_cast(c); + p[3] = static_cast(c >> 24); +} + +} // namespace + +void Image::resize(std::uint32_t w, std::uint32_t h, Argb c) { + width = w; + height = h; + rgba.resize(std::size_t(w) * h * 4); + for (std::size_t i = 0; i < std::size_t(w) * h; ++i) { + rgba[i * 4 + 0] = static_cast(c >> 16); + rgba[i * 4 + 1] = static_cast(c >> 8); + rgba[i * 4 + 2] = static_cast(c); + rgba[i * 4 + 3] = static_cast(c >> 24); + } +} + +Rect place(const Layer& layer, float win_w, float win_h) { + Rect r; + if (!layer.image || !layer.image->width || !layer.image->height) return r; + const float iw = float(layer.image->width), ih = float(layer.image->height); + if (layer.anchor == Anchor::Center) { + float s = std::min(win_w * 0.9f / iw, win_h * 0.9f / ih); + if (s >= 1.0f) s = float(int(s)); + r.w = iw * s; + r.h = ih * s; + r.x = float(int((win_w - r.w) * 0.5f)); + r.y = float(int((win_h - r.h) * 0.5f)); + return r; + } + const float scale = float(1 + int(win_h) / 1800); + const float margin = 8.0f * scale; + r.w = iw * scale; + r.h = ih * scale; + switch (layer.anchor) { + case Anchor::TopLeft: + r.x = margin; + r.y = margin; + break; + case Anchor::TopRight: + r.x = win_w - r.w - margin; + r.y = margin; + break; + case Anchor::RightMiddle: + default: + r.x = win_w - r.w - margin; + r.y = float(int((win_h - r.h) * 0.5f)); + break; + } + return r; +} + +namespace raster { + +void fill(Image& img, int x, int y, int w, int h, Argb c) { + const int x0 = std::max(x, 0), y0 = std::max(y, 0); + const int x1 = std::min(x + w, int(img.width)), y1 = std::min(y + h, int(img.height)); + for (int yy = y0; yy < y1; ++yy) + for (int xx = x0; xx < x1; ++xx) put(img, xx, yy, c); +} + +void stroke(Image& img, int x, int y, int w, int h, Argb c) { + fill(img, x, y, w, 1, c); + fill(img, x, y + h - 1, w, 1, c); + fill(img, x, y, 1, h, c); + fill(img, x + w - 1, y, 1, h, c); +} + +void text(Image& img, int x, int y, const std::string& s, Argb c, int scale) { + for (std::size_t i = 0; i < s.size(); ++i) { + unsigned char ch = static_cast(s[i]); + if (ch < 32 || ch > 126) ch = '?'; + const std::uint8_t* g = kFont8x8[ch - 32]; + const int ox = x + int(i) * kGlyph * scale; + for (int row = 0; row < kGlyph; ++row) { + for (int col = 0; col < kGlyph; ++col) { + if (!(g[row] & (1u << col))) continue; + fill(img, ox + col * scale, y + row * scale, scale, scale, c); + } + } + } +} + +int text_width(const std::string& s, int scale) { return int(s.size()) * kGlyph * scale; } + +void blit(Image& img, int x, int y, int w, int h, const std::uint8_t* rgba, std::uint32_t src_w, + std::uint32_t src_h) { + if (!rgba || !src_w || !src_h || w <= 0 || h <= 0) return; + for (int yy = 0; yy < h; ++yy) { + const std::uint32_t sy = std::uint32_t(yy) * src_h / std::uint32_t(h); + for (int xx = 0; xx < w; ++xx) { + const std::uint32_t sx = std::uint32_t(xx) * src_w / std::uint32_t(w); + const std::uint8_t* p = rgba + (std::size_t(sy) * src_w + sx) * 4; + put(img, x + xx, y + yy, + 0xFF000000u | Argb(p[0]) << 16 | Argb(p[1]) << 8 | Argb(p[2])); + } + } +} + +} // namespace raster +} // namespace retro::overlay diff --git a/overlay/overlay_raster.hpp b/overlay/overlay_raster.hpp new file mode 100644 index 0000000..a78738f --- /dev/null +++ b/overlay/overlay_raster.hpp @@ -0,0 +1,56 @@ +#pragma once + +// The overlay's drawing primitives: rectangles and 8x8 text into an Image. +// Internal to retro_overlay. + +#include "overlay.hpp" + +#include +#include + +namespace retro::overlay::raster { + +constexpr int kGlyph = 8; + +extern const std::uint8_t kFont8x8[95][8]; + +// The palette every overlay piece draws from (0xAARRGGBB). The browser's +// colours are n64lle's save-state browser's, which were psxrecomp's. +namespace color { +constexpr Argb kPanel = 0xC0202020u; // OSD boxes: translucent dark grey +constexpr Argb kText = 0xFFFFFFFFu; +constexpr Argb kGold = 0xFFFFD24Du; // TURBO, headings, the selection +constexpr Argb kTrough = 0xFF505050u; // the volume meter's empty part +constexpr Argb kTick = 0xFFC0C0C0u; +constexpr Argb kMuted = 0xFF808080u; +// The browser. +constexpr Argb kBrowserBg = 0xFF0F1118u; +constexpr Argb kBar = 0xFF171B25u; +constexpr Argb kRow = 0xFF191D27u; +constexpr Argb kRowSelected = 0xFF2B2830u; +constexpr Argb kRowEdge = 0xFF303746u; +constexpr Argb kTile = 0xFF242A35u; +constexpr Argb kTileEdge = 0xFF3A4352u; +constexpr Argb kLabel = 0xFFE2E5EBu; +constexpr Argb kSub = 0xFFB2B8C2u; +constexpr Argb kHint = 0xFFB8BDC8u; +constexpr Argb kDim = 0xFF7F8796u; +constexpr Argb kFaint = 0xFF707887u; +constexpr Argb kSaved = 0xFF9AA3B2u; +constexpr Argb kWarn = 0xFFFF8A65u; +constexpr Argb kLoad = 0xFF6BE06Bu; // green +constexpr Argb kSave = 0xFFB8BDC8u; // grey +constexpr Argb kBack = 0xFF5FA8FFu; // blue +constexpr Argb kButtonInk = 0xFF10131Au; +} // namespace color + +void fill(Image& img, int x, int y, int w, int h, Argb c); +void stroke(Image& img, int x, int y, int w, int h, Argb c); +// `scale` pixels per font pixel. Characters outside 32..126 draw as '?'. +void text(Image& img, int x, int y, const std::string& s, Argb c, int scale = 1); +int text_width(const std::string& s, int scale = 1); +// An RGBA8 picture scaled (nearest) into a w x h box, opaque. +void blit(Image& img, int x, int y, int w, int h, const std::uint8_t* rgba, std::uint32_t src_w, + std::uint32_t src_h); + +} // namespace retro::overlay::raster diff --git a/overlay/savestate_menu.cpp b/overlay/savestate_menu.cpp new file mode 100644 index 0000000..133bcb9 --- /dev/null +++ b/overlay/savestate_menu.cpp @@ -0,0 +1,437 @@ +// The save-state browser. +// +// Carried from n64lle's host_savestate_menu.rs, which carried psxrecomp's +// psx_savestate_menu.c: the same 640x480 panel, three rows of twelve slots +// with a thumbnail each, the same colours and the same footer legend. What +// changed: +// - It does not save or load anything. It asks, and the host carries the +// request to the runner, which writes and checks the envelope +// (state/state_envelope.hpp). A refusal comes back as the runner's +// reason, and the panel shows it. +// - The thumbnail is inside the envelope, not a sidecar file. +// - Face buttons are read and drawn by POSITION, so the legend is the same +// for every console: n64lle drew the N64's A / Z / B. + +#include "overlay.hpp" +#include "overlay_raster.hpp" + +#include "state_envelope.hpp" + +#include +#include +#include + +namespace retro::overlay { + +namespace { + +using namespace raster; + +constexpr int kW = 640, kH = 480; +constexpr int kVisibleRows = 3; +constexpr int kRowsX = 28, kRowsY = 62, kRowsW = 584, kRowH = 112, kRowGap = 8; +constexpr int kTileW = 136, kTileH = 102; +constexpr int kFooterY = 418; // below the third row (62 + 3 * 112 + 2 * 8 = 414) + +// A held direction waits this long, then repeats this often. +constexpr std::uint64_t kRepeatDelayMs = 350; +constexpr std::uint64_t kRepeatRateMs = 90; +constexpr int kStickDeadzone = 16000; + +constexpr std::uint32_t kChord = RCORE_PAD_SELECT | RCORE_PAD_R1; + +std::string two_digits(int n) { + char b[8]; + std::snprintf(b, sizeof b, "%02d", n); + return b; +} + +std::string upper(std::string s) { + for (char& c : s) c = static_cast(std::toupper(static_cast(c))); + return s; +} + +std::string local_time(std::int64_t unix_s) { + const std::time_t t = static_cast(unix_s); + std::tm tm{}; +#if defined(_WIN32) + localtime_s(&tm, &t); +#else + localtime_r(&t, &tm); +#endif + char b[32]; + std::strftime(b, sizeof b, "%Y-%m-%d %H:%M", &tm); + return b; +} + +// Runs of 16+ hex digits (the runner names hashes in full) shortened to 12, +// so a refusal fits the panel. The whole sentence is in the runner's log. +std::string shorten_hashes(const std::string& s) { + std::string o; + std::size_t i = 0; + while (i < s.size()) { + std::size_t j = i; + while (j < s.size() && std::isxdigit(static_cast(s[j]))) ++j; + if (j - i >= 16) { + o += s.substr(i, 12) + ".."; + i = j; + } else if (j > i) { + o += s.substr(i, j - i); + i = j; + } else { + o += s[i++]; + } + } + return o; +} + +// Word-wrapped to `cols` characters, at most `max_lines` lines. +std::vector wrap(const std::string& s, std::size_t cols, std::size_t max_lines) { + std::vector lines; + std::string line, word; + auto flush_word = [&] { + if (word.empty()) return; + if (!line.empty() && line.size() + 1 + word.size() > cols) { + lines.push_back(line); + line.clear(); + } + while (word.size() > cols) { + lines.push_back(word.substr(0, cols)); + word.erase(0, cols); + } + line += (line.empty() ? "" : " ") + word; + word.clear(); + }; + for (char c : s) { + if (c == ' ') flush_word(); + else word += c; + } + flush_word(); + if (!line.empty()) lines.push_back(line); + if (lines.size() > max_lines) { + lines.resize(max_lines); + std::string& last = lines.back(); + if (last.size() + 2 > cols) last.resize(cols - 2); + last += ".."; + } + return lines; +} + +// A face button by position: four dots in a diamond, the one meant filled. +void face_button(Image& img, int x, int y, std::uint32_t which, Argb c) { + struct Dot { + std::uint32_t bit; + int dx, dy; + }; + static constexpr Dot kDots[] = { + {RCORE_PAD_NORTH, 6, 0}, + {RCORE_PAD_WEST, 0, 6}, + {RCORE_PAD_EAST, 12, 6}, + {RCORE_PAD_SOUTH, 6, 12}, + }; + for (const Dot& d : kDots) { + if (d.bit == which) fill(img, x + d.dx, y + d.dy, 6, 6, c); + else stroke(img, x + d.dx, y + d.dy, 6, 6, color::kDim); + } +} + +} // namespace + +SavestateMenu::SavestateMenu() { panel_.resize(kW, kH, color::kBrowserBg); } + +void SavestateMenu::configure(const fs::path& dir, const std::string& core_id, + const std::string& core_sha256) { + dir_ = dir; + core_id_ = core_id; + core_sha_ = core_sha256; + if (open_) scan(); + dirty_ = true; +} + +void SavestateMenu::set_hint(const std::string& hint) { + hint_ = upper(hint); + dirty_ = true; +} + +fs::path SavestateMenu::slot_path(int slot) const { + return dir_ / ("slot" + two_digits(slot + 1) + ".rstate"); +} + +void SavestateMenu::scan_slot(int i) { + Slot& s = slots_[i]; + s = Slot{}; + const fs::path p = slot_path(i); + std::error_code ec; + if (!fs::is_regular_file(p, ec)) return; + s.exists = true; + state::StateHeader h; + std::string err; + if (!state::read_state_header(p, h, &s.thumb, &err)) { + s.mark = "UNREADABLE"; + s.thumb.clear(); + return; + } + s.readable = true; + s.saved_unix = h.saved_unix; + s.frame = h.frame_number; + if (h.thumb_w != state::kThumbWidth || h.thumb_h != state::kThumbHeight) s.thumb.clear(); + if (!core_id_.empty() && h.identity.core_id != core_id_) { + s.mark = "OTHER CORE"; + } else if (!h.identity.state_compat_id && !core_sha_.empty() && + h.identity.core_sha256 != core_sha_) { + s.mark = "OTHER BUILD"; + } +} + +void SavestateMenu::scan() { + for (int i = 0; i < kSlots; ++i) scan_slot(i); +} + +void SavestateMenu::open() { + if (open_) return; + open_ = true; + status_.clear(); + detail_.clear(); + repeat_dir_ = 0; + scan(); + dirty_ = true; +} + +void SavestateMenu::close() { + open_ = false; + repeat_dir_ = 0; + status_.clear(); + detail_.clear(); + dirty_ = true; +} + +void SavestateMenu::move(int delta) { + selected_ = ((selected_ + delta) % kSlots + kSlots) % kSlots; + status_.clear(); + detail_.clear(); + dirty_ = true; +} + +void SavestateMenu::jump(int slot) { + if (!open_ || pending() || slot < 0 || slot >= kSlots) return; + selected_ = slot; + status_.clear(); + detail_.clear(); + dirty_ = true; +} + +void SavestateMenu::request(Request::Kind kind) { + if (pending()) return; + const std::string n = two_digits(selected_ + 1); + detail_.clear(); + if (kind == Request::Load && !slots_[selected_].exists) { + status_ = "SLOT " + n + " IS EMPTY"; + dirty_ = true; + return; + } + pending_.kind = kind; + pending_.slot = selected_; + pending_.path = slot_path(selected_); + ready_ = pending_; + status_ = (kind == Request::Save ? "SAVING SLOT " : "LOADING SLOT ") + n + "..."; + dirty_ = true; +} + +SavestateMenu::Request SavestateMenu::take_request() { + Request r = ready_; + ready_ = Request{}; + return r; +} + +void SavestateMenu::finish(bool ok, const std::string& detail) { + const Request r = pending_; + pending_ = Request{}; + ready_ = Request{}; + if (r.kind == Request::None) return; + const std::string n = two_digits(r.slot + 1); + const std::string plain = std::to_string(r.slot + 1); + detail_.clear(); + if (r.kind == Request::Save) { + if (ok) { + // Stays open: the picture appearing in the row is the player's + // evidence the state was written. + status_ = "SAVED SLOT " + n; + toast_ = "Slot " + plain + " saved"; + scan_slot(r.slot); + } else { + status_ = "SAVE FAILED: SLOT " + n; + detail_ = detail; + } + } else if (ok) { + toast_ = "Slot " + plain + " loaded"; + close(); + } else { + // A refused load left the machine as it was; the slot stays listed. + status_ = "LOAD REFUSED: SLOT " + n; + detail_ = detail; + } + dirty_ = true; +} + +std::string SavestateMenu::take_toast() { + std::string t; + t.swap(toast_); + return t; +} + +bool SavestateMenu::poll_pad(std::uint32_t buttons, std::int16_t stick_y, std::uint64_t now_ms) { + const std::uint32_t prev = prev_buttons_; + prev_buttons_ = buttons; + const std::uint32_t pressed = buttons & ~prev; + if (!open_) { + // An edge on the WHOLE chord, not on either button: holding SELECT + // and then tapping R1 is the gesture, and so is the reverse. + if ((buttons & kChord) == kChord && (prev & kChord) != kChord) { + open(); + return true; + } + return false; + } + if (pending()) return false; + // The chord closes as well as opens, so an accidental open is undone the + // same way. + if ((pressed & kChord) && (buttons & kChord) == kChord) { + close(); + return false; + } + if (pressed & (RCORE_PAD_START | RCORE_PAD_EAST)) { + close(); + return false; + } + if (pressed & RCORE_PAD_SOUTH) { + request(Request::Load); + return false; + } + if (pressed & RCORE_PAD_NORTH) { + request(Request::Save); + return false; + } + const int dir = (buttons & RCORE_PAD_DPAD_DOWN) || stick_y < -kStickDeadzone ? 1 + : (buttons & RCORE_PAD_DPAD_UP) || stick_y > kStickDeadzone ? -1 + : 0; + if (!dir) { + repeat_dir_ = 0; + return false; + } + if (dir != repeat_dir_) { + repeat_dir_ = dir; + repeat_next_ms_ = now_ms + kRepeatDelayMs; + move(dir); + } else if (now_ms >= repeat_next_ms_) { + repeat_next_ms_ = now_ms + kRepeatRateMs; + move(dir); + } + return false; +} + +void SavestateMenu::key(Key k) { + if (!open_ || pending()) return; + switch (k) { + case Key::Up: move(-1); break; + case Key::Down: move(1); break; + case Key::Load: request(Request::Load); break; + case Key::Save: request(Request::Save); break; + case Key::Back: close(); break; + } +} + +void SavestateMenu::rasterize() { + Image& p = panel_; + p.resize(kW, kH, color::kBrowserBg); + + fill(p, 0, 0, kW, 46, color::kBar); + text(p, 24, 14, "SAVE STATES", color::kGold, 2); + text(p, kW - 24 - text_width(hint_), 18, hint_, color::kHint); + + int first = selected_ - 1; + if (first < 0) first = 0; + if (first > kSlots - kVisibleRows) first = kSlots - kVisibleRows; + text(p, 24, 48, + two_digits(first + 1) + "-" + two_digits(first + kVisibleRows) + " / " + + two_digits(kSlots), + color::kDim); + + for (int i = first; i < first + kVisibleRows; ++i) { + const Slot& s = slots_[i]; + const int y = kRowsY + (i - first) * (kRowH + kRowGap); + const bool sel = i == selected_; + fill(p, kRowsX, y, kRowsW, kRowH, sel ? color::kRowSelected : color::kRow); + stroke(p, kRowsX, y, kRowsW, kRowH, sel ? color::kGold : color::kRowEdge); + text(p, kRowsX + 16, y + 16, "SLOT " + two_digits(i + 1), + sel ? color::kGold : color::kLabel); + + const int tx = kRowsX + 112, ty = y + 5; + if (!s.thumb.empty()) { + blit(p, tx, ty, kTileW, kTileH, s.thumb.data(), state::kThumbWidth, + state::kThumbHeight); + } else { + fill(p, tx, ty, kTileW, kTileH, color::kTile); + const std::string what = s.exists ? "SAVED" : "NEW"; + text(p, tx + (kTileW - text_width(what)) / 2, ty + 47, what, + s.exists ? color::kSaved : color::kFaint); + } + stroke(p, tx, ty, kTileW, kTileH, color::kTileEdge); + + const int cx = kRowsX + 272; + const Argb sub = sel ? color::kText : color::kSub; + if (!s.exists) { + text(p, cx, y + 48, "EMPTY", sub); + } else if (s.readable) { + text(p, cx, y + 40, local_time(s.saved_unix), sub); + text(p, cx, y + 56, "FRAME " + std::to_string(s.frame), color::kDim); + } + if (!s.mark.empty()) text(p, cx, y + 72, s.mark, color::kWarn); + } + + fill(p, 0, kFooterY, kW, kH - kFooterY, color::kBar); + face_button(p, 28, 424, RCORE_PAD_SOUTH, color::kLoad); + text(p, 52, 429, "LOAD", color::kLabel); + face_button(p, 128, 424, RCORE_PAD_NORTH, color::kSave); + text(p, 152, 429, "SAVE", color::kLabel); + face_button(p, 228, 424, RCORE_PAD_EAST, color::kBack); + text(p, 252, 429, "BACK", color::kLabel); + text(p, 340, 429, "D-PAD OR STICK SLOT", color::kDim); + if (detail_.empty()) { + text(p, 28, 450, "KEYS: UP/DOWN SLOT ENTER LOAD S SAVE ESC BACK 1-0 - = JUMP", + color::kHint); + if (!status_.empty()) text(p, 28, 466, status_, color::kGold); + } else { + // A refusal: the label, then the runner's reason in two lines. + text(p, 28, 448, status_, color::kGold); + int y = 460; + for (const std::string& l : wrap(shorten_hashes(detail_), 73, 2)) { + text(p, 28, y, l, color::kWarn); + y += 10; + } + } + ++rev_; + dirty_ = false; +} + +const Layer* SavestateMenu::layer() { + if (!open_) return nullptr; + if (dirty_) rasterize(); + layer_ = {kLayerSavestates, &panel_, rev_, Anchor::Center}; + return &layer_; +} + +void InputGuard::arm(const rcore_pad* pads, std::size_t count) { + std::uint32_t held = 0; + for (std::size_t i = 0; i < count; ++i) held |= pads[i].buttons; + mask_ |= held; +} + +void InputGuard::apply(rcore_pad* pads, std::size_t count) { + if (!mask_) return; + std::uint32_t held = 0; + for (std::size_t i = 0; i < count; ++i) held |= pads[i].buttons; + mask_ &= held; // a released button is free again + for (std::size_t i = 0; i < count; ++i) pads[i].buttons &= ~mask_; +} + +} // namespace retro::overlay diff --git a/runner/runner_link.cpp b/runner/runner_link.cpp index 5d73821..8095016 100644 --- a/runner/runner_link.cpp +++ b/runner/runner_link.cpp @@ -1,6 +1,7 @@ #include "runner_link.hpp" #include "../corelink/link_io.hpp" +#include "state_keeper.hpp" #include #include @@ -80,9 +81,21 @@ class LinkSink final : public Sink { info.aspect_den = f.aspect_den; info.frame_number = frame_number; // Publish: our back becomes middle, and middle's old slot becomes ours. + published_ = static_cast(back_); back_ = shm_->middle.exchange(back_ | kFresh, std::memory_order_acq_rel) & ~kFresh; } + // The last published picture, shrunk for a savestate. Between frames that + // slot is the middle or the hub's front: nobody writes it until the runner + // publishes again, which it is not doing while it answers a request. + std::vector thumbnail() const { + if (published_ < 0) return {}; + const FrameInfo& info = shm_->slot[published_]; + return state::make_thumbnail(base_ + shm_->frames_offset + + std::size_t(published_) * kFrameSlotBytes, + info.width, info.height, info.stride); + } + void audio(const std::int16_t* samples, std::uint32_t n) override { const std::uint64_t cap = shm_->audio_capacity; const std::uint64_t w = shm_->audio_write.load(std::memory_order_relaxed); @@ -121,6 +134,7 @@ class LinkSink final : public Sink { SharedHeader* shm_; std::uint8_t* base_; std::uint32_t back_ = 2; // the triple buffer's initial back slot + int published_ = -1; // the slot last swapped into middle std::ofstream log_, events_; }; @@ -262,12 +276,17 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li } session.adopt_external_save_regions(regs, nregs, memory); + StateKeeper keeper(core, session, a.rom, a.package_sha256, bindings); + // A core that can save gets its content hashed now, off the core thread, + // so the player's first save does not wait on it. + if (core.info->capabilities & RCORE_CAP_SAVESTATE) keeper.hash_in_background(); if (a.load_state) { - std::ifstream in(*a.load_state, std::ios::binary); - const std::vector bytes((std::istreambuf_iterator(in)), {}); - if (bytes.empty() || !core.api->unserialize || - core.api->unserialize(bytes.data(), bytes.size()) != RCORE_OK) { - return exiting(sock, 2, "unserialize " + a.load_state->string() + " failed"); + // The launch's own state: an envelope is checked; a bare state, as the + // gates write them, goes to the core as it always did. + std::uint64_t bytes = 0; + std::string why; + if (!keeper.load(*a.load_state, true, &bytes, &why)) { + return exiting(sock, 2, "--load-state " + a.load_state->string() + ": " + why); } } @@ -283,6 +302,35 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li Msg t{}; packet_type(buf, t); if (t == Msg::Quit) break; + if (t == Msg::SaveState || t == Msg::LoadState) { + // 1.1. Between frames by construction: one grant at a time, and + // the hub sends a request only when none is outstanding. + StateDoneMsg d{}; + d.h.type = Msg::StateDone; + d.request = t; + StateRequestMsg r{}; + std::string why; + std::uint64_t bytes = 0; + bool ok = false; + if (!as_msg(buf, r)) { + why = "malformed request"; + } else { + r.path[sizeof r.path - 1] = '\0'; + const fs::path path = utf8_path(r.path); + ok = t == Msg::SaveState + ? keeper.save(path, r.frame_number, sink.thumbnail(), &bytes, &why) + : keeper.load(path, false, &bytes, &why); + std::fprintf(stderr, "state: %s %s: %s\n", + t == Msg::SaveState ? "save" : "load", r.path, + ok ? ("ok, " + std::to_string(bytes) + " bytes").c_str() + : why.c_str()); + } + d.ok = ok ? 1 : 0; + d.bytes = bytes; + copy_str(d.detail, sizeof d.detail, why.c_str()); + send_msg(sock, d); + continue; + } GrantMsg g{}; if (t != Msg::Grant || !as_msg(buf, g)) { code = exiting(sock, 2, "link: unexpected message from the hub"); diff --git a/runner/runner_link.hpp b/runner/runner_link.hpp index d078706..7f76912 100644 --- a/runner/runner_link.hpp +++ b/runner/runner_link.hpp @@ -16,6 +16,7 @@ namespace retro::runner { struct LinkArgs { std::string rom; std::string package; // --package; empty for a core without game_package + std::string package_sha256; // the runner's hash of it, for savestate envelopes std::string title_dir = "."; fs::path out; // session dir: core.log, events.tsv, the core's cache_dir bool gl = false; diff --git a/runner/runner_main.cpp b/runner/runner_main.cpp index a6b0708..9b6719c 100644 --- a/runner/runner_main.cpp +++ b/runner/runner_main.cpp @@ -38,6 +38,7 @@ #include "runner_link.hpp" #include "runtime_version.h" #include "sha256.hpp" +#include "state_keeper.hpp" #include "transport.hpp" #include @@ -352,6 +353,7 @@ int main(int argc, char** argv) { // ---- the game package: required by a GAME_PACKAGE core, refused otherwise const bool wants_package = (info.capabilities & RCORE_CAP_GAME_PACKAGE) != 0; + std::string pkg_sha; if (wants_package && package.empty()) { die(std::string("core '") + info.core_id + "' declares game_package: --package (the title's generated code) is required"); @@ -367,7 +369,7 @@ int main(int argc, char** argv) { const fs::path pkg = retro::corelink::utf8_path(package); std::error_code pec; if (!fs::is_regular_file(pkg, pec)) die("--package " + package + ": not a file"); - const std::string pkg_sha = file_sha256_hex(pkg); + pkg_sha = file_sha256_hex(pkg); if (pkg_sha.empty()) die("--package " + package + ": unreadable"); std::printf("package: %s sha256 %s\n", package.c_str(), pkg_sha.c_str()); } @@ -383,6 +385,7 @@ int main(int argc, char** argv) { LinkArgs la; la.rom = rom; la.package = package; + la.package_sha256 = pkg_sha; la.title_dir = title_dir; la.out = out; la.gl = gl; @@ -457,13 +460,17 @@ int main(int argc, char** argv) { } if (load_state) { - std::ifstream in(*load_state, std::ios::binary); - const std::vector bytes((std::istreambuf_iterator(in)), {}); - if (!in && bytes.empty()) die(load_state->string() + ": unreadable"); - if (!core.api->unserialize || - core.api->unserialize(bytes.data(), bytes.size()) != RCORE_OK) { - die("unserialize " + load_state->string() + " failed"); + // An envelope is checked by the load rule; a bare state (what n64lle's + // gates write) goes to the core as it always did. + StateKeeper keeper(core, session, rom, pkg_sha, bindings); + std::uint64_t bytes = 0; + std::string why; + if (!keeper.load(*load_state, true, &bytes, &why)) { + die("--load-state " + load_state->string() + ": " + why); } + std::printf("state: loaded %s (%s, %llu bytes)\n", load_state->string().c_str(), + retro::state::is_envelope(*load_state) ? "envelope" : "bare", + static_cast(bytes)); } // ---- run -------------------------------------------------------------- diff --git a/runner/sha256.cpp b/runner/sha256.cpp index 88673a0..daf05fb 100644 --- a/runner/sha256.cpp +++ b/runner/sha256.cpp @@ -118,8 +118,24 @@ void sha256_final(Sha256Ctx& ctx, uint8_t digest[32]) { digest[i] = uint8_t((ctx.state[i >> 2] >> ((3 - (i & 3)) * 8)) & 0xff); } +std::string digest_hex(const uint8_t digest[32]) { + std::ostringstream oss; + for (int i = 0; i < 32; ++i) + oss << std::hex << std::nouppercase << std::setw(2) << std::setfill('0') << int(digest[i]); + return oss.str(); +} + } // namespace +std::string sha256_hex(const void* data, std::size_t size) { + Sha256Ctx ctx; + sha256_init(ctx); + if (size) sha256_update(ctx, static_cast(data), size); + uint8_t digest[32]; + sha256_final(ctx, digest); + return digest_hex(digest); +} + std::string file_sha256_hex(const std::filesystem::path& path) { std::ifstream in(path, std::ios::binary); if (!in) return {}; @@ -133,10 +149,7 @@ std::string file_sha256_hex(const std::filesystem::path& path) { } uint8_t digest[32]; sha256_final(ctx, digest); - std::ostringstream oss; - for (uint8_t b : digest) - oss << std::hex << std::nouppercase << std::setw(2) << std::setfill('0') << int(b); - return oss.str(); + return digest_hex(digest); } } // namespace retro::runner diff --git a/runner/sha256.hpp b/runner/sha256.hpp index 838422e..7f91c0e 100644 --- a/runner/sha256.hpp +++ b/runner/sha256.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -8,4 +9,7 @@ namespace retro::runner { // Lowercase hex SHA-256 of a file's bytes; empty on any I/O error. std::string file_sha256_hex(const std::filesystem::path& path); +// Lowercase hex SHA-256 of `size` bytes in memory. +std::string sha256_hex(const void* data, std::size_t size); + } // namespace retro::runner diff --git a/runner/state_keeper.cpp b/runner/state_keeper.cpp new file mode 100644 index 0000000..fa497c6 --- /dev/null +++ b/runner/state_keeper.cpp @@ -0,0 +1,143 @@ +#include "state_keeper.hpp" + +#include "sha256.hpp" +#include "transport.hpp" + +#include +#include +#include + +namespace retro::runner { + +StateKeeper::StateKeeper(const LoadedCore& core, const HostSession& session, std::string rom, + std::string package_sha256, + const std::vector& bindings) + : core_(core), session_(session), rom_(std::move(rom)), package_sha_(std::move(package_sha256)) { + for (const rcore_accessory_binding& b : bindings) { + accessories_.push_back({b.seat, b.slot, b.type_id ? b.type_id : "", + b.content_path ? b.content_path : ""}); + } +} + +StateKeeper::~StateKeeper() { + if (hashing_.valid()) hashing_.wait(); +} + +StateKeeper::ContentHashes StateKeeper::hash_content() const { + ContentHashes h; + h.content = file_sha256_hex(corelink::utf8_path(rom_)); + for (const Accessory& a : accessories_) { + h.accessories.push_back(a.content_path.empty() + ? std::string() + : file_sha256_hex(corelink::utf8_path(a.content_path))); + } + return h; +} + +void StateKeeper::hash_in_background() { + if (have_identity_ || hashing_.valid()) return; + hashing_ = std::async(std::launch::async, [this] { return hash_content(); }); +} + +const state::StateIdentity& StateKeeper::identity() { + if (have_identity_) return identity_; + const ContentHashes hashes = hashing_.valid() ? hashing_.get() : hash_content(); + const rcore_core_info& info = *core_.info; + state::StateIdentity& id = identity_; + id.abi_major = info.abi_major; + id.core_id = info.core_id ? info.core_id : ""; + id.core_sha256 = core_.sha256; + if (RCORE_HAS(&info, rcore_core_info, state_compat_id) && info.state_compat_id) { + id.state_compat_id = info.state_compat_id; + } + id.package_sha256 = package_sha_; + id.content_sha256 = hashes.content; + for (std::size_t i = 0; i < accessories_.size(); ++i) { + state::AccessoryIdentity ai; + ai.seat = accessories_[i].seat; + ai.slot = accessories_[i].slot; + ai.type_id = accessories_[i].type_id; + ai.content_sha256 = hashes.accessories[i]; + id.accessories.push_back(ai); + } + // Only the options that change what the machine computes: the ones the + // core flagged NETPLAY. A renderer choice does not invalidate a state. + std::uint32_t n = 0; + const rcore_option* opts = core_.api->options ? core_.api->options(&n) : nullptr; + for (std::uint32_t i = 0; opts && i < n; ++i) { + if (!(opts[i].flags & RCORE_OPT_FLAG_NETPLAY) || !opts[i].key) continue; + const auto it = session_.options().find(opts[i].key); + id.sim_options[opts[i].key] = it == session_.options().end() ? std::nullopt : it->second; + } + have_identity_ = true; + return identity_; +} + +bool StateKeeper::save(const fs::path& path, std::uint64_t frame_number, + const std::vector& thumb, std::uint64_t* bytes, + std::string* detail) { + const rcore_core_api& api = *core_.api; + if (!(core_.info->capabilities & RCORE_CAP_SAVESTATE) || !api.serialize_size || !api.serialize) { + *detail = "the core does not declare savestate"; + return false; + } + const std::uint64_t size = api.serialize_size(); + if (!size) { + *detail = "the core could not size its state (serialize_size() is 0; its log says why)"; + return false; + } + std::vector buf(static_cast(size)); + if (const rcore_result rc = api.serialize(buf.data(), size); rc != RCORE_OK) { + *detail = "serialize() -> " + std::to_string(rc) + " (the core's log says why)"; + return false; + } + state::StateHeader h; + h.identity = identity(); + h.frame_number = frame_number; + h.saved_unix = static_cast(std::time(nullptr)); + if (!state::write_state(path, h, thumb, buf.data(), size, detail)) return false; + *bytes = size; + return true; +} + +bool StateKeeper::load(const fs::path& path, bool allow_bare, std::uint64_t* bytes, + std::string* detail) { + const rcore_core_api& api = *core_.api; + if (!(core_.info->capabilities & RCORE_CAP_SAVESTATE) || !api.unserialize) { + *detail = "the core does not declare savestate"; + return false; + } + std::vector buf; + if (!state::is_envelope(path)) { + if (!allow_bare) { + *detail = path.string() + ": not a savestate envelope"; + return false; + } + std::ifstream in(path, std::ios::binary); + if (!in) { + *detail = path.string() + ": cannot open"; + return false; + } + buf.assign(std::istreambuf_iterator(in), {}); + } else { + state::StateHeader h; + if (!state::read_state(path, h, buf, detail)) return false; + if (std::string why = state::check_state(h, identity(), buf); !why.empty()) { + *detail = "refused: " + why; + return false; + } + } + if (buf.empty()) { + *detail = path.string() + ": empty"; + return false; + } + if (const rcore_result rc = api.unserialize(buf.data(), buf.size()); rc != RCORE_OK) { + *detail = "the core refused the state: unserialize() -> " + std::to_string(rc) + + " (the core's log says why)"; + return false; + } + *bytes = buf.size(); + return true; +} + +} // namespace retro::runner diff --git a/runner/state_keeper.hpp b/runner/state_keeper.hpp new file mode 100644 index 0000000..e307ac3 --- /dev/null +++ b/runner/state_keeper.hpp @@ -0,0 +1,68 @@ +#pragma once + +// Player savestates, as the runner keeps them: the core's bytes inside an +// envelope recording everything they are valid against, written and checked +// here because the runner holds every identity the load rule compares +// (docs/CORE_ABI.md, "Savestates"; state/state_envelope.hpp). + +#include "core_library.hpp" +#include "host_session.hpp" +#include "state_envelope.hpp" + +#include +#include +#include +#include + +namespace retro::runner { + +class StateKeeper { +public: + // `rom` and each binding's content are hashed the first time an identity + // is needed, unless hash_in_background() started it sooner. + StateKeeper(const LoadedCore& core, const HostSession& session, std::string rom, + std::string package_sha256, const std::vector& bindings); + ~StateKeeper(); + + // Hash the content now, on another thread, so the player's first save does + // not stall on it (a CD image is hundreds of megabytes). It only reads + // files: the core is never called from that thread. + void hash_in_background(); + + // The identity this session's states carry and are checked against. + const state::StateIdentity& identity(); + + // Serializes now -- the caller is at a frame boundary -- and writes the + // envelope at `path`. `thumb` is kThumbWidth x kThumbHeight RGBA8, or empty. + bool save(const fs::path& path, std::uint64_t frame_number, const std::vector& thumb, + std::uint64_t* bytes, std::string* detail); + + // Checks the envelope at `path` by the load rule, then unserializes. A + // refusal leaves the machine untouched and names the first mismatch. + // `allow_bare`: a file without an envelope goes to the core as it is -- + // the bare states n64lle's gates and headless --load-state have always + // used. Never over the hub's menu, whose slots are all envelopes. + bool load(const fs::path& path, bool allow_bare, std::uint64_t* bytes, std::string* detail); + +private: + struct Accessory { + std::uint32_t seat, slot; + std::string type_id, content_path; + }; + + const LoadedCore& core_; + const HostSession& session_; + std::string rom_, package_sha_; + std::vector accessories_; + struct ContentHashes { + std::string content; + std::vector accessories; // one per accessories_ entry + }; + ContentHashes hash_content() const; + + bool have_identity_ = false; + state::StateIdentity identity_; + std::future hashing_; +}; + +} // namespace retro::runner diff --git a/state/state_envelope.cpp b/state/state_envelope.cpp new file mode 100644 index 0000000..a6dbafb --- /dev/null +++ b/state/state_envelope.cpp @@ -0,0 +1,358 @@ +#include "state_envelope.hpp" + +#include "sha256.hpp" + +#include +#include +#include + +// The header's lines, one `key=value` each, values escaped (\\ and \n): +// +// abi_major=0 +// core_id=n64lle +// core_sha256= +// state_compat_id= absent when the core declares none +// package_sha256= absent without a game package +// content_sha256= +// accessory=,,, one per binding +// option== one per NETPLAY option that is set +// option_unset= one per NETPLAY option that is not +// state_size= +// state_sha256= +// frame= +// saved_unix= +// thumb=x absent without a thumbnail + +namespace retro::state { + +namespace { + +std::string escape(const std::string& s) { + std::string o; + for (char c : s) { + if (c == '\\') o += "\\\\"; + else if (c == '\n') o += "\\n"; + else o += c; + } + return o; +} + +std::string unescape(const std::string& s) { + std::string o; + for (std::size_t i = 0; i < s.size(); ++i) { + if (s[i] == '\\' && i + 1 < s.size()) { + o += s[i + 1] == 'n' ? '\n' : s[i + 1]; + ++i; + } else { + o += s[i]; + } + } + return o; +} + +void put_u32(std::string& out, std::uint32_t v) { + for (int i = 0; i < 4; ++i) out += static_cast((v >> (8 * i)) & 0xff); +} +void put_u64(std::string& out, std::uint64_t v) { + for (int i = 0; i < 8; ++i) out += static_cast((v >> (8 * i)) & 0xff); +} +bool get_u32(std::istream& in, std::uint32_t& v) { + unsigned char b[4]; + if (!in.read(reinterpret_cast(b), 4)) return false; + v = std::uint32_t(b[0]) | std::uint32_t(b[1]) << 8 | std::uint32_t(b[2]) << 16 | + std::uint32_t(b[3]) << 24; + return true; +} +bool get_u64(std::istream& in, std::uint64_t& v) { + unsigned char b[8]; + if (!in.read(reinterpret_cast(b), 8)) return false; + v = 0; + for (int i = 7; i >= 0; --i) v = v << 8 | b[i]; + return true; +} + +std::string header_text(const StateHeader& h) { + const StateIdentity& id = h.identity; + std::ostringstream o; + o << "abi_major=" << id.abi_major << '\n'; + o << "core_id=" << escape(id.core_id) << '\n'; + o << "core_sha256=" << id.core_sha256 << '\n'; + if (id.state_compat_id) o << "state_compat_id=" << escape(*id.state_compat_id) << '\n'; + if (!id.package_sha256.empty()) o << "package_sha256=" << id.package_sha256 << '\n'; + o << "content_sha256=" << id.content_sha256 << '\n'; + for (const AccessoryIdentity& a : id.accessories) { + o << "accessory=" << a.seat << ',' << a.slot << ',' << escape(a.type_id) << ',' + << a.content_sha256 << '\n'; + } + for (const auto& [k, v] : id.sim_options) { + if (v) o << "option=" << escape(k) << '=' << escape(*v) << '\n'; + else o << "option_unset=" << escape(k) << '\n'; + } + o << "state_size=" << h.state_size << '\n'; + o << "state_sha256=" << h.state_sha256 << '\n'; + o << "frame=" << h.frame_number << '\n'; + o << "saved_unix=" << h.saved_unix << '\n'; + if (h.thumb_w && h.thumb_h) o << "thumb=" << h.thumb_w << 'x' << h.thumb_h << '\n'; + return o.str(); +} + +std::uint64_t to_u64(const std::string& s) { return std::strtoull(s.c_str(), nullptr, 10); } + +void parse_header_text(const std::string& text, StateHeader& h) { + StateIdentity& id = h.identity; + std::istringstream in(text); + std::string line; + while (std::getline(in, line)) { + const auto eq = line.find('='); + if (eq == std::string::npos) continue; + const std::string k = line.substr(0, eq); + const std::string v = line.substr(eq + 1); + if (k == "abi_major") id.abi_major = static_cast(to_u64(v)); + else if (k == "core_id") id.core_id = unescape(v); + else if (k == "core_sha256") id.core_sha256 = v; + else if (k == "state_compat_id") id.state_compat_id = unescape(v); + else if (k == "package_sha256") id.package_sha256 = v; + else if (k == "content_sha256") id.content_sha256 = v; + else if (k == "accessory") { + // seat,slot,type,content: the type id may not contain a comma + // (it is a dotted identifier), the content is hex. + AccessoryIdentity a; + std::size_t p1 = v.find(','), p2 = v.find(',', p1 + 1), p3 = v.rfind(','); + if (p1 == std::string::npos || p2 == std::string::npos || p3 <= p2) continue; + a.seat = static_cast(to_u64(v.substr(0, p1))); + a.slot = static_cast(to_u64(v.substr(p1 + 1, p2 - p1 - 1))); + a.type_id = unescape(v.substr(p2 + 1, p3 - p2 - 1)); + a.content_sha256 = v.substr(p3 + 1); + id.accessories.push_back(a); + } else if (k == "option") { + // key=value inside the value: option keys are dotted identifiers. + const auto eq2 = v.find('='); + if (eq2 == std::string::npos) continue; + id.sim_options[unescape(v.substr(0, eq2))] = unescape(v.substr(eq2 + 1)); + } else if (k == "option_unset") { + id.sim_options[unescape(v)] = std::nullopt; + } else if (k == "state_size") h.state_size = to_u64(v); + else if (k == "state_sha256") h.state_sha256 = v; + else if (k == "frame") h.frame_number = to_u64(v); + else if (k == "saved_unix") h.saved_unix = std::strtoll(v.c_str(), nullptr, 10); + else if (k == "thumb") { + const auto x = v.find('x'); + if (x == std::string::npos) continue; + h.thumb_w = static_cast(to_u64(v.substr(0, x))); + h.thumb_h = static_cast(to_u64(v.substr(x + 1))); + } + // Anything else is a later version's field: kept out, not refused. + } +} + +// Opens the file and reads through the thumbnail. On return `in` is +// positioned at the state's length field. +bool read_through_thumb(std::ifstream& in, const fs::path& path, StateHeader& out, + std::vector* thumb, std::string* error) { + auto fail = [&](const std::string& m) { + if (error) *error = path.string() + ": " + m; + return false; + }; + in.open(path, std::ios::binary); + if (!in) return fail("cannot open"); + char magic[8]; + if (!in.read(magic, 8) || std::memcmp(magic, kEnvelopeMagic, 8) != 0) { + return fail("not a savestate envelope"); + } + std::uint32_t version = 0, header_len = 0; + if (!get_u32(in, version) || !get_u32(in, header_len)) return fail("truncated"); + if (version == 0 || version > kEnvelopeVersion) { + return fail("envelope version " + std::to_string(version) + ", this build reads up to " + + std::to_string(kEnvelopeVersion)); + } + if (header_len > (1u << 20)) return fail("header too large"); + std::string text(header_len, '\0'); + if (header_len && !in.read(text.data(), header_len)) return fail("truncated header"); + out = StateHeader{}; + out.version = version; + parse_header_text(text, out); + std::uint64_t thumb_len = 0; + if (!get_u64(in, thumb_len)) return fail("truncated"); + const std::uint64_t want = std::uint64_t(out.thumb_w) * out.thumb_h * 4; + if (thumb_len != want) return fail("thumbnail size disagrees with its header"); + if (thumb) { + thumb->resize(thumb_len); + if (thumb_len && !in.read(reinterpret_cast(thumb->data()), std::streamsize(thumb_len))) { + return fail("truncated thumbnail"); + } + } else { + in.seekg(std::streamoff(thumb_len), std::ios::cur); + } + return true; +} + +} // namespace + +std::vector make_thumbnail(const std::uint8_t* rgba, std::uint32_t width, + std::uint32_t height, std::uint32_t stride) { + std::vector out; + if (!rgba || !width || !height) return out; + out.resize(std::size_t(kThumbWidth) * kThumbHeight * 4); + for (std::uint32_t y = 0; y < kThumbHeight; ++y) { + const std::uint8_t* row = rgba + std::size_t(y * height / kThumbHeight) * stride; + std::uint8_t* dst = out.data() + std::size_t(y) * kThumbWidth * 4; + for (std::uint32_t x = 0; x < kThumbWidth; ++x) { + const std::uint8_t* px = row + std::size_t(x * width / kThumbWidth) * 4; + dst[x * 4 + 0] = px[0]; + dst[x * 4 + 1] = px[1]; + dst[x * 4 + 2] = px[2]; + dst[x * 4 + 3] = 0xff; + } + } + return out; +} + +bool write_state(const fs::path& path, StateHeader header, const std::vector& thumb, + const void* state, std::uint64_t size, std::string* error) { + auto fail = [&](const std::string& m) { + if (error) *error = path.string() + ": " + m; + return false; + }; + const bool has_thumb = thumb.size() == std::size_t(kThumbWidth) * kThumbHeight * 4; + header.version = kEnvelopeVersion; + header.state_size = size; + header.state_sha256 = runner::sha256_hex(state, static_cast(size)); + header.thumb_w = has_thumb ? kThumbWidth : 0; + header.thumb_h = has_thumb ? kThumbHeight : 0; + + std::string head(kEnvelopeMagic, 8); + put_u32(head, kEnvelopeVersion); + const std::string text = header_text(header); + put_u32(head, static_cast(text.size())); + head += text; + put_u64(head, has_thumb ? thumb.size() : 0); + + std::error_code ec; + if (path.has_parent_path()) fs::create_directories(path.parent_path(), ec); + const fs::path tmp = path.string() + ".tmp"; + { + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + if (!out) return fail("cannot write"); + out.write(head.data(), std::streamsize(head.size())); + if (has_thumb) out.write(reinterpret_cast(thumb.data()), std::streamsize(thumb.size())); + std::string len; + put_u64(len, size); + out.write(len.data(), 8); + out.write(static_cast(state), std::streamsize(size)); + if (!out) { + out.close(); + fs::remove(tmp, ec); + return fail("write failed"); + } + } + fs::rename(tmp, path, ec); + if (ec) { + fs::remove(tmp, ec); + return fail("cannot replace: " + ec.message()); + } + return true; +} + +bool is_envelope(const fs::path& path) { + std::ifstream in(path, std::ios::binary); + char magic[8]; + return in.read(magic, 8) && std::memcmp(magic, kEnvelopeMagic, 8) == 0; +} + +bool read_state_header(const fs::path& path, StateHeader& out, std::vector* thumb, + std::string* error) { + std::ifstream in; + return read_through_thumb(in, path, out, thumb, error); +} + +bool read_state(const fs::path& path, StateHeader& out, std::vector& state, + std::string* error) { + std::ifstream in; + if (!read_through_thumb(in, path, out, nullptr, error)) return false; + std::uint64_t len = 0; + if (!get_u64(in, len)) { + if (error) *error = path.string() + ": truncated"; + return false; + } + if (len != out.state_size) { + if (error) { + *error = path.string() + ": the state is " + std::to_string(len) + + " bytes, its header says " + std::to_string(out.state_size); + } + return false; + } + state.resize(static_cast(len)); + if (len && !in.read(reinterpret_cast(state.data()), std::streamsize(len))) { + if (error) *error = path.string() + ": truncated state"; + return false; + } + return true; +} + +std::string check_state(const StateHeader& saved, const StateIdentity& running, + const std::vector& state) { + const StateIdentity& s = saved.identity; + auto differs = [](const std::string& what, const std::string& in_state, + const std::string& here, const char* verb = "differs") { + return what + " " + verb + ": the state has " + (in_state.empty() ? "(none)" : in_state) + + ", this session has " + (here.empty() ? "(none)" : here); + }; + // 1. ABI major, core id. + if (s.abi_major != running.abi_major) { + return differs("rcore ABI major", std::to_string(s.abi_major), + std::to_string(running.abi_major)); + } + if (s.core_id != running.core_id) return differs("core id", s.core_id, running.core_id); + // 2. Build identity: the core's promise when it made one, else its file. + if (running.state_compat_id || s.state_compat_id) { + if (s.state_compat_id != running.state_compat_id) { + return differs("state_compat_id", s.state_compat_id.value_or(""), + running.state_compat_id.value_or("")); + } + } else if (s.core_sha256 != running.core_sha256) { + return differs("core build (SHA-256; this core binds states to its exact file)", + s.core_sha256, running.core_sha256); + } + // 3. Game package, content. + if (s.package_sha256 != running.package_sha256) { + return differs("game package SHA-256", s.package_sha256, running.package_sha256); + } + if (s.content_sha256 != running.content_sha256) { + return differs("content SHA-256", s.content_sha256, running.content_sha256); + } + // 4. Accessories, then simulation options. + auto describe = [](const std::vector& v) { + std::string o; + for (const AccessoryIdentity& a : v) { + if (!o.empty()) o += "; "; + o += "seat " + std::to_string(a.seat) + " slot " + std::to_string(a.slot) + " " + + a.type_id + (a.content_sha256.empty() ? "" : " " + a.content_sha256); + } + return o; + }; + if (!(s.accessories == running.accessories)) { + return differs("accessories", describe(s.accessories), describe(running.accessories), + "differ"); + } + for (const auto& [k, v] : running.sim_options) { + const auto it = s.sim_options.find(k); + const std::string here = v ? *v : "(unset)"; + const std::string there = + it == s.sim_options.end() ? "(not recorded)" : it->second ? *it->second : "(unset)"; + if (it == s.sim_options.end() || it->second != v) return differs("option " + k, there, here); + } + for (const auto& [k, v] : s.sim_options) { + if (!running.sim_options.count(k)) { + return differs("option " + k, v ? *v : "(unset)", "(not declared)"); + } + } + // 5. The core's bytes, against the hash taken when they were written. + if (state.size() != saved.state_size || + runner::sha256_hex(state.data(), state.size()) != saved.state_sha256) { + return "the state's bytes do not match the SHA-256 recorded when it was saved " + "(the file is corrupt)"; + } + return {}; +} + +} // namespace retro::state diff --git a/state/state_envelope.hpp b/state/state_envelope.hpp new file mode 100644 index 0000000..d83d283 --- /dev/null +++ b/state/state_envelope.hpp @@ -0,0 +1,103 @@ +#pragma once + +// The savestate envelope (docs/CORE_ABI.md, "Savestates"): the core's own +// bytes, wrapped in a record of everything they are valid against. The +// envelope is a host format, not ABI -- a core never sees it. +// +// The runner writes and checks envelopes (it holds every identity the load +// rule names); a host only reads their headers, to list slots with a picture, +// a time and a hint of whether they will load. +// +// File layout, little-endian: +// +// magic 8 bytes "RCSTATE\0" +// version u32 kEnvelopeVersion +// header u32 length, then UTF-8 `key=value` lines (see state_envelope.cpp) +// thumbnail u64 length, then RGBA8 bytes (thumb_w * thumb_h * 4), or 0 +// state u64 length, then the core's bytes +// +// A text header so a later version can add a field without a reader of this +// one refusing it: unknown keys are kept and ignored. + +#include +#include +#include +#include +#include +#include + +namespace retro::state { + +namespace fs = std::filesystem; + +constexpr char kEnvelopeMagic[8] = {'R', 'C', 'S', 'T', 'A', 'T', 'E', '\0'}; +constexpr std::uint32_t kEnvelopeVersion = 1; +// Thumbnails are stored at this size, whatever the picture was. +constexpr std::uint32_t kThumbWidth = 160; +constexpr std::uint32_t kThumbHeight = 120; + +struct AccessoryIdentity { + std::uint32_t seat = 0, slot = 0; + std::string type_id; + std::string content_sha256; // empty = the accessory takes no content + bool operator==(const AccessoryIdentity& o) const { + return seat == o.seat && slot == o.slot && type_id == o.type_id && + content_sha256 == o.content_sha256; + } +}; + +// Everything the load rule compares, in its order. +struct StateIdentity { + std::uint32_t abi_major = 0; + std::string core_id; + std::string core_sha256; // the host's hash of the loaded library + std::optional state_compat_id; // the core's promise, if it made one + std::string package_sha256; // CAP_GAME_PACKAGE; empty otherwise + std::string content_sha256; + std::vector accessories; // every binding + // Every RCORE_OPT_FLAG_NETPLAY option, resolved; nullopt = unset. + std::map> sim_options; +}; + +struct StateHeader { + std::uint32_t version = kEnvelopeVersion; + StateIdentity identity; + std::uint64_t state_size = 0; + std::string state_sha256; + // For display only. + std::uint64_t frame_number = 0; + std::int64_t saved_unix = 0; // seconds + std::uint32_t thumb_w = 0, thumb_h = 0; +}; + +// A picture shrunk to the thumbnail size (nearest neighbour), RGBA8, opaque. +std::vector make_thumbnail(const std::uint8_t* rgba, std::uint32_t width, + std::uint32_t height, std::uint32_t stride); + +// Writes beside, then renames, so a crash never leaves half a state where a +// good one was. `thumb` is kThumbWidth x kThumbHeight RGBA8, or empty. +// header.state_size / state_sha256 / thumb_* are filled in here. +bool write_state(const fs::path& path, StateHeader header, const std::vector& thumb, + const void* state, std::uint64_t size, std::string* error); + +// True when the file begins with the envelope magic. A file that does not is +// a bare core state (what n64lle's gates and `--load-state` have always used). +bool is_envelope(const fs::path& path); + +// The header, and the thumbnail when `thumb` is given -- never the state +// bytes, so listing twelve slots of an 8 MB machine reads a few KB each. +bool read_state_header(const fs::path& path, StateHeader& out, std::vector* thumb, + std::string* error); + +// The whole envelope. +bool read_state(const fs::path& path, StateHeader& out, std::vector& state, + std::string* error); + +// The load rule (docs/CORE_ABI.md): refuse, never attempt. Checks in the +// rule's order and stops at the first mismatch, naming both values. Returns +// an empty string when the state may be loaded. `state` is the core's bytes +// as read; their hash is the last check (a corrupt file). +std::string check_state(const StateHeader& saved, const StateIdentity& running, + const std::vector& state); + +} // namespace retro::state diff --git a/tests/overlay_test.cpp b/tests/overlay_test.cpp new file mode 100644 index 0000000..9db0618 --- /dev/null +++ b/tests/overlay_test.cpp @@ -0,0 +1,418 @@ +// retro-overlay-test -- the overlay (overlay/overlay.hpp) and the savestate +// envelope (state/state_envelope.hpp), without a window or a core. +// +// retro-overlay-test run the checks +// retro-overlay-test --dump DIR also write each layer, drawn +// over a grey picture, as PPM +// files to look at + +#include "overlay.hpp" +#include "state_envelope.hpp" + +#include +#include +#include +#include + +using namespace retro; +using namespace retro::overlay; + +namespace { + +int g_failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++g_failures; \ + } \ + } while (0) + +constexpr std::uint64_t kMs = 1000000ull; + +const Layer* find(const std::vector& v, std::uint32_t id) { + for (const Layer& l : v) + if (l.id == id) return &l; + return nullptr; +} + +// Alpha of the pixel at (x, y). +unsigned alpha_at(const Image& img, std::uint32_t x, std::uint32_t y) { + return img.rgba[(std::size_t(y) * img.width + x) * 4 + 3]; +} + +void test_osd() { + Osd osd; + std::uint64_t t = 1000 * kMs; + CHECK(osd.layers(t).empty()); + + // FPS: 60 Hz frames read 60, whether or not the readout is showing. + osd.set_fps_visible(true); + for (int i = 0; i < 100; ++i) osd.note_frame(t += 16666667ull); + CHECK(osd.fps() > 59.9 && osd.fps() < 60.1); + const Layer* st = find(osd.layers(t), kLayerStatus); + CHECK(st && st->anchor == Anchor::TopLeft && st->image->width > 0); + // "60 FPS": six glyphs at 2x plus padding. + CHECK(st && st->image->width == 2 * 4 + 6 * 16); + const std::uint64_t rev = st ? st->revision : 0; + osd.note_frame(t += 16666667ull); + st = find(osd.layers(t), kLayerStatus); + CHECK(st && st->revision == rev); // same text, no redraw + + // Uneven arrival -- 15 ms, 25 ms, ... -- is still about 50 frames a second + // (63 intervals in the window, so 49.8 or 50.2); a mean of 1/dt says 53.3. + for (int i = 0; i < 64; ++i) osd.note_frame(t += (i & 1) ? 25 * kMs : 15 * kMs); + CHECK(osd.fps() > 49.5 && osd.fps() < 50.5); + // Under turbo frames come faster than presents: the readout follows them, + // from the moment turbo starts. + osd.set_turbo(true); + for (int i = 0; i < 10; ++i) osd.note_frame(t += 4166667ull); + CHECK(osd.fps() > 239.0 && osd.fps() < 241.0); + osd.set_turbo(false); + CHECK(osd.fps() == 0.0); + osd.note_frame(t += 20 * kMs); + osd.note_frame(t += 20 * kMs); + CHECK(osd.fps() > 49.9 && osd.fps() < 50.1); + // A pause is a break, not a slow frame. + osd.note_frame(t += 2000 * kMs); + CHECK(osd.fps() == 0.0); + osd.note_frame(t += 20 * kMs); + CHECK(osd.fps() > 49.9 && osd.fps() < 50.1); + + // TURBO sits alone in the top right. + osd.set_turbo(true); + const Layer* tb = find(osd.layers(t), kLayerTurbo); + CHECK(tb && tb->anchor == Anchor::TopRight); + osd.set_turbo(false); + CHECK(!find(osd.layers(t), kLayerTurbo)); + + // Volume: shown for 1.5 s, clamped. + osd.show_volume(140, t); + const Layer* vol = find(osd.layers(t), kLayerVolume); + CHECK(vol && vol->anchor == Anchor::RightMiddle); + const std::uint64_t vrev = vol ? vol->revision : 0; + osd.show_volume(90, t + 100 * kMs); + vol = find(osd.layers(t + 100 * kMs), kLayerVolume); + CHECK(vol && vol->revision != vrev); + CHECK(find(osd.layers(t + 1500 * kMs), kLayerVolume)); + CHECK(!find(osd.layers(t + 1601 * kMs), kLayerVolume)); + + // A toast adds a row under the FPS line, then goes. + const std::uint32_t one_row = find(osd.layers(t), kLayerStatus)->image->height; + osd.toast("Slot 3 saved", t, 2000); + const Layer* two = find(osd.layers(t), kLayerStatus); + CHECK(two && two->image->height > one_row); + CHECK(find(osd.layers(t + 2001 * kMs), kLayerStatus)->image->height == one_row); + osd.set_fps_visible(false); + CHECK(!find(osd.layers(t + 2001 * kMs), kLayerStatus)); + osd.toast("Slot 1 loaded", t, 500); + CHECK(find(osd.layers(t), kLayerStatus)); // a toast shows without the FPS line +} + +void test_place() { + Image small; + small.resize(100, 20, 0); + Layer l{kLayerStatus, &small, 1, Anchor::TopLeft}; + Rect r = place(l, 1920, 1080); + CHECK(r.x == 8 && r.y == 8 && r.w == 100 && r.h == 20); + r = place(l, 3840, 2160); // 4K: 2x, inset 16 + CHECK(r.x == 16 && r.y == 16 && r.w == 200 && r.h == 40); + l.anchor = Anchor::TopRight; + r = place(l, 1920, 1080); + CHECK(r.x == 1920 - 100 - 8 && r.y == 8); + l.anchor = Anchor::RightMiddle; + r = place(l, 1920, 1080); + CHECK(r.x == 1920 - 100 - 8 && r.y == (1080 - 20) / 2); + + Image panel; + panel.resize(640, 480, 0); + Layer p{kLayerSavestates, &panel, 1, Anchor::Center}; + r = place(p, 1920, 1080); // 2x fits 90% of 1080 + CHECK(r.w == 1280 && r.h == 960 && r.x == 320 && r.y == 60); + r = place(p, 640, 480); // smaller than 90%: shrinks + CHECK(r.w < 640 && r.h < 480); +} + +void write_slot(const fs::path& path, const std::string& core_id, const std::string& sha, + std::uint64_t frame) { + state::StateHeader h; + h.identity.core_id = core_id; + h.identity.core_sha256 = sha; + h.frame_number = frame; + h.saved_unix = 1790000000; + std::vector px(64 * 48 * 4, 0x80); + for (std::size_t i = 0; i < 64 * 48; ++i) px[i * 4] = static_cast(i % 64 * 4); + const auto thumb = state::make_thumbnail(px.data(), 64, 48, 64 * 4); + const char bytes[] = "state"; + std::string err; + CHECK(state::write_state(path, h, thumb, bytes, sizeof bytes, &err)); +} + +void test_menu(const fs::path& dir) { + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + SavestateMenu m; + m.configure(dir, "fake", "aaaa"); + CHECK(m.slot_path(0) == dir / "slot01.rstate"); + CHECK(m.slot_path(11) == dir / "slot12.rstate"); + + // The chord opens on its edge only, from either order. + CHECK(!m.poll_pad(RCORE_PAD_SELECT, 0, 0)); + CHECK(m.poll_pad(RCORE_PAD_SELECT | RCORE_PAD_R1, 0, 10)); + CHECK(m.is_open() && m.layer()); + CHECK(!m.poll_pad(RCORE_PAD_SELECT | RCORE_PAD_R1, 0, 20)); // held: nothing + m.poll_pad(0, 0, 30); + + // Loading an empty slot asks nothing. + m.poll_pad(RCORE_PAD_SOUTH, 0, 40); + CHECK(m.take_request().kind == SavestateMenu::Request::None); + m.poll_pad(0, 0, 50); + + // Save: one request, then nothing until finish(). + m.poll_pad(RCORE_PAD_NORTH, 0, 60); + SavestateMenu::Request r = m.take_request(); + CHECK(r.kind == SavestateMenu::Request::Save && r.slot == 0 && r.path == m.slot_path(0)); + CHECK(m.pending()); + m.poll_pad(0, 0, 70); + m.poll_pad(RCORE_PAD_NORTH, 0, 80); + CHECK(m.take_request().kind == SavestateMenu::Request::None); + write_slot(r.path, "fake", "aaaa", 1234); // what the runner does + m.finish(true, ""); + CHECK(m.is_open() && !m.pending()); // a save stays open + CHECK(m.take_toast() == "Slot 1 saved"); + + // Down with repeat: one step at once, then after 350 ms every 90 ms. + m.poll_pad(0, 0, 100); + m.poll_pad(RCORE_PAD_DPAD_DOWN, 0, 200); // -> slot 2 + m.poll_pad(RCORE_PAD_DPAD_DOWN, 0, 500); // not yet + m.poll_pad(RCORE_PAD_DPAD_DOWN, 0, 551); // -> slot 3 + m.poll_pad(RCORE_PAD_DPAD_DOWN, 0, 600); // not yet + m.poll_pad(RCORE_PAD_DPAD_DOWN, 0, 641); // -> slot 4 + m.poll_pad(0, -30000, 700); // stick down: new direction? no, same + m.poll_pad(0, 0, 710); + m.poll_pad(RCORE_PAD_NORTH, 0, 720); + r = m.take_request(); + CHECK(r.kind == SavestateMenu::Request::Save && r.slot == 3); + m.finish(false, "the core could not size its state"); + CHECK(m.is_open() && !m.pending()); + + // Keys: jump, load; a refused load stays open, a good one closes. + m.jump(0); + m.key(SavestateMenu::Key::Load); + r = m.take_request(); + CHECK(r.kind == SavestateMenu::Request::Load && r.slot == 0); + m.finish(false, "refused: core build (SHA-256; this core binds states to its exact file) " + "differs: the state has 0123456789abcdef0123456789abcdef0123456789abcdef0123" + "456789abcdef, this session has fedcba9876543210fedcba9876543210fedcba98765432" + "10fedcba9876543210"); + CHECK(m.is_open() && m.layer()); + m.key(SavestateMenu::Key::Load); + r = m.take_request(); + m.finish(true, ""); + CHECK(!m.is_open() && !m.layer()); + CHECK(m.take_toast() == "Slot 1 loaded"); + + // Another build's slot is marked; the runner still decides. + write_slot(m.slot_path(1), "fake", "bbbb", 5); + write_slot(m.slot_path(2), "other", "aaaa", 5); + m.open(); + CHECK(m.layer()); + m.key(SavestateMenu::Key::Back); + CHECK(!m.is_open()); + + // East and Start close too. + m.open(); + m.poll_pad(0, 0, 1000); + m.poll_pad(RCORE_PAD_EAST, 0, 1010); + CHECK(!m.is_open()); +} + +void test_guard() { + InputGuard g; + rcore_pad pads[2]{}; + pads[0].buttons = RCORE_PAD_START | RCORE_PAD_R1; + g.arm(pads, 2); + pads[0].buttons = RCORE_PAD_START | RCORE_PAD_SOUTH; + g.apply(pads, 2); + CHECK(pads[0].buttons == RCORE_PAD_SOUTH); // START still held: kept out + pads[0].buttons = RCORE_PAD_START; // R1 was released in between + g.apply(pads, 2); + CHECK(pads[0].buttons == 0); // START never let go + pads[0].buttons = RCORE_PAD_R1; // R1 pressed again: it is free + g.apply(pads, 2); + CHECK(pads[0].buttons == RCORE_PAD_R1); + pads[0].buttons = 0; + g.apply(pads, 2); + pads[0].buttons = RCORE_PAD_START; + g.apply(pads, 2); + CHECK(pads[0].buttons == RCORE_PAD_START); +} + +void test_envelope(const fs::path& dir) { + state::StateIdentity id; + id.abi_major = 0; + id.core_id = "fake"; + id.core_sha256 = "c0"; + id.package_sha256 = "p0"; + id.content_sha256 = "r0"; + id.accessories.push_back({0, 0, "n64.transfer_pak", "g0"}); + id.sim_options["cpu.overclock"] = "1"; + id.sim_options["region"] = std::nullopt; + id.sim_options["note"] = "a=b\nc\\d"; // escaping survives + state::StateHeader h; + h.identity = id; + h.frame_number = 77; + h.saved_unix = 1790000000; + const std::vector bytes = {1, 2, 3, 4, 5}; + const fs::path p = dir / "env.rstate"; + std::string err; + CHECK(state::write_state(p, h, {}, bytes.data(), bytes.size(), &err)); + CHECK(state::is_envelope(p)); + state::StateHeader back; + std::vector got; + CHECK(state::read_state(p, back, got, &err)); + CHECK(got == bytes && back.frame_number == 77 && back.thumb_w == 0); + CHECK(back.identity.sim_options == id.sim_options); + CHECK(back.identity.accessories.size() == 1 && back.identity.accessories[0] == id.accessories[0]); + CHECK(state::check_state(back, id, got).empty()); + + auto refused = [&](state::StateIdentity running, const std::string& want) { + const std::string why = state::check_state(back, running, got); + if (why.find(want) != 0) { + std::fprintf(stderr, " wanted \"%s...\", got \"%s\"\n", want.c_str(), why.c_str()); + ++g_failures; + } + }; + state::StateIdentity r = id; + r.abi_major = 1; + refused(r, "rcore ABI major differs"); + r = id; + r.core_id = "other"; + refused(r, "core id differs: the state has fake, this session has other"); + r = id; + r.core_sha256 = "c1"; + refused(r, "core build"); + r = id; + r.state_compat_id = "v1"; // the running core promises; the state predates it + refused(r, "state_compat_id differs"); + r = id; + r.package_sha256 = "p1"; + refused(r, "game package SHA-256 differs"); + r = id; + r.content_sha256 = "r1"; + refused(r, "content SHA-256 differs"); + r = id; + r.accessories.clear(); + refused(r, "accessories differ"); + r = id; + r.sim_options["cpu.overclock"] = "0"; + refused(r, "option cpu.overclock differs: the state has 1, this session has 0"); + r = id; + r.sim_options.erase("region"); + refused(r, "option region differs"); + std::vector corrupt = got; + corrupt[2] ^= 1; + CHECK(state::check_state(back, id, corrupt).find("corrupt") != std::string::npos); + + // The compat id, when both carry it, replaces the file hash. + state::StateHeader promised = back; + promised.identity.state_compat_id = "v1"; + r = id; + r.state_compat_id = "v1"; + r.core_sha256 = "a rebuild"; + CHECK(state::check_state(promised, r, got).empty()); + + // A bare state is not an envelope; a truncated one is refused, not trusted. + const fs::path bare = dir / "bare.state"; + std::ofstream(bare, std::ios::binary) << "raw core bytes"; + CHECK(!state::is_envelope(bare)); + CHECK(!state::read_state_header(bare, back, nullptr, &err)); + fs::resize_file(p, fs::file_size(p) - 2); + CHECK(!state::read_state(p, back, got, &err)); +} + +// Each layer over a mid-grey 640x480 "game", written as PPM. +void dump(const fs::path& out) { + fs::create_directories(out); + auto write = [&](const std::string& name, const std::vector& layers, int w, int h) { + std::vector rgb(std::size_t(w) * h * 3); + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) { + const std::uint8_t g = ((x / 32 + y / 32) & 1) ? 0x60 : 0x70; + std::memset(&rgb[(std::size_t(y) * w + x) * 3], g, 3); + } + for (const Layer& l : layers) { + const Rect r = place(l, float(w), float(h)); + for (int y = 0; y < int(r.h); ++y) + for (int x = 0; x < int(r.w); ++x) { + const int dx = int(r.x) + x, dy = int(r.y) + y; + if (dx < 0 || dy < 0 || dx >= w || dy >= h) continue; + const std::uint32_t sx = std::uint32_t(x * l.image->width / r.w); + const std::uint32_t sy = std::uint32_t(y * l.image->height / r.h); + const std::uint8_t* s = &l.image->rgba[(std::size_t(sy) * l.image->width + sx) * 4]; + std::uint8_t* d = &rgb[(std::size_t(dy) * w + dx) * 3]; + for (int c = 0; c < 3; ++c) d[c] = std::uint8_t((s[c] * s[3] + d[c] * (255 - s[3])) / 255); + } + } + std::ofstream f(out / (name + ".ppm"), std::ios::binary); + f << "P6\n" << w << ' ' << h << "\n255\n"; + f.write(reinterpret_cast(rgb.data()), std::streamsize(rgb.size())); + }; + Osd osd; + std::uint64_t t = 1000 * kMs; + osd.set_fps_visible(true); + for (int i = 0; i < 70; ++i) osd.note_frame(t += 16683350ull); + osd.set_turbo(true); + osd.show_volume(70, t); + osd.toast("Slot 3 saved", t); + write("osd", osd.layers(t), 1280, 720); + + SavestateMenu m; + const fs::path dir = out / "slots"; + fs::remove_all(dir); + write_slot(dir / "slot01.rstate", "fake", "aaaa", 1234); + write_slot(dir / "slot02.rstate", "fake", "bbbb", 99); + m.configure(dir, "fake", "aaaa"); + m.set_hint("SELECT+R1 or F7"); + m.open(); + std::vector layers; + layers.push_back(*m.layer()); + write("savestates", layers, 1280, 720); + m.key(SavestateMenu::Key::Load); + m.take_request(); + m.finish(false, "refused: core build (SHA-256; this core binds states to its exact file) " + "differs: the state has 0123456789abcdef0123456789abcdef0123456789abcdef0123" + "456789abcdef, this session has fedcba9876543210fedcba9876543210fedcba98765432" + "10fedcba9876543210"); + layers[0] = *m.layer(); + write("savestates_refused", layers, 1280, 720); +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: retro-overlay-test [--dump DIR]\n"); + return 2; + } + const fs::path scratch = argv[1]; + fs::create_directories(scratch); + test_osd(); + test_place(); + test_menu(scratch / "slots"); + test_guard(); + test_envelope(scratch); + if (argc >= 4 && std::string(argv[2]) == "--dump") dump(argv[3]); + // alpha_at keeps the image layout honest: the OSD box is translucent, + // its text opaque. + { + Osd osd; + osd.set_turbo(true); + const Layer* tb = find(osd.layers(0), kLayerTurbo); + CHECK(tb && alpha_at(*tb->image, 0, 0) == 0xC0); + } + std::printf("overlay-test: %s (%d failure(s))\n", g_failures ? "FAILED" : "ok", g_failures); + return g_failures ? 1 : 0; +} diff --git a/tests/rcore_fake_core.c b/tests/rcore_fake_core.c index d41ec8e..555c891 100644 --- a/tests/rcore_fake_core.c +++ b/tests/rcore_fake_core.c @@ -8,6 +8,12 @@ * frame k writes k & 0xff at offset k % 64. So after N frames the save is * known exactly, whichever host held it. * + * It declares SAVESTATE: its state is 12 bytes, "FAKE" and the frame count + * (little-endian u64). Frame k's picture is k & 0xff everywhere, so after a + * state saved at frame K is loaded, the next picture is K+1 -- a host can see + * a load land without reading anything but pixels. unserialize() refuses + * anything else with RCORE_ERR_CONTENT, touching nothing. + * * FAKE_CORE_CRASH_AT=N (an instrument knob) makes frame N kill the process * outright, with no unload: a crash, as far as the host can tell. The host * link must still write the save as frames 1..N-1 left it. @@ -46,11 +52,11 @@ static rcore_save_region k_regions[1]; #if defined(FAKE_GAME_PACKAGE) # define FAKE_ID "fake_pkg" -# define FAKE_CAPS (RCORE_CAP_RUN_FRAME | RCORE_CAP_GAME_PACKAGE) +# define FAKE_CAPS (RCORE_CAP_RUN_FRAME | RCORE_CAP_SAVESTATE | RCORE_CAP_GAME_PACKAGE) # define FAKE_PACKAGE_MAGIC "rcore fake game package" #else # define FAKE_ID "fake" -# define FAKE_CAPS RCORE_CAP_RUN_FRAME +# define FAKE_CAPS (RCORE_CAP_RUN_FRAME | RCORE_CAP_SAVESTATE) #endif static const rcore_core_info k_info = { @@ -148,6 +154,32 @@ static rcore_result run_frame(void) { return RCORE_OK; } +#define STATE_SIZE 12u + +static uint64_t state_size(void) { return STATE_SIZE; } + +static rcore_result serialize(void* out, uint64_t size) { + unsigned char* p = (unsigned char*)out; + int i; + if (!out || size < STATE_SIZE) return RCORE_ERR_INTERNAL; + memcpy(p, "FAKE", 4); + for (i = 0; i < 8; ++i) p[4 + i] = (unsigned char)(g_frames >> (8 * i)); + return RCORE_OK; +} + +static rcore_result unserialize(const void* in, uint64_t size) { + const unsigned char* p = (const unsigned char*)in; + unsigned long long frames = 0; + int i; + if (!in || size != STATE_SIZE || memcmp(p, "FAKE", 4) != 0) { + g_host->log(g_host->host_ctx, RCORE_LOG_ERROR, "FAKE_STATE refused: not a fake state"); + return RCORE_ERR_CONTENT; + } + for (i = 7; i >= 0; --i) frames = (frames << 8) | p[4 + i]; + g_frames = frames; + return RCORE_OK; +} + static void unload(void) { char line[64]; snprintf(line, sizeof line, "FAKE_DONE frames=%llu", g_frames); @@ -158,7 +190,7 @@ static void deinit(void) {} static const rcore_core_api k_api = { sizeof(rcore_core_api), 0, &k_info, opts, descs, init, load, run_frame, NULL, - NULL, NULL, NULL, NULL, unload, deinit, + NULL, state_size, serialize, unserialize, unload, deinit, }; RCORE_EXPORT const rcore_core_api* rcore_entry(uint32_t host_abi_major) { diff --git a/tests/state_test.cmake b/tests/state_test.cmake new file mode 100644 index 0000000..ed30a7e --- /dev/null +++ b/tests/state_test.cmake @@ -0,0 +1,50 @@ +# Savestates over the link (link 1.1) and headless, with the fake core, whose +# picture after frame k is k & 0xff everywhere. +# +# roundtrip save after frame 4, load after frame 8: frames 9 and 10 run from +# 4 again, so the last picture is 6 +# refused the same state into a session with other content: refused by +# the load rule, naming both hashes; the session runs on +# headless headless --load-state takes the envelope +# (A corrupt state -- bytes that no longer match their recorded hash -- is +# checked in retro-overlay-test, which can write arbitrary bytes.) +foreach(v LINK_TEST RUNNER CORE ROM OTHER_ROM OUT) + if(NOT DEFINED ${v}) + message(FATAL_ERROR "state_test.cmake: -D${v}= is required") + endif() +endforeach() +file(REMOVE_RECURSE "${OUT}") +file(MAKE_DIRECTORY "${OUT}") +set(state "${OUT}/slot01.rstate") + +function(run_expect name) + cmake_parse_arguments(A "" "" "ARGS;EXPECT" ${ARGN}) + execute_process(COMMAND ${EMULATOR} ${A_ARGS} + RESULT_VARIABLE rc OUTPUT_VARIABLE out ERROR_VARIABLE err) + foreach(want ${A_EXPECT}) + if(NOT out MATCHES "${want}") + message(FATAL_ERROR "${name}: expected /${want}/ (rc ${rc})\nstdout:\n${out}\nstderr:\n${err}") + endif() + endforeach() + if(NOT rc EQUAL 0) + message(FATAL_ERROR "${name}: exit ${rc}\nstdout:\n${out}\nstderr:\n${err}") + endif() + message(STATUS "${name}: ok") +endfunction() + +run_expect(roundtrip + ARGS "${LINK_TEST}" --runner "${RUNNER}" --core "${CORE}" --rom "${ROM}" --frames 10 + --out "${OUT}/roundtrip" --state-save-at "4:${state}" --state-load-at "8:${state}" + EXPECT "state: save at frame 4 ok \\(12 bytes\\)" "state: load at frame 8 ok \\(12 bytes\\)" + "picture: first byte 6" "10 frame\\(s\\) granted and done, runner exit 0") + +run_expect(refused + ARGS "${LINK_TEST}" --runner "${RUNNER}" --core "${CORE}" --rom "${OTHER_ROM}" --frames 3 + --out "${OUT}/refused" --state-load-at "2:${state}" + EXPECT "state: load at frame 2 failed: refused: content SHA-256 differs: the state has [0-9a-f]+, this session has [0-9a-f]+" + "picture: first byte 3" "runner exit 0") + +run_expect(headless + ARGS "${RUNNER}" --core "${CORE}" --rom "${ROM}" --frames 1 --out "${OUT}/headless" + --load-state "${state}" + EXPECT "state: loaded .*slot01.rstate \\(envelope, 12 bytes\\)") From c69f744e2343cb13bebe2acdc60295d1e2300473 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Sat, 26 Sep 2026 21:39:12 -0400 Subject: [PATCH 2/2] docs: OVERLAY.md, link 1.1 and savestates at their claim sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OVERLAY.md: the overlay, the browser, the envelope, the hub's hotkeys and how it was checked (unit tests, the link test, and the hub's PlaySession driven offscreen against n64lle Pokemon Stadium; a real screen and controller are not checked). CORE_LINK: the minor rule as the code keeps it, 1.1's messages, savestates off the not-built list, the Direct-mode keys. LINK_TRANSPORTS §10: resolved. CORE_RUNNER: the keeper and --load-state. HOST_LIFECYCLE: no State block (superseded); where hotkeys stand. CORE_ABI: the envelope is implemented. README: overlay/, state/, link 1.1. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++- docs/CORE_ABI.md | 5 ++ docs/CORE_LINK.md | 52 ++++++++--- docs/CORE_RUNNER.md | 14 +++ docs/HOST_LIFECYCLE.md | 13 +++ docs/LINK_TRANSPORTS.md | 8 +- docs/OVERLAY.md | 189 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 273 insertions(+), 18 deletions(-) create mode 100644 docs/OVERLAY.md diff --git a/README.md b/README.md index 51fc7c2..c7c8a5f 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ title's core. |---|---| | `include/rcore/rcore.h` | The host ↔ core contract. A core is a shared library exporting one symbol, `rcore_entry`. Draft revision 5, `RCORE_ABI_MAJOR 0`. | | `corelink/` | The host ↔ runner link: its protocol, and `retro_corelink`, the client a host embeds. | +| `overlay/` | `retro_overlay`: what a host draws over a running core. It covers FPS, TURBO, the volume meter, toasts and the save-state browser, and looks the same for every core. | +| `state/` | `retro_state`: the savestate envelope. The runner writes and checks it; hosts list it. | | `runner/` | `retro-core-runner`, the child process that loads a core and runs it: headless, or linked to a host. Also `retro_core_support`, the sidecar-manifest reader and core loader hosts use. | | `tests/rcore_fake_core.c` | The smallest core, for tests. | -| `docs/` | `HOST_LIFECYCLE.md` (the design), `CORE_ABI.md` (the contract), `CORE_LINK.md` (the link), `CORE_RUNNER.md` (the runner), `RELEASES.md` (releases and runner updates). | +| `docs/` | `HOST_LIFECYCLE.md` (the design), `CORE_ABI.md` (the contract), `CORE_LINK.md` (the link), `CORE_RUNNER.md` (the runner), `OVERLAY.md` (the play overlay and savestates), `RELEASES.md` (releases and runner updates). | ## Build @@ -28,8 +30,8 @@ ctest --test-dir build (`docs/LINK_TRANSPORTS.md`); the protocol above it does not. A host pulls this in with `add_subdirectory()` and links `retro_rcore`, -`retro_core_support` and `retro_corelink`. A host that already found SDL3 can -pass `-DRETRO_RUNTIME_SDL3_TARGET=`. +`retro_core_support`, `retro_corelink` and `retro_overlay`. A host that +already found SDL3 can pass `-DRETRO_RUNTIME_SDL3_TARGET=`. ## Releases @@ -52,7 +54,7 @@ Three contracts, each with a major that must match and append-only minors: | Contract | Where | Current | |---|---|---| | rcore ABI | `RCORE_ABI_MAJOR` / `RCORE_DRAFT_REVISION` | 0 (draft), revision 5 | -| Link protocol | `kProtocolMajor` / `kProtocolMinor` | 1.0 | +| Link protocol | `kProtocolMajor` / `kProtocolMinor` | 1.1 (savestates) | | Sidecar manifest | `abi_major`, `draft_revision` | follows the ABI | The runner can therefore update separately from hosts and cores: diff --git a/docs/CORE_ABI.md b/docs/CORE_ABI.md index d75daf1..67087f5 100644 --- a/docs/CORE_ABI.md +++ b/docs/CORE_ABI.md @@ -312,6 +312,11 @@ proves its states survive rebuilds with the same id. Without that gate, leave it NULL and accept that core updates invalidate states. Stating otherwise is a claim with nothing enforcing it. +**Implemented 2026-09-26** as `state/state_envelope.*`. The runner writes and +checks it (`runner/state_keeper.*`); the hub lists it (`OVERLAY.md`). The +thumbnail is inside the envelope, not a sidecar, and the load rule never +reads it. + **Rollback netplay uses no envelope.** Rollback states live in memory, inside one session whose peers already matched on the full identity at session start. diff --git a/docs/CORE_LINK.md b/docs/CORE_LINK.md index b54bc8e..4e03963 100644 --- a/docs/CORE_LINK.md +++ b/docs/CORE_LINK.md @@ -38,17 +38,23 @@ The runner is released and updated separately from the hosts that start it speaks the lower of the two, and neither side sends anything the other's minor does not know. -**Known defect (2026-09-26): the minor rule is not implemented.** -- `as_msg` accepts only a packet of exactly `sizeof(M)`, and every sender - sends `sizeof(M)` whatever the session's minor. -- So a field appended in a minor release would make an older peer silently - drop that whole message. -- Before the first minor bump, either senders must size messages by the - session's minor, or `as_msg` must accept a longer packet and ignore the tail - (`LINK_TRANSPORTS.md` §10). - -**1.0** (2026-09-25) is the layout described on this page. It resets the -unreleased development counter, which had reached 3. +**The minor rule, as the code keeps it** (fixed 2026-09-26, with 1.1; until +then `as_msg` took only an exact size, `LINK_TRANSPORTS.md` §10): +- A reader takes a packet at least as long as the message it knows, ignores a + longer packet's tail (a newer peer appended fields), and zero-fills a field + an older peer did not send, down to the message's pre-append size + (`as_msg`'s `min_size`; `link_io.hpp`). +- A 1.0 peer still demands the exact 1.0 size. So a message that grows must be + sent at its old size to a session speaking an older minor. No message has + grown yet: 1.1 only adds message types. +- A side sends a new message type only when the session's minor has it: the + hub checks `CoreIdentity::protocol_minor` (`CoreLink::states_supported()`). + +**1.0** (2026-09-25) is the layout described below, less the savestate +messages. It reset the unreleased development counter, which had reached 3. + +**1.1** (2026-09-26) adds savestates: `SaveState` and `LoadState` (hub to +runner) and `StateDone` (runner to hub), described under "Savestates" below. ## A session @@ -66,6 +72,10 @@ hub runner Grant(frame k, every seat's pad) --> run_frame; picture -> shared slot <-- FrameDone(k) ... + SaveState(path) / LoadState(path) --> 1.1, between frames: serialize or + check + unserialize; the envelope + <-- StateDone ok, or the reason + ... Quit --> unload, deinit, exit 0 ``` @@ -78,6 +88,21 @@ hub runner `--version` (`RunnerVersion::game_package`, 0 for a runner from before it). - **Input rides inside each Grant**, so the contract's "identical within one frame" holds by construction. +- **Savestates (1.1) are taken between frames.** The hub sends `SaveState` or + `LoadState` only while no grant is outstanding, and grants nothing until + `StateDone` (`CoreLink::request_save_state` / `request_load_state` / + `take_state_result`). + - **The runner writes and checks the envelope** + (`state/state_envelope.hpp`, `CORE_ABI.md` "Savestates"), because it + holds every identity the load rule compares. + - The path is the hub's choice. The thumbnail is the last frame the runner + published. + - A refused or failed load leaves the machine as it was and answers with the + reason, naming both values. The session runs on. + - A 1.0 runner never receives these messages: the hub asks + `states_supported()` first. + - The hub's save-state browser is Retro-Runtime's `retro_overlay` + (`OVERLAY.md`). - **SavesFilled carries the seats as they stand before frame 1.** A core may read input outside a frame, and a seat's `connected` flag is guest-visible. Without this, n64lle reading the controllers while `unserialize()` restored a @@ -124,7 +149,9 @@ package; `--title-dir` then defaults to the shim's directory. `game_package 1`, the window shows the error and the hub exits 1. A background runtime update applies from the next launch, never mid-session. - **Menu:** the guide button, Esc or F1 open the paused quick menu (Resume, - Close game). F11 toggles fullscreen. + Save states, Show FPS, Volume, Close game). F11 toggles fullscreen. +- **Overlay** (`OVERLAY.md`): F3 FPS, Tab (held) turbo, +/- volume, F7 or + SELECT + R1 save states. - **Input:** - Gamepads fill seats 0–3 in the order SDL lists them. - With none attached, the keyboard is port 1: arrows = D-pad, X/Z/C/S = the @@ -167,7 +194,6 @@ All on `pokemonstadium_core.so` built clean from n64lle `ffa84cfc`. - **Per-title options, accessories and save choice** from the title page. Library launch itself exists: see `CORE_LIBRARY.md`. - **Netplay through the runner** (rev 4). -- **Savestates from the quick menu,** with the envelope. - **Options UI.** Options come only from `--opt`. - **Accessory binding UI, per-seat remapping, hot-plug.** - **The hub on macOS and Windows.** Retro Launcher builds `hub_play.cpp` only on diff --git a/docs/CORE_RUNNER.md b/docs/CORE_RUNNER.md index 7d88113..441aeee 100644 --- a/docs/CORE_RUNNER.md +++ b/docs/CORE_RUNNER.md @@ -52,6 +52,20 @@ This page covers what exists and how it is checked. (`--core --package --rom --title-dir --out --frames --load-state --tpak1-rom --tpak1-save --tpak1-rtc --gl --strict --no-seats --replay-at --opt --input-script --list-options`). +5. **Savestates** (`runner/state_keeper.*`, 2026-09-26). The runner writes + and checks the savestate envelope (`OVERLAY.md`, `CORE_ABI.md` + "Savestates"). It holds every identity the load rule compares: the core's + hash, the package's, the content's, the accessories', and the NETPLAY + options. + - **Link mode** serves 1.1's `SaveState` / `LoadState` between frames + (`CORE_LINK.md`). It logs `state: save|load : ok, N bytes` or the + reason to `runner.log`. It hashes the content on a background thread + from the start, so the first save does not wait for it. + - **`--load-state`**, headless and at link start, checks an envelope by + the load rule. A refusal is exit 2, naming the first mismatch. A file + without the envelope's magic is a bare core state, as n64lle's gates + write them, and goes to the core unchecked as before. Headless prints + `state: loaded (envelope|bare, N bytes)`. `--version` prints the release version, commit, link protocol and rcore ABI compiled in, whether `--gl` is available, and `game_package 1` (this runner diff --git a/docs/HOST_LIFECYCLE.md b/docs/HOST_LIFECYCLE.md index 5c7e565..52bb164 100644 --- a/docs/HOST_LIFECYCLE.md +++ b/docs/HOST_LIFECYCLE.md @@ -149,6 +149,12 @@ One shared region per session, created by the host and inherited by the runner | Events | runner → host | always-on ring of misses, bridges, faults, log lines | | State | runner ↔ host | savestate transfer buffer | +> **Superseded, 2026-09-26** (`CORE_LINK.md` 1.1, `OVERLAY.md`): there is no +> State block. The hub names a file, and the runner serializes into it (or +> checks it and unserializes), wrapped in the envelope. That is the only place +> every identity the load rule needs is at hand. The core's bytes never cross +> the link. + Wake-ups use an eventfd / Windows event pair; nothing polls with sleeps. **Pause** is the host withholding the grant: a call-per-frame core is simply not @@ -192,3 +198,10 @@ lockstep instead. 3. Save-state compatibility across core updates within one ABI version. 4. Hotkey layout and the overlay's controller binding when a game uses every button. + - *Where it stands, 2026-09-26, not a ruling:* the hub binds F3 FPS, + Tab turbo (held), +/- volume, F7 save states, and Esc/F1/Guide the quick + menu (`OVERLAY.md`). + - On a pad only Guide and the SELECT + R1 chord are taken. The chord is + the one psxrecomp, snesrecomp and n64lle already share. + - A game that uses SELECT + R1 together would still open the browser, so + the question stays open. diff --git a/docs/LINK_TRANSPORTS.md b/docs/LINK_TRANSPORTS.md index 2cfc20c..d88e188 100644 --- a/docs/LINK_TRANSPORTS.md +++ b/docs/LINK_TRANSPORTS.md @@ -28,7 +28,7 @@ must keep all of them: | Property | Why it is load-bearing | |---|---| -| One message per packet, with the whole packet or nothing | `as_msg` copies exact sizes; a torn message is a malformed session | +| One message per packet, with the whole packet or nothing | `as_msg` reads whole packets (since 1.1, a longer one's tail is ignored); a torn message is a malformed session | | Handles travel with the message that names them | `SaveRegions` carries one memory handle per region, in order | | **The hub sees EOF when the runner dies**, however it dies | The only way a hub learns of a crash mid-frame; saves are written on it | | The shared region and the save memory are the **hub's** memory | A runner crash cannot lose the picture, the queued audio or a save | @@ -281,6 +281,12 @@ Linux (`CMakeLists.txt:380`). It must build on Windows and macOS once This design adds no fields, so it does not depend on the fix. It must land before the first minor bump. Filed at the claim site in `CORE_LINK.md`. + + **Resolved 2026-09-26, with 1.1** (the first minor bump): `as_msg` now + ignores a longer packet's tail and zero-fills a shorter one down to a + message's pre-append size. A message that grows must still be sent at its old + size to a 1.0 peer. 1.1 grows none; it adds message types + (`CORE_LINK.md`, "Versioning"). - `CORE_LINK.md` names `src/corelink/`, `src/runner/` and `src/hub/`. Since the split they are `corelink/`, `runner/`, and Retro Launcher's `src/hub/`. diff --git a/docs/OVERLAY.md b/docs/OVERLAY.md new file mode 100644 index 0000000..703201e --- /dev/null +++ b/docs/OVERLAY.md @@ -0,0 +1,189 @@ +# The play overlay — `retro_overlay` + +What a host draws over a running core: an FPS readout, a TURBO marker, a volume +meter, toasts, and the save-state browser. Code: `overlay/` (the library), +`state/` (the savestate envelope it lists), and the runner's savestate path +(`runner/state_keeper.*`, link 1.1 in `CORE_LINK.md`). + +**Status, 2026-09-26: built, and used by Retro Launcher's hub** (branch +`feat/runtime-overlay`, `src/hub/hub_play.cpp`). Checked as described at the +end of this page. Nobody has yet played with it on a real screen with a real +controller; that verdict is Alex's. + +## Why it lives in the runtime + +- **Cores never draw it.** A core owns no window (`rcore.h`). The overlay is + composited after the core has produced its picture. It is not in the frame + the core submitted, not in a savestate, and not in anything a netplay peer + sees. +- **Hosts do not each re-implement it.** psxrecomp (`host_osd.c`, + `psx_savestate_menu.c`), snesrecomp (`snes_osd.c`, + `snes_savestate_menu.c`) and n64lle's own host (`host_turbo.rs`, + `host_savestate_menu.rs`) each carried a copy. Their headers already argued + against "25 copies". Under rcore every core runs behind the same host, so the + one copy lives here. +- **It looks the same for every core.** It uses the same 8x8 font those three + projects share (`font8x8_basic`), OSD text at 2x in translucent dark boxes, + and the browser's layout and colours (n64lle's, which were psxrecomp's). The + save-state legend draws face buttons by **position**, never one console's + letters. + +It depends on the C++ library and `rcore.h` only: no SDL, no GL, no clock. The +host passes the time in, feeds it events, and draws the images it hands back. + +## Pieces + +| Piece | Where | When | +|---|---|---| +| FPS | top left | while the host's "show FPS" is on | +| Toasts | top left, under FPS | about 2 s after something happened ("Slot 3 saved") | +| `>> TURBO` | top right, gold | while the host runs the core faster than its rate | +| Volume meter | right edge, centred | 1.5 s after the volume changes | +| Save states | centre | while the browser is open | + +**FPS counts emulated frames**, the frames the core finished, not presents. +Under turbo the core runs several frames per present, and the reading should +say how fast the machine is running. The reading is the last 64 frames divided +by the time they span. It is not a mean of 1/dt: that reads high whenever +frames arrive unevenly. For example, 15 ms and 25 ms alternating gives a 1/dt +mean of 53.3 fps, where the true rate is 50. The reading starts afresh on a gap +over 0.5 s, when turbo starts or stops, and when the host calls `restart_fps()` +(the hub does on resuming from a pause). + +## Drawing it (the host's side) + +```cpp +for (const Layer& l : osd.layers(now_ns)) // + browser.layer() when open + draw(texture_for(l.id, l.image, l.revision), place(l, win_w, win_h)); +``` + +- **One texture per `Layer::id`.** Re-upload only when `Layer::revision` + changes. The images are RGBA8 (the same byte order as an + `RCORE_PIXEL_RGBA8` frame) with straight alpha. +- **Draw at `place()`, nearest-filtered.** `place()` puts every layer in the + same spot on every host: + - Corner and edge layers use integer scale `1 + height / 1800` (1 at 1080p, + 2 at 4K), inset 8 units per scale step. + - The browser uses the largest integer scale that fits 90% of the window + (2x at 1080p). +- It is window chrome. It does not scale or move with the letterboxed game + picture. + +## The save-state browser + +Twelve slots per title: `/slot01.rstate` … `slot12.rstate`. Each is an +envelope (below). The **runner** writes and checks them. The browser lists +them, draws the panel, and turns input into **requests**. The host carries a +request to the runner and reports back with `finish(ok, detail)`. + +| Input | Pad (by position) | Keyboard | +|---|---|---| +| open / close | SELECT + R1 (the psxrecomp / snesrecomp / n64lle chord), or the host's hotkey | host's hotkey (the hub: F7) | +| choose a slot | D-pad, left stick (held: 350 ms, then every 90 ms) | Up / Down; 1–9, 0, -, = jump to 1–12 | +| load | SOUTH | Enter | +| save | NORTH | S | +| back | EAST, Start | Esc, Backspace | + +- **Pads are read physically**, not through the player's bindings, so a remap + cannot strand the menu. +- **A request blocks the browser** until it is answered. While it waits, the + panel says so ("SAVING SLOT 03..."). +- **A save stays open.** The thumbnail appearing in its row is the player's + evidence that it was written. +- **A good load closes the browser** and toasts. +- **A refused load stays open** and shows the runner's reason. Hashes are + shortened to 12 digits on the panel; the full sentence is in the runner's + log. +- **Slots from another core or build are marked** "OTHER CORE" or "OTHER + BUILD", from the envelope's header against the link's Hello. The mark is a + hint. The runner's load rule decides. +- **`InputGuard`**: buttons held when the browser closes (the chord, an Enter) + stay out of the game until they are released. + +## The envelope + +`state/state_envelope.hpp` implements `CORE_ABI.md` "Savestates". The file is +laid out as: + +- magic `RCSTATE\0` and a version; +- a text header of `key=value` lines; +- a 160x120 RGBA8 thumbnail; +- the core's bytes. + +The header records the rcore ABI major, core id, core file SHA-256, +`state_compat_id`, game package SHA-256, content SHA-256, every accessory +binding, every `NETPLAY` option's value, and the core bytes' size and SHA-256. +For display it also records the frame number and time. Unknown header keys are +ignored, so a later version can add fields. + +**The load rule** is `check_state()`. It follows the order `CORE_ABI.md` +gives, stops at the first mismatch, and names both values: + +1. ABI major, core id. +2. `state_compat_id` if either side has one, otherwise the core file hash. +3. Package, then content. +4. Accessories, then options. +5. The bytes' hash (a corrupt file). + +A refused state stays on disk. + +**The thumbnail is inside the envelope.** n64lle kept a sidecar file so that a +picture could never stand in the way of state compatibility. Here the envelope +is the host's format and the load rule ignores the picture. One file is +simpler to copy and sync. The runner takes the picture from the last frame it +published to the hub. + +**Bare states still load where they always did.** A file without the magic +goes to the core unchecked through headless `--load-state` and the link's +launch-time `--load-state` (n64lle's gates write bare states). The browser's +requests accept only envelopes. + +## The hub's hotkeys + +These are the hub's choices, listed here because every core gets them: + +| Key | Does | +|---|---| +| F3 | show / hide FPS (also a setting: "Show FPS", saved in `/play.ini`) | +| Tab (held) | turbo; sound is dropped while it runs | +| + / - (`=` `-` or keypad) | volume, 10% steps, meter on the right (saved in `play.ini`) | +| F7, or SELECT + R1 | save states | +| Esc, F1, Guide | the pause menu, which also has Save states, Show FPS and Volume | + +- The recomp-ui family binds FPS to F. In the hub F drives C-Left (the TFGH + right stick), so FPS is F3. +- Turbo holds on key events and drops when the window loses focus. +- Nothing here is bound on a pad except the browser's chord and the existing + Guide. + +## How it was checked (2026-09-26) + +- **`retro-overlay-test`** (ctest `overlay`) checks: + - the FPS method, including uneven arrival and turbo; + - layer lifetimes and revisions, and `place()`; + - the browser: chord edges, auto-repeat, requests, refusals, marks; + - `InputGuard`; + - the envelope: round trip, escaping, every load-rule mismatch in order, + corrupt bytes, truncation. + + `--dump DIR` writes each layer composited over a picture, for a person to + look at. +- **`link_savestates`** (ctest, fake core): + - over the link, a state saved after frame 4 and loaded after frame 8 makes + the last picture 6; + - the same state in a session with other content is refused, naming both + hashes, and the session runs on; + - headless `--load-state` accepts the envelope. +- **The hub's real `PlaySession`** was driven by a scratch harness (not in any + repo) on SDL's offscreen driver with injected key events, and each step's + framebuffer was read back: + - with the fake core: F3, Tab, `-`, F7, S, Enter; + - with n64lle 0.375.0 running Pokémon Stadium (US 1.0, `pokemonstadium_game.so`): + - the FPS readout, TURBO marker and volume meter drew in place; + - the browser opened over the dimmed game; + - a 2.97 MB state saved in about 40 ms, with the game's own frame as its + thumbnail, and loaded back; + - the session's `events.tsv` was empty (no miss, bridge or fault). +- **Not checked:** a real screen, a real controller (the SELECT + R1 chord, + stick repeat), sound and volume by ear, and the settings page's new section + as drawn. Those are Alex's to judge.