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
11 changes: 11 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,17 @@ if(RETRO_RUNTIME_TOOLS)
--out ${CMAKE_CURRENT_BINARY_DIR}/test-link)
set_tests_properties(link_fake_core PROPERTIES
PASS_REGULAR_EXPRESSION "10 frame\\(s\\) granted and done, runner exit 0")
add_test(NAME link_presentation_size
COMMAND ${CMAKE_COMMAND}
-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
-DOUT=${CMAKE_CURRENT_BINARY_DIR}/test-presentation
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/presentation_test.cmake)
add_executable(retro-presentation-protocol-test tests/presentation_protocol_test.cpp)
target_link_libraries(retro-presentation-protocol-test PRIVATE retro_corelink)
add_test(NAME presentation_protocol COMMAND retro-presentation-protocol-test)
# Link 2.2: FrameDone carries the game's count and the runner's timing.
add_test(NAME link_frame_stats
COMMAND retro-core-link-test --runner $<TARGET_FILE:retro-core-runner>
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ title's core.

| Path | What |
|---|---|
| `include/rcore/rcore.h` | The host ↔ core contract. A core is a shared library exporting one symbol, `rcore_entry`. Draft revision 7, `RCORE_ABI_MAJOR 0`. |
| `include/rcore/rcore.h` | The host ↔ core contract. A core is a shared library exporting one symbol, `rcore_entry`. Draft revision 9, `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 the FPS / VI / ms-per-VI readout, 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. |
Expand Down Expand Up @@ -76,8 +76,8 @@ 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 7 |
| Link protocol | `kProtocolMajor` / `kProtocolMinor` | 2.1 (accessory data) |
| rcore ABI | `RCORE_ABI_MAJOR` / `RCORE_DRAFT_REVISION` | 0 (draft), revision 9 |
| Link protocol | `kProtocolMajor` / `kProtocolMinor` | 2.3 (presentation area) |
| Sidecar manifest | `abi_major`, `draft_revision` | follows the ABI |

The runner can therefore update separately from hosts and cores:
Expand Down
9 changes: 7 additions & 2 deletions corelink/core_link.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -218,13 +218,18 @@ void CoreLink::handle_packet(const std::vector<unsigned char>& buf,
}
}

bool CoreLink::grant(const rcore_pad pads[RCORE_MAX_SEATS]) {
bool CoreLink::grant(const rcore_pad pads[RCORE_MAX_SEATS], std::uint32_t width, std::uint32_t height) {
if (!can_grant()) return false;
GrantMsg g{};
g.h.type = Msg::Grant;
g.frame_number = granted_ + 1;
for (std::uint32_t i = 0; i < RCORE_MAX_SEATS; ++i) g.pads[i] = pads[i];
if (!send_msg(channel_, g)) return false;
if (width && height) {
g.presentation_width = width;
g.presentation_height = height;
}
const auto bytes = link_has_presentation_size(identity_.protocol_minor) ? sizeof(g) : kGrantSize22;
if (!send_msg_sized(channel_, g, bytes)) return false;
++granted_;
++outstanding_;
return true;
Expand Down
4 changes: 3 additions & 1 deletion corelink/core_link.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ class CoreLink {
bool can_grant() const {
return state_ == LinkState::Ready && outstanding_ == 0 && !state_pending_;
}
bool grant(const rcore_pad pads[RCORE_MAX_SEATS]);
// Available physical pixels before letterboxing; zeros when unknown.
bool grant(const rcore_pad pads[RCORE_MAX_SEATS], std::uint32_t width = 0,
std::uint32_t height = 0);
std::uint64_t frames_granted() const { return granted_; }
std::uint64_t frames_done() const { return done_; }
const FrameStats& frame_stats() const { return stats_; }
Expand Down
4 changes: 2 additions & 2 deletions corelink/link_io.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ inline bool packet_type(const std::vector<unsigned char>& buf, Msg& out) {
// 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. FrameDone
// grew in 2.2 (its reader passes kFrameDoneSize21); every other reader passes
// sizeof(M).
// grew in 2.2 (kFrameDoneSize21), Grant in 2.3 (kGrantSize22); unchanged
// message readers pass 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
Expand Down
13 changes: 11 additions & 2 deletions corelink/link_protocol.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ constexpr std::uint32_t kProtocolMajor = 2;
// numbers behind the overlay's FPS / VI / ms-per-VI readout. The first grown
// 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.
constexpr std::uint32_t kProtocolMinor = 2;
// 2.3: Grant appends the available drawable pixel area (rcore rev 9).
constexpr std::uint32_t kProtocolMinor = 3;
constexpr bool link_has_presentation_size(std::uint32_t peer_minor) {
return kProtocolMajor > 2 || peer_minor >= 3;
}
// A link only comes up between peers of the same major, and a new major
// carries every message the old one had: savestates arrived in 1.1, so a 2.x
// peer has them whatever its minor. Gate on this, never on the minor alone.
Expand Down Expand Up @@ -233,7 +237,10 @@ struct GrantMsg {
MsgHeader h;
std::uint64_t frame_number; // 1-based, as the headless runner counts
rcore_pad pads[RCORE_MAX_SEATS];
// 2.3: before letterboxing; both zero means unavailable.
std::uint32_t presentation_width, presentation_height;
};
constexpr std::size_t kGrantSize22 = 240;

struct EmptyMsg {
MsgHeader h;
Expand Down Expand Up @@ -364,10 +371,12 @@ static_assert(offsetof(ExitingMsg, reason) == 12, "ExitingMsg::reason");
static_assert(sizeof(SavesFilledMsg) == 232 && alignof(SavesFilledMsg) == 4, "SavesFilledMsg");
static_assert(offsetof(SavesFilledMsg, h) == 0, "SavesFilledMsg::h");
static_assert(offsetof(SavesFilledMsg, pads) == 8, "SavesFilledMsg::pads");
static_assert(sizeof(GrantMsg) == 240 && alignof(GrantMsg) == 8, "GrantMsg");
static_assert(sizeof(GrantMsg) == 248 && alignof(GrantMsg) == 8, "GrantMsg");
static_assert(offsetof(GrantMsg, h) == 0, "GrantMsg::h");
static_assert(offsetof(GrantMsg, frame_number) == 8, "GrantMsg::frame_number");
static_assert(offsetof(GrantMsg, pads) == 16, "GrantMsg::pads");
static_assert(offsetof(GrantMsg, presentation_width) == kGrantSize22, "GrantMsg::presentation_width");
static_assert(offsetof(GrantMsg, presentation_height) == 244, "GrantMsg::presentation_height");
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");
Expand Down
7 changes: 6 additions & 1 deletion corelink/link_test_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ int main(int argc, char** argv) {
spec.gl = false;
std::uint64_t frames = 60;
bool seat0 = true;
bool presentation_test = false;
std::vector<std::pair<std::uint64_t, std::uint32_t>> script;
struct StateOp {
std::uint64_t at;
Expand Down Expand Up @@ -154,6 +155,7 @@ int main(int argc, char** argv) {
else if (a == "--gl") spec.gl = true;
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 == "--opt") {
const std::string kv = val();
const auto eq = kv.find('=');
Expand Down Expand Up @@ -242,7 +244,10 @@ int main(int argc, char** argv) {
if (at <= k) pads[0].buttons = buttons;
}
}
if (!link.grant(pads)) die("grant " + std::to_string(k) + " refused");
// 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");
while (link.state() == LinkState::Ready && link.frames_done() < k) link.pump(100);
print_notifies();
if (link.state() != LinkState::Ready) ok = false;
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. |
| 9 | 2026-10-14 | Optional host `presentation_size(ctx, width, height)`: the available drawable physical pixel area before letterboxing, stable for a granted frame. Returns 0 and zeros when unavailable. Core checks host `struct_size` before reading the appended callback. Presentation policy only; never guest input or serialized simulation state. |
| 8 | 2026-10-08 | `rcore_frame::game_frame`: the pictures the GAME has finished, as distinct from the frames the machine ran (an N64 game drawing at 30 fps runs 60 VI fields a second). 0 = the core cannot tell them apart. For a host's readout only (link 2.2 carries it to the hub; `OVERLAY.md`), never pacing or netplay. n64lle fills it from `n64_vi_swap_count()`, the game's VI_ORIGIN changes. |
| 7 | 2026-10-01 | `accessory_poll` / `accessory_notify` on the host table and `RCORE_CAP_ACCESSORY_DATA`: bytes between the host and a plugged accessory whose input is not a pad, host->core drained at the frame boundary and stable for the frame, core->host for the host's UI. The first such accessory is `n64.vru`, the Voice Recognition Unit (n64lle PR #65 gave it a debug-port seam; Alex's ruling 2026-10-01: a seat's pak dropdown selects "VRU Microphone" and the hub owns the recognizer). |
| 6 | 2026-09-29 | `net_row_from_pad` / `net_row_to_pad` and `rcore_net_row`: a core's own netplay input row. A row is 16 buttons and one 8-bit stick, and n64lle's C buttons ride `RCORE_AXIS_RX/RY`, so rev 4's "pads need no core hook" did not hold (amended at its claim site in `rcore.h`). Driven by the Direct-mode netplay work (Retro-Launcher `docs/NETPLAY_DIRECT.md`). |
Expand Down
11 changes: 11 additions & 0 deletions docs/CORE_LINK.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ region's layout: a major, so a 1.x peer is refused at hello.
(hub to runner) and `AccessoryNotify = 9` (runner to hub), described under
"Accessory data" below. Two new message types, nothing grown.

**2.3** (2026-10-14) appends `presentation_width` and `presentation_height`
to `Grant` (248 bytes; its 2.2 prefix remains 240 bytes). The hub sends the
available drawable area in physical pixels **before** aspect letterboxing;
zero in either dimension means unavailable. The runner stores it before the
grant runs and serves it through rcore rev 9 `presentation_size`, unchanged
for the grant and its resim calls. Headless sessions report unavailable.
A 2.3 hub sends only the 240-byte prefix to an older runner; a 2.3 runner
accepts an older hub's prefix with both dimensions zero. Frame slots and
shared memory do not change. This local presentation state is neither pad
input nor netplay identity and does not enter serialized simulation state.

**2.2** (2026-10-08) grows `FrameDone`, the first message to grow. It appends
`game_frame` (rcore rev 8: pictures the game finished, as of the core's last
picture; 0 = the core does not say) and `work_ns` (how long `run_frame` took on the core thread, resims included, a
Expand Down
9 changes: 8 additions & 1 deletion include/rcore/rcore.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ extern "C" {

#define RCORE_ABI_MAJOR 0u /* 0 = draft; the first implemented contract is 1 */
#define RCORE_ABI_MINOR 0u
#define RCORE_DRAFT_REVISION 8u /* draft-only counter; see docs/CORE_ABI.md */
#define RCORE_DRAFT_REVISION 9u /* draft-only counter; see docs/CORE_ABI.md */

#if defined(_WIN32)
# define RCORE_EXPORT __declspec(dllexport)
Expand Down Expand Up @@ -415,6 +415,13 @@ typedef struct rcore_host_api {
* a frame. */
void (*accessory_notify)(void* host_ctx, uint32_t seat, uint32_t slot,
const void* data, size_t len);
/* --- appended in draft revision 9: host presentation area --- */
/* Available drawable area in physical pixels, BEFORE aspect letterboxing.
* Returns 1 and nonzero dimensions when known, 0 and zeros otherwise.
* Optional: check struct_size before reading this slot. Stable throughout
* a granted frame, including its resim calls. Presentation policy only;
* never guest input, session identity, or serialized simulation state. */
uint32_t (*presentation_size)(void* host_ctx, uint32_t* width, uint32_t* height);
} rcore_host_api;

/* ------------------------------------------------------------------------ */
Expand Down
8 changes: 8 additions & 0 deletions runner/host_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ HostSession::HostSession(const LoadedCore& core, Sink& sink) : core_(core), sink
host_.set_frame_rate = h_frame_rate;
host_.accessory_poll = h_accessory_poll;
host_.accessory_notify = h_accessory_notify;
host_.presentation_size = h_presentation_size;
}

std::uint32_t HostSession::h_presentation_size(void* ctx, std::uint32_t* width, std::uint32_t* height) {
const auto* s = self(ctx);
if (width) *width = s->presentation_width_;
if (height) *height = s->presentation_height_;
return s->presentation_width_ && s->presentation_height_ ? 1u : 0u;
}

void HostSession::bind_data_accessory(std::uint32_t seat, std::uint32_t slot) {
Expand Down
6 changes: 6 additions & 0 deletions runner/host_session.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ class HostSession {

// The host table the core is given. Stable for the session's lifetime.
const rcore_host_api* host_api() const { return &host_; }
void set_presentation_size(std::uint32_t width, std::uint32_t height) {
presentation_width_ = width && height ? width : 0;
presentation_height_ = width && height ? height : 0;
}

// After load(): allocate every declared region, filled with its erase
// value, then from `files[id]` where one is given.
Expand Down Expand Up @@ -131,6 +135,7 @@ class HostSession {
static void* h_gl_proc(void*, const char*);
static std::uint64_t h_wall_clock(void*);
static void h_frame_rate(void*, std::uint32_t, std::uint32_t);
static std::uint32_t h_presentation_size(void*, std::uint32_t*, std::uint32_t*);
static std::size_t h_accessory_poll(void*, std::uint32_t, std::uint32_t, void*, std::size_t);
static void h_accessory_notify(void*, std::uint32_t, std::uint32_t, const void*, std::size_t);

Expand All @@ -145,6 +150,7 @@ class HostSession {
const LoadedCore& core_;
Sink& sink_;
rcore_host_api host_{};
std::uint32_t presentation_width_ = 0, presentation_height_ = 0;
std::map<std::string, std::optional<std::string>> options_;
std::vector<SaveRegion> regions_;
std::vector<AccessoryQueue> accessories_;
Expand Down
3 changes: 2 additions & 1 deletion runner/runner_link.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -504,12 +504,13 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li
continue;
}
GrantMsg g{};
if (t != Msg::Grant || !as_msg(buf, g)) {
if (t != Msg::Grant || !as_msg(buf, g, kGrantSize22)) {
code = exiting(sock, 2, "link: unexpected message from the hub");
break;
}
std::copy(std::begin(g.pads), std::end(g.pads), std::begin(sink.pads));
sink.frame_number = g.frame_number;
session.set_presentation_size(g.presentation_width, g.presentation_height);
// Everything that arrived before this grant is the frame's accessory
// sequence; a message that arrives while the frame runs waits for the
// next (the runner reads nothing from the hub mid-frame).
Expand Down
19 changes: 19 additions & 0 deletions tests/presentation_protocol_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#include "link_io.hpp"
#include <cstring>
#include <vector>
using namespace retro::corelink;
int main() {
if (link_has_presentation_size(2) || !link_has_presentation_size(3)) return 1;
GrantMsg sent{}; sent.h.type = Msg::Grant; sent.frame_number = 42;
sent.pads[0].buttons = 123; sent.presentation_width = 2560; sent.presentation_height = 1080;
std::vector<unsigned char> bytes(sizeof sent);
std::memcpy(bytes.data(), &sent, sizeof sent);
GrantMsg got{};
if (!as_msg(bytes, got, kGrantSize22) || got.presentation_width != 2560 || got.presentation_height != 1080) return 2;
bytes.resize(kGrantSize22);
if (!as_msg(bytes, got, kGrantSize22) || got.frame_number != 42 || got.pads[0].buttons != 123 ||
got.presentation_width || got.presentation_height) return 3;
bytes.resize(kGrantSize22 - 1);
if (as_msg(bytes, got, kGrantSize22)) return 4;
return 0;
}
16 changes: 16 additions & 0 deletions tests/presentation_test.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
execute_process(COMMAND "${LINK_TEST}" --runner "${RUNNER}" --core "${CORE}"
--rom "${ROM}" --out "${OUT}" --frames 4 --presentation-test RESULT_VARIABLE rc)
if(NOT rc EQUAL 0)
message(FATAL_ERROR "presentation link run failed: ${rc}")
endif()
file(READ "${OUT}/core.log" log)
foreach(expected
"frame=1 known=1 size=1280x720"
"frame=2 known=1 size=2560x1080"
"frame=3 known=0 size=0x0"
"frame=4 known=1 size=2560x1080")
string(FIND "${log}" "FAKE_PRESENTATION ${expected}" pos)
if(pos EQUAL -1)
message(FATAL_ERROR "missing callback result: ${expected}\n${log}")
endif()
endforeach()
10 changes: 10 additions & 0 deletions tests/rcore_fake_core.c
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,16 @@ static void die_now(void) {

static rcore_result run_frame(void) {
++g_frames;
if (g_host->struct_size >= offsetof(rcore_host_api, presentation_size) + sizeof(g_host->presentation_size) &&
g_host->presentation_size) {
uint32_t w = 99, h = 99, w2 = 0, h2 = 0;
uint32_t known = g_host->presentation_size(g_host->host_ctx, &w, &h);
uint32_t known2 = g_host->presentation_size(g_host->host_ctx, &w2, &h2);
char line[160];
if (known != known2 || w != w2 || h != h2 || (!known && (w || h))) return RCORE_ERR_STATE;
snprintf(line, sizeof line, "FAKE_PRESENTATION frame=%llu known=%u size=%ux%u", g_frames, known, w, h);
g_host->log(g_host->host_ctx, RCORE_LOG_INFO, line);
}
if (g_crash_at && g_frames == g_crash_at) die_now();
#if defined(FAKE_ACCESSORY_DATA)
{
Expand Down
Loading