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
33 changes: 31 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ if(WIN32)
endif()

add_library(retro_state STATIC state/state_envelope.cpp)
add_library(retro_route STATIC runner/route.cpp)
target_link_libraries(retro_route PUBLIC retro_core_support retro_corelink)
target_include_directories(retro_state PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/state)
target_link_libraries(retro_state PUBLIC retro_core_support)

Expand Down Expand Up @@ -119,8 +121,35 @@ if(RETRO_RUNTIME_TOOLS)
)
add_executable(retro-gpu-host-test tests/gpu_host_test.cpp runner/host_session.cpp)
target_include_directories(retro-gpu-host-test PRIVATE runner)
target_link_libraries(retro-gpu-host-test PRIVATE retro_core_support)
target_link_libraries(retro-gpu-host-test PRIVATE retro_core_support retro_route)
add_test(NAME gpu_host_contract COMMAND retro-gpu-host-test)
add_executable(retro-route-test tests/route_contract_test.cpp runner/host_session.cpp)
target_include_directories(retro-route-test PRIVATE runner)
target_link_libraries(retro-route-test PRIVATE retro_route)
add_test(NAME route_input_roundtrip COMMAND retro-route-test ${CMAKE_CURRENT_BINARY_DIR}/route-contract)
add_test(NAME runner_routes COMMAND ${CMAKE_COMMAND}
-DRUNNER=$<TARGET_FILE:retro-core-runner> -DCORE=$<TARGET_FILE:rcore_fake_core>
-DLINK=$<TARGET_FILE:retro-core-link-test> -DOUT=${CMAKE_CURRENT_BINARY_DIR}/route-integration
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/route_test.cmake)
add_test(NAME route_watch COMMAND ${CMAKE_COMMAND}
-DRUNNER=$<TARGET_FILE:retro-core-runner> -DCORE=$<TARGET_FILE:rcore_fake_core>
-DLINK=$<TARGET_FILE:retro-core-link-test> -DOUT=${CMAKE_CURRENT_BINARY_DIR}/route-watch
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/route_watch_test.cmake)
add_test(NAME route_anchor COMMAND ${CMAKE_COMMAND}
-DRUNNER=$<TARGET_FILE:retro-core-runner> -DCORE=$<TARGET_FILE:rcore_fake_core>
-DLINK=$<TARGET_FILE:retro-core-link-test> -DOUT=${CMAKE_CURRENT_BINARY_DIR}/route-anchor
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/route_anchor_test.cmake)
find_package(Python3 COMPONENTS Interpreter QUIET)
if(Python3_Interpreter_FOUND)
file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/route-tmp)
add_test(NAME route_recovery COMMAND ${Python3_EXECUTABLE} -I -B
${CMAKE_CURRENT_SOURCE_DIR}/tests/route_recover_test.py)
set_tests_properties(route_recovery PROPERTIES ENVIRONMENT
"TEMP=${CMAKE_CURRENT_BINARY_DIR}/route-tmp;TMP=${CMAKE_CURRENT_BINARY_DIR}/route-tmp;PYTHONDONTWRITEBYTECODE=1")
else()
add_test(NAME route_recovery COMMAND ${CMAKE_COMMAND} -E echo "SKIP: Python3 is unavailable")
set_tests_properties(route_recovery PROPERTIES SKIP_REGULAR_EXPRESSION "SKIP:")
endif()
# Netplay (runner/net_session.hpp): recomp-net's episode driver and UDP
# transport, compiled in from a checkout when one is named. Without it the
# runner answers `netplay 0` and refuses --net-*.
Expand Down Expand Up @@ -172,7 +201,7 @@ if(RETRO_RUNTIME_TOOLS)
endif()
# Threads: the savestate keeper hashes content off the core thread.
find_package(Threads REQUIRED)
target_link_libraries(retro-core-runner PRIVATE retro_core_support retro_corelink retro_state
target_link_libraries(retro-core-runner PRIVATE retro_core_support retro_corelink retro_state retro_route
Threads::Threads)
target_include_directories(retro-core-runner PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated)
if(_sdl)
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,6 @@ The runner can therefore update separately from hosts and cores:

This repository was split out of Retro Launcher on 2026-09-25, with its
history.

See [Input replays](docs/BOOT_ROUTES.md) for recording, anchored playback,
headless verification and link recording controls.
33 changes: 33 additions & 0 deletions corelink/core_link.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ CoreLink::~CoreLink() {
channel_.close();
}

bool CoreLink::restart(const LaunchSpec& spec, std::string* error) {
stop();
this->~CoreLink();
new (this) CoreLink();
return start(spec,error);
}

bool CoreLink::start(const LaunchSpec& spec, std::string* error) {
auto fail = [&](const std::string& m) {
if (error) *error = m;
Expand Down Expand Up @@ -256,6 +263,13 @@ void CoreLink::handle_packet(const std::vector<unsigned char>& buf,
if (as_msg(buf, m)) exit_reason_ = m.reason;
break;
}
case Msg::ReplayStatus: {
ReplayStatusMsg m{};
if (!as_msg(buf,m)) break;
m.path[sizeof m.path-1]=0; m.detail[sizeof m.detail-1]=0;
replay_status_=m; replay_pending_=false;
break;
}
case Msg::StateDone: {
StateDoneMsg m{};
if (!as_msg(buf, m) || !state_pending_) break;
Expand Down Expand Up @@ -300,6 +314,20 @@ bool CoreLink::states_supported() const {
(identity_.capabilities & RCORE_CAP_SAVESTATE);
}

bool CoreLink::replays_supported() const {
return states_supported() && link_has_replays(identity_.protocol_minor);
}
bool CoreLink::request_recording(bool record, const fs::path& path) {
if (!replays_supported() || !can_grant()) return false;
ReplayControlMsg m{}; m.h.type=Msg::ReplayControl; m.record=record ? 1 : 0;
const auto 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;
replay_pending_=true;
return true;
}

bool CoreLink::request_state(Msg type, const fs::path& path) {
if (!can_request_state()) return false;
StateRequestMsg m{};
Expand Down Expand Up @@ -425,6 +453,11 @@ void CoreLink::on_ended(int code) {
exit_code_ = code;
state_ = LinkState::Ended;
outstanding_ = 0;
if (replay_pending_) {
replay_pending_=false;
replay_status_.state=ReplayState::Failed;
std::strcpy(replay_status_.detail,"the runner ended before it answered");
}
if (state_pending_) {
StateResult r = state_request_;
r.ok = false;
Expand Down
9 changes: 8 additions & 1 deletion corelink/core_link.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ class CoreLink {
~CoreLink();

bool start(const LaunchSpec& spec, std::string* error);
bool restart(const LaunchSpec& spec, std::string* error);

// Handles every message waiting (up to timeout_ms for the first).
void pump(int timeout_ms = 0);
Expand All @@ -154,7 +155,7 @@ class CoreLink {
// 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_;
return state_ == LinkState::Ready && outstanding_ == 0 && !state_pending_ && !replay_pending_;
}
// Available physical pixels before letterboxing; zeros when unknown.
bool grant(const rcore_pad pads[RCORE_MAX_SEATS], std::uint32_t width = 0,
Expand All @@ -175,6 +176,10 @@ class CoreLink {
// The answer, once, when it has arrived. A runner that ends first
// answers with a failure naming that.
std::optional<StateResult> take_state_result();
bool replays_supported() const;
bool request_recording(bool record, const fs::path& path = {});
bool replay_pending() const { return replay_pending_; }
const ReplayStatusMsg& replay_status() const { return replay_status_; }

// Accessory data (link 2.1, rcore rev 7; docs/CORE_ABI.md "Accessory
// data"). send_accessory() queues one whole message for the accessory at
Expand Down Expand Up @@ -249,6 +254,8 @@ class CoreLink {
std::uint32_t front_ = 0; // the triple buffer's initial front slot
bool have_frame_ = false;
bool state_pending_ = false;
bool replay_pending_ = false;
ReplayStatusMsg replay_status_{};
StateResult state_request_;
std::optional<StateResult> state_result_;
std::deque<AccessoryNotify> notifies_;
Expand Down
20 changes: 19 additions & 1 deletion corelink/link_protocol.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ constexpr std::uint32_t kProtocolMajor = 2;
// reads a 2.1 runner's with both fields zero.
// 2.3: Grant appends the available drawable pixel area (rcore rev 9).
// 2.4: optional core-owned GPU rings, native handle transfer and release events.
constexpr std::uint32_t kProtocolMinor = 4;
// 2.5: between-frame replay recording controls and authoritative status.
constexpr std::uint32_t kProtocolMinor = 5;
constexpr bool link_has_replays(std::uint32_t peer_minor) { return peer_minor >= 5; }
constexpr bool link_has_gpu_frames(std::uint32_t peer_minor) {
return kProtocolMajor > 2 || peer_minor >= 4;
}
Expand Down Expand Up @@ -154,6 +156,7 @@ enum class Msg : std::uint32_t {
GpuRing = 10, // 2.4: metadata plus 3 native handles per image
GpuFrame = 11, // accepted GPU picture, before its FrameDone
GpuUnregister = 12, // retire a generation after all GPU users finish
ReplayStatus = 13,
// hub -> runner
SavesFilled = 64, // every region filled from its file; the seats at power-on
Grant = 65, // run exactly one frame, with these pads
Expand All @@ -164,13 +167,28 @@ enum class Msg : std::uint32_t {
GpuCapabilities = 70, // 2.4: once after Hello, before core init
GpuStatus = 71, // resource ownership events, not capabilities
StateThumbnail = 72, // 2.4: displayed image observer for the next save
ReplayControl = 73,
};

struct MsgHeader {
Msg type;
std::uint32_t size; // whole packet, header included
};

enum class ReplayState : std::uint32_t { Idle, Recording, Saved, Playing, TakenOver, Failed, Complete, Stopped };
struct ReplayControlMsg {
MsgHeader h;
std::uint32_t record; // 1: start at a restored anchor, 0: stop recording
char path[4096]; // exclusive destination, never overwrite
};
struct ReplayStatusMsg {
MsgHeader h;
ReplayState state;
std::uint64_t fields;
char path[4096];
char detail[512];
};

struct GpuCapabilitiesMsg {
MsgHeader h;
rcore_gpu_capabilities caps;
Expand Down
51 changes: 49 additions & 2 deletions corelink/link_test_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
using namespace retro::corelink;

namespace {
constexpr std::uint32_t kLeft = 1u << 20, kRight = 1u << 21,
kUp = 1u << 22, kDown = 1u << 23;
constexpr std::uint32_t kStick = kLeft | kRight | kUp | kDown;

[[noreturn]] void die(const std::string& m) {
std::fprintf(stderr, "retro-core-link-test: %s\n", m.c_str());
Expand All @@ -63,6 +66,10 @@ std::uint32_t script_buttons(const std::string& s) {
else if (t == "ddown") b |= RCORE_PAD_DPAD_DOWN;
else if (t == "dleft") b |= RCORE_PAD_DPAD_LEFT;
else if (t == "dright") b |= RCORE_PAD_DPAD_RIGHT;
else if (t == "left") b |= kLeft;
else if (t == "right") b |= kRight;
else if (t == "up") b |= kUp;
else if (t == "down") b |= kDown;
else die("--input-script: unknown button '" + t + "'");
if (plus == std::string::npos) break;
start = plus + 1;
Expand All @@ -81,13 +88,16 @@ int main(int argc, char** argv) {
std::uint64_t frames = 60;
bool seat0 = true;
bool presentation_test = false;
bool expect_route_end = false;
std::string restart_route;
std::vector<std::pair<std::uint64_t, std::uint32_t>> script;
struct StateOp {
std::uint64_t at;
bool save;
std::string path;
};
std::vector<StateOp> state_ops;
std::vector<StateOp> record_ops;
struct AccessorySend {
std::uint64_t before_frame;
std::uint32_t seat, slot;
Expand Down Expand Up @@ -117,6 +127,7 @@ int main(int argc, char** argv) {
v.game_package, v.describe, v.accessory_data);
return 0;
} else if (a == "--runner") spec.runner = utf8_path(val());
else if (a == "--restart-route") restart_route=val();
else if (a == "--core") spec.core = utf8_path(val());
else if (a == "--rom") spec.rom = val();
else if (a == "--package") spec.package = val();
Expand Down Expand Up @@ -156,6 +167,7 @@ int main(int argc, char** argv) {
else if (a == "--strict") spec.strict = true;
else if (a == "--no-seats") seat0 = false;
else if (a == "--presentation-test") presentation_test = true;
else if (a == "--expect-route-end") expect_route_end = true;
else if (a == "--opt") {
const std::string kv = val();
const auto eq = kv.find('=');
Expand All @@ -175,6 +187,12 @@ int main(int argc, char** argv) {
if (comma == std::string::npos) break;
start = comma + 1;
}
} else if (a == "--record-start-at") {
const auto v=val(); const auto colon=v.find(':');
if (colon==std::string::npos) die("--record-start-at K:PATH");
record_ops.push_back({std::strtoull(v.substr(0,colon).c_str(),nullptr,10),true,v.substr(colon+1)});
} else if (a == "--record-stop-at") {
record_ops.push_back({std::strtoull(val().c_str(),nullptr,10),false,{}});
} else if (a == "--state-save-at") {
state_op(val(), true);
} else if (a == "--state-load-at") {
Expand Down Expand Up @@ -208,6 +226,14 @@ int main(int argc, char** argv) {
(link.exit_reason().empty() ? "" : ": " + link.exit_reason()) + "); see " +
link.runner_log().string());
}
if (!restart_route.empty()) {
auto replay=spec;
replay.env.push_back("RETRO_PLAY_ROUTE="+restart_route);
replay.env.push_back("RETRO_ROUTE_TAKEOVER=1");
if (!link.restart(replay,&err)) die(err);
while (link.state()==LinkState::Starting) link.pump(100);
if (link.state()!=LinkState::Ready) die("selected replay refused: "+link.exit_reason());
}
const CoreIdentity& id = link.identity();
std::printf("link: %s %s sha256 %s draft %u%s\n", id.core_id.c_str(), id.core_version.c_str(),
id.sha256.c_str(), id.draft_revision, id.engine_dirty ? " (engine dirty)" : "");
Expand Down Expand Up @@ -243,18 +269,39 @@ int main(int argc, char** argv) {
for (const auto& [at, buttons] : script) {
if (at <= k) pads[0].buttons = buttons;
}
const auto held = pads[0].buttons;
pads[0].buttons &= ~kStick;
pads[0].axes[RCORE_AXIS_LX] = held & kLeft ? -32767 : held & kRight ? 32767 : 0;
pads[0].axes[RCORE_AXIS_LY] = held & kUp ? 32767 : held & kDown ? -32767 : 0;
}
// Fixture exercises resize, unavailable/minimized, then recovery.
const std::uint32_t pw = presentation_test && k != 3 ? (k == 1 ? 1280 : 2560) : 0;
const std::uint32_t ph = presentation_test && k != 3 ? (k == 1 ? 720 : 1080) : 0;
if (!link.grant(pads, pw, ph)) die("grant " + std::to_string(k) + " refused");
if (!link.grant(pads, pw, ph)) {
if (expect_route_end) {
link.pump(100);
if (link.state()==LinkState::Ended && !link.exit_code()) break;
}
die("grant " + std::to_string(k) + " refused");
}
while (link.state() == LinkState::Ready && link.frames_done() < k) link.pump(100);
print_notifies();
if (link.state() != LinkState::Ready) ok = false;
const bool clean_route_end = expect_route_end && link.state()==LinkState::Ended && !link.exit_code();
if (link.state() != LinkState::Ready && !clean_route_end) ok = false;
link.take_frame();
std::int16_t sink[4096];
while (link.drain_audio(sink, 2048)) {
}
if (clean_route_end) break;
for (const StateOp& op : record_ops) {
if (op.at!=k || !ok) continue;
if (!link.request_recording(op.save,utf8_path(op.path))) die("replay request refused");
while (link.replay_pending() && link.state()==LinkState::Ready) link.pump(100);
const auto& status=link.replay_status();
std::printf("replay: %s at %llu state %u fields %llu %s\n",op.save ? "start" : "stop",
static_cast<unsigned long long>(k),unsigned(status.state),
static_cast<unsigned long long>(status.fields),status.detail);
}
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))
Expand Down
Loading
Loading