Files
ARMSX2/pcsx2/ImGui/ImGuiOverlays.cpp
T
Brian Degenhardt 7f93a80dd7 PerformanceMetrics: count the GS back thread
Under GSBackThreadMode >= Lockstep roughly half the GS work moves to a second
thread, and every surface that reports GS cost -- OSD, PerfLog, the Qt status
bar, PINE stats, gsrunner's @HWSTAT@ block -- measured the MTGS thread alone.
So the split read as a large GS saving. It is not: on a Rogue Galaxy savestate
here, mode 0 costs 15.8% / 2.63 ms and mode 3 costs 17.0% / 2.84 ms plus
14.2% / 2.37 ms on the back thread -- about twice the total GS CPU time, bought
to halve the critical path. That is a real trade, but nobody could see it.

The back thread registers its own handle at entry, as the SW rasterizer workers
do; StopBackThread clears it after the join. Unlike every other handle here it
is written by a thread other than the one sampling it, so the handle and its
running total sit behind a mutex taken twice a second. Installing a handle
rebases the total off it, so the first window after a GSreopen respawn measures
the new thread rather than its difference against the retired one's.

The figure is omitted, not reported as zero, wherever a back thread does not
exist -- otherwise a mode 0 vs mode 3 comparison reads a permanent 0% as
meaningful. gsrunner latches the presence flag during the run because DumpStats
executes after VMManager::Shutdown, by which point the thread has joined.
2026-07-30 21:55:58 -07:00

2162 lines
81 KiB
C++

// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team
// SPDX-License-Identifier: GPL-3.0+
#include "BuildVersion.h"
#include "Config.h"
#include "Counters.h"
#include "GS/GS.h"
#include "GS/GSShaderCompileIndicator.h"
#include "GS/GSCapture.h"
#include "GS/GSVector.h"
#include "GS/Renderers/Common/GSDevice.h"
#ifdef _WIN32
#include "GS/Renderers/DX12/GSDevice12.h"
#endif
#include "GS/Renderers/HW/GSTextureReplacements.h"
#include "Host.h"
#include "IconsFontAwesome.h"
#include "IconsPromptFont.h"
#include "ImGui/FullscreenUI.h"
#include "ImGui/ImGuiAnimated.h"
#include "ImGui/ImGuiFullscreen.h"
#include "ImGui/ImGuiManager.h"
#include "ImGui/ImGuiOverlays.h"
#include "Input/InputManager.h"
#include "MTGS.h"
#include "Patch.h"
#include "PerformanceMetrics.h"
#include "Recording/InputRecording.h"
#include "SIO/Pad/Pad.h"
#include "SIO/Pad/PadBase.h"
#include "USB/USB.h"
#include "VMManager.h"
#include "common/BitUtils.h"
#include "common/Error.h"
#include "common/FileSystem.h"
#include "common/Path.h"
#include "common/Timer.h"
#include "fmt/chrono.h"
#include "fmt/format.h"
#include "imgui.h"
#if defined(__APPLE__)
#include <TargetConditionals.h>
#endif
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <span>
#include <tuple>
InputRecordingUI::InputRecordingData g_InputRecordingData;
// Start timers at 0 so we immediately get lines to cache.
static constexpr double ONE_BILLION = 1000000000;
static constexpr double UPDATE_INTERVAL = 0.1 * ONE_BILLION;
static constexpr double UPDATE_INTERVAL_CPU_INFO = 5.0 * ONE_BILLION;
Common::Timer s_last_update_timer = Common::Timer(0.0);
Common::Timer s_last_update_timer_cpu_info = Common::Timer(0.0);
ImU32 s_speed_line_color;
SmallString s_speed_line;
SmallString s_gs_stats_line;
SmallString s_gs_memory_stats_line;
SmallString s_gs_frame_times_line;
SmallString s_resolution_line;
SmallString s_hardware_info_cpu_line;
SmallString s_hardware_info_gpu_line;
SmallString s_cpu_jit_line;
SmallString s_cpu_usage_ee_line;
SmallString s_cpu_usage_gs_line;
SmallString s_cpu_usage_gs_back_line;
SmallString s_cpu_usage_vu_line;
std::vector<SmallString> s_software_thread_lines;
SmallString s_capture_line;
SmallString s_gpu_usage_line;
SmallString s_gpu_debug_info_line;
SmallString s_gpu_stats_line;
SmallString s_speed_icon;
#if defined(__APPLE__) && TARGET_OS_IPHONE
SmallString s_ios_device_stats_line;
// Set by the iOS CMakeLists alongside the bundle version, so the overlay and
// the About screen cannot disagree. "dev" only shows up in a build that came
// from somewhere else.
#if defined(ARMSX2_VERSION_STR)
#define ARMSX2_IOS_OSD_VERSION ARMSX2_VERSION_STR
#else
#define ARMSX2_IOS_OSD_VERSION "dev"
#endif
#endif
// Shrink-to-fit for the performance overlay. Only ever comes down, and only far enough for the widest
// line to fit, so a value gaining a digit can't resize the block under the reader.
static float s_osd_font_size = 0.0f;
static float s_osd_fit_avail = -1.0f;
static float s_osd_fit_base = -1.0f;
static u32 s_osd_fit_lines = 0;
static float s_osd_widest = 0.0f;
constexpr ImU32 white_color = IM_COL32(255, 255, 255, 255);
#if defined(__APPLE__) && TARGET_OS_IPHONE
// Battery, heat and RAM come from the iOS frontend. There is no header for these three; the bridge
// forward-declares its own the same way.
extern "C" bool ARMSX2_iOSShouldShowDeviceStatsOverlay();
extern "C" int ARMSX2_iOSGetDeviceStatsOverlaySeverity();
extern "C" const char* ARMSX2_iOSGetDeviceStatsOverlayLine();
#endif
/// The OSD's normal text colour. GSConfig.OsdColor is 0xRRGGBB, and 0 means "unset" — every
/// frontend but Android leaves it there, so the overlay keeps its classic white by default.
/// Read per line rather than cached: it's a field load, and caching it would need invalidating
/// on every settings apply for no measurable gain.
__fi static ImU32 OsdTextColor()
{
const u32 rgb = GSConfig.OsdColor;
if (rgb == 0)
return white_color;
return IM_COL32((rgb >> 16) & 0xFFu, (rgb >> 8) & 0xFFu, rgb & 0xFFu, 255);
}
#if defined(__APPLE__) && TARGET_OS_IPHONE
// Same red as the sub-95% speed line, so the OSD keeps one vocabulary of alarm.
static ImU32 ARMSX2IOSDeviceStatsColor()
{
switch (ARMSX2_iOSGetDeviceStatsOverlaySeverity())
{
case 2:
return IM_COL32(255, 100, 100, 255);
case 1:
return IM_COL32(255, 220, 100, 255);
default:
return OsdTextColor();
}
}
#endif
// OSD positioning funcs
ImVec2 CalculateOSDPosition(OsdOverlayPos position, float margin, const ImVec2& text_size, float window_width, float window_height)
{
switch (position)
{
case OsdOverlayPos::TopLeft:
return ImVec2(margin, margin);
case OsdOverlayPos::TopCenter:
return ImVec2((window_width - text_size.x) * 0.5f, margin);
case OsdOverlayPos::TopRight:
return ImVec2(window_width - margin - text_size.x, margin);
case OsdOverlayPos::CenterLeft:
return ImVec2(margin, (window_height - text_size.y) * 0.5f);
case OsdOverlayPos::Center:
return ImVec2((window_width - text_size.x) * 0.5f, (window_height - text_size.y) * 0.5f);
case OsdOverlayPos::CenterRight:
return ImVec2(window_width - margin - text_size.x, (window_height - text_size.y) * 0.5f);
case OsdOverlayPos::BottomLeft:
return ImVec2(margin, window_height - margin - text_size.y);
case OsdOverlayPos::BottomCenter:
return ImVec2((window_width - text_size.x) * 0.5f, window_height - margin - text_size.y);
case OsdOverlayPos::BottomRight:
return ImVec2(window_width - margin - text_size.x, window_height - margin - text_size.y);
case OsdOverlayPos::None:
default:
return ImVec2(0.0f, 0.0f);
}
}
ImVec2 CalculatePerformanceOverlayTextPosition(OsdOverlayPos position, float margin, const ImVec2& text_size, float window_width, float position_y)
{
const float abs_margin = std::abs(margin);
// Get the X position based on horizontal alignment
float x_pos;
switch (position)
{
case OsdOverlayPos::TopLeft:
case OsdOverlayPos::CenterLeft:
case OsdOverlayPos::BottomLeft:
x_pos = abs_margin; // Left alignment
break;
case OsdOverlayPos::TopCenter:
case OsdOverlayPos::Center:
case OsdOverlayPos::BottomCenter:
x_pos = (window_width - text_size.x) * 0.5f; // Center alignment
break;
case OsdOverlayPos::TopRight:
case OsdOverlayPos::CenterRight:
case OsdOverlayPos::BottomRight:
default:
x_pos = window_width - text_size.x - abs_margin; // Right alignment
break;
}
// A line wider than the window would otherwise start at negative x and run off the left edge,
// which is far worse than being clipped on the right.
return ImVec2(std::max(abs_margin, x_pos), position_y);
}
bool ShouldUseLeftAlignment(OsdOverlayPos position)
{
return (position == OsdOverlayPos::TopLeft || position == OsdOverlayPos::CenterLeft || position == OsdOverlayPos::BottomLeft);
}
namespace ImGuiManager
{
static void FormatProcessorStat(SmallStringBase& text, double usage, double time);
static void DrawPerformanceOverlay(float& position_y, float scale, float margin, float bottom_margin, float spacing);
static void DrawShaderCompileIndicator(float scale, float margin, float bottom_margin, float spacing);
static void DrawSettingsOverlay(float scale, float margin, float bottom_margin, float spacing);
static void DrawInputsOverlay(float scale, float margin, float bottom_margin, float spacing);
static void DrawInputRecordingOverlay(float& position_y, float scale, float margin, float spacing);
static void DrawVideoCaptureOverlay(float& position_y, float scale, float margin, float spacing);
static void DrawTextureReplacementsOverlay(float& position_y, float scale, float margin, float spacing);
static void DrawIndicatorsOverlay(float& position_y, float scale, float margin, float spacing);
} // namespace ImGuiManager
static std::tuple<float, float> GetMinMax(std::span<const float> values)
{
GSVector4 vmin(GSVector4::load<false>(values.data()));
GSVector4 vmax(vmin);
const u32 count = static_cast<u32>(values.size());
const u32 aligned_count = Common::AlignDownPow2(count, 4);
u32 i = 4;
for (; i < aligned_count; i += 4)
{
const GSVector4 v(GSVector4::load<false>(&values[i]));
vmin = vmin.min(v);
vmax = vmax.max(v);
}
float min = std::min(vmin.x, std::min(vmin.y, std::min(vmin.z, vmin.w)));
float max = std::max(vmax.x, std::max(vmax.y, std::max(vmax.z, vmax.w)));
for (; i < count; i++)
{
min = std::min(min, values[i]);
max = std::max(max, values[i]);
}
return std::tie(min, max);
}
__ri void ImGuiManager::FormatProcessorStat(SmallStringBase& text, double usage, double time)
{
// Some values, such as GPU (and even CPU to some extent) can be out of phase with the wall clock,
// which the processor time is divided by to get a utilization percentage. Let's clamp it at 100%,
// so that people don't get confused, and remove the decimal places when it's there while we're at it.
if (usage >= 99.95)
text.append_format("100% ({:.2f}ms)", time);
else
text.append_format("{:.1f}% ({:.2f}ms)", usage, time);
}
__ri void ImGuiManager::DrawPerformanceOverlay(float& position_y, float scale, float margin, float bottom_margin, float spacing)
{
// The perf-OSD flags in GSConfig are refreshed from the authoritative EmuConfig.GS at
// the top of RenderOverlays (see the note there). When every perf line is off, draw
// nothing and return BEFORE any draw call, so a line string cached before a pause can't
// linger on screen (the rebuild block below is skipped while the VM is paused, which is
// why toggling in the menu looked inert). Packed rather than a chain of ors because the
// shrink-to-fit below needs to notice the set changing, not just emptying.
u32 enabled_lines =
(static_cast<u32>(GSConfig.OsdShowFPS) << 0) | (static_cast<u32>(GSConfig.OsdShowVPS) << 1) |
(static_cast<u32>(GSConfig.OsdShowSpeed) << 2) | (static_cast<u32>(GSConfig.OsdShowResolution) << 3) |
(static_cast<u32>(GSConfig.OsdShowCPU) << 4) | (static_cast<u32>(GSConfig.OsdShowGPU) << 5) |
(static_cast<u32>(GSConfig.OsdShowGSStats) << 6) | (static_cast<u32>(GSConfig.OsdShowFrameTimes) << 7) |
(static_cast<u32>(GSConfig.OsdShowHardwareInfo) << 8) | (static_cast<u32>(GSConfig.OsdShowVersion) << 9) |
(static_cast<u32>(GSConfig.OsdShowGPUStats) << 10);
#if defined(__APPLE__) && TARGET_OS_IPHONE
// A Custom preset with nothing but Device Stats ticked is reachable, and it lands here.
enabled_lines |= static_cast<u32>(ARMSX2_iOSShouldShowDeviceStatsOverlay()) << 11;
#endif
if (enabled_lines == 0)
return;
const float shadow_offset = std::ceil(scale);
ImFont* const osd_font = ImGuiManager::GetOSDFont();
const float base_font_size = ImGuiManager::GetFontSizeStandard();
const float avail = GetWindowWidth() - 2.0f * margin;
// Deriving the size from the current text every frame is what made the block dance — the widest
// line changes width constantly, and right-aligned rows each move by their own share of the
// rescale. Start over only when the space or the line set changes; in between, ratchet and hold.
if (avail != s_osd_fit_avail || base_font_size != s_osd_fit_base || enabled_lines != s_osd_fit_lines)
{
s_osd_fit_avail = avail;
s_osd_fit_base = base_font_size;
s_osd_fit_lines = enabled_lines;
s_osd_font_size = base_font_size;
s_osd_widest = 0.0f;
}
else if (avail > 0.0f && s_osd_widest > avail)
{
// Widths are linear in the size, so one step gets there. Integral, or ImGui bakes a fresh
// atlas for every sub-pixel wobble; half size is as small as this stays readable.
const float needed = std::floor(s_osd_font_size * avail / s_osd_widest);
s_osd_font_size = std::clamp(needed, std::max(1.0f, std::floor(base_font_size * 0.5f)), s_osd_font_size);
}
s_osd_widest = 0.0f;
const float font_size = s_osd_font_size;
const float fit = font_size / base_font_size;
const float line_height = ImGuiFullscreen::GetLineHeight({ osd_font, font_size });
ImDrawList* dl = ImGui::GetBackgroundDrawList();
ImVec2 text_size;
// Adjust initial Y position based on vertical alignment
switch (GSConfig.OsdPerformancePos)
{
case OsdOverlayPos::CenterLeft:
case OsdOverlayPos::Center:
case OsdOverlayPos::CenterRight:
position_y = (GetWindowHeight() - (line_height * 8.0f)) * 0.5f;
break;
case OsdOverlayPos::BottomLeft:
case OsdOverlayPos::BottomCenter:
case OsdOverlayPos::BottomRight:
position_y = GetWindowHeight() - bottom_margin - (line_height * 15.0f + spacing * 14.0f);
break;
case OsdOverlayPos::TopLeft:
case OsdOverlayPos::TopCenter:
case OsdOverlayPos::TopRight:
default:
// Top alignment keeps the passed position_y
break;
}
#define DRAW_LINE(font, size, text, color) \
do \
{ \
text_size = font->CalcTextSizeA(size, std::numeric_limits<float>::max(), -1.0f, (text), nullptr, nullptr); \
s_osd_widest = std::max(s_osd_widest, text_size.x); \
const ImVec2 text_pos = CalculatePerformanceOverlayTextPosition(GSConfig.OsdPerformancePos, margin, text_size, GetWindowWidth(), position_y); \
const bool __bold_osd = GSConfig.OsdBoldText; \
dl->AddText(font, size, ImVec2(text_pos.x + shadow_offset, text_pos.y + shadow_offset), IM_COL32(0, 0, 0, 100), (text)); \
dl->AddText(font, size, text_pos, color, (text)); \
const auto __is_all_digits = [](const char* __begin, const char* __end) -> bool \
{ \
for (const char* __c = __begin; __c < __end; __c++) \
{ \
if (!std::isdigit(static_cast<unsigned char>(*__c))) \
return false; \
} \
return true; \
}; \
const auto __is_all_alpha = [](const char* __begin, const char* __end) -> bool \
{ \
for (const char* __c = __begin; __c < __end; __c++) \
{ \
if (!std::isalpha(static_cast<unsigned char>(*__c))) \
return false; \
} \
return true; \
}; \
for (const char* __p = (text); __p && *__p;) \
{ \
const char* __sep = strstr(__p, " | "); \
const char* __seg_end = __sep ? __sep : __p + strlen(__p); \
const char* __label_begin = nullptr; \
const char* __label_end = nullptr; \
const unsigned char __first = static_cast<unsigned char>(*__p); \
const bool __starts_numeric = (std::isdigit(__first) || __first == '+' || __first == '-' || __first == '.'); \
for (const char* __c = __p; __c < __seg_end; __c++) \
{ \
if (*__c == ':') \
{ \
__label_begin = __p; \
__label_end = __c + 1; \
break; \
} \
} \
if (!__label_begin) \
{ \
if (!__starts_numeric) \
{ \
__label_begin = __p; \
__label_end = __seg_end; \
} \
else \
{ \
const char* __first_space = __p; \
while (__first_space < __seg_end && *__first_space != ' ') \
__first_space++; \
if (__first_space < __seg_end) \
{ \
const char* __x = __p; \
while (__x < __first_space && *__x != 'x' && *__x != 'X') \
__x++; \
const bool __has_resolution_prefix = \
(__x > __p && (__x + 1) < __first_space && \
__is_all_digits(__p, __x) && __is_all_digits(__x + 1, __first_space)); \
if (__has_resolution_prefix && (__first_space + 1) < __seg_end) \
{ \
__label_begin = __first_space + 1; \
__label_end = __seg_end; \
const char* __trim_end = __label_end; \
while (__trim_end > __label_begin && std::isspace(static_cast<unsigned char>(*(__trim_end - 1)))) \
__trim_end--; \
if (__trim_end > __label_begin && *(__trim_end - 1) == ')') \
{ \
const char* __open = __trim_end - 1; \
while (__open > __label_begin && *__open != '(') \
__open--; \
if (__open > __label_begin && *__open == '(' && *(__open - 1) == ' ') \
__label_end = __open - 1; \
} \
} \
} \
if (!__label_begin) \
{ \
const char* __space = __seg_end; \
while (__space > __p && *(__space - 1) != ' ') \
__space--; \
if (__space > __p && __space < __seg_end && __is_all_alpha(__space, __seg_end)) \
{ \
__label_begin = __space; \
__label_end = __seg_end; \
} \
} \
} \
} \
if (__label_begin && __label_end && __label_begin < __label_end) \
{ \
const float __x0 = font->CalcTextSizeA(size, FLT_MAX, -1.0f, (text), __label_begin).x; \
const ImVec2 __pos(text_pos.x + __x0, text_pos.y); \
if (__bold_osd) \
{ \
dl->AddText(font, size, __pos, color, __label_begin, __label_end); \
dl->AddText(font, size, ImVec2(__pos.x + 0.6f, __pos.y), color, __label_begin, __label_end); \
} \
} \
__p = __sep ? __sep + 3 : nullptr; \
} \
position_y += text_size.y + spacing; \
} while (0)
if (VMManager::GetState() != VMState::Paused)
{
if (s_last_update_timer.GetTimeNanoseconds() >= UPDATE_INTERVAL)
{
s_last_update_timer.Reset();
const float speed = PerformanceMetrics::GetSpeed();
s_speed_line.clear();
#if defined(__ANDROID__)
// Display-rate limiters, FIRST in the line so they read as a qualifier on the FPS that
// follows rather than a stray value. Shown only while ACTIVE, so they cost nothing and
// need no toggle of their own. Both lower the ON-SCREEN rate while emulation keeps
// running full speed, so without a label a capped display is indistinguishable from the
// emulator running badly — which is a good part of why these looked broken.
if (const u32 fps_cap = GSGetMaxPresentFps(); fps_cap > 0)
{
s_speed_line.append_format("FPS CAP: {}", fps_cap);
// The cap is deliberately bypassed while fast-forwarding so the speed-up stays
// visible; say so, or it looks like the cap is simply being ignored.
if (GSGetPresentCapSuspended())
s_speed_line.append(" (off: FF)");
}
if (const u32 skip = GSGetManualFrameSkip(); skip > 0)
s_speed_line.append_format("{}SKIP: {}", s_speed_line.empty() ? "" : " | ", skip);
#endif
if (GSConfig.OsdShowFPS)
{
switch (PerformanceMetrics::GetInternalFPSMethod())
{
case PerformanceMetrics::InternalFPSMethod::GSPrivilegedRegister:
s_speed_line.append_format("FPS: {:.2f} [P]", PerformanceMetrics::GetInternalFPS());
break;
case PerformanceMetrics::InternalFPSMethod::DISPFBBlit:
s_speed_line.append_format("FPS: {:.2f} [B]", PerformanceMetrics::GetInternalFPS());
break;
case PerformanceMetrics::InternalFPSMethod::None:
default:
s_speed_line.append("FPS: N/A");
break;
}
}
if (GSConfig.OsdShowVPS)
s_speed_line.append_format("{}VPS: {:.2f}", s_speed_line.empty() ? "" : " | ", PerformanceMetrics::GetFPS());
if (GSConfig.OsdShowSpeed)
{
s_speed_line.append_format("{}Speed: {}%", s_speed_line.empty() ? "" : " | ", static_cast<u32>(std::round(speed)));
const float target_speed = VMManager::GetTargetSpeed();
if (target_speed == 0.0f)
s_speed_line.append(" (T: Max)");
else
s_speed_line.append_format(" (T: {:.0f}%)", target_speed * 100.0f);
}
if (GSConfig.OsdShowVersion)
{
#if defined(__APPLE__) && !TARGET_OS_IPHONE
if (BuildVersion::GitTagHi != 0 || BuildVersion::GitTagMid != 0 || BuildVersion::GitTagLo != 0)
{
s_speed_line.append_format("{}ARMSX2-MacOS 2.1 | Core: {}.{}.{}",
s_speed_line.empty() ? "" : " | ", BuildVersion::GitTagHi, BuildVersion::GitTagMid, BuildVersion::GitTagLo);
}
else
{
s_speed_line.append_format("{}ARMSX2-MacOS 2.1 | Core: {}",
s_speed_line.empty() ? "" : " | ", BuildVersion::GitRev);
}
#elif defined(__APPLE__) && TARGET_OS_IPHONE
// Version comes from the one place it is set, so this cannot drift
// the way the two branches either side of it have.
s_speed_line.append_format("{}ARMSX2 " ARMSX2_IOS_OSD_VERSION " | Core: {}",
s_speed_line.empty() ? "" : " | ", BuildVersion::GitRev);
#elif defined(__ANDROID__)
s_speed_line.append_format("{}ARMSX2 2.7", s_speed_line.empty() ? "" : " | ");
#else
s_speed_line.append_format("{}PCSX2 {}", s_speed_line.empty() ? "" : " | ", BuildVersion::GitRev);
#endif
}
if (!s_speed_line.empty())
{
if (speed < 95.0f)
s_speed_line_color = IM_COL32(255, 100, 100, 255); // red
else if (speed > 105.0f)
s_speed_line_color = IM_COL32(100, 255, 100, 255); // green
else
s_speed_line_color = OsdTextColor();
DRAW_LINE(osd_font, font_size, s_speed_line.c_str(), s_speed_line_color);
}
#if defined(__APPLE__) && TARGET_OS_IPHONE
if (ARMSX2_iOSShouldShowDeviceStatsOverlay())
{
const char* stats_line = ARMSX2_iOSGetDeviceStatsOverlayLine();
s_ios_device_stats_line.assign(stats_line ? stats_line : "");
if (!s_ios_device_stats_line.empty())
DRAW_LINE(osd_font, font_size, s_ios_device_stats_line.c_str(), ARMSX2IOSDeviceStatsColor());
}
#endif
if (GSConfig.OsdShowGSStats)
{
GSgetStats(s_gs_stats_line);
GSgetMemoryStats(s_gs_memory_stats_line);
s_gs_frame_times_line.format("{} QF | Min: {:.2f}ms | Avg: {:.2f}ms | Max: {:.2f}ms",
MTGS::GetCurrentVsyncQueueSize() - 1, // subtract one for the current frame
PerformanceMetrics::GetMinimumFrameTime(),
PerformanceMetrics::GetAverageFrameTime(),
PerformanceMetrics::GetMaximumFrameTime());
if (!s_gs_stats_line.empty())
DRAW_LINE(osd_font, font_size, s_gs_stats_line.c_str(), OsdTextColor());
if (!s_gs_memory_stats_line.empty())
DRAW_LINE(osd_font, font_size, s_gs_memory_stats_line.c_str(), OsdTextColor());
DRAW_LINE(osd_font, font_size, s_gs_frame_times_line.c_str(), OsdTextColor());
}
if (GSConfig.OsdShowResolution)
{
int iwidth, iheight;
GSgetInternalResolution(&iwidth, &iheight);
s_resolution_line.format("{}x{} {} {}", iwidth, iheight, ReportVideoMode(), ReportInterlaceMode());
DRAW_LINE(osd_font, font_size, s_resolution_line.c_str(), OsdTextColor());
}
if (GSConfig.OsdShowHardwareInfo)
{
// GPU can change on the fly with settings, but CPU change of any kind is a rare edge case.
if (s_last_update_timer_cpu_info.GetTimeNanoseconds() >= UPDATE_INTERVAL_CPU_INFO)
{
s_last_update_timer_cpu_info.Reset();
// CPU
const CPUInfo& info = GetCPUInfo();
const bool has_small = info.num_small_cores > 0;
const bool has_smt = info.num_threads != info.num_big_cores + info.num_small_cores;
s_hardware_info_cpu_line.format("CPU: {}", info.name);
if (has_smt && has_small)
s_hardware_info_cpu_line.append_format(" ({}P/{}E/{}T)", info.num_big_cores, info.num_small_cores, info.num_threads);
else if (has_small)
s_hardware_info_cpu_line.append_format(" ({}P/{}E)", info.num_big_cores, info.num_small_cores);
else
s_hardware_info_cpu_line.append_format(" ({}C/{}T)", info.num_big_cores, info.num_threads);
}
DRAW_LINE(osd_font, font_size, s_hardware_info_cpu_line.c_str(), OsdTextColor());
// GPU
const char* gpu_suffix = "";
if (GSConfig.UseDebugDevice && GSConfig.HWROV)
gpu_suffix = " (Debug & ROV)";
else if (GSConfig.UseDebugDevice)
gpu_suffix = " (Debug)";
else if (GSConfig.HWROV)
gpu_suffix = " (ROV)";
s_hardware_info_gpu_line.format(
"GPU: {}{}",
g_gs_device->GetName(),
gpu_suffix);
DRAW_LINE(osd_font, font_size, s_hardware_info_gpu_line.c_str(), OsdTextColor());
}
if (GSConfig.OsdShowCPU)
{
#if defined(__APPLE__) && !TARGET_OS_IPHONE
s_cpu_jit_line.format("EE:{} | IOP:{} | VU0:{} | VU1:{}",
EmuConfig.Cpu.Recompiler.EnableEE ? "JIT" : "INT",
EmuConfig.Cpu.Recompiler.EnableIOP ? "JIT" : "INT",
EmuConfig.Cpu.Recompiler.EnableVU0 ? "JIT" : "INT",
EmuConfig.Cpu.Recompiler.EnableVU1 ? "JIT" : "INT");
DRAW_LINE(osd_font, font_size, s_cpu_jit_line.c_str(), OsdTextColor());
#endif
if (EmuConfig.Speedhacks.EECycleRate != 0 || EmuConfig.Speedhacks.EECycleSkip != 0)
s_cpu_usage_ee_line.format("EE[{}/{}]: ", EmuConfig.Speedhacks.EECycleRate, EmuConfig.Speedhacks.EECycleSkip);
else
s_cpu_usage_ee_line.assign("EE: ");
FormatProcessorStat(s_cpu_usage_ee_line, PerformanceMetrics::GetCPUThreadUsage(), PerformanceMetrics::GetCPUThreadAverageTime());
DRAW_LINE(osd_font, font_size, s_cpu_usage_ee_line.c_str(), OsdTextColor());
s_cpu_usage_gs_line.assign("GS: ");
FormatProcessorStat(s_cpu_usage_gs_line, PerformanceMetrics::GetGSThreadUsage(), PerformanceMetrics::GetGSThreadAverageTime());
DRAW_LINE(osd_font, font_size, s_cpu_usage_gs_line.c_str(), OsdTextColor());
// Only exists under GSBackThreadMode >= Lockstep. The line above is the MTGS
// thread alone, so without this one the split's second half is invisible.
if (PerformanceMetrics::HasGSBackThread())
{
s_cpu_usage_gs_back_line.assign("GSB: ");
FormatProcessorStat(s_cpu_usage_gs_back_line, PerformanceMetrics::GetGSBackThreadUsage(),
PerformanceMetrics::GetGSBackThreadAverageTime());
DRAW_LINE(osd_font, font_size, s_cpu_usage_gs_back_line.c_str(), OsdTextColor());
}
if (THREAD_VU1)
{
s_cpu_usage_vu_line.assign("VU: ");
FormatProcessorStat(s_cpu_usage_vu_line, PerformanceMetrics::GetVUThreadUsage(), PerformanceMetrics::GetVUThreadAverageTime());
DRAW_LINE(osd_font, font_size, s_cpu_usage_vu_line.c_str(), OsdTextColor());
}
const u32 gs_sw_threads = PerformanceMetrics::GetGSSWThreadCount();
for (u32 thread = 0; thread < gs_sw_threads; thread++)
{
if (thread < s_software_thread_lines.size())
s_software_thread_lines[thread].format("SW-{}: ", thread);
else
s_software_thread_lines.push_back(SmallString("SW-{}: ", thread));
FormatProcessorStat(s_software_thread_lines[thread], PerformanceMetrics::GetGSSWThreadUsage(thread), PerformanceMetrics::GetGSSWThreadAverageTime(thread));
DRAW_LINE(osd_font, font_size, s_software_thread_lines[thread].c_str(), OsdTextColor());
}
if (GSCapture::IsCapturing())
{
s_capture_line.assign("CAP: ");
FormatProcessorStat(s_capture_line, PerformanceMetrics::GetCaptureThreadUsage(), PerformanceMetrics::GetCaptureThreadAverageTime());
DRAW_LINE(osd_font, font_size, s_capture_line.c_str(), OsdTextColor());
}
}
if (GSConfig.OsdShowGPU)
{
s_gpu_usage_line.assign("GPU: ");
FormatProcessorStat(s_gpu_usage_line, PerformanceMetrics::GetGPUUsage(), PerformanceMetrics::GetGPUAverageTime());
DRAW_LINE(osd_font, font_size, s_gpu_usage_line.c_str(), OsdTextColor());
}
if (GSConfig.OsdShowGPUDebug)
{
#ifdef _WIN32
if (g_gs_device->GetRenderAPI() == RenderAPI::D3D12)
{
GSDevice12* dev12 = static_cast<GSDevice12*>(g_gs_device.get());
s_gpu_debug_info_line.format("D3D12 Descriptor Heaps | SRV/UAV: {}/{} | RTV: {}/{} | DSV {}/{}",
dev12->GetDescriptorHeapManager().GetAllocatedDescriptors(), dev12->GetDescriptorHeapManager().GetNumDescriptors(),
dev12->GetRTVHeapManager().GetAllocatedDescriptors(), dev12->GetRTVHeapManager().GetNumDescriptors(),
dev12->GetDSVHeapManager().GetAllocatedDescriptors(), dev12->GetDSVHeapManager().GetNumDescriptors());
DRAW_LINE(osd_font, font_size, s_gpu_debug_info_line.c_str(), OsdTextColor());
}
#endif
}
if (GSConfig.OsdShowGPUStats)
{
const auto FormatUnits = [](double val) {
if (val >= 1e9)
return fmt::format("{:.5}B", val / 1e9);
if (val >= 1e6)
return fmt::format("{:.5}M", val / 1e6);
if (val >= 1e3)
return fmt::format("{:.5}K", val / 1e3);
return fmt::format("{:.5}", val);
};
s_gpu_stats_line.format("VSI: {} | PSI: {}",
FormatUnits(PerformanceMetrics::GetGPUAverageVSInvocations()),
FormatUnits(PerformanceMetrics::GetGPUAveragePSInvocations()));
DRAW_LINE(osd_font, font_size, s_gpu_stats_line.c_str(), OsdTextColor());
}
}
// No refresh yet. Display cached lines.
else
{
if (GSConfig.OsdShowFPS || GSConfig.OsdShowVPS || GSConfig.OsdShowSpeed || GSConfig.OsdShowVersion)
DRAW_LINE(osd_font, font_size, s_speed_line.c_str(), s_speed_line_color);
#if defined(__APPLE__) && TARGET_OS_IPHONE
if (ARMSX2_iOSShouldShowDeviceStatsOverlay() && !s_ios_device_stats_line.empty())
DRAW_LINE(osd_font, font_size, s_ios_device_stats_line.c_str(), ARMSX2IOSDeviceStatsColor());
#endif
if (GSConfig.OsdShowGSStats)
{
if (!s_gs_stats_line.empty())
DRAW_LINE(osd_font, font_size, s_gs_stats_line.c_str(), OsdTextColor());
if (!s_gs_memory_stats_line.empty())
DRAW_LINE(osd_font, font_size, s_gs_memory_stats_line.c_str(), OsdTextColor());
DRAW_LINE(osd_font, font_size, s_gs_frame_times_line.c_str(), OsdTextColor());
}
if (GSConfig.OsdShowResolution)
DRAW_LINE(osd_font, font_size, s_resolution_line.c_str(), OsdTextColor());
if (GSConfig.OsdShowHardwareInfo)
{
DRAW_LINE(osd_font, font_size, s_hardware_info_cpu_line.c_str(), OsdTextColor());
DRAW_LINE(osd_font, font_size, s_hardware_info_gpu_line.c_str(), OsdTextColor());
}
if (GSConfig.OsdShowCPU)
{
#if defined(__APPLE__) && !TARGET_OS_IPHONE
if (!s_cpu_jit_line.empty())
DRAW_LINE(osd_font, font_size, s_cpu_jit_line.c_str(), OsdTextColor());
#endif
DRAW_LINE(osd_font, font_size, s_cpu_usage_ee_line.c_str(), OsdTextColor());
DRAW_LINE(osd_font, font_size, s_cpu_usage_gs_line.c_str(), OsdTextColor());
if (PerformanceMetrics::HasGSBackThread())
DRAW_LINE(osd_font, font_size, s_cpu_usage_gs_back_line.c_str(), OsdTextColor());
if (THREAD_VU1)
DRAW_LINE(osd_font, font_size, s_cpu_usage_vu_line.c_str(), OsdTextColor());
const u32 thread_count = std::min(
PerformanceMetrics::GetGSSWThreadCount(),
static_cast<u32>(s_software_thread_lines.size()));
for (u32 thread = 0; thread < thread_count; thread++)
DRAW_LINE(osd_font, font_size, s_software_thread_lines[thread].c_str(), OsdTextColor());
if (GSCapture::IsCapturing())
DRAW_LINE(osd_font, font_size, s_capture_line.c_str(), OsdTextColor());
}
if (GSConfig.OsdShowGPU)
DRAW_LINE(osd_font, font_size, s_gpu_usage_line.c_str(), OsdTextColor());
if (GSConfig.OsdShowGPUDebug)
{
#ifdef _WIN32
if (g_gs_device->GetRenderAPI() == RenderAPI::D3D12)
DRAW_LINE(osd_font, font_size, s_gpu_debug_info_line.c_str(), OsdTextColor());
#endif
}
if (GSConfig.OsdShowGPUStats)
{
DRAW_LINE(osd_font, font_size, s_gpu_stats_line.c_str(), OsdTextColor());
}
}
// Check every OSD frame because this is an animation.
if (GSConfig.OsdShowFrameTimes)
{
const auto& history = PerformanceMetrics::GetFrameTimeHistory();
const u32 sample_count = PerformanceMetrics::NUM_FRAME_TIME_SAMPLES;
const u32 hist_pos = PerformanceMetrics::GetFrameTimeHistoryPos();
static constexpr u32 SCALE_WINDOW = 60u;
static constexpr float DEFAULT_FT_MIN = 0.0f;
static constexpr float DEFAULT_FT_MAX = 20.0f;
static constexpr float DEFAULT_VPS_MIN = 0.0f;
static constexpr float DEFAULT_VPS_MAX = 100.0f;
static constexpr float SCALE_SMOOTHING = 0.25f;
const auto compute_window_extents = [&](const auto& values) {
float lo = 1.0e9f;
float hi = 0.0f;
for (u32 k = 0; k < SCALE_WINDOW && k < sample_count; k++)
{
const float v = values[(hist_pos + sample_count - SCALE_WINDOW + k) % sample_count];
if (v > 0.0f)
{
lo = std::min(lo, v);
hi = std::max(hi, v);
}
}
return std::pair(lo, hi);
};
auto [initial_lo, initial_hi] = compute_window_extents(history);
auto [min_val, max_val] = [&]() {
float lo = initial_lo;
float hi = initial_hi;
if (hi < lo)
return std::pair(DEFAULT_FT_MIN, DEFAULT_FT_MAX);
if ((hi - lo) < 4.0f)
{
lo = lo - std::fmod(lo, 1.0f);
hi = hi - std::fmod(hi, 1.0f) + 1.0f;
lo = std::max(lo - 2.0f, 0.0f);
hi += 2.0f;
}
return std::pair(lo, std::max(hi, lo + 1.0f));
}();
PerformanceMetrics::FrameTimeHistory ft_history = history;
float last_ft = 0.0f;
for (u32 k = 0; k < sample_count; k++)
{
const u32 idx = (hist_pos + sample_count - 1 - k) % sample_count;
const float ft = ft_history[idx];
if (ft > 0.0f)
{
last_ft = ft;
break;
}
}
for (u32 i = 0; i < sample_count; i++)
{
const u32 idx = (hist_pos + i) % sample_count;
float& ft = ft_history[idx];
if (ft > 0.0f)
{
last_ft = ft;
continue;
}
ft = last_ft;
}
std::array<float, PerformanceMetrics::NUM_FRAME_TIME_SAMPLES> vps_history;
for (u32 i = 0; i < sample_count; i++)
vps_history[i] = (ft_history[i] >= 0.01f) ? std::min(10000.0f, 1000.0f / ft_history[i]) : 0.0f;
auto [vps_initial_lo, vps_initial_hi] = compute_window_extents(vps_history);
auto [vps_scale_min, vps_scale_max] = [&]() {
float lo = vps_initial_lo;
float hi = vps_initial_hi;
if (hi < lo)
return std::pair(DEFAULT_VPS_MIN, DEFAULT_VPS_MAX);
if (hi - lo < 10.0f)
{
lo = std::floor(lo / 50.0f) * 50.0f;
hi = std::ceil(hi / 50.0f) * 50.0f;
lo = std::max(0.0f, lo - 5.0f);
hi = std::max(hi + 5.0f, lo + 10.0f);
}
return std::pair(lo, hi);
}();
static float s_ft_min = DEFAULT_FT_MIN;
static float s_ft_max = DEFAULT_FT_MAX;
static float s_vps_min = DEFAULT_VPS_MIN;
static float s_vps_max = DEFAULT_VPS_MAX;
s_ft_min += (min_val - s_ft_min) * SCALE_SMOOTHING;
s_ft_max += (max_val - s_ft_max) * SCALE_SMOOTHING;
s_vps_min += (vps_scale_min - s_vps_min) * SCALE_SMOOTHING;
s_vps_max += (vps_scale_max - s_vps_max) * SCALE_SMOOTHING;
min_val = s_ft_min;
max_val = std::max(s_ft_max, min_val + 1.0f);
float min_vps = s_vps_min;
float max_vps = std::max(s_vps_max, min_vps + 10.0f);
// The graph has to come down with the text or it overhangs a shrunken block. Off the size
// the text actually got, not the ratio we asked for, or the two disagree by up to a step.
const float graph_scale = scale * fit;
SmallString label_buf;
label_buf.format("{:.1f}", max_val);
const float y_label_w = osd_font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, label_buf.c_str(), label_buf.c_str() + label_buf.length()).x + 4.0f * graph_scale;
label_buf.format("{:.0f}", max_vps);
const float right_label_w = osd_font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, label_buf.c_str(), label_buf.c_str() + label_buf.length()).x + 4.0f * graph_scale;
const float pad = 4.0f * graph_scale;
const float row_gap = 2.0f * graph_scale;
const float legend_h = (font_size * 2.0f) + row_gap + pad;
const ImVec2 graph_size(200.0f * graph_scale, 60.0f * graph_scale);
const ImVec2 total_size(y_label_w + graph_size.x + right_label_w + 2.0f * pad, graph_size.y + legend_h + 2.0f * pad);
s_osd_widest = std::max(s_osd_widest, total_size.x);
ImGui::SetNextWindowSize(total_size);
ImGui::SetNextWindowPos(CalculatePerformanceOverlayTextPosition(GSConfig.OsdPerformancePos, margin, total_size, GetWindowWidth(), position_y));
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.45f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f * graph_scale);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushFont(osd_font, font_size);
if (ImGui::Begin("##frame_times", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs))
{
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImVec2 wpos(ImGui::GetWindowPos());
const ImVec2 plot_tl(wpos.x + pad + y_label_w, wpos.y + pad);
const ImVec2 plot_br(plot_tl.x + graph_size.x, plot_tl.y + graph_size.y);
dl->AddRectFilled(plot_tl, plot_br, IM_COL32(0, 0, 0, 60));
const int num_ticks = std::max(1, std::min(8, static_cast<int>(graph_size.y / (font_size * 1.1f))));
const float left_label_x = wpos.x + pad + y_label_w;
const float right_label_x = plot_br.x + 2.0f * graph_scale;
const ImU32 ft_col = IM_COL32(100, 200, 255, 230);
const ImU32 vps_col = IM_COL32(100, 255, 100, 230);
auto draw_grid_and_labels = [&](int ticks) {
SmallString s;
for (int i = 0; i <= ticks; i++)
{
const float frac = static_cast<float>(i) / ticks;
const float grid_y = plot_br.y - frac * graph_size.y;
const float ly = grid_y - font_size * 0.5f;
dl->AddLine(ImVec2(plot_tl.x, grid_y), ImVec2(plot_br.x, grid_y), IM_COL32(255, 255, 255, 40), 1.0f);
s.format("{:.1f}", min_val + (max_val - min_val) * frac);
const float left_text_w = osd_font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.c_str(), s.c_str() + s.length()).x;
const float lx = left_label_x - left_text_w - 2.0f * graph_scale;
dl->AddText(osd_font, font_size, ImVec2(lx + shadow_offset, ly + shadow_offset), IM_COL32(0, 0, 0, 100), s.c_str(), s.c_str() + s.length());
dl->AddText(osd_font, font_size, ImVec2(lx, ly), ft_col, s.c_str(), s.c_str() + s.length());
s.format("{:.0f}", min_vps + (max_vps - min_vps) * frac);
dl->AddText(osd_font, font_size, ImVec2(right_label_x + shadow_offset, ly + shadow_offset), IM_COL32(0, 0, 0, 100), s.c_str(), s.c_str() + s.length());
dl->AddText(osd_font, font_size, ImVec2(right_label_x, ly), vps_col, s.c_str(), s.c_str() + s.length());
}
};
draw_grid_and_labels(num_ticks);
const auto col32_to_vec4 = [](ImU32 col) -> ImVec4 {
return ImVec4(
static_cast<float>((col >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f,
static_cast<float>((col >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f,
static_cast<float>((col >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f,
static_cast<float>((col >> IM_COL32_A_SHIFT) & 0xFF) / 255.0f);
};
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0, 0, 0, 0));
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f);
ImGui::SetCursorScreenPos(plot_tl);
ImGui::PushStyleColor(ImGuiCol_PlotLines, col32_to_vec4(ft_col));
ImGui::PlotLines("##frame_time_plot", ft_history.data(), static_cast<int>(sample_count), static_cast<int>(hist_pos),
nullptr, min_val, max_val, graph_size);
ImGui::PopStyleColor();
ImGui::SetCursorScreenPos(plot_tl);
ImGui::PushStyleColor(ImGuiCol_PlotLines, col32_to_vec4(vps_col));
ImGui::PlotLines("##vps_plot", vps_history.data(), static_cast<int>(sample_count), static_cast<int>(hist_pos),
nullptr, min_vps, max_vps, graph_size);
ImGui::PopStyleColor();
ImGui::PopStyleVar();
ImGui::PopStyleColor(2);
const float legend_y = plot_br.y + pad * 0.5f;
const float legend_square_size = font_size * 0.65f;
const float legend_gap = 4.0f * graph_scale;
SmallString frame_part, vps_part;
frame_part.format("Frame: {:.2f} ms", PerformanceMetrics::GetAverageFrameTime());
vps_part.format("V-Blank: {:.2f}", PerformanceMetrics::GetFPS());
const float fw = osd_font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, frame_part.c_str(), nullptr).x;
const float vw = osd_font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, vps_part.c_str(), nullptr).x;
const float max_text_w = std::max(fw, vw);
const float row_width = legend_square_size + legend_gap + max_text_w;
float base_x = wpos.x + (total_size.x - row_width) * 0.5f;
auto draw_legend_entry = [&](ImU32 col, const char* text, float text_w, float y) {
float lx = base_x;
dl->AddRectFilled(ImVec2(lx, y + (font_size - legend_square_size) * 0.5f),
ImVec2(lx + legend_square_size, y + (font_size + legend_square_size) * 0.5f), col);
lx += legend_square_size + legend_gap;
dl->AddText(osd_font, font_size, ImVec2(lx + shadow_offset, y + shadow_offset), IM_COL32(0, 0, 0, 100), text, nullptr);
dl->AddText(osd_font, font_size, ImVec2(lx, y), OsdTextColor(), text, nullptr);
};
draw_legend_entry(IM_COL32(100, 200, 255, 230), frame_part.c_str(), fw, legend_y);
draw_legend_entry(IM_COL32(100, 255, 100, 230), vps_part.c_str(), vw, legend_y + font_size + row_gap);
}
ImGui::End();
ImGui::PopFont();
ImGui::PopStyleVar(3);
ImGui::PopStyleColor(1);
}
}
#undef DRAW_LINE
}
// How tall the settings string ended up. It wraps now, so the indicator below can no longer
// assume one line. DrawSettingsOverlay runs first, so this is current.
static float s_settings_overlay_height = 0.0f;
__ri void ImGuiManager::DrawShaderCompileIndicator(float scale, float margin, float bottom_margin, float spacing)
{
static bool s_indicator_was_visible = false;
static double s_indicator_fade_in_start = 0.0;
if (!GSConfig.OsdShowGPU || !GSShaderCompileIndicator::IsVisible())
{
s_indicator_was_visible = false;
return;
}
const double imgui_time = ImGui::GetTime();
if (!s_indicator_was_visible)
s_indicator_fade_in_start = imgui_time;
s_indicator_was_visible = true;
constexpr double fade_in_seconds = 0.12;
const float fade_in_alpha = static_cast<float>(
std::min(1.0, (imgui_time - s_indicator_fade_in_start) / fade_in_seconds));
const float fade_out_alpha = GSShaderCompileIndicator::GetFadeAlpha();
const float alpha = std::clamp(fade_in_alpha * fade_out_alpha, 0.0f, 1.0f);
const ImU32 text_col = IM_COL32(255, 255, 255, static_cast<int>(std::lround(255.0f * alpha)));
const ImU32 shadow_col = IM_COL32(0, 0, 0, static_cast<int>(std::lround(100.0f * alpha)));
const ImU32 spinner_track_col = IM_COL32(255, 255, 255, static_cast<int>(std::lround(45.0f * alpha)));
static constexpr const char* COMPILED_ONE =
TRANSLATE_NOOP("ImGuiOverlays", "Compiled {0} shader in {1}ms");
static constexpr const char* COMPILED_MANY =
TRANSLATE_NOOP("ImGuiOverlays", "Compiled {0} shaders in {1}ms");
const u32 count = GSShaderCompileIndicator::GetCount();
const u32 time_ms = GSShaderCompileIndicator::GetTimeMs();
const std::string label = (count == 1) ?
fmt::format(TRANSLATE_FS("ImGuiOverlays", COMPILED_ONE), count, time_ms) :
fmt::format(TRANSLATE_FS("ImGuiOverlays", COMPILED_MANY), count, time_ms);
ImFont* const font = ImGuiManager::GetOSDFont();
const float font_size = ImGuiManager::GetFontSizeStandard();
const float baseline_y = GetWindowHeight() - bottom_margin - s_settings_overlay_height;
const float radius = std::ceil(10.0f * scale);
const float cx = GetWindowWidth() - margin - radius;
const float cy = baseline_y - spacing - radius;
const ImVec2 center(cx, cy);
const ImVec2 text_size = font->CalcTextSizeA(
font_size, std::numeric_limits<float>::max(), -1.0f, label.c_str(), label.c_str() + label.length(), nullptr);
const float shadow_offset = std::ceil(scale);
const float text_gap = std::ceil(6.0f * scale);
const float text_x = cx - radius - text_gap - text_size.x;
const float text_y = cy - font_size * 0.5f;
ImDrawList* const dl = ImGui::GetBackgroundDrawList();
const float a0 = static_cast<float>(ImGui::GetTime()) * 10.0f;
const float a1 = a0 + IM_PI * 1.12f;
const float thickness = std::ceil(2.5f * scale);
dl->AddText(font, font_size, ImVec2(text_x + shadow_offset, text_y + shadow_offset), shadow_col,
label.c_str(), label.c_str() + label.length());
dl->AddText(font, font_size, ImVec2(text_x, text_y), text_col, label.c_str(), label.c_str() + label.length());
if (GSConfig.OsdBoldText)
{
dl->AddText(font, font_size, ImVec2(text_x + 0.6f, text_y), text_col, label.c_str(),
label.c_str() + label.length());
}
dl->PathClear();
dl->PathArcTo(center, radius, 0.0f, 2.0f * IM_PI, 32);
dl->PathStroke(spinner_track_col, std::max(1.0f, thickness * 0.65f), false);
dl->PathClear();
dl->PathArcTo(center, radius, a0, a1, 24);
dl->PathStroke(text_col, thickness, false);
}
__ri void ImGuiManager::DrawSettingsOverlay(float scale, float margin, float bottom_margin, float spacing)
{
s_settings_overlay_height = 0.0f;
if (!GSConfig.OsdShowSettings ||
FullscreenUI::HasActiveWindow())
return;
std::string text;
text.reserve(512);
#define APPEND(...) \
do \
{ \
fmt::format_to(std::back_inserter(text), __VA_ARGS__); \
} while (0)
if (Patch::GetAllActivePatchesCount() > 0 && EmuConfig.GS.OsdshowPatches)
APPEND("DB={} P={} C={} | ",
Patch::GetActiveGameDBPatchesCount(),
Patch::GetActivePatchesCount(),
Patch::GetActiveCheatsCount());
if (EmuConfig.Speedhacks.EECycleRate != 0)
APPEND("CR={} ", EmuConfig.Speedhacks.EECycleRate);
if (EmuConfig.Speedhacks.EECycleSkip != 0)
APPEND("CS={} ", EmuConfig.Speedhacks.EECycleSkip);
if (EmuConfig.Speedhacks.fastCDVD)
APPEND("FCDVD ");
if (EmuConfig.Speedhacks.vu1Instant)
APPEND("IVU ");
if (EmuConfig.Speedhacks.vuThread)
APPEND("MTVU ");
if (EmuConfig.GS.VsyncEnable)
APPEND("VSYNC ");
APPEND("EER={} EEC={} VUR={} VUC={} VQS={} ", static_cast<unsigned>(EmuConfig.Cpu.FPUFPCR.GetRoundMode()),
EmuConfig.Cpu.Recompiler.GetEEClampMode(), static_cast<unsigned>(EmuConfig.Cpu.VU0FPCR.GetRoundMode()),
EmuConfig.Cpu.Recompiler.GetVUClampMode(), EmuConfig.GS.VsyncQueueSize);
if (GSIsHardwareRenderer())
{
if ((GSConfig.UpscaleMultiplier - std::floor(GSConfig.UpscaleMultiplier)) > 0.01)
APPEND("IR={:.2f} ", static_cast<float>(GSConfig.UpscaleMultiplier));
else
APPEND("IR={} ", static_cast<unsigned>(GSConfig.UpscaleMultiplier));
APPEND("BL={} TPL={} ", static_cast<unsigned>(GSConfig.AccurateBlendingUnit), static_cast<unsigned>(GSConfig.TexturePreloading));
if (GSConfig.GPUPaletteConversion)
APPEND("PLTX ");
if (GSConfig.HWDownloadMode != GSHardwareDownloadMode::Enabled)
APPEND("HWDM={} ", static_cast<unsigned>(GSConfig.HWDownloadMode));
if (GSConfig.HWMipmap)
APPEND("MM ");
if (GSConfig.HWAccurateAlphaTest)
APPEND("AAT ");
if (GSConfig.HWAA1)
APPEND("AA1 ");
if (GSConfig.HWROV)
APPEND("ROV ");
if (GSConfig.HWROVBarriersVK)
APPEND("RBVK ");
// deliberately test global and print local here for auto values
if (EmuConfig.GS.TextureFiltering != BiFiltering::PS2)
APPEND("BF={} ", static_cast<unsigned>(GSConfig.TextureFiltering));
if (EmuConfig.GS.TriFilter != TriFiltering::Automatic)
APPEND("TF={} ", static_cast<unsigned>(GSConfig.TriFilter));
if (GSConfig.MaxAnisotropy > 1)
APPEND("AF={} ", EmuConfig.GS.MaxAnisotropy);
if (GSConfig.Dithering != 2)
APPEND("DI={} ", GSConfig.Dithering);
if (GSConfig.UserHacks_HalfPixelOffset != GSHalfPixelOffset::Off)
APPEND("HPO={} ", static_cast<u32>(GSConfig.UserHacks_HalfPixelOffset));
if (GSConfig.UserHacks_RoundSprite > 0)
APPEND("RS={} ", GSConfig.UserHacks_RoundSprite);
if (GSConfig.UserHacks_NativeScaling > GSNativeScaling::Off)
APPEND("NS={} ", static_cast<unsigned>(GSConfig.UserHacks_NativeScaling));
if (GSConfig.UserHacks_TCOffsetX != 0 || GSConfig.UserHacks_TCOffsetY != 0)
APPEND("TCO={}/{} ", GSConfig.UserHacks_TCOffsetX, GSConfig.UserHacks_TCOffsetY);
if (GSConfig.UserHacks_CPUSpriteRenderBW != 0)
APPEND("CSBW={}/{} ", GSConfig.UserHacks_CPUSpriteRenderBW, GSConfig.UserHacks_CPUSpriteRenderLevel);
if (GSConfig.UserHacks_CPUCLUTRender != 0)
APPEND("CCLUT={} ", GSConfig.UserHacks_CPUCLUTRender);
if (GSConfig.UserHacks_GPUTargetCLUTMode != GSGPUTargetCLUTMode::Disabled)
APPEND("GCLUT={} ", static_cast<unsigned>(GSConfig.UserHacks_GPUTargetCLUTMode));
if (GSConfig.SkipDrawStart != 0 || GSConfig.SkipDrawEnd != 0)
APPEND("SD={}/{} ", GSConfig.SkipDrawStart, GSConfig.SkipDrawEnd);
if (GSConfig.UserHacks_TextureInsideRt != GSTextureInRtMode::Disabled)
APPEND("TexRT={} ", static_cast<unsigned>(GSConfig.UserHacks_TextureInsideRt));
if (GSConfig.UserHacks_Limit24BitDepth != GSLimit24BitDepth::Disabled)
APPEND("LDR={} ", static_cast<unsigned>(GSConfig.UserHacks_Limit24BitDepth));
if (GSConfig.UserHacks_BilinearHack != GSBilinearDirtyMode::Automatic)
APPEND("BLU={} ", static_cast<unsigned>(GSConfig.UserHacks_BilinearHack));
if (GSConfig.UserHacks_ForceEvenSpritePosition)
APPEND("FESP ");
if (GSConfig.UserHacks_NativePaletteDraw)
APPEND("NPD ");
if (GSConfig.UserHacks_MergePPSprite)
APPEND("MS ");
if (GSConfig.UserHacks_AlignSpriteX)
APPEND("AS ");
if (GSConfig.UserHacks_AutoFlush != GSHWAutoFlushLevel::Disabled)
APPEND("ATFL={} ", static_cast<unsigned>(GSConfig.UserHacks_AutoFlush));
if (GSConfig.UserHacks_CPUFBConversion)
APPEND("FBC ");
if (GSConfig.UserHacks_ReadTCOnClose)
APPEND("RTOC ");
if (GSConfig.UserHacks_DisableDepthSupport)
APPEND("DDC ");
if (GSConfig.UserHacks_DisablePartialInvalidation)
APPEND("DPIV ");
if (GSConfig.UserHacks_DisableSafeFeatures)
APPEND("DSF ");
if (GSConfig.UserHacks_DisableRenderFixes)
APPEND("DRF ");
if (GSConfig.PreloadFrameWithGSData)
APPEND("PLFD ");
if (GSConfig.UserHacks_EstimateTextureRegion)
APPEND("ETR ");
if (GSConfig.UserHacks_DrawBuffering)
APPEND("DRWB ");
if (GSConfig.HWSpinGPUForReadbacks)
APPEND("RBSG ");
if (GSConfig.HWSpinCPUForReadbacks)
APPEND("RBSC ");
}
#undef APPEND
if (text.empty())
return;
else if (text.back() == ' ')
text.pop_back();
const float shadow_offset = std::ceil(scale);
ImFont* const font = ImGuiManager::GetOSDFont();
const float base_font_size = ImGuiManager::GetFontSizeStandard();
const float avail = GetWindowWidth() - 2.0f * margin;
ImDrawList* dl = ImGui::GetBackgroundDrawList();
ImVec2 text_size =
font->CalcTextSizeA(base_font_size, std::numeric_limits<float>::max(), -1.0f, text.c_str(), text.c_str() + text.length(), nullptr);
// This one runs to a couple of hundred characters, so shrinking it far enough to fit on a single
// line would leave it unreadable. Take it down a little, then let it wrap onto two or three.
float font_size = base_font_size;
float wrap_width = 0.0f;
if (avail > 0.0f && text_size.x > avail)
{
font_size = std::max(1.0f, std::floor(base_font_size * std::clamp(avail / text_size.x, 0.6f, 1.0f)));
wrap_width = avail;
text_size = font->CalcTextSizeA(font_size, std::numeric_limits<float>::max(), wrap_width, text.c_str(), text.c_str() + text.length(), nullptr);
}
s_settings_overlay_height = text_size.y;
const float position_y = GetWindowHeight() - bottom_margin - text_size.y;
const ImVec2 text_pos(std::max(margin, GetWindowWidth() - margin - text_size.x), position_y);
const bool bold_osd = GSConfig.OsdBoldText;
dl->AddText(font, font_size,
ImVec2(text_pos.x + shadow_offset, text_pos.y + shadow_offset), IM_COL32(0, 0, 0, 100),
text.c_str(), text.c_str() + text.length(), wrap_width);
dl->AddText(font, font_size, text_pos, white_color,
text.c_str(), text.c_str() + text.length(), wrap_width);
if (bold_osd)
{
dl->AddText(font, font_size, ImVec2(text_pos.x + 0.6f, text_pos.y), white_color,
text.c_str(), text.c_str() + text.length(), wrap_width);
}
}
__ri void ImGuiManager::DrawInputsOverlay(float scale, float margin, float bottom_margin, float spacing)
{
// Technically this is racing the CPU thread.. but it doesn't really matter, at worst, the inputs get displayed onscreen late.
if (!GSConfig.OsdShowInputs ||
FullscreenUI::HasActiveWindow())
return;
const float shadow_offset = std::ceil(scale);
ImFont* const font = ImGuiManager::GetStandardFont();
const float font_size = ImGuiManager::GetFontSizeStandard();
const float line_height = ImGuiFullscreen::GetLineHeight({ font, font_size });
static constexpr u32 text_color = IM_COL32(0xff, 0xff, 0xff, 255);
static constexpr u32 shadow_color = IM_COL32(0x00, 0x00, 0x00, 100);
const ImVec2& display_size = ImGui::GetIO().DisplaySize;
ImDrawList* dl = ImGui::GetBackgroundDrawList();
u32 num_ports = 0;
for (u32 slot = 0; slot < Pad::NUM_CONTROLLER_PORTS; slot++)
{
if (Pad::HasConnectedPad(slot))
num_ports++;
}
for (u32 port = 0; port < USB::NUM_PORTS; port++)
{
if (EmuConfig.USB.Ports[port].DeviceType >= 0)
num_ports++;
}
float current_x = ImFloor(margin);
float current_y = ImFloor(display_size.y - bottom_margin - ((static_cast<float>(num_ports) * (line_height + spacing)) - spacing));
const ImVec4 clip_rect(current_x, current_y, display_size.x - margin, display_size.y);
SmallString text;
for (u32 slot = 0; slot < Pad::NUM_CONTROLLER_PORTS; slot++)
{
const PadBase* const pad = Pad::GetPad(slot);
const Pad::ControllerType ctype = pad->GetType();
if (ctype == Pad::ControllerType::NotConnected)
continue;
const Pad::ControllerInfo& cinfo = pad->GetInfo();
text.format("{} {} • {} |", ICON_FA_GAMEPAD, slot + 1u, cinfo.icon_name ? cinfo.icon_name : ICON_FA_TRIANGLE_EXCLAMATION);
for (u32 bind = 0; bind < static_cast<u32>(cinfo.bindings.size()); bind++)
{
const InputBindingInfo& bi = cinfo.bindings[bind];
switch (bi.bind_type)
{
case InputBindingInfo::Type::Axis:
case InputBindingInfo::Type::HalfAxis:
{
// axes are only shown if not resting/past deadzone. values are normalized.
const float value = pad->GetEffectiveInput(bind);
const float abs_value = std::abs(value);
if (abs_value >= (254.0f / 255.0f))
text.append_format(" {}", bi.icon_name ? bi.icon_name : bi.name);
else if (abs_value >= (1.0f / 255.0f))
text.append_format(" {}: {:.2f}", bi.icon_name ? bi.icon_name : bi.name, value);
}
break;
case InputBindingInfo::Type::Button:
{
// buttons display the value from 0 through 255.
const float value = pad->GetEffectiveInput(bind);
if (value >= 254.0f)
text.append_format(" {}", bi.icon_name ? bi.icon_name : bi.name);
else if (value > 0.0f)
text.append_format(" {}: {:.0f}", bi.icon_name ? bi.icon_name : bi.name, value);
}
break;
case InputBindingInfo::Type::Motor:
case InputBindingInfo::Type::Macro:
case InputBindingInfo::Type::Unknown:
default:
break;
}
}
dl->AddText(font, font_size, ImVec2(current_x + shadow_offset, current_y + shadow_offset), shadow_color,
text.c_str(), text.c_str() + text.length(), 0.0f, &clip_rect);
dl->AddText(font, font_size, ImVec2(current_x, current_y), text_color,
text.c_str(), text.c_str() + text.length(), 0.0f, &clip_rect);
current_y += line_height + spacing;
}
for (u32 port = 0; port < USB::NUM_PORTS; port++)
{
if (EmuConfig.USB.Ports[port].DeviceType < 0)
continue;
const std::span<const InputBindingInfo> bindings(USB::GetDeviceBindings(port));
const char* icon = USB::GetDeviceIconName(port);
text.format("{} {} • {} | ", ICON_PF_USB, port + 1u, icon ? icon : ICON_FA_TRIANGLE_EXCLAMATION);
for (const InputBindingInfo& bi : bindings)
{
switch (bi.bind_type)
{
case InputBindingInfo::Type::Axis:
case InputBindingInfo::Type::HalfAxis:
{
// axes are only shown if not resting/past deadzone. values are normalized.
const float value = static_cast<float>(USB::GetDeviceBindValue(port, bi.bind_index));
if (value >= (254.0f / 255.0f))
text.append_format(" {}", bi.icon_name ? bi.icon_name : bi.name);
else if (value > (1.0f / 255.0f))
text.append_format(" {}: {:.2f}", bi.icon_name ? bi.icon_name : bi.name, value);
}
break;
case InputBindingInfo::Type::Button:
{
// buttons display the value from 0 through 255. values are normalized, so denormalize them.
const float value = static_cast<float>(USB::GetDeviceBindValue(port, bi.bind_index)) * 255.0f;
if (value >= 254.0f)
text.append_format(" {}", bi.icon_name ? bi.icon_name : bi.name);
else if (value > 0.0f)
text.append_format(" {}: {:.0f}", bi.icon_name ? bi.icon_name : bi.name, value);
}
break;
case InputBindingInfo::Type::Motor:
case InputBindingInfo::Type::Macro:
case InputBindingInfo::Type::Unknown:
default:
break;
}
}
dl->AddText(font, font_size, ImVec2(current_x + shadow_offset, current_y + shadow_offset), shadow_color,
text.c_str(), text.c_str() + text.length(), 0.0f, &clip_rect);
dl->AddText(font, font_size, ImVec2(current_x, current_y), text_color,
text.c_str(), text.c_str() + text.length(), 0.0f, &clip_rect);
current_y += line_height + spacing;
}
}
__ri void ImGuiManager::DrawInputRecordingOverlay(float& position_y, float scale, float margin, float spacing)
{
if (!GSConfig.OsdShowInputRec ||
!g_InputRecording.isActive() ||
FullscreenUI::HasActiveWindow())
return;
const float shadow_offset = std::ceil(scale);
ImFont* const osd_font = ImGuiManager::GetOSDFont();
const float font_size = ImGuiManager::GetFontSizeStandard();
ImDrawList* dl = ImGui::GetBackgroundDrawList();
std::string text;
ImVec2 text_size;
text.reserve(128);
#define DRAW_LINE(font, size, text, color) \
do \
{ \
text_size = font->CalcTextSizeA(size, std::numeric_limits<float>::max(), -1.0f, (text), nullptr, nullptr); \
dl->AddText(font, size, \
ImVec2(GetWindowWidth() - margin - text_size.x + shadow_offset, position_y + shadow_offset), \
IM_COL32(0, 0, 0, 100), (text)); \
dl->AddText(font, size, ImVec2(GetWindowWidth() - margin - text_size.x, position_y), color, (text)); \
position_y += text_size.y + spacing; \
} while (0)
// Status Indicators
if (g_InputRecordingData.is_recording)
{
DRAW_LINE(osd_font, font_size, TinyString::from_format(TRANSLATE_FS("ImGuiOverlays", "{} Recording Input"), ICON_PF_CIRCLE).c_str(), IM_COL32(255, 0, 0, 255));
}
else
{
DRAW_LINE(osd_font, font_size, TinyString::from_format(TRANSLATE_FS("ImGuiOverlays", "{} Replaying"), ICON_FA_PLAY).c_str(), IM_COL32(97, 240, 84, 255));
}
// Input Recording Metadata
DRAW_LINE(osd_font, font_size, g_InputRecordingData.recording_active_message.c_str(), IM_COL32(117, 255, 241, 255));
DRAW_LINE(osd_font, font_size, g_InputRecordingData.frame_data_message.c_str(), IM_COL32(117, 255, 241, 255));
DRAW_LINE(osd_font, font_size, g_InputRecordingData.undo_count_message.c_str(), IM_COL32(117, 255, 241, 255));
#undef DRAW_LINE
}
__ri void ImGuiManager::DrawVideoCaptureOverlay(float& position_y, float scale, float margin, float spacing)
{
if (!GSConfig.OsdShowVideoCapture ||
!GSCapture::IsCapturing() ||
FullscreenUI::HasActiveWindow())
return;
const float shadow_offset = std::ceil(scale);
ImFont* const osd_font = ImGuiManager::GetOSDFont();
float font_size = ImGuiManager::GetFontSizeStandard();
ImDrawList* dl = ImGui::GetBackgroundDrawList();
static constexpr const char* ICON = ICON_PF_CIRCLE;
const TinyString text_msg = TinyString::from_format(" {}", GSCapture::GetElapsedTime());
const ImVec2 icon_size = osd_font->CalcTextSizeA(font_size, std::numeric_limits<float>::max(),
-1.0f, ICON, nullptr, nullptr);
const ImVec2 text_size = osd_font->CalcTextSizeA(font_size, std::numeric_limits<float>::max(),
-1.0f, text_msg.c_str(), text_msg.end_ptr(), nullptr);
// Shadow
dl->AddText(osd_font, font_size,
ImVec2(GetWindowWidth() - margin - text_size.x - icon_size.x + shadow_offset, position_y + shadow_offset),
IM_COL32(0, 0, 0, 100), ICON);
dl->AddText(osd_font, font_size,
ImVec2(GetWindowWidth() - margin - text_size.x + shadow_offset, position_y + shadow_offset),
IM_COL32(0, 0, 0, 100), text_msg.c_str(), text_msg.end_ptr());
// Text
dl->AddText(osd_font, font_size,
ImVec2(GetWindowWidth() - margin - text_size.x - icon_size.x, position_y), IM_COL32(255, 0, 0, 255), ICON);
dl->AddText(osd_font, font_size,
ImVec2(GetWindowWidth() - margin - text_size.x, position_y), white_color, text_msg.c_str(),
text_msg.end_ptr());
position_y += std::max(icon_size.y, text_size.y) + spacing;
}
__ri void ImGuiManager::DrawTextureReplacementsOverlay(float& position_y, float scale, float margin, float spacing)
{
if (!GSConfig.OsdShowTextureReplacements ||
FullscreenUI::HasActiveWindow())
return;
const bool dumping_active = GSConfig.DumpReplaceableTextures;
const bool replacement_active = GSConfig.LoadTextureReplacements;
if (!dumping_active && !replacement_active)
return;
const float shadow_offset = std::ceil(scale);
ImFont* const osd_font = ImGuiManager::GetOSDFont();
const float font_size = ImGuiManager::GetFontSizeStandard();
ImDrawList* dl = ImGui::GetBackgroundDrawList();
SmallString texture_line;
if (replacement_active)
{
const u32 loaded_count = GSTextureReplacements::GetLoadedTextureCount();
texture_line.format("{} Replaced: {}", ICON_FA_IMAGES, loaded_count);
}
if (dumping_active)
{
if (!texture_line.empty())
texture_line.append(" | ");
const u32 dumped_count = GSTextureReplacements::GetDumpedTextureCount();
texture_line.append_format("{} Dumped: {}", ICON_FA_DOWNLOAD, dumped_count);
}
ImVec2 text_size = osd_font->CalcTextSizeA(font_size, std::numeric_limits<float>::max(), -1.0f, texture_line.c_str(), nullptr, nullptr);
const ImVec2 text_pos(GetWindowWidth() - margin - text_size.x, position_y);
dl->AddText(osd_font, font_size, ImVec2(text_pos.x + shadow_offset, text_pos.y + shadow_offset), IM_COL32(0, 0, 0, 100), texture_line.c_str());
dl->AddText(osd_font, font_size, text_pos, OsdTextColor(), texture_line.c_str());
position_y += text_size.y + spacing;
}
__ri void ImGuiManager::DrawIndicatorsOverlay(float& position_y, float scale, float margin, float spacing)
{
if (!GSConfig.OsdShowIndicators ||
FullscreenUI::HasActiveWindow())
return;
const float shadow_offset = std::ceil(scale);
ImFont* const osd_font = ImGuiManager::GetOSDFont();
const float font_size = ImGuiManager::GetFontSizeStandard();
ImDrawList* dl = ImGui::GetBackgroundDrawList();
std::string text;
ImVec2 text_size;
text.reserve(64);
#define DRAW_LINE(font, size, text, color) \
do \
{ \
text_size = font->CalcTextSizeA(size, std::numeric_limits<float>::max(), -1.0f, (text), nullptr, nullptr); \
dl->AddText(font, size, \
ImVec2(GetWindowWidth() - margin - text_size.x + shadow_offset, position_y + shadow_offset), \
IM_COL32(0, 0, 0, 100), (text)); \
dl->AddText(font, size, ImVec2(GetWindowWidth() - margin - text_size.x, position_y), color, (text)); \
position_y += text_size.y + spacing; \
} while (0)
if (VMManager::GetState() != VMState::Paused)
{
// Draw Speed indicator
const float target_speed = VMManager::GetTargetSpeed();
const bool is_normal_speed = (target_speed == EmuConfig.EmulationSpeed.NominalScalar ||
VMManager::IsTargetSpeedAdjustedToHost());
if (!is_normal_speed)
{
if (target_speed == EmuConfig.EmulationSpeed.SlomoScalar) // Slow-Motion
s_speed_icon = ICON_PF_SLOW_MOTION;
else if (target_speed == EmuConfig.EmulationSpeed.TurboScalar) // Turbo
s_speed_icon = ICON_FA_FORWARD_FAST;
else // Unlimited
s_speed_icon = ICON_FA_FORWARD;
DRAW_LINE(osd_font, font_size, s_speed_icon, OsdTextColor());
}
}
else
{
// Draw Pause indicator
const TinyString pause_msg = TinyString::from_format(TRANSLATE_FS("ImGuiOverlays", "{} Paused"), ICON_FA_PAUSE);
DRAW_LINE(osd_font, font_size, pause_msg, OsdTextColor());
}
#undef DRAW_LINE
}
namespace SaveStateSelectorUI
{
namespace
{
struct ListEntry
{
std::string title;
std::string summary;
std::string filename;
std::unique_ptr<GSTexture> preview_texture;
};
} // namespace
static void InitializePlaceholderListEntry(ListEntry* li, std::string path, s32 slot);
static void InitializeListEntry(const std::string& serial, u32 crc, ListEntry* li, s32 slot);
static void RefreshHotkeyLegend();
static void Draw();
static void ShowSlotOSDMessage();
static std::string GetSaveStateTimestampSummary(const std::time_t& modification_time);
bool IsOpen();
static constexpr const char* SAVED_AGO_DAYS_TIME_DATE =
TRANSLATE_NOOP("ImGuiOverlays", "Saved {0} days ago at {1:%H:%M} on {1:%a} {1:%Y/%m/%d}");
static constexpr const char* SAVED_FUTURE_TIME_DATE =
TRANSLATE_NOOP("ImGuiOverlays", "Saved in the future at {0:%H:%M} on {0:%a} {0:%Y/%m/%d}");
static constexpr const char* SAVED_AGO_HOURS_MINUTES =
TRANSLATE_NOOP("ImGuiOverlays", "Saved {0} hours, {1} minutes ago at {2:%H:%M}");
static constexpr const char* SAVED_AGO_MINUTES = TRANSLATE_NOOP("ImGuiOverlays", "Saved {0} minutes ago at {1:%H:%M}");
static constexpr const char* SAVED_AGO_SECONDS = TRANSLATE_NOOP("ImGuiOverlays", "Saved {} seconds ago");
static constexpr const char* SAVED_AGO_NOW = TRANSLATE_NOOP("ImGuiOverlays", "Saved just now");
static constexpr std::time_t ONE_HOUR = 60 * 60; // 3600
static constexpr std::time_t TWENTY_FOUR_HOURS = ONE_HOUR * 24; // 86400
static std::shared_ptr<GSTexture> s_placeholder_texture;
static std::string s_load_legend;
static std::string s_save_legend;
static std::string s_prev_legend;
static std::string s_next_legend;
static std::string s_close_legend;
static std::array<ListEntry, VMManager::NUM_SAVE_STATE_SLOTS> s_slots;
static std::atomic_int32_t s_current_slot{0};
static float s_open_time = 0.0f;
static float s_close_time = 0.0f;
static ImAnimatedFloat s_scroll_animated;
static ImAnimatedFloat s_background_animated;
static bool s_open = false;
} // namespace SaveStateSelectorUI
void SaveStateSelectorUI::Open(float open_time /* = DEFAULT_OPEN_TIME */)
{
const std::string serial = VMManager::GetDiscSerial();
if (serial.empty())
{
Host::AddIconOSDMessage("SaveStateSelectorUIUnavailable", ICON_PF_MEMORY_CARD,
TRANSLATE_SV("ImGuiOverlays", "Save state selector is unavailable without a valid game serial."));
return;
}
s_open_time = 0.0f;
s_close_time = open_time;
if (s_open)
return;
if (!s_placeholder_texture)
s_placeholder_texture = ImGuiFullscreen::LoadTexture("fullscreenui/no-save.png");
s_scroll_animated.Reset(0.0f);
s_background_animated.Reset(0.0f);
s_open = true;
RefreshList(serial, VMManager::GetDiscCRC());
RefreshHotkeyLegend();
}
bool SaveStateSelectorUI::IsOpen()
{
return s_open;
}
void SaveStateSelectorUI::Close()
{
s_open = false;
s_load_legend = {};
s_save_legend = {};
s_prev_legend = {};
s_next_legend = {};
s_close_legend = {};
}
void SaveStateSelectorUI::RefreshList(const std::string& serial, u32 crc)
{
for (ListEntry& entry : s_slots)
{
if (entry.preview_texture)
g_gs_device->Recycle(entry.preview_texture.release());
}
for (u32 i = 0; i < VMManager::NUM_SAVE_STATE_SLOTS; i++)
InitializeListEntry(serial, crc, &s_slots[i], static_cast<s32>(i + 1));
}
void SaveStateSelectorUI::Clear()
{
// called on CPU thread at shutdown, textures should already be deleted, unless running
// big picture UI, in which case we have to delete them here...
for (ListEntry& li : s_slots)
{
if (li.preview_texture)
{
MTGS::RunOnGSThread([tex = li.preview_texture.release()]() {
g_gs_device->Recycle(tex);
});
}
li = {};
}
s_current_slot.store(0, std::memory_order_release);
}
void SaveStateSelectorUI::DestroyTextures()
{
Close();
for (ListEntry& entry : s_slots)
{
if (entry.preview_texture)
g_gs_device->Recycle(entry.preview_texture.release());
}
s_placeholder_texture.reset();
}
void SaveStateSelectorUI::RefreshHotkeyLegend()
{
auto format_legend_entry = [](SmallString binding, std::string_view caption) {
InputManager::PrettifyInputBinding(binding);
if (binding.empty())
binding.append(TRANSLATE_STR("ImGuiOverlays", "Empty"));
return fmt::format("{} - {}", binding, caption);
};
s_load_legend = format_legend_entry(Host::GetSmallStringSettingValue("Hotkeys", "LoadStateFromSlot"),
TRANSLATE_STR("ImGuiOverlays", "Load"));
s_save_legend = format_legend_entry(Host::GetSmallStringSettingValue("Hotkeys", "SaveStateToSlot"),
TRANSLATE_STR("ImGuiOverlays", "Save"));
s_prev_legend = format_legend_entry(Host::GetSmallStringSettingValue("Hotkeys", "PreviousSaveStateSlot"),
TRANSLATE_STR("ImGuiOverlays", "Select Previous"));
s_next_legend = format_legend_entry(Host::GetSmallStringSettingValue("Hotkeys", "NextSaveStateSlot"),
TRANSLATE_STR("ImGuiOverlays", "Select Next"));
s_close_legend = format_legend_entry(Host::GetSmallStringSettingValue("Hotkeys", "OpenPauseMenu"),
TRANSLATE_STR("ImGuiOverlays", "Close Menu"));
}
void SaveStateSelectorUI::SelectNextSlot(bool open_selector)
{
const s32 current_slot = s_current_slot.load(std::memory_order_acquire);
s_current_slot.store((current_slot == (VMManager::NUM_SAVE_STATE_SLOTS - 1)) ? 0 : (current_slot + 1), std::memory_order_release);
if (open_selector)
{
MTGS::RunOnGSThread([]() {
if (!s_open)
Open();
s_open_time = 0.0f;
});
}
else
{
ShowSlotOSDMessage();
}
}
void SaveStateSelectorUI::SelectPreviousSlot(bool open_selector)
{
const s32 current_slot = s_current_slot.load(std::memory_order_acquire);
s_current_slot.store((current_slot == 0) ? (VMManager::NUM_SAVE_STATE_SLOTS - 1) : (current_slot - 1), std::memory_order_release);
if (open_selector)
{
MTGS::RunOnGSThread([]() {
if (!s_open)
Open();
s_open_time = 0.0f;
});
}
else
{
ShowSlotOSDMessage();
}
}
void SaveStateSelectorUI::InitializeListEntry(const std::string& serial, u32 crc, ListEntry* li, s32 slot)
{
std::string path = VMManager::GetSaveStateFileName(serial.c_str(), crc, slot);
FILESYSTEM_STAT_DATA sd;
if (!FileSystem::StatFile(path.c_str(), &sd))
{
InitializePlaceholderListEntry(li, std::move(path), slot);
return;
}
li->title = fmt::format(TRANSLATE_FS("ImGuiOverlays", "Save Slot {0}"), slot);
li->summary = GetSaveStateTimestampSummary(sd.ModificationTime);
li->filename = Path::GetFileName(path);
u32 screenshot_width, screenshot_height;
std::vector<u32> screenshot_pixels;
if (SaveState_ReadScreenshot(path, &screenshot_width, &screenshot_height, &screenshot_pixels))
{
li->preview_texture =
std::unique_ptr<GSTexture>(g_gs_device->CreateTexture(screenshot_width, screenshot_height, 1, GSTexture::Format::Color));
if (!li->preview_texture || !li->preview_texture->Update(GSVector4i(0, 0, screenshot_width, screenshot_height),
screenshot_pixels.data(), sizeof(u32) * screenshot_width))
{
Console.Error("Failed to upload save state image to GPU");
if (li->preview_texture)
g_gs_device->Recycle(li->preview_texture.release());
}
}
}
void SaveStateSelectorUI::InitializePlaceholderListEntry(ListEntry* li, std::string path, s32 slot)
{
li->title = fmt::format(TRANSLATE_FS("ImGuiOverlays", "Save Slot {0}"), slot);
li->summary = TRANSLATE_STR("ImGuiOverlays", "No save present in this slot");
li->filename = Path::GetFileName(path);
}
void SaveStateSelectorUI::Draw()
{
static constexpr float SCROLL_ANIMATION_TIME = 0.25f;
static constexpr float BG_ANIMATION_TIME = 0.15f;
const auto& io = ImGui::GetIO();
const float scale = ImGuiManager::GetGlobalScale();
const float width = (600.0f * scale);
const float height = (430.0f * scale);
const float padding_and_rounding = 10.0f * scale;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, padding_and_rounding);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding_and_rounding, padding_and_rounding));
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.11f, 0.15f, 0.17f, 0.8f));
ImGui::SetNextWindowSize(ImVec2(width, height), ImGuiCond_Always);
ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f), ImGuiCond_Always,
ImVec2(0.5f, 0.5f));
if (ImGui::Begin("##save_state_selector", nullptr,
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar))
{
// Leave 2 lines for the legend
const float legend_margin = ImGui::GetFontSize() * 3.0f + ImGui::GetStyle().ItemSpacing.y * 3.0f;
const float padding = 10.0f * scale;
ImGui::BeginChild("##item_list", ImVec2(0, -legend_margin), false,
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoBackground);
{
const s32 current_slot = s_current_slot.load(std::memory_order_acquire);
const ImVec2 image_size = ImVec2(128.0f * scale, (128.0f / (4.0f / 3.0f)) * scale);
const float item_width = std::floor(width - (padding_and_rounding * 2.0f) - ImGui::GetStyle().ScrollbarSize);
const float item_height = std::floor(image_size.y + padding * 2.0f);
const float text_indent = image_size.x + padding + padding;
for (size_t i = 0; i < s_slots.size(); i++)
{
const ListEntry& entry = s_slots[i];
const float y_start = item_height * static_cast<float>(i);
if (i == static_cast<size_t>(current_slot))
{
ImGui::SetCursorPosY(y_start);
const ImVec2 p_start(ImGui::GetCursorScreenPos());
const ImVec2 p_end(p_start.x + item_width, p_start.y + item_height);
const ImRect item_rect(p_start, p_end);
const ImRect& window_rect = ImGui::GetCurrentWindow()->ClipRect;
if (!window_rect.Contains(item_rect))
{
float scroll_target = ImGui::GetScrollY();
if (item_rect.Min.y < window_rect.Min.y)
scroll_target = (ImGui::GetScrollY() - (window_rect.Min.y - item_rect.Min.y));
else if (item_rect.Max.y > window_rect.Max.y)
scroll_target = (ImGui::GetScrollY() + (item_rect.Max.y - window_rect.Max.y));
if (scroll_target != s_scroll_animated.GetEndValue())
s_scroll_animated.Start(ImGui::GetScrollY(), scroll_target, SCROLL_ANIMATION_TIME);
}
if (s_scroll_animated.IsActive())
ImGui::SetScrollY(s_scroll_animated.UpdateAndGetValue());
if (s_background_animated.GetEndValue() != p_start.y)
s_background_animated.Start(s_background_animated.UpdateAndGetValue(), p_start.y, BG_ANIMATION_TIME);
ImVec2 highlight_pos;
if (s_background_animated.IsActive())
highlight_pos = ImVec2(p_start.x, s_background_animated.UpdateAndGetValue());
else
highlight_pos = p_start;
ImGui::GetWindowDrawList()->AddRectFilled(highlight_pos,
ImVec2(highlight_pos.x + item_width, highlight_pos.y + item_height),
ImColor(0.22f, 0.30f, 0.34f, 0.9f), padding_and_rounding);
}
if (GSTexture* preview_texture = entry.preview_texture ? entry.preview_texture.get() : s_placeholder_texture.get())
{
ImGui::SetCursorPosY(y_start + padding);
ImGui::SetCursorPosX(padding);
ImGui::Image(reinterpret_cast<ImTextureID>(preview_texture->GetNativeHandle()), image_size);
}
ImGui::SetCursorPosY(y_start + padding);
ImGui::Indent(text_indent);
ImGui::TextUnformatted(entry.title.c_str(), entry.title.c_str() + entry.title.length());
ImGui::TextUnformatted(entry.summary.c_str(), entry.summary.c_str() + entry.summary.length());
ImGui::PushFont(ImGuiManager::GetFixedFont(), ImGuiManager::GetFontSizeStandard());
ImGui::TextUnformatted(entry.filename.c_str(), entry.filename.c_str() + entry.filename.length());
ImGui::PopFont();
ImGui::Unindent(text_indent);
ImGui::SetCursorPosY(y_start);
ImGui::ItemSize(ImVec2(item_width, item_height));
}
}
ImGui::EndChild();
ImGui::BeginChild("##legend", ImVec2(0, 0), false,
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground);
{
ImGui::SetCursorPosX(padding);
if (ImGui::BeginTable("table", 2))
{
ImGui::TableNextColumn();
ImGui::TextUnformatted(s_load_legend.c_str());
ImGui::TableNextColumn();
ImGui::TextUnformatted(s_prev_legend.c_str());
ImGui::TableNextColumn();
ImGui::TextUnformatted(s_save_legend.c_str());
ImGui::TableNextColumn();
ImGui::TextUnformatted(s_next_legend.c_str());
ImGui::TableNextColumn();
ImGui::TextUnformatted(s_close_legend.c_str());
ImGui::EndTable();
}
}
ImGui::EndChild();
}
ImGui::End();
ImGui::PopStyleVar(2);
ImGui::PopStyleColor();
// auto-close
s_open_time += io.DeltaTime;
if (s_open_time >= s_close_time)
Close();
}
s32 SaveStateSelectorUI::GetCurrentSlot()
{
return s_current_slot.load(std::memory_order_acquire) + 1;
}
void SaveStateSelectorUI::LoadCurrentSlot()
{
Host::RunOnCPUThread([slot = GetCurrentSlot()]() {
Error error;
if (!VMManager::LoadStateFromSlot(slot, false, &error))
FullscreenUI::ReportStateLoadError(error.GetDescription(), slot, false);
});
Close();
}
void SaveStateSelectorUI::LoadCurrentBackupSlot()
{
Host::RunOnCPUThread([slot = GetCurrentSlot()]() {
Error error;
if (!VMManager::LoadStateFromSlot(slot, true, &error))
FullscreenUI::ReportStateLoadError(error.GetDescription(), slot, true);
});
Close();
}
void SaveStateSelectorUI::SaveCurrentSlot()
{
Host::RunOnCPUThread([slot = GetCurrentSlot()]() {
VMManager::SaveStateToSlot(slot, true, [slot](const std::string& error) {
FullscreenUI::ReportStateSaveError(error, slot);
});
});
Close();
}
void SaveStateSelectorUI::ShowSlotOSDMessage()
{
const s32 slot = GetCurrentSlot();
const u32 crc = VMManager::GetDiscCRC();
const std::string serial = VMManager::GetDiscSerial();
const std::string filename = VMManager::GetSaveStateFileName(serial.c_str(), crc, slot);
FILESYSTEM_STAT_DATA sd;
std::string timestamp_summary;
if (!filename.empty() && FileSystem::StatFile(filename.c_str(), &sd))
timestamp_summary = GetSaveStateTimestampSummary(sd.ModificationTime);
else
timestamp_summary = TRANSLATE_STR("ImGuiOverlays", "no save yet");
Host::AddIconOSDMessage("ShowSlotOSDMessage", ICON_FA_MAGNIFYING_GLASS,
fmt::format(TRANSLATE_FS("Hotkeys", "Save slot {0} selected ({1})."), slot, timestamp_summary),
Host::OSD_QUICK_DURATION);
}
#ifdef __ANDROID__
namespace {
// Reload-immune snapshot of the Android UI's OSD choice. VMManager::ApplySettings
// re-derives EmuConfig.GS from the layered settings interface (base + per-game) every
// time it runs, which can resurrect an OSD the user just turned off (a per-game layer
// or a reload race overriding the base). The native applyOsdSetting() choke point
// records the user's intent HERE after every OSD change, and RenderOverlays honours
// it — so no core settings reload can revert the on-screen state.
struct AndroidOSDVisibility
{
bool valid = false;
bool fps = false, vps = false, speed = false, resolution = false, cpu = false,
gpu = false, gsStats = false, frameTimes = false, hardwareInfo = false,
version = false, gpuStats = false, settings = false, inputs = false;
};
AndroidOSDVisibility s_android_osd_vis;
} // namespace
void ImGuiManager::SetAndroidOSDVisibility(bool fps, bool vps, bool speed, bool resolution,
bool cpu, bool gpu, bool gsStats, bool frameTimes, bool hardwareInfo, bool version,
bool gpuStats, bool settings, bool inputs)
{
s_android_osd_vis = {true, fps, vps, speed, resolution, cpu, gpu, gsStats, frameTimes,
hardwareInfo, version, gpuStats, settings, inputs};
}
#endif
void ImGuiManager::RenderOverlays()
{
// Android: the live GSConfig can be stale — the GS device reopens whenever the
// pause/overlay releases the render surface, and GSopen re-derives GSConfig from a
// freshly-loaded config, so an OSD the user turned off silently came back. The Android
// UI records the user's authoritative OSD choice via SetAndroidOSDVisibility (immune to
// any VMManager::ApplySettings reload); honour that here when present, else fall back to
// EmuConfig.GS. Mirror every perf / settings-summary / inputs flag once, before any
// overlay draws, so the perf, settings and inputs overlays all honour their switches.
#ifdef __ANDROID__
if (s_android_osd_vis.valid)
{
GSConfig.OsdShowFPS = s_android_osd_vis.fps;
GSConfig.OsdShowVPS = s_android_osd_vis.vps;
GSConfig.OsdShowSpeed = s_android_osd_vis.speed;
GSConfig.OsdShowResolution = s_android_osd_vis.resolution;
GSConfig.OsdShowCPU = s_android_osd_vis.cpu;
GSConfig.OsdShowGPU = s_android_osd_vis.gpu;
GSConfig.OsdShowGSStats = s_android_osd_vis.gsStats;
GSConfig.OsdShowFrameTimes = s_android_osd_vis.frameTimes;
GSConfig.OsdShowHardwareInfo = s_android_osd_vis.hardwareInfo;
GSConfig.OsdShowVersion = s_android_osd_vis.version;
GSConfig.OsdShowGPUStats = s_android_osd_vis.gpuStats;
GSConfig.OsdShowSettings = s_android_osd_vis.settings;
GSConfig.OsdShowInputs = s_android_osd_vis.inputs;
}
else
#endif
{
GSConfig.OsdShowFPS = EmuConfig.GS.OsdShowFPS;
GSConfig.OsdShowVPS = EmuConfig.GS.OsdShowVPS;
GSConfig.OsdShowSpeed = EmuConfig.GS.OsdShowSpeed;
GSConfig.OsdShowResolution = EmuConfig.GS.OsdShowResolution;
GSConfig.OsdShowCPU = EmuConfig.GS.OsdShowCPU;
GSConfig.OsdShowGPU = EmuConfig.GS.OsdShowGPU;
GSConfig.OsdShowGSStats = EmuConfig.GS.OsdShowGSStats;
GSConfig.OsdShowFrameTimes = EmuConfig.GS.OsdShowFrameTimes;
GSConfig.OsdShowHardwareInfo = EmuConfig.GS.OsdShowHardwareInfo;
GSConfig.OsdShowVersion = EmuConfig.GS.OsdShowVersion;
GSConfig.OsdShowGPUStats = EmuConfig.GS.OsdShowGPUStats;
GSConfig.OsdShowSettings = EmuConfig.GS.OsdShowSettings;
GSConfig.OsdShowInputs = EmuConfig.GS.OsdShowInputs;
}
const float scale = ImGuiManager::GetGlobalScale();
const float base_margin = std::ceil(GSConfig.OsdMargin * scale);
const float spacing = std::ceil(5.0f * scale);
// The frontend hands us the cut-out and home-indicator clearance on every rotation. Only one
// horizontal margin is threaded through the draw functions, so take the worse side; the bottom
// needs its own, or content anchored down there is spaced off the notch instead of the indicator.
float inset_left = 0.0f, inset_top = 0.0f, inset_right = 0.0f, inset_bottom = 0.0f;
ImGuiManager::GetOSDSafeAreaInsets(&inset_left, &inset_top, &inset_right, &inset_bottom);
const float margin = base_margin + std::max(inset_left, inset_right);
const float bottom_margin = base_margin + inset_bottom;
float position_y = base_margin + inset_top;
DrawIndicatorsOverlay(position_y, scale, margin, spacing);
DrawVideoCaptureOverlay(position_y, scale, margin, spacing);
DrawInputRecordingOverlay(position_y, scale, margin, spacing);
DrawTextureReplacementsOverlay(position_y, scale, margin, spacing);
if (GSConfig.OsdPerformancePos != OsdOverlayPos::None)
DrawPerformanceOverlay(position_y, scale, margin, bottom_margin, spacing);
DrawSettingsOverlay(scale, margin, bottom_margin, spacing);
DrawShaderCompileIndicator(scale, margin, bottom_margin, spacing);
DrawInputsOverlay(scale, margin, bottom_margin, spacing);
if (SaveStateSelectorUI::s_open)
SaveStateSelectorUI::Draw();
}
std::string SaveStateSelectorUI::GetSaveStateTimestampSummary(const std::time_t& modification_time)
{
std::tm tm_modification_local = {};
#ifdef _MSC_VER
localtime_s(&tm_modification_local, &modification_time);
#else
localtime_r(&modification_time, &tm_modification_local);
#endif
const std::time_t current_time = std::time(nullptr);
const std::time_t time_since_save = current_time - std::mktime(&tm_modification_local);
if (time_since_save >= TWENTY_FOUR_HOURS)
{
return fmt::format(TRANSLATE_FS("ImGuiOverlays", SAVED_AGO_DAYS_TIME_DATE),
time_since_save / TWENTY_FOUR_HOURS, tm_modification_local);
}
else if (time_since_save >= ONE_HOUR)
{
return fmt::format(TRANSLATE_FS("ImGuiOverlays", SAVED_AGO_HOURS_MINUTES),
time_since_save / ONE_HOUR, (time_since_save / 60) % 60, tm_modification_local);
}
else if (time_since_save >= 60)
{
return fmt::format(TRANSLATE_FS("ImGuiOverlays", SAVED_AGO_MINUTES),
time_since_save / 60, tm_modification_local);
}
else if (time_since_save >= 5)
{
return fmt::format(TRANSLATE_FS("ImGuiOverlays", SAVED_AGO_SECONDS),
time_since_save);
}
else if (time_since_save >= 0)
{
return TRANSLATE_STR("ImGuiOverlays", SAVED_AGO_NOW);
}
else
{
return fmt::format(TRANSLATE_FS("ImGuiOverlays", SAVED_FUTURE_TIME_DATE),
tm_modification_local);
}
}