diff --git a/src/Cafe/OS/libs/snd_core/ax_out.cpp b/src/Cafe/OS/libs/snd_core/ax_out.cpp index 134e6a40..e851a365 100644 --- a/src/Cafe/OS/libs/snd_core/ax_out.cpp +++ b/src/Cafe/OS/libs/snd_core/ax_out.cpp @@ -573,6 +573,28 @@ namespace snd_core #ifdef RETRO_CORE if (s_libretro_ax_budget.load(std::memory_order_relaxed) < AX_SAMPLES_PER_3MS_48KHZ) return; + + // The budget says how much audio to make, not how fast. A slow device's + // retro_run grants 100 ms or more at once, and spent as soon as it + // arrived that is thirty-odd 3 ms frames back to back - with no time + // between them for the title's own threads, which is when a streaming + // voice gets its next buffer. Starved of that, the voice loops what it + // already has: sco8487's Deus Ex repeating a line ("stand stand + // stand") while the NPCs wait for it to end. The wall-clock path below + // keeps frames at least 1.7 ms apart for the same reason, so this one + // does too - fast enough to catch up on a backlog at nearly twice real + // time, slow enough to leave the title room between frames. + constexpr static auto kLibretroMinSpacing = std::chrono::duration_cast(std::chrono::microseconds(1700)); +#if BOOST_OS_WINDOWS + const auto libretroNow = tick_cached(); + static auto s_libretro_last_frame = libretroNow - kLibretroMinSpacing; +#else + const auto libretroNow = now_cached(); + static auto s_libretro_last_frame = libretroNow - kLibretroMinSpacing; +#endif + if ((libretroNow - s_libretro_last_frame) < kLibretroMinSpacing) + return; + g_ax_update_passed.fetch_add(1, std::memory_order_relaxed); if (snd_core::isInitialized() && numQueuedFramesSndGeneric == snd_core::getNumProcessedFrames()) { @@ -580,6 +602,7 @@ namespace snd_core snd_core::AXIst_QueueFrame(); numQueuedFramesSndGeneric++; s_libretro_ax_budget.fetch_sub(AX_SAMPLES_PER_3MS_48KHZ, std::memory_order_relaxed); + s_libretro_last_frame = libretroNow; } return; #endif diff --git a/src/libretro/CemuLibretro.cpp b/src/libretro/CemuLibretro.cpp index 7c18151f..a24ae0b8 100644 --- a/src/libretro/CemuLibretro.cpp +++ b/src/libretro/CemuLibretro.cpp @@ -642,9 +642,15 @@ std::atomic_bool s_frame_ready{false}; // title. The Vulkan renderer used to store the flag on its own and did exactly // that, while the OpenGL path signalled properly, which is why one was half the // speed of the other. +// Frames the title finished, for the Show Game FPS option. Not +// s_prof_frames_ready, which the profiling log resets on its own schedule. +static std::atomic s_game_frames{0}; +static bool s_show_game_fps = false; + void libretro_signal_frame_ready() { s_prof_frames_ready.fetch_add(1, std::memory_order_relaxed); + s_game_frames.fetch_add(1, std::memory_order_relaxed); { std::lock_guard lock(s_frame_mutex); s_frame_ready.store(true, std::memory_order_release); @@ -2189,6 +2195,13 @@ static void libretro_apply_core_options() // LatteOverlay_pushNotification to SET_MESSAGE_EXT. cfg.notification.position = ScreenPosition::kDisabled; + if (const char* v = libretro_get_option_value("cemu_show_game_fps")) + { + bool b; + if (libretro_parse_enabled_disabled(v, b)) + s_show_game_fps = b; + } + // Async shader compilation if (const char* v = libretro_get_option_value("cemu_async_shader_compile")) { @@ -4659,11 +4672,71 @@ extern GLuint libretro_getBackbufferRBO(); // frontend's audio callback (see the audio grant in retro_run). static int64_t s_last_audio_wait_us = 0; +// The game's own frame rate, once a second, as a status line on the +// frontend's OSD. RetroArch's FPS counter cannot show it: retro_run keeps +// coming at 60 Hz whatever the title renders at, presenting the last image +// again when no new one is ready, so a title dropping to 40 still counts 60 +// there and only the frame time shows it (NNshi). +static void libretro_report_game_fps(std::chrono::steady_clock::time_point now) +{ + static std::chrono::steady_clock::time_point s_since{}; + static bool s_have_ext = false, s_checked_ext = false; + static std::string s_text; + + if (!s_show_game_fps || !environ_cb) + { + s_since = {}; + s_game_frames.store(0, std::memory_order_relaxed); + return; + } + if (!s_checked_ext) + { + unsigned version = 0; + s_have_ext = environ_cb(RETRO_ENVIRONMENT_GET_MESSAGE_INTERFACE_VERSION, &version) && version >= 1; + s_checked_ext = true; + } + if (s_since == std::chrono::steady_clock::time_point{}) + { + s_since = now; + s_game_frames.store(0, std::memory_order_relaxed); + return; + } + + const double seconds = std::chrono::duration(now - s_since).count(); + if (seconds < 1.0) + return; + + const uint32_t frames = s_game_frames.exchange(0, std::memory_order_relaxed); + s_since = now; + s_text = fmt::format("Game: {:.1f} FPS", frames / seconds); + + if (s_have_ext) + { + // A status message is the frontend's line for figures like this one, + // replaced in place rather than stacked up as notifications. + struct retro_message_ext message = {}; + message.msg = s_text.c_str(); + message.duration = 1500; + message.priority = 1; + message.level = RETRO_LOG_INFO; + message.target = RETRO_MESSAGE_TARGET_OSD; + message.type = RETRO_MESSAGE_TYPE_STATUS; + message.progress = -1; + environ_cb(RETRO_ENVIRONMENT_SET_MESSAGE_EXT, &message); + } + else + { + struct retro_message message{s_text.c_str(), 90}; + environ_cb(RETRO_ENVIRONMENT_SET_MESSAGE, &message); + } +} + static void libretro_finish_run(std::chrono::steady_clock::time_point start, std::chrono::steady_clock::time_point waited, bool timedOut) { using prof_clock = std::chrono::steady_clock; const auto presented = prof_clock::now(); + libretro_report_game_fps(presented); LibretroAudioAPI::FlushAudio(); s_last_audio_wait_us = std::chrono::duration_cast(prof_clock::now() - presented).count(); @@ -4895,15 +4968,35 @@ RETRO_API void retro_run() // itself instead: a long wait means a smaller grant next time, the // frontend's buffer has room again, and the wait goes away. Capped at // 250 ms against a stall. + // + // But never less than one frame's worth. With audio sync on, waiting in + // the audio callback is how RetroArch holds retro_run to 60 a second, so + // a device that keeps up spends much of every frame there - and leaving + // all of it out granted a fraction of the 800 samples a 60 Hz frame + // needs. sco8487's phone sat in its menu at 60 fps with the frontend's + // buffer underrunning 70-90% of the time. 800 a frame is what any core + // hands over; only the time beyond it is what a slow frame adds. + // + // And never more than has played. The floor on its own gave 800 to every + // retro_run however soon it came after the last, so a frontend that runs + // them unevenly - 10 ms, then 23 ms - was granted more audio than the time + // that passed, and one running faster than 60 Hz more still. NNshi saw it + // as RetroArch blocking 20-50 % of the time on audio it had too much of. + // Wall time fills a bucket, capped at the same 250 ms, and a grant takes + // no more than the bucket holds: at 60 Hz that is 800 either way, and over + // any stretch of time the grants add up to at most the time itself. { static std::chrono::steady_clock::time_point s_last_audio_grant{}; + static int64_t s_audio_bucket = 0; // in thousandths of a sample, so no fraction is lost const auto now = std::chrono::steady_clock::now(); int32_t samples = 800; if (s_last_audio_grant.time_since_epoch().count() != 0) { - const int64_t us = std::chrono::duration_cast(now - s_last_audio_grant).count() - - s_last_audio_wait_us; - samples = (int32_t)std::clamp(us * 48 / 1000, 0, 12000); + const int64_t elapsed_us = std::chrono::duration_cast(now - s_last_audio_grant).count(); + s_audio_bucket = std::min(s_audio_bucket + elapsed_us * 48, 12000 * 1000); + const int64_t us = elapsed_us - s_last_audio_wait_us; + samples = (int32_t)std::min(std::clamp(us * 48 / 1000, 800, 12000), s_audio_bucket / 1000); + s_audio_bucket -= int64_t(samples) * 1000; } s_last_audio_grant = now; snd_core::AXOut_LibretroGrantSamples(samples); diff --git a/src/libretro/LibretroAudioAPI.h b/src/libretro/LibretroAudioAPI.h index 466249fa..ecdb5226 100644 --- a/src/libretro/LibretroAudioAPI.h +++ b/src/libretro/LibretroAudioAPI.h @@ -12,9 +12,13 @@ class LibretroAudioRingBuffer { public: - // ~128 ms at 48 kHz stereo. Large enough to absorb host scheduling jitter - // but small enough that draining everything per retro_run stays cheap. - static constexpr size_t kBufferFrames = 6144; + // ~341 ms at 48 kHz stereo. It has to hold everything AX may make between + // two retro_runs, and a retro_run grants up to 250 ms (see + // AXOut_LibretroGrantSamples). At 128 ms a slow device's frames - 8 or 9 + // a second in sco8487's heavier Deus Ex scenes - got grants the ring could + // not take, and the rest was dropped. Drained every retro_run, so the + // size adds no latency. + static constexpr size_t kBufferFrames = 16384; static constexpr size_t kChannels = 2; static constexpr size_t kBufferSamples = kBufferFrames * kChannels; diff --git a/src/libretro/libretro_core_options.h b/src/libretro/libretro_core_options.h index 60ead91b..dddf2e75 100644 --- a/src/libretro/libretro_core_options.h +++ b/src/libretro/libretro_core_options.h @@ -194,6 +194,20 @@ struct retro_core_option_v2_definition option_defs_us[] = { }, "linear" }, + { + "cemu_show_game_fps", + "Show Game FPS", + NULL, + "Show the frame rate the game renders at. RetroArch's own FPS counter counts how often the core runs, and that stays at 60 when the game drops frames, because every run carries a frame's worth of audio.", + NULL, + "video", + { + { "disabled", NULL }, + { "enabled", NULL }, + { NULL, NULL }, + }, + "disabled" + }, { "cemu_downscale_filter", "Downscale Filter",