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
8 changes: 8 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,14 @@ 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")
# 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>
--core $<TARGET_FILE:rcore_fake_core>
--rom ${CMAKE_CURRENT_SOURCE_DIR}/LICENSE --frames 10
--out ${CMAKE_CURRENT_BINARY_DIR}/test-link-stats)
set_tests_properties(link_frame_stats PROPERTIES
PASS_REGULAR_EXPRESSION "frame stats: game 5 work measured")
# probe_runner: what a host reads to choose and verify a runner.
add_test(NAME runner_probe COMMAND retro-core-link-test --probe $<TARGET_FILE:retro-core-runner>)
set_tests_properties(runner_probe PROPERTIES PASS_REGULAR_EXPRESSION
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ title's core.
|---|---|
| `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`. |
| `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. |
| `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. |
| `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. |
Expand Down
5 changes: 4 additions & 1 deletion corelink/core_link.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,12 @@ void CoreLink::handle_packet(const std::vector<unsigned char>& buf,
break;
case Msg::FrameDone: {
FrameDoneMsg m{};
if (!as_msg(buf, m)) break;
if (!as_msg(buf, m, kFrameDoneSize21)) break; // 2.1 runner: stats read 0
if (outstanding_ > 0) --outstanding_;
done_ = m.frame_number;
if (m.game_frame) stats_.game_frame = m.game_frame; // a frame with no picture
// keeps the last count
stats_.work_ns += m.work_ns;
break;
}
case Msg::Log: {
Expand Down
9 changes: 9 additions & 0 deletions corelink/core_link.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ enum class LinkState {
Ended, // the runner exited (see exit_code / exit_reason)
};

// What the finished frames add up to (link 2.2), for a host's readout with
// frames_done() (overlay Osd::note_counters). Both 0 from a 2.1 runner.
struct FrameStats {
std::uint64_t game_frame = 0; // pictures the game finished (rcore rev 8); 0 = untold
std::uint64_t work_ns = 0; // time run_frame took, summed over every frame done
};

class CoreLink {
public:
CoreLink() = default;
Expand All @@ -126,6 +133,7 @@ class CoreLink {
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_; }
const FrameStats& frame_stats() const { return stats_; }

// Savestates (link 1.1), between frames. The runner writes and checks the
// envelope at `path` (docs/CORE_ABI.md, "Savestates"). False without
Expand Down Expand Up @@ -205,6 +213,7 @@ class CoreLink {
RunnerProcess process_;
std::vector<Region> regions_;
std::uint64_t granted_ = 0, done_ = 0;
FrameStats stats_;
int outstanding_ = 0;
std::uint32_t front_ = 0; // the triple buffer's initial front slot
bool have_frame_ = false;
Expand Down
17 changes: 14 additions & 3 deletions corelink/link_io.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ bool send_msg(Channel& ch, M& m, const NativeHandle* handles = nullptr, std::siz
return send_packet(ch, &m, sizeof(M), handles, count);
}

// The first `size` bytes of `m`: a grown message sent at its older size to a
// peer speaking an older minor.
template <typename M>
bool send_msg_sized(Channel& ch, M& m, std::size_t size) {
if (size < sizeof(MsgHeader) || size > sizeof(M)) return false;
m.h.size = static_cast<std::uint32_t>(size);
return send_packet(ch, &m, size);
}

// The packet's type, once its size is at least a header.
inline bool packet_type(const std::vector<unsigned char>& buf, Msg& out) {
if (buf.size() < sizeof(MsgHeader)) return false;
Expand All @@ -30,11 +39,13 @@ inline bool packet_type(const std::vector<unsigned char>& buf, Msg& out) {
// (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).
// 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).
//
// 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.
// therefore be sent at its old size to a session speaking an older minor
// (send_msg_sized).
template <typename 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;
Expand Down
25 changes: 22 additions & 3 deletions corelink/link_protocol.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ constexpr std::uint32_t kProtocolMajor = 2;
// 2.1 (2026-10-01): AccessoryData (hub -> runner) and AccessoryNotify
// (runner -> hub), the bytes of a data accessory (rcore rev 7, docs/CORE_ABI.md
// "Accessory data"). Append-only: two new message types, nothing grown.
constexpr std::uint32_t kProtocolMinor = 1;
// 2.2 (2026-10-08): FrameDone grows game_frame and work_ns, the
// 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;
// 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 All @@ -50,6 +54,10 @@ constexpr bool link_has_savestates(std::uint32_t peer_minor) {
constexpr bool link_has_accessory_data(std::uint32_t peer_minor) {
return kProtocolMajor > 2 || peer_minor >= 1;
}
// FrameDone's frame statistics arrived in 2.2.
constexpr bool link_has_frame_stats(std::uint32_t peer_minor) {
return kProtocolMajor > 2 || peer_minor >= 2;
}
constexpr char kMagic[8] = {'R', 'C', 'L', 'I', 'N', 'K', '1', '\0'};

// Frame slots big enough for any frame a core presents: 640x480 at a console's
Expand Down Expand Up @@ -181,7 +189,15 @@ struct FrameDoneMsg {
std::uint64_t frame_number;
std::int32_t result; // run_frame's rcore_result
std::uint32_t _pad;
// 2.2. What the overlay's readout is made of, so it can tell the rate the
// machine runs at from the rate the game draws at. All 0 from a 2.1 runner.
std::uint64_t game_frame; // rcore_frame::game_frame at the core's last video_submit
// (rev 8); 0 = the core does not count the game's pictures
std::uint64_t work_ns; // how long this grant's run_frame took on the core
// thread (resims included; a netplay stall is not)
};
// FrameDone as protocol 2.1 sent it, for a peer speaking 2.1.
constexpr std::size_t kFrameDoneSize21 = 24;

struct LogMsg {
MsgHeader h;
Expand Down Expand Up @@ -272,7 +288,8 @@ static_assert(kMaxMsgSize >= sizeof(SaveRegionsMsg) && kMaxMsgSize >= sizeof(Log
// Linux GCC build laid it out when protocol 1.0 shipped; a compiler that
// disagrees fails here instead of corrupting a session (LINK_TRANSPORTS.md §6).
// Measured 2026-09-26 on GCC 16 x86_64 and confirmed identical on MinGW-w64.
// A change to any line below is a protocol major bump.
// A change to any offset below is a protocol major bump. A minor appends
// fields and adds their lines (and so grows a sizeof); it moves nothing.

static_assert(sizeof(FrameInfo) == 32 && alignof(FrameInfo) == 8, "FrameInfo");
static_assert(offsetof(FrameInfo, width) == 0, "FrameInfo::width");
Expand Down Expand Up @@ -324,10 +341,12 @@ static_assert(sizeof(SaveRegionsMsg) == 1168 && alignof(SaveRegionsMsg) == 8, "S
static_assert(offsetof(SaveRegionsMsg, h) == 0, "SaveRegionsMsg::h");
static_assert(offsetof(SaveRegionsMsg, count) == 8, "SaveRegionsMsg::count");
static_assert(offsetof(SaveRegionsMsg, region) == 16, "SaveRegionsMsg::region");
static_assert(sizeof(FrameDoneMsg) == 24 && alignof(FrameDoneMsg) == 8, "FrameDoneMsg");
static_assert(sizeof(FrameDoneMsg) == 40 && alignof(FrameDoneMsg) == 8, "FrameDoneMsg");
static_assert(offsetof(FrameDoneMsg, h) == 0, "FrameDoneMsg::h");
static_assert(offsetof(FrameDoneMsg, frame_number) == 8, "FrameDoneMsg::frame_number");
static_assert(offsetof(FrameDoneMsg, result) == 16, "FrameDoneMsg::result");
static_assert(offsetof(FrameDoneMsg, game_frame) == kFrameDoneSize21, "FrameDoneMsg::game_frame");
static_assert(offsetof(FrameDoneMsg, work_ns) == 32, "FrameDoneMsg::work_ns");
static_assert(sizeof(LogMsg) == 1036 && alignof(LogMsg) == 4, "LogMsg");
static_assert(offsetof(LogMsg, h) == 0, "LogMsg::h");
static_assert(offsetof(LogMsg, level) == 8, "LogMsg::level");
Expand Down
4 changes: 4 additions & 0 deletions corelink/link_test_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,10 @@ int main(int argc, char** argv) {
if (const FrameInfo* f = link.frame_info(); f && f->width && link.frame_pixels()) {
std::printf("picture: first byte %u\n", unsigned(link.frame_pixels()[0]));
}
// 2.2: what the overlay's readout is made of.
std::printf("frame stats: game %llu work %s\n",
static_cast<unsigned long long>(link.frame_stats().game_frame),
link.frame_stats().work_ns ? "measured" : "none");
std::printf("link-test: %llu frame(s) granted and done, runner exit %d, %u fault(s)\n",
static_cast<unsigned long long>(link.frames_done()), link.exit_code(), faults);
return (ok && !faults) ? 0 : 1;
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. |
| 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`). |
| 5 | 2026-09-25 | `set_frame_rate(num, den)` on the host table: the nominal frame rate as an exact fraction, stated after `load()` and whenever the guest reprograms its video timing. Closes the pacing gap the hub link found. |
Expand Down
9 changes: 9 additions & 0 deletions docs/CORE_LINK.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ 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.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
netplay stall not). They are what the overlay's FPS / VI / ms-per-VI readout
is made of (`OVERLAY.md`), and nothing else reads them: not pacing, not
netplay. A 2.2 runner sends `FrameDone` at its 2.1 size (24 bytes) to a 2.1
hub; a 2.2 hub reads a 2.1 runner's with both fields 0. The host side
sums them in `CoreLink::frame_stats()`.

## A session

```
Expand Down
52 changes: 40 additions & 12 deletions docs/OVERLAY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# The play overlay — `retro_overlay`

What a host draws over a running core: an FPS readout, a TURBO marker, a volume
What a host draws over a running core: a frame-rate readout, a TURBO marker, a volume
meter, toasts, and the save-state browser. Code: `overlay/` (the library),
`state/` (the savestate envelope it lists), and the runner's savestate path
(`runner/state_keeper.*`, link 1.1 in `CORE_LINK.md`).
Expand Down Expand Up @@ -35,20 +35,47 @@ host passes the time in, feeds it events, and draws the images it hands back.

| Piece | Where | When |
|---|---|---|
| FPS | top left | while the host's "show FPS" is on |
| Toasts | top left, under FPS | about 2 s after something happened ("Slot 3 saved") |
| FPS / VI / ms/VI | top left | while the host's "show FPS" is on |
| Toasts | top left, under the readout | about 2 s after something happened ("Slot 3 saved") |
| `>> TURBO` | top right, gold | while the host runs the core faster than its rate |
| Volume meter | right edge, centred | 1.5 s after the volume changes |
| Save states | centre | while the browser is open |

**FPS counts emulated frames**, the frames the core finished, not presents.
Under turbo the core runs several frames per present, and the reading should
say how fast the machine is running. The reading is the last 64 frames divided
by the time they span. It is not a mean of 1/dt: that reads high whenever
frames arrive unevenly. For example, 15 ms and 25 ms alternating gives a 1/dt
mean of 53.3 fps, where the true rate is 50. The reading starts afresh on a gap
over 0.5 s, when turbo starts or stops, and when the host calls `restart_fps()`
(the hub does on resuming from a pause).
**The readout is three rows** (2026-10-08; it was one "60 FPS" until then,
and that number was the VI rate):

```
FPS 30.0 the rate the GAME draws at: new pictures a second
VI 60.0 the rate the machine runs and presents frames at
ms/VI 4.21 emulation time per machine frame: the headroom
```

- **FPS** counts the game's own pictures, `rcore_frame::game_frame` (rcore
rev 8). On an N64 that is the game changing VI_ORIGIN
(`n64_vi_swap_count()`), so a game drawing every other field reads 30
while VI reads 60. A core that cannot tell them apart leaves it 0 and the row
reads `--`.
- **VI** counts the frames the core finished and presented: one per grant
(`CoreLink::frames_done()`), a VI field on an N64. Not the core's own
`frame_number`: n64lle's counts rollback replays and any field a request
ran past, and neither reached the screen.
- **ms/VI** is the runner's measured `run_frame` time over those frames (link
2.2 `work_ns`). It is the core thread's work, not the wall time between
fields (that is 1000 / VI): 16.7 ms is a 60 Hz frame's whole budget. A
2.1 runner does not measure it, and the row reads `--`.

Rates are counted on emulated frames, not presents. Under turbo the core runs
several frames per present, and the reading should say how fast the machine is
running. The host hands in running totals (`note_counters`, from
`CoreLink::frame_stats()`), whenever it looks; each rate is the change over the
last second divided by the time it spans. That is not a mean of 1/dt, which
reads high whenever frames arrive unevenly: 15 ms and 25 ms alternating gives a
1/dt mean of 53.3 fps, where the true rate is 50. The reading starts afresh on
a gap over 0.5 s, when a total goes backwards, when turbo starts or stops, and
when the host calls `restart_fps()` (the hub does on resuming from a pause);
the last reading stays up until the fresh window has one of its own. The digits
refresh four times a second, so they can be read. A host with no counters calls
`note_frame()` once per emulated frame and gets VI alone.

## Drawing it (the host's side)

Expand Down Expand Up @@ -160,7 +187,8 @@ These are the hub's choices, listed here because every core gets them:
## How it was checked (2026-09-26)

- **`retro-overlay-test`** (ctest `overlay`) checks:
- the FPS method, including uneven arrival and turbo;
- the readout's three rates, including uneven arrival, turbo, a reset and
a core that does not count (`--`);
- layer lifetimes and revisions, and `place()`;
- the browser: chord edges, auto-repeat, requests, refusals, marks;
- `InputGuard`;
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 7u /* draft-only counter; see docs/CORE_ABI.md */
#define RCORE_DRAFT_REVISION 8u /* draft-only counter; see docs/CORE_ABI.md */

#if defined(_WIN32)
# define RCORE_EXPORT __declspec(dllexport)
Expand Down Expand Up @@ -128,6 +128,13 @@ typedef struct rcore_frame {
uint32_t _pad0;
const void* pixels; /* valid only for the duration of video_submit() */
uint64_t frame_number; /* core's own count, monotonically increasing */
/* rev 8. 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
* and finishes 30 pictures. Counted from load(); 0 = the core cannot tell
* them apart (or none finished yet). A host reads it only when
* struct_size covers it, and only for its readout: never for pacing,
* never for netplay. */
uint64_t game_frame;
} rcore_frame;

/* ------------------------------------------------------------------------ */
Expand Down
Loading
Loading