Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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})
Expand All @@ -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 $<TARGET_FILE_DIR:rcore_fake_core>/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 = \"$<TARGET_FILE_NAME:rcore_fake_core>\"\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 = \"$<TARGET_FILE_NAME:rcore_fake_core>\"\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
Expand All @@ -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 $<TARGET_FILE_DIR:rcore_fake_pkg_core>/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 = \"$<TARGET_FILE_NAME:rcore_fake_pkg_core>\"\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 = \"$<TARGET_FILE_NAME:rcore_fake_pkg_core>\"\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
Expand Down Expand Up @@ -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=$<TARGET_FILE:retro-core-link-test>
-DRUNNER=$<TARGET_FILE:retro-core-runner>
-DCORE=$<TARGET_FILE:rcore_fake_core>
-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).
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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=<target>`.
`retro_core_support`, `retro_corelink` and `retro_overlay`. A host that
already found SDL3 can pass `-DRETRO_RUNTIME_SDL3_TARGET=<target>`.

## Releases

Expand All @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions corelink/core_link.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,18 @@ void CoreLink::handle_packet(const std::vector<unsigned char>& 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;
}
Expand All @@ -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<StateResult> CoreLink::take_state_result() {
std::optional<StateResult> 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;
Expand Down Expand Up @@ -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
}

Expand Down
33 changes: 31 additions & 2 deletions corelink/core_link.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<StateResult> 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).
Expand Down Expand Up @@ -123,6 +148,7 @@ class CoreLink {

private:
void handle_packet(const std::vector<unsigned char>& buf, std::vector<NativeHandle>& handles);
bool request_state(Msg type, const fs::path& path);
void on_ended(int code);
void reap(int timeout_ms);

Expand All @@ -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<StateResult> state_result_;
int exit_code_ = 0;
std::string exit_reason_;
};
Expand Down
17 changes: 13 additions & 4 deletions corelink/link_io.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,20 @@ inline bool packet_type(const std::vector<unsigned char>& 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 <typename M>
bool as_msg(const std::vector<unsigned char>& buf, M& out) {
if (buf.size() != sizeof(M)) return false;
std::memcpy(&out, buf.data(), sizeof(M));
bool as_msg(const std::vector<unsigned char>& 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;
}

Expand Down
42 changes: 38 additions & 4 deletions corelink/link_protocol.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ------------------------------------------------
//
Expand Down Expand Up @@ -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
Loading
Loading