From c6209ca026b99f70943504beeb8b41d6eb57cd5f Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 14 Oct 2026 10:43:20 -0700 Subject: [PATCH] feat: carry drawable presentation size through CoreLink grants --- CMakeLists.txt | 11 +++++++++++ README.md | 6 +++--- corelink/core_link.cpp | 9 +++++++-- corelink/core_link.hpp | 4 +++- corelink/link_io.hpp | 4 ++-- corelink/link_protocol.hpp | 13 +++++++++++-- corelink/link_test_main.cpp | 7 ++++++- docs/CORE_ABI.md | 1 + docs/CORE_LINK.md | 11 +++++++++++ include/rcore/rcore.h | 9 ++++++++- runner/host_session.cpp | 8 ++++++++ runner/host_session.hpp | 6 ++++++ runner/runner_link.cpp | 3 ++- tests/presentation_protocol_test.cpp | 19 +++++++++++++++++++ tests/presentation_test.cmake | 16 ++++++++++++++++ tests/rcore_fake_core.c | 10 ++++++++++ 16 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 tests/presentation_protocol_test.cpp create mode 100644 tests/presentation_test.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 0f93fed..b669937 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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=$ + -DRUNNER=$ + -DCORE=$ + -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 $ diff --git a/README.md b/README.md index c17f80b..9af78e3 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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: diff --git a/corelink/core_link.cpp b/corelink/core_link.cpp index 1ba0823..aeb5042 100644 --- a/corelink/core_link.cpp +++ b/corelink/core_link.cpp @@ -218,13 +218,18 @@ void CoreLink::handle_packet(const std::vector& 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; diff --git a/corelink/core_link.hpp b/corelink/core_link.hpp index 6e2db21..f11dbac 100644 --- a/corelink/core_link.hpp +++ b/corelink/core_link.hpp @@ -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_; } diff --git a/corelink/link_io.hpp b/corelink/link_io.hpp index 268d2ac..5fc66c4 100644 --- a/corelink/link_io.hpp +++ b/corelink/link_io.hpp @@ -40,8 +40,8 @@ inline bool packet_type(const std::vector& 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 diff --git a/corelink/link_protocol.hpp b/corelink/link_protocol.hpp index fd0c61d..10f51f1 100644 --- a/corelink/link_protocol.hpp +++ b/corelink/link_protocol.hpp @@ -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. @@ -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; @@ -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"); diff --git a/corelink/link_test_main.cpp b/corelink/link_test_main.cpp index 1545c96..646f5b5 100644 --- a/corelink/link_test_main.cpp +++ b/corelink/link_test_main.cpp @@ -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> script; struct StateOp { std::uint64_t at; @@ -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('='); @@ -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; diff --git a/docs/CORE_ABI.md b/docs/CORE_ABI.md index 890bf8e..4023d28 100644 --- a/docs/CORE_ABI.md +++ b/docs/CORE_ABI.md @@ -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`). | diff --git a/docs/CORE_LINK.md b/docs/CORE_LINK.md index b817b0c..47f15ed 100644 --- a/docs/CORE_LINK.md +++ b/docs/CORE_LINK.md @@ -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 diff --git a/include/rcore/rcore.h b/include/rcore/rcore.h index ee16000..1978222 100644 --- a/include/rcore/rcore.h +++ b/include/rcore/rcore.h @@ -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) @@ -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; /* ------------------------------------------------------------------------ */ diff --git a/runner/host_session.cpp b/runner/host_session.cpp index b501414..d1db2f1 100644 --- a/runner/host_session.cpp +++ b/runner/host_session.cpp @@ -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) { diff --git a/runner/host_session.hpp b/runner/host_session.hpp index da42708..7363836 100644 --- a/runner/host_session.hpp +++ b/runner/host_session.hpp @@ -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. @@ -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); @@ -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> options_; std::vector regions_; std::vector accessories_; diff --git a/runner/runner_link.cpp b/runner/runner_link.cpp index 254fc9f..623d518 100644 --- a/runner/runner_link.cpp +++ b/runner/runner_link.cpp @@ -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). diff --git a/tests/presentation_protocol_test.cpp b/tests/presentation_protocol_test.cpp new file mode 100644 index 0000000..a0cab55 --- /dev/null +++ b/tests/presentation_protocol_test.cpp @@ -0,0 +1,19 @@ +#include "link_io.hpp" +#include +#include +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 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; +} diff --git a/tests/presentation_test.cmake b/tests/presentation_test.cmake new file mode 100644 index 0000000..a93ee3b --- /dev/null +++ b/tests/presentation_test.cmake @@ -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() diff --git a/tests/rcore_fake_core.c b/tests/rcore_fake_core.c index f02dfe5..85268fd 100644 --- a/tests/rcore_fake_core.c +++ b/tests/rcore_fake_core.c @@ -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) {