From ed79d1674aff74a61dc6e6efe881898f2a1d9be0 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Thu, 8 Oct 2026 12:33:56 -0400 Subject: [PATCH] overlay: FPS / VI / ms-per-VI readout (rcore rev 8, link 2.2) The readout's one "60 FPS" number was the VI rate: one tick per run_frame. It is now three labelled rows: FPS the game's own pictures a second (rcore_frame::game_frame, rev 8) VI frames the core finished and presented a second (frames done) ms/VI the runner's measured run_frame time per frame (link 2.2 work_ns) - rcore.h rev 8: rcore_frame appends game_frame (0 = the core cannot tell). - Link 2.2: FrameDone grows game_frame and work_ns, the first grown message. A 2.2 runner sends it at its 2.1 size to a 2.1 hub (send_msg_sized); a 2.2 hub reads a 2.1 runner's with both zero. CoreLink::frame_stats() sums them. - Osd::note_counters takes running totals; rates span the last second, FPS anchored on the samples where the game's count moved so a window edge does not read 29.5 / 30.5 for a 30 fps game. Digits refresh 4x a second; labels dim, values white; "--" for a reading the core or link cannot supply. note_frame() remains for a host with no counters (VI only). - Tests: overlay (counters, uneven arrival, turbo, reset, pause, labels), link_frame_stats (fake core game 5 of 10, work measured). --dump renders both readouts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P8WyraAmnCpC7UwtCEWdw5 --- CMakeLists.txt | 8 ++ README.md | 2 +- corelink/core_link.cpp | 5 +- corelink/core_link.hpp | 9 +++ corelink/link_io.hpp | 17 ++++- corelink/link_protocol.hpp | 25 +++++- corelink/link_test_main.cpp | 4 + docs/CORE_ABI.md | 1 + docs/CORE_LINK.md | 9 +++ docs/OVERLAY.md | 52 ++++++++++--- include/rcore/rcore.h | 9 ++- overlay/osd.cpp | 148 +++++++++++++++++++++++++++--------- overlay/overlay.hpp | 68 +++++++++++++---- runner/runner_link.cpp | 31 +++++++- tests/overlay_test.cpp | 106 ++++++++++++++++++++++---- tests/rcore_fake_core.c | 1 + 16 files changed, 405 insertions(+), 90 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b2f121..69e3118 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 $ + --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 $) set_tests_properties(runner_probe PROPERTIES PASS_REGULAR_EXPRESSION diff --git a/README.md b/README.md index 5c10efc..c17f80b 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/corelink/core_link.cpp b/corelink/core_link.cpp index c61a35c..1ba0823 100644 --- a/corelink/core_link.cpp +++ b/corelink/core_link.cpp @@ -173,9 +173,12 @@ void CoreLink::handle_packet(const std::vector& 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: { diff --git a/corelink/core_link.hpp b/corelink/core_link.hpp index 17f12ac..6e2db21 100644 --- a/corelink/core_link.hpp +++ b/corelink/core_link.hpp @@ -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; @@ -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 @@ -205,6 +213,7 @@ class CoreLink { RunnerProcess process_; std::vector 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; diff --git a/corelink/link_io.hpp b/corelink/link_io.hpp index a253519..268d2ac 100644 --- a/corelink/link_io.hpp +++ b/corelink/link_io.hpp @@ -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 +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(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& buf, Msg& out) { if (buf.size() < sizeof(MsgHeader)) return false; @@ -30,11 +39,13 @@ inline bool packet_type(const std::vector& 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 bool as_msg(const std::vector& buf, M& out, std::size_t min_size = sizeof(M)) { if (buf.size() < min_size || min_size < sizeof(MsgHeader)) return false; diff --git a/corelink/link_protocol.hpp b/corelink/link_protocol.hpp index 79a651d..fd0c61d 100644 --- a/corelink/link_protocol.hpp +++ b/corelink/link_protocol.hpp @@ -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. @@ -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 @@ -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; @@ -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"); @@ -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"); diff --git a/corelink/link_test_main.cpp b/corelink/link_test_main.cpp index bf2fa8f..1545c96 100644 --- a/corelink/link_test_main.cpp +++ b/corelink/link_test_main.cpp @@ -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(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(link.frames_done()), link.exit_code(), faults); return (ok && !faults) ? 0 : 1; diff --git a/docs/CORE_ABI.md b/docs/CORE_ABI.md index 3d8bc81..13e6c8b 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. | +| 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. | diff --git a/docs/CORE_LINK.md b/docs/CORE_LINK.md index 7fcb568..b817b0c 100644 --- a/docs/CORE_LINK.md +++ b/docs/CORE_LINK.md @@ -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 ``` diff --git a/docs/OVERLAY.md b/docs/OVERLAY.md index 3f046a5..d358521 100644 --- a/docs/OVERLAY.md +++ b/docs/OVERLAY.md @@ -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`). @@ -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) @@ -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`; diff --git a/include/rcore/rcore.h b/include/rcore/rcore.h index b9f8b15..ee16000 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 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) @@ -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; /* ------------------------------------------------------------------------ */ diff --git a/overlay/osd.cpp b/overlay/osd.cpp index f469bdf..9be1d33 100644 --- a/overlay/osd.cpp +++ b/overlay/osd.cpp @@ -1,10 +1,11 @@ -// The OSD: FPS readout, TURBO marker, volume meter, toasts. +// The OSD: frame-rate readout, TURBO marker, volume meter, toasts. // // Carried from snesrecomp's snes_osd.c (itself psxrecomp's host_osd.c): the -// same font at 2x, the same boxes, the same volume meter, the same 64-frame -// FPS mean counted on emulated frames. What changed is where the pieces sit -// -- TURBO has its own corner instead of trailing the FPS line -- and that -// the time comes from the host instead of SDL. +// same font at 2x, the same boxes, the same volume meter, rates counted on +// emulated frames. What changed is where the pieces sit -- TURBO has its own +// corner instead of trailing the FPS line -- that the time comes from the +// host instead of SDL, and that the one FPS number became three (FPS, VI, +// ms/VI: n64lle's own host showed the same three, host_present.rs). #include "overlay.hpp" #include "overlay_raster.hpp" @@ -24,6 +25,9 @@ constexpr int kPad = 2 * kScale; // inside every box constexpr int kRowGap = 1 * kScale; constexpr std::uint64_t kNsPerMs = 1000000ull; constexpr std::uint64_t kFpsGapNs = 500 * kNsPerMs; // longer than this is a break +constexpr std::uint64_t kRateWindowNs = 1000 * kNsPerMs; // rates span the last second +constexpr std::uint64_t kReadoutEveryNs = 250 * kNsPerMs; // digits a player can read +constexpr int kLabelCols = 6; // "ms/VI " and the value after it constexpr std::uint64_t kVolumeShowNs = 1500 * kNsPerMs; // The volume meter, in font pixels (x kScale in the image). @@ -48,25 +52,67 @@ void Osd::set_fps_visible(bool on) { // Start from a clean window: an average built partly from before the // readout opened reports frames the player never watched. restart_fps(); + readout_.clear(); } -void Osd::restart_fps() { pos_ = count_ = 0; } +// The readout keeps the text it last showed until the new window can say +// something, then refreshes at once. +void Osd::restart_fps() { + pos_ = count_ = 0; + readout_stale_ = true; +} -void Osd::note_frame(std::uint64_t now_ns) { +void Osd::note_counters(std::uint64_t now_ns, const FrameCounters& c) { + if (c.game) game_counted_ = true; if (count_) { - const std::uint64_t last = stamps_[(pos_ + kFpsWindow - 1) % kFpsWindow]; - if (now_ns < last || now_ns - last > kFpsGapNs) restart_fps(); + const Sample& last = samples_[(pos_ + kSamples - 1) % kSamples]; + if (c.frames == last.c.frames) return; // nothing ran since + const bool back = + c.frames < last.c.frames || c.game < last.c.game || c.work_ns < last.c.work_ns; + if (back || now_ns < last.ns || now_ns - last.ns > kFpsGapNs) restart_fps(); } - stamps_[pos_] = now_ns; - pos_ = (pos_ + 1) % kFpsWindow; - if (count_ < kFpsWindow) ++count_; + samples_[pos_] = {now_ns, c}; + pos_ = (pos_ + 1) % kSamples; + if (count_ < kSamples) ++count_; +} + +void Osd::note_frame(std::uint64_t now_ns) { + // Counted on from the last sample, so a gap restarts the window but the + // total never goes backwards. + note_counters(now_ns, {++own_frames_, 0, 0}); } -double Osd::fps() const { - if (count_ < 2) return 0.0; - const std::uint64_t newest = stamps_[(pos_ + kFpsWindow - 1) % kFpsWindow]; - const std::uint64_t oldest = stamps_[(pos_ + kFpsWindow - count_) % kFpsWindow]; - return newest > oldest ? double(count_ - 1) * 1e9 / double(newest - oldest) : 0.0; +FrameRates Osd::rates() const { + FrameRates r; + if (count_ < 2) return r; + auto at = [&](int i) -> const Sample& { return samples_[(pos_ + kSamples - count_ + i) % kSamples]; }; + const Sample& n = at(count_ - 1); + int i = 0; + while (i < count_ - 2 && n.ns - at(i).ns > kRateWindowNs) ++i; + const Sample& o = at(i); + if (n.ns <= o.ns) return r; + const double dt = double(n.ns - o.ns); + const std::uint64_t frames = n.c.frames - o.c.frames; + r.vi = double(frames) * 1e9 / dt; + // FPS spans the game's own pictures: from the first sample in the window + // where the count moved to the last, so a window edge falling between two + // pictures does not read 29.5 or 30.5 for a game drawing at 30. + r.has_fps = game_counted_; + if (r.has_fps) { + int first = -1, last = -1; + for (int k = i > 0 ? i : 1; k < count_; ++k) { + if (at(k).c.game == at(k - 1).c.game) continue; + if (first < 0) first = k; + last = k; + } + if (first >= 0 && last > first) + r.fps = double(at(last).c.game - at(first).c.game) * 1e9 / + double(at(last).ns - at(first).ns); + } + const std::uint64_t work = n.c.work_ns - o.c.work_ns; + r.has_ms = frames && work; + if (r.has_ms) r.ms_per_vi = double(work) / 1e6 / double(frames); + return r; } void Osd::set_turbo(bool on) { @@ -86,22 +132,44 @@ void Osd::toast(const std::string& text_in, std::uint64_t now_ns, std::uint32_t toast_until_ns_ = now_ns + std::uint64_t(duration_ms ? duration_ms : 2000) * kNsPerMs; } +// "FPS 30.0" / "VI 60.0" / "ms/VI 4.21", or "--" where the reading +// is not available. +std::string Osd::format_readout(const FrameRates& r, bool have_window) { + char fps[16] = "--", vi[16] = "--", ms[16] = "--"; + if (have_window) std::snprintf(vi, sizeof vi, "%.1f", r.vi); + if (have_window && r.has_fps) std::snprintf(fps, sizeof fps, "%.1f", r.fps); + if (have_window && r.has_ms) std::snprintf(ms, sizeof ms, "%.2f", r.ms_per_vi); + char buf[96]; + std::snprintf(buf, sizeof buf, "%-*s%5s\n%-*s%5s\n%-*s%5s", kLabelCols, "FPS", fps, kLabelCols, + "VI", vi, kLabelCols, "ms/VI", ms); + return buf; +} + void Osd::rasterize_status() { - // status_drawn_ holds "\n"; either may be empty. - const auto nl = status_drawn_.find('\n'); - const std::string fps_line = status_drawn_.substr(0, nl); - const std::string toast_line = nl == std::string::npos ? "" : status_drawn_.substr(nl + 1); - const int rows = (fps_line.empty() ? 0 : 1) + (toast_line.empty() ? 0 : 1); - const std::size_t widest = std::max(fps_line.size(), toast_line.size()); + // status_drawn_ holds the rows, '\n'-separated: the readout's three (if + // showing; labels dimmer than their values), then the toast (if any). + std::vector rows; + for (std::size_t at = 0;;) { + const auto nl = status_drawn_.find('\n', at); + rows.push_back(status_drawn_.substr(at, nl == std::string::npos ? nl : nl - at)); + if (nl == std::string::npos) break; + at = nl + 1; + } + std::size_t widest = 0; + for (const std::string& r : rows) widest = std::max(widest, r.size()); + const int n = int(rows.size()); status_.resize(std::uint32_t(kPad * 2 + int(widest) * kGlyph * kScale), - std::uint32_t(kPad * 2 + rows * kGlyph * kScale + (rows - 1) * kRowGap), - color::kPanel); + std::uint32_t(kPad * 2 + n * kGlyph * kScale + (n - 1) * kRowGap), color::kPanel); int y = kPad; - if (!fps_line.empty()) { - text(status_, kPad, y, fps_line, color::kText, kScale); - y += (kGlyph + 1) * kScale; + for (int i = 0; i < n; ++i, y += (kGlyph + 1) * kScale) { + if (i < status_readout_rows_ && int(rows[i].size()) > kLabelCols) { + text(status_, kPad, y, rows[i].substr(0, kLabelCols), color::kSub, kScale); + text(status_, kPad + kLabelCols * kGlyph * kScale, y, rows[i].substr(kLabelCols), + color::kText, kScale); + } else { + text(status_, kPad, y, rows[i], color::kText, kScale); + } } - if (!toast_line.empty()) text(status_, kPad, y, toast_line, color::kText, kScale); ++status_rev_; } @@ -131,17 +199,27 @@ void Osd::rasterize_volume() { const std::vector& Osd::layers(std::uint64_t now_ns) { layers_.clear(); - std::string fps_line; if (fps_visible_) { - char buf[32]; - std::snprintf(buf, sizeof buf, "%d FPS", int(std::lround(fps()))); - fps_line = buf; + // Refreshed four times a second, and at once when a fresh window + // first has two samples; until then the last reading stands. + const bool have_window = count_ >= 2; + if (readout_.empty() || + (have_window && (readout_stale_ || now_ns - readout_ns_ >= kReadoutEveryNs))) { + readout_ = format_readout(rates(), have_window); + readout_ns_ = now_ns; + readout_stale_ = !have_window; + } } + const std::string fps_rows = fps_visible_ ? readout_ : std::string(); if (!toast_.empty() && now_ns >= toast_until_ns_) toast_.clear(); - const std::string want = toast_.empty() ? fps_line : fps_line + "\n" + toast_; + const std::string want = toast_.empty() ? fps_rows + : fps_rows.empty() ? toast_ + : fps_rows + "\n" + toast_; if (!want.empty()) { - if (want != status_drawn_) { + const int readout_rows = fps_rows.empty() ? 0 : 3; + if (want != status_drawn_ || readout_rows != status_readout_rows_) { status_drawn_ = want; + status_readout_rows_ = readout_rows; rasterize_status(); } layers_.push_back({kLayerStatus, &status_, status_rev_, Anchor::TopLeft}); diff --git a/overlay/overlay.hpp b/overlay/overlay.hpp index e6b5ea2..9ac8727 100644 --- a/overlay/overlay.hpp +++ b/overlay/overlay.hpp @@ -1,7 +1,7 @@ #pragma once -// retro_overlay -- what a host draws over a running core: the FPS readout -// (top left), the TURBO marker (top right), the volume meter (right edge), +// retro_overlay -- what a host draws over a running core: the frame-rate +// readout (top left), the TURBO marker (top right), the volume meter (right edge), // toasts, and the save-state browser (centre). docs/OVERLAY.md is the design. // // It lives here, in the runtime, and not in a core or a host, so that it looks @@ -77,26 +77,53 @@ struct Rect { // of the window (or shrinks to fit a window smaller than it). Rect place(const Layer& layer, float win_w, float win_h); -// ---- OSD: FPS, TURBO, volume, toasts ------------------------------------------ +// ---- OSD: frame rates, TURBO, volume, toasts ----------------------------------- + +// The readout's inputs, as running totals since the core loaded +// (corelink::FrameStats carries the same three over the link). +struct FrameCounters { + std::uint64_t frames = 0; // frames the machine has run: VI fields on an N64 + std::uint64_t game = 0; // pictures the GAME has finished (rcore_frame::game_frame); + // 0 = the core does not tell them apart + std::uint64_t work_ns = 0; // time the core took to run those frames; 0 = unmeasured +}; + +// Three rates, each over the last second of samples. +struct FrameRates { + double fps = 0; // the game's pictures per second (valid when has_fps) + double vi = 0; // machine frames per second: what is presented + double ms_per_vi = 0; // emulation time per machine frame (valid when has_ms) + bool has_fps = false, has_ms = false; +}; class Osd { public: Osd(); - // FPS readout, top left. The host's "show FPS" setting and its hotkey - // both set this; the OSD only draws what it is told. + // The frame-rate readout, top left. The host's "show FPS" setting and its + // hotkey both set this; the OSD only draws what it is told. Three rows: + // FPS the rate the game draws at (an N64 game at 30 reads 30 here) + // VI the rate the machine runs and presents frames at (60 there) + // ms/VI how long the core took to emulate one of them: the headroom, + // against the 16.7 ms a 60 Hz frame allows + // A reading the core or the link cannot supply shows "--". void set_fps_visible(bool on); bool fps_visible() const { return fps_visible_; } - // Once per EMULATED frame -- each frame the core finished -- not per - // present. Under turbo the core runs several frames per present, and the - // readout should say how fast the machine is running, which is what a - // player holding turbo is asking. Frames over the time they span, for the - // last 64 -- not a mean of 1/dt, which reads high whenever frames arrive - // unevenly. A gap of more than half a second starts it afresh rather than - // averaging the gap in. + // The totals as they stand, whenever the host has looked (once a host + // frame is plenty; calls where nothing moved are ignored). Rates are the + // change over the last second of these, 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. A total that goes + // backwards (a reset) or half a second without a frame starts it afresh + // rather than averaging the break in. + void note_counters(std::uint64_t now_ns, const FrameCounters& c); + // One more machine frame and nothing else known: for a host with no + // counters. FPS and ms/VI then read "--". void note_frame(std::uint64_t now_ns); - double fps() const; + FrameRates rates() const; + // The rows as last drawn (or about to be): "FPS 30.0\nVI 60.0\n...". + const std::string& readout() const { return readout_; } // Start the reading afresh: a host calls it when play resumes after a // pause or a menu. Turbo starting or stopping does it too. void restart_fps(); @@ -118,14 +145,24 @@ class Osd { private: void rasterize_status(); + static std::string format_readout(const FrameRates& r, bool have_window); void rasterize_volume(); bool fps_visible_ = false; bool turbo_ = false; - static constexpr int kFpsWindow = 64; - std::uint64_t stamps_[kFpsWindow]{}; // when each of the last frames finished + struct Sample { + std::uint64_t ns; + FrameCounters c; + }; + static constexpr int kSamples = 256; // a second of samples, even under turbo + Sample samples_[kSamples]{}; int pos_ = 0, count_ = 0; + bool game_counted_ = false; // the core has said a nonzero game count + std::uint64_t own_frames_ = 0; // note_frame's running total + std::string readout_; // the rows last formatted + std::uint64_t readout_ns_ = 0; // when; refreshed 4 times a second + bool readout_stale_ = true; // refresh as soon as the window allows std::string toast_; std::uint64_t toast_until_ns_ = 0; @@ -134,6 +171,7 @@ class Osd { std::uint64_t volume_until_ns_ = 0; std::string status_drawn_; + int status_readout_rows_ = 0; // how many of its rows are the readout's Image status_, turbo_img_, volume_img_; std::uint64_t status_rev_ = 0, volume_rev_ = 0; std::vector layers_; diff --git a/runner/runner_link.cpp b/runner/runner_link.cpp index c4843a0..0444547 100644 --- a/runner/runner_link.cpp +++ b/runner/runner_link.cpp @@ -5,6 +5,8 @@ #include "state_keeper.hpp" #include +#include +#include #include #include #include @@ -150,6 +152,10 @@ class LinkSink final : public Sink { info.aspect_num = f.aspect_num; info.aspect_den = f.aspect_den; info.frame_number = frame_number; + // 2.2: the game's own count, carried in FrameDone for the overlay. + last_game_frame = f.struct_size >= offsetof(rcore_frame, game_frame) + sizeof f.game_frame + ? f.game_frame + : 0; // Publish: our back becomes middle, and middle's old slot becomes ours. published_ = static_cast(back_); back_ = shm_->middle.exchange(back_ | kFresh, std::memory_order_acq_rel) & ~kFresh; @@ -206,6 +212,8 @@ class LinkSink final : public Sink { rcore_pad pads[RCORE_MAX_SEATS]{}; std::uint64_t frame_number = 0; // the granted frame being run + // rcore_frame::game_frame at the core's last video_submit (FrameDoneMsg 2.2). + std::uint64_t last_game_frame = 0; std::uint32_t faults = 0; std::uint32_t bad_frames = 0; @@ -271,6 +279,12 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li copy_str(hello.platforms, sizeof hello.platforms, core.info->platforms); if (!send_msg(sock, hello)) return 2; + // The minor this session speaks: min(host, runner). A FrameDone goes to a + // 2.1 hub at its 2.1 size. + const std::uint32_t session_minor = std::min(shm->protocol_minor, kProtocolMinor); + const std::size_t frame_done_size = + link_has_frame_stats(session_minor) ? sizeof(FrameDoneMsg) : kFrameDoneSize21; + LinkSink sink(sock, shm, a.out); HostSession session(core, sink); for (const DataAccessory& d : a.data_accessories) session.bind_data_accessory(d.seat, d.slot); @@ -493,8 +507,16 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li FrameDoneMsg done{}; done.h.type = Msg::FrameDone; done.frame_number = g.frame_number; + using Clock = std::chrono::steady_clock; + auto timed = [&](rcore_result (*fn)()) { + const Clock::time_point t0 = Clock::now(); + const rcore_result rc = fn(); + done.work_ns += static_cast( + std::chrono::duration_cast(Clock::now() - t0).count()); + return rc; + }; if (!a.netplay) { - done.result = core.api->run_frame(); + done.result = timed(core.api->run_frame); } else { // One grant = one LIVE frame. Replays the driver asks for run // first, silently; a stall waits on the network -- which is what @@ -524,8 +546,8 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li } net_tick = ns.tick(); const rcore_result rc = adm == NetSession::Admit::Live - ? core.api->run_frame() - : core.api->run_frame_resim(); + ? timed(core.api->run_frame) + : timed(core.api->run_frame_resim); ns.finish(adm); if (rc != RCORE_OK || adm == NetSession::Admit::Live) { done.result = rc; @@ -534,7 +556,8 @@ int run_link_mode(const LoadedCore& core, const CoreManifest& manifest, const Li } if (quit) break; } - send_msg(sock, done); + done.game_frame = sink.last_game_frame; + send_msg_sized(sock, done, frame_done_size); if (sink.bad_frames) { code = exiting(sock, 1, "the core submitted a frame this link cannot carry " "(format, or larger than 1024x1024)"); diff --git a/tests/overlay_test.cpp b/tests/overlay_test.cpp index 50b0d82..ddc62cb 100644 --- a/tests/overlay_test.cpp +++ b/tests/overlay_test.cpp @@ -47,38 +47,99 @@ void test_osd() { std::uint64_t t = 1000 * kMs; CHECK(osd.layers(t).empty()); - // FPS: 60 Hz frames read 60, whether or not the readout is showing. + // A host with no counters (note_frame): 60 Hz frames read 60 VI, and the + // readings it cannot know read "--". osd.set_fps_visible(true); for (int i = 0; i < 100; ++i) osd.note_frame(t += 16666667ull); - CHECK(osd.fps() > 59.9 && osd.fps() < 60.1); + FrameRates r = osd.rates(); + CHECK(r.vi > 59.9 && r.vi < 60.1); + CHECK(!r.has_fps && !r.has_ms); const Layer* st = find(osd.layers(t), kLayerStatus); - CHECK(st && st->anchor == Anchor::TopLeft && st->image->width > 0); - // "60 FPS": six glyphs at 2x plus padding. - CHECK(st && st->image->width == 2 * 4 + 6 * 16); + CHECK(st && st->anchor == Anchor::TopLeft); + CHECK(osd.readout() == "FPS --\nVI 60.0\nms/VI --"); + // Three rows of 11 glyphs at 2x, plus padding and row gaps. + CHECK(st && st->image->width == 2 * 4 + 11 * 16); + CHECK(st && st->image->height == 2 * 4 + 3 * 16 + 2 * 2); + // Labels are dimmer than their values: no white in the first row's label + // ("FPS", left of column 6), and white in its value. + if (st) { + auto red_in = [&](int x0, int x1, unsigned red) { + for (int y = 4; y < 4 + 16; ++y) + for (int x = x0; x < x1; ++x) { + const std::size_t i = (std::size_t(y) * st->image->width + x) * 4; + if (st->image->rgba[i + 3] == 0xFF && st->image->rgba[i] == red) return true; + } + return false; + }; + CHECK(!red_in(4, 4 + 6 * 16, 0xFF) && red_in(4, 4 + 6 * 16, 0xB2)); + CHECK(red_in(4 + 6 * 16, 4 + 11 * 16, 0xFF)); + } const std::uint64_t rev = st ? st->revision : 0; osd.note_frame(t += 16666667ull); st = find(osd.layers(t), kLayerStatus); CHECK(st && st->revision == rev); // same text, no redraw // Uneven arrival -- 15 ms, 25 ms, ... -- is still about 50 frames a second - // (63 intervals in the window, so 49.8 or 50.2); a mean of 1/dt says 53.3. + // over the window; a mean of 1/dt says 53.3. for (int i = 0; i < 64; ++i) osd.note_frame(t += (i & 1) ? 25 * kMs : 15 * kMs); - CHECK(osd.fps() > 49.5 && osd.fps() < 50.5); + r = osd.rates(); + CHECK(r.vi > 49.5 && r.vi < 50.5); + + // A core that counts: an N64 game drawing every other field of a 59.94 Hz + // VI, each field taking 4.2 ms to emulate. Sampled once per hub frame, + // whatever that is (here 120 Hz, so half the samples see nothing new). + { + Osd n64; + n64.set_fps_visible(true); + FrameCounters c; + std::uint64_t tt = 1000 * kMs, next_field = tt; + for (int i = 0; i < 240; ++i) { + tt += 8333333ull; + while (next_field <= tt) { + ++c.frames; + if (c.frames % 2 == 0) ++c.game; + c.work_ns += 4200000ull; + next_field += 16683350ull; + } + n64.note_counters(tt, c); + n64.layers(tt); + } + const FrameRates nr = n64.rates(); + CHECK(nr.has_fps && nr.has_ms); + CHECK(nr.vi > 59.0 && nr.vi < 61.0); + CHECK(nr.fps > 29.9 && nr.fps < 30.05); // 29.97: no window-edge error + CHECK(nr.ms_per_vi > 4.19 && nr.ms_per_vi < 4.21); + CHECK(n64.readout().find("ms/VI 4.20") != std::string::npos); + // A reset (the totals go backwards) is a fresh window, not a negative rate. + n64.note_counters(tt += 16 * kMs, {1, 0, 4000000}); + CHECK(n64.rates().vi == 0.0); + n64.note_counters(tt += 20 * kMs, {2, 1, 8000000}); + CHECK(n64.rates().vi > 49.9 && n64.rates().vi < 50.1); + } + // Under turbo frames come faster than presents: the readout follows them, // from the moment turbo starts. osd.set_turbo(true); for (int i = 0; i < 10; ++i) osd.note_frame(t += 4166667ull); - CHECK(osd.fps() > 239.0 && osd.fps() < 241.0); + r = osd.rates(); + CHECK(r.vi > 239.0 && r.vi < 241.0); osd.set_turbo(false); - CHECK(osd.fps() == 0.0); + CHECK(osd.rates().vi == 0.0); osd.note_frame(t += 20 * kMs); osd.note_frame(t += 20 * kMs); - CHECK(osd.fps() > 49.9 && osd.fps() < 50.1); - // A pause is a break, not a slow frame. + CHECK(osd.rates().vi > 49.9 && osd.rates().vi < 50.1); + // A pause is a break, not a slow frame; the last reading stands until the + // fresh window has one of its own. + osd.layers(t); + const std::string before = osd.readout(); osd.note_frame(t += 2000 * kMs); - CHECK(osd.fps() == 0.0); + CHECK(osd.rates().vi == 0.0); + osd.layers(t); + CHECK(osd.readout() == before); osd.note_frame(t += 20 * kMs); - CHECK(osd.fps() > 49.9 && osd.fps() < 50.1); + CHECK(osd.rates().vi > 49.9 && osd.rates().vi < 50.1); + osd.layers(t); + CHECK(osd.readout().find("VI 50.0") != std::string::npos); // TURBO sits alone in the top right. osd.set_turbo(true); @@ -380,15 +441,30 @@ void dump(const fs::path& out) { f << "P6\n" << w << ' ' << h << "\n255\n"; f.write(reinterpret_cast(rgb.data()), std::streamsize(rgb.size())); }; + // Counters shaped like an N64 game drawing every other field of a 59.94 Hz + // VI, 5.1 ms of emulation a field (illustrative numbers, not a measurement). Osd osd; std::uint64_t t = 1000 * kMs; osd.set_fps_visible(true); - for (int i = 0; i < 70; ++i) osd.note_frame(t += 16683350ull); - osd.set_turbo(true); + osd.set_turbo(true); // first: turbo starting restarts the reading + FrameCounters c; + for (int i = 0; i < 70; ++i) { + ++c.frames; + c.game += i & 1; + c.work_ns += 5100000ull; + osd.note_counters(t += 16683350ull, c); + } osd.show_volume(70, t); osd.toast("Slot 3 saved", t); write("osd", osd.layers(t), 1280, 720); + // A host with no counters (or a 2.1 runner): VI alone. + Osd plain; + plain.set_fps_visible(true); + t = 1000 * kMs; + for (int i = 0; i < 70; ++i) plain.note_frame(t += 16683350ull); + write("osd_no_counters", plain.layers(t), 1280, 720); + SavestateMenu m; const fs::path dir = out / "slots"; fs::remove_all(dir); diff --git a/tests/rcore_fake_core.c b/tests/rcore_fake_core.c index eecfef7..f02dfe5 100644 --- a/tests/rcore_fake_core.c +++ b/tests/rcore_fake_core.c @@ -298,6 +298,7 @@ static rcore_result run_frame(void) { f.pixel_format = RCORE_PIXEL_RGBA8; f.pixels = g_pixels; f.frame_number = g_frames; + f.game_frame = g_frames / 2; /* rev 8: a game drawing every other frame */ g_host->video_submit(g_host->host_ctx, &f); return RCORE_OK; }