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
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ if(RETRO_RUNTIME_TOOLS)
runner/net_session.cpp
runner/net_module.cpp
)
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)
add_test(NAME gpu_host_contract COMMAND retro-gpu-host-test)
# 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
97 changes: 96 additions & 1 deletion corelink/core_link.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,66 @@ void CoreLink::handle_packet(const std::vector<unsigned char>& buf,
identity_.draft_revision = m.draft_revision;
identity_.engine_dirty = m.engine_dirty != 0;
identity_.protocol_minor = std::min(m.protocol_minor, kProtocolMinor);
if (link_has_gpu_frames(identity_.protocol_minor)) {
GpuCapabilitiesMsg caps{};
caps.h.type = Msg::GpuCapabilities;
caps.caps = spec_.gpu_capabilities;
caps.caps.struct_size = sizeof caps.caps;
send_msg(channel_, caps);
}
break;
}
case Msg::GpuRing: {
GpuRingMsg m{};
if (!link_has_gpu_frames(identity_.protocol_minor) || !as_msg(buf, m) ||
m.ring.struct_size < sizeof m.ring || !m.ring.generation ||
!m.ring.image_count || m.ring.image_count > RCORE_GPU_MAX_IMAGES ||
handles.size() != m.ring.image_count * 3u || gpu_events_.size() >= 32) {
exit_reason_ = "link: malformed GPU ring";
stop(0);
return;
}
GpuEvent event;
event.kind = GpuEvent::Kind::Register;
event.ring = m.ring;
event.generation = m.ring.generation;
event.handles.swap(handles);
gpu_events_.push_back(std::move(event));
break;
}
case Msg::GpuFrame: {
GpuFrameMsg m{};
if (!link_has_gpu_frames(identity_.protocol_minor) || !as_msg(buf, m) ||
m.frame.struct_size < sizeof m.frame || !m.frame.generation ||
!m.frame.frame_number || m.frame.frame_number > granted_ || !handles.empty() ||
m.frame.image_index >= RCORE_GPU_MAX_IMAGES || gpu_events_.size() >= 32) {
exit_reason_ = "link: malformed GPU frame";
stop(0);
return;
}
{
GpuEvent event;
event.kind = GpuEvent::Kind::Frame;
event.frame = m.frame;
event.generation = m.frame.generation;
gpu_events_.push_back(std::move(event));
}
break;
}
case Msg::GpuUnregister: {
GpuUnregisterMsg m{};
if (!link_has_gpu_frames(identity_.protocol_minor) || !as_msg(buf, m) ||
!m.generation || !handles.empty() || gpu_events_.size() >= 32) {
exit_reason_ = "link: malformed GPU retirement";
stop(0);
return;
}
{
GpuEvent event;
event.kind = GpuEvent::Kind::Unregister;
event.generation = m.generation;
gpu_events_.push_back(std::move(event));
}
break;
}
case Msg::SaveRegions: {
Expand Down Expand Up @@ -257,7 +317,18 @@ bool CoreLink::request_state(Msg type, const fs::path& path) {
return true;
}

bool CoreLink::request_save_state(const fs::path& path) { return request_state(Msg::SaveState, path); }
bool CoreLink::request_save_state(const fs::path& path, const std::vector<std::uint8_t>& thumbnail) {
if (!can_request_state()) return false;
if (!thumbnail.empty() && link_has_gpu_frames(identity_.protocol_minor)) {
if (thumbnail.size() != kStateThumbBytes) return false;
StateThumbnailMsg msg{};
msg.h.type = Msg::StateThumbnail;
msg.frame_number = done_;
std::copy(thumbnail.begin(), thumbnail.end(), msg.rgba);
if (!send_msg(channel_, msg)) return false;
}
return request_state(Msg::SaveState, path);
}
bool CoreLink::request_load_state(const fs::path& path) { return request_state(Msg::LoadState, path); }

bool CoreLink::accessory_data_supported() const {
Expand Down Expand Up @@ -364,6 +435,30 @@ void CoreLink::on_ended(int code) {
persist_saves(); // the hub's mappings survive the runner
}

std::optional<GpuEvent> CoreLink::poll_gpu_event() {
if (gpu_events_.empty()) return std::nullopt;
GpuEvent event = std::move(gpu_events_.front());
gpu_events_.pop_front();
return event;
}

bool CoreLink::gpu_status(std::uint64_t generation, std::uint32_t state,
std::uint32_t released,
const std::array<std::uint64_t, RCORE_GPU_MAX_IMAGES>& frames) {
if (!generation || !link_has_gpu_frames(identity_.protocol_minor) ||
state_ == LinkState::Idle || state_ == LinkState::Ended ||
(state != RCORE_GPU_RING_READY && state != RCORE_GPU_RING_REFUSED &&
state != RCORE_GPU_RING_RETIRED) ||
released >= (1u << RCORE_GPU_MAX_IMAGES)) return false;
GpuStatusMsg msg{};
msg.h.type = Msg::GpuStatus;
msg.generation = generation;
msg.state = state;
msg.released = released;
std::copy(frames.begin(), frames.end(), std::begin(msg.frame_number));
return send_msg(channel_, msg);
}

void CoreLink::stop(int grace_ms) {
if (state_ == LinkState::Idle) return;
if (process_.running()) {
Expand Down
32 changes: 31 additions & 1 deletion corelink/core_link.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include <map>
#include <optional>
#include <string>
#include <utility>
#include <vector>

namespace retro::corelink {
Expand All @@ -32,6 +33,7 @@ struct LaunchSpec {
fs::path session_dir; // runner.log, core.log, events.tsv, the core's cache
bool gl = true;
bool strict = false;
rcore_gpu_capabilities gpu_capabilities{}; // actual local import support, sampled once
std::map<std::string, std::string> options;
std::optional<fs::path> load_state;
// Seat N's Transfer Pak cartridge (argv --tpakN-rom), N = 1-4; empty for
Expand Down Expand Up @@ -95,6 +97,30 @@ struct LinkEvent {
std::string detail;
};

struct GpuEvent {
enum class Kind { Register, Frame, Unregister } kind = Kind::Register;
rcore_gpu_ring ring{};
rcore_gpu_frame frame{};
std::uint64_t generation = 0;
std::vector<NativeHandle> handles;
GpuEvent() = default;
GpuEvent(const GpuEvent&) = delete;
GpuEvent& operator=(const GpuEvent&) = delete;
GpuEvent(GpuEvent&& other) noexcept
: kind(other.kind), ring(other.ring), frame(other.frame),
generation(other.generation), handles(std::move(other.handles)) { other.handles.clear(); }
GpuEvent& operator=(GpuEvent&& other) noexcept {
if (this != &other) {
for (auto h : handles) close_handle(h);
kind = other.kind; ring = other.ring; frame = other.frame;
generation = other.generation; handles = std::move(other.handles);
other.handles.clear();
}
return *this;
}
~GpuEvent() { for (auto h : handles) close_handle(h); }
};

enum class LinkState {
Idle, // not started
Starting, // spawned; identity, load and saves in progress
Expand Down Expand Up @@ -143,7 +169,7 @@ class CoreLink {
// 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_save_state(const fs::path& path, const std::vector<std::uint8_t>& thumbnail = {});
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
Expand All @@ -168,6 +194,9 @@ class CoreLink {
// The picture last taken (nullptr before the first one).
const FrameInfo* frame_info() const;
const std::uint8_t* frame_pixels() const;
std::optional<GpuEvent> poll_gpu_event();
bool gpu_status(std::uint64_t generation, std::uint32_t state, std::uint32_t released,
const std::array<std::uint64_t, RCORE_GPU_MAX_IMAGES>& frames = {});

std::uint32_t audio_rate() const;
// The core's stated frame rate; false while it has stated none.
Expand Down Expand Up @@ -223,6 +252,7 @@ class CoreLink {
StateResult state_request_;
std::optional<StateResult> state_result_;
std::deque<AccessoryNotify> notifies_;
std::deque<GpuEvent> gpu_events_;
int exit_code_ = 0;
std::string exit_reason_;
};
Expand Down
49 changes: 46 additions & 3 deletions corelink/link_protocol.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ constexpr std::uint32_t kProtocolMajor = 2;
// message: a 2.2 runner sends it at its 2.1 size to a 2.1 hub, and a 2.2 hub
// reads a 2.1 runner's with both fields zero.
// 2.3: Grant appends the available drawable pixel area (rcore rev 9).
constexpr std::uint32_t kProtocolMinor = 3;
// 2.4: optional core-owned GPU rings, native handle transfer and release events.
constexpr std::uint32_t kProtocolMinor = 4;
constexpr bool link_has_gpu_frames(std::uint32_t peer_minor) {
return kProtocolMajor > 2 || peer_minor >= 4;
}
constexpr bool link_has_presentation_size(std::uint32_t peer_minor) {
return kProtocolMajor > 2 || peer_minor >= 3;
}
Expand Down Expand Up @@ -147,20 +151,58 @@ enum class Msg : std::uint32_t {
Exiting = 7, // the runner is stopping on purpose; the reason follows
StateDone = 8, // 1.1: how the last SaveState / LoadState went
AccessoryNotify = 9, // 2.1: a data accessory's bytes for the hub (accessory_notify)
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
// 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
AccessoryData = 69, // 2.1: a data accessory's bytes for the core (accessory_poll)
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
};

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

struct GpuCapabilitiesMsg {
MsgHeader h;
rcore_gpu_capabilities caps;
};
struct GpuRingMsg {
MsgHeader h;
// Handle fields are zero on wire; channel handles own the references.
rcore_gpu_ring ring;
};
struct GpuFrameMsg {
MsgHeader h;
rcore_gpu_frame frame;
};
struct GpuUnregisterMsg {
MsgHeader h;
std::uint64_t generation;
};
struct GpuStatusMsg {
MsgHeader h;
std::uint64_t generation;
std::uint32_t state;
std::uint32_t released;
// Match the submitted link-frame number; stale releases cannot free reuse.
std::uint64_t frame_number[RCORE_GPU_MAX_IMAGES];
};
constexpr std::size_t kStateThumbBytes = 160u * 120u * 4u;
struct StateThumbnailMsg {
MsgHeader h;
std::uint64_t frame_number;
std::uint8_t rgba[kStateThumbBytes];
};

struct HelloMsg {
MsgHeader h;
std::uint32_t protocol_major;
Expand Down Expand Up @@ -282,8 +324,9 @@ struct AccessoryDataMsg {
};
constexpr std::size_t kAccessoryMsgHeaderSize = offsetof(AccessoryDataMsg, data);

constexpr std::size_t kMaxMsgSize = sizeof(AccessoryDataMsg); // the largest message
static_assert(kMaxMsgSize >= sizeof(SaveRegionsMsg) && kMaxMsgSize >= sizeof(LogMsg) &&
constexpr std::size_t kMaxMsgSize = sizeof(StateThumbnailMsg); // the largest message
static_assert(kMaxMsgSize >= sizeof(AccessoryDataMsg) &&
kMaxMsgSize >= sizeof(SaveRegionsMsg) && kMaxMsgSize >= sizeof(LogMsg) &&
kMaxMsgSize >= sizeof(StateRequestMsg) && kMaxMsgSize >= sizeof(StateDoneMsg) &&
kMaxMsgSize >= sizeof(EventMsg),
"kMaxMsgSize must hold every message");
Expand Down
2 changes: 2 additions & 0 deletions corelink/transport.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ constexpr NativeHandle kNoHandle = -1;
#endif

void close_handle(NativeHandle h);
// Retain an independent reference in this process; caller still owns h.
NativeHandle retain_handle(NativeHandle h);

// A shared memory object and this process's mapping of it.
struct SharedMemory {
Expand Down
4 changes: 4 additions & 0 deletions corelink/transport_posix.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ void close_handle(NativeHandle h) {
if (h >= 0) ::close(h);
}

NativeHandle retain_handle(NativeHandle h) {
return h == kNoHandle ? kNoHandle : ::fcntl(h, F_DUPFD_CLOEXEC, 0);
}

// ---- memory -------------------------------------------------------------------

bool create_shared(std::size_t size, const char* name, SharedMemory& out, std::string* error) {
Expand Down
7 changes: 7 additions & 0 deletions corelink/transport_win32.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,13 @@ void close_handle(NativeHandle h) {
if (h != kNoHandle) CloseHandle(H(h));
}

NativeHandle retain_handle(NativeHandle h) {
HANDLE copy = nullptr;
if (!h || !DuplicateHandle(GetCurrentProcess(), H(h), GetCurrentProcess(), &copy,
0, FALSE, DUPLICATE_SAME_ACCESS)) return kNoHandle;
return N(copy);
}

// ---- memory -------------------------------------------------------------------

bool create_shared(std::size_t size, const char* name, SharedMemory& out, std::string* error) {
Expand Down
1 change: 1 addition & 0 deletions docs/CORE_ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ only the shape of the contract and why.
| Rev | Date | Change |
|---|---|---|
| 1 | 2026-09-23 | First draft. |
| 11 | 2026-10-19 | Optional `RCORE_CAP_GPU_FRAMES` and five appended host callbacks for a fixed actual-device offer, image-ring registration, resource status, shared-frame submission and retirement. CPU RGBA8 remains the fallback; no generated game ABI change. See [GPU_FRAMES.md](GPU_FRAMES.md). |
| 10 | 2026-10-14 | Optional pre-init `execution_profiles()` capability/qualification query; appended init `execution_profile` index, valid with `RCORE_INIT_PROFILE_AGREED`. The runner exchanges offers/ACKs before init/guest boot, folds the common profile and canonical capability transcript into existing netplay identity, and keeps selection fixed through replay/reset/rollback. Old runners retain the core's conservative startup default. |
| 9 | 2026-10-14 | Optional host `presentation_size(ctx, width, height)`: the available drawable physical pixel area before letterboxing, stable for a granted frame. Returns 0 and zeros when unavailable. Core checks host `struct_size` before reading the appended callback. Presentation policy only; never guest input or serialized simulation state. |
| 8 | 2026-10-08 | `rcore_frame::game_frame`: the pictures the GAME has finished, as distinct from the frames the machine ran (an N64 game drawing at 30 fps runs 60 VI fields a second). 0 = the core cannot tell them apart. For a host's readout only (link 2.2 carries it to the hub; `OVERLAY.md`), never pacing or netplay. n64lle fills it from `n64_vi_swap_count()`, the game's VI_ORIGIN changes. |
Expand Down
7 changes: 7 additions & 0 deletions docs/CORE_LINK.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ protocol is the same on all three.

## Transport

**2.4 (2026-10-19):** optional GPU ring/frame/retirement messages 10–12,
startup capabilities 70, ownership status 71 and explicit save thumbnail 72.
The existing shared frame-region layout is unchanged. These messages require
minor 4; old peers retain CPU frames. Native handles travel through the
platform transport rather than as raw integers. See
[GPU_FRAMES.md](GPU_FRAMES.md) for setup, lifetime, completion and fallback.

| Channel | What | Owner |
|---|---|---|
| control | `SOCK_SEQPACKET` socketpair, one message per packet; the runner's end at fd 3 | — |
Expand Down
Loading
Loading