Files
emulator/src/citron/util/performance_overlay.cpp
2026-01-04 22:01:19 +00:00

547 lines
21 KiB
C++

// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QApplication>
#include <QPainter>
#include <QPainterPath>
#include <QScreen>
#include <QSizeGrip>
#include <QGridLayout>
#include <QTimer>
#include <QMouseEvent>
#include <QtMath>
#include <algorithm>
#include <numeric>
#include <cstdlib>
#include <QtGlobal>
#include <QWindow>
#include <QDir>
#include <QFile>
#include <QStringList>
#ifdef Q_OS_WIN
#include <Windows.h>
#include <comdef.h>
#include <WbemIdl.h>
#pragma comment(lib, "wbemuuid.lib")
#endif
#ifdef Q_OS_ANDROID
#include <QtAndroidExtras>
#endif
#include "citron/main.h"
#include "citron/util/performance_overlay.h"
#include "citron/uisettings.h"
#include "core/core.h"
#include "core/perf_stats.h"
#include "video_core/gpu.h"
#include "video_core/renderer_base.h"
namespace {
bool IsGamescope() {
static bool gamescope = qgetenv("XDG_CURRENT_DESKTOP") == "gamescope";
return gamescope;
}
}
PerformanceOverlay::PerformanceOverlay(QWidget* parent) : QWidget(IsGamescope() ? nullptr : parent) {
if (parent) {
main_window = qobject_cast<GMainWindow*>(parent);
}
if (IsGamescope()) {
setWindowFlags(Qt::ToolTip | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::WindowDoesNotAcceptFocus);
setAttribute(Qt::WA_ShowWithoutActivating);
} else {
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
}
setAttribute(Qt::WA_TranslucentBackground, true);
setAttribute(Qt::WA_NoSystemBackground);
setAttribute(Qt::WA_WState_ExplicitShowHide);
if (IsGamescope()) {
title_font = QFont(QString::fromUtf8("Segoe UI"), 8, QFont::Bold);
value_font = QFont(QString::fromUtf8("Segoe UI"), 9, QFont::Bold);
small_font = QFont(QString::fromUtf8("Segoe UI"), 7, QFont::Normal);
setMinimumSize(150, 110);
resize(170, 140); // Taller for 2-line stats
} else {
title_font = QFont(QString::fromUtf8("Segoe UI"), 9, QFont::Medium);
value_font = QFont(QString::fromUtf8("Segoe UI"), 11, QFont::Bold);
small_font = QFont(QString::fromUtf8("Segoe UI"), 8, QFont::Normal);
setMinimumSize(220, 180);
resize(220, 180);
}
auto* layout = new QGridLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
size_grip = new QSizeGrip(this);
layout->addWidget(size_grip, 0, 0, Qt::AlignBottom | Qt::AlignRight);
temperature_color = QColor(76, 175, 80, 255);
graph_background_color = QColor(40, 40, 40, 100);
graph_line_color = QColor(76, 175, 80, 200);
graph_fill_color = QColor(76, 175, 80, 60);
update_timer.setSingleShot(false);
connect(&update_timer, &QTimer::timeout, this, &PerformanceOverlay::UpdatePerformanceStats);
if (main_window) {
connect(main_window, &GMainWindow::themeChanged, this, &PerformanceOverlay::UpdateTheme);
}
UpdateTheme();
UpdatePosition();
}
PerformanceOverlay::~PerformanceOverlay() = default;
void PerformanceOverlay::SetVisible(bool visible) {
is_enabled = visible;
is_visible = visible; // Update the state so the check works next time
if (visible) {
show();
update_timer.start(500);
} else {
update_timer.stop(); // Stop the timer first
hide();
}
}
void PerformanceOverlay::paintEvent(QPaintEvent* event) {
Q_UNUSED(event)
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::TextAntialiasing, true);
QPainterPath background_path;
background_path.addRoundedRect(rect(), corner_radius, corner_radius);
if (!IsGamescope()) {
QPainterPath shadow_path = background_path.translated(1, 1);
painter.fillPath(shadow_path, QColor(0, 0, 0, 40));
}
painter.fillPath(background_path, background_color);
painter.setPen(QPen(border_color, border_width));
painter.drawPath(background_path);
DrawPerformanceInfo(painter);
DrawFrameGraph(painter);
}
void PerformanceOverlay::resizeEvent(QResizeEvent* event) {
QWidget::resizeEvent(event);
UpdatePosition();
}
void PerformanceOverlay::mousePressEvent(QMouseEvent* event) {
if (event->button() == Qt::LeftButton && !size_grip->geometry().contains(event->pos())) {
#if defined(Q_OS_LINUX)
if (!IsGamescope() && windowHandle()) {
windowHandle()->startSystemMove();
} else {
is_dragging = true;
drag_start_pos = event->globalPosition().toPoint() - this->pos();
}
#else
is_dragging = true;
drag_start_pos = event->globalPosition().toPoint() - this->pos();
#endif
event->accept();
}
}
void PerformanceOverlay::mouseMoveEvent(QMouseEvent* event) {
if (is_dragging) {
move(event->globalPosition().toPoint() - drag_start_pos);
event->accept();
}
}
void PerformanceOverlay::mouseReleaseEvent(QMouseEvent* event) {
if (event->button() == Qt::LeftButton) {
is_dragging = false;
has_been_moved = true;
setCursor(Qt::ArrowCursor);
event->accept();
}
QWidget::mouseReleaseEvent(event);
}
void PerformanceOverlay::UpdatePerformanceStats() {
if (!main_window || !is_enabled) return;
if (IsGamescope()) {
bool ui_active = (QApplication::activePopupWidget() != nullptr);
if (!ui_active) {
for (QWidget* w : QApplication::topLevelWidgets()) {
if (w->isVisible() && w != main_window && w != this &&
!w->inherits("GRenderWindow") &&
!w->inherits("VramOverlay") &&
!w->inherits("ControllerOverlay") &&
!w->inherits("PerformanceOverlay")) {
ui_active = true;
break;
}
}
}
if (ui_active) {
if (!this->isHidden()) this->hide();
return;
}
if (this->isHidden()) {
this->show();
}
} else {
// Desktop: Only force a show if the user actually has it enabled in the menu
if (is_enabled && this->isHidden()) {
this->show();
}
}
shaders_building = main_window->GetShadersBuilding();
static int update_counter = 0;
update_counter++;
if (update_counter % 2 == 0) {
try {
current_fps = main_window->GetCurrentFPS();
current_frame_time = main_window->GetCurrentFrameTime();
emulation_speed = main_window->GetEmulationSpeed();
if (std::isnan(current_fps) || current_fps < 0.0 || current_fps > 1000.0) current_fps = 60.0;
if (std::isnan(current_frame_time) || current_frame_time < 0.0 || current_frame_time > 100.0) current_frame_time = 16.67;
if (std::isnan(emulation_speed) || emulation_speed < 0.0 || emulation_speed > 1000.0) emulation_speed = 100.0;
if (current_fps > 0.0) current_frame_time = 1000.0 / current_fps;
} catch (...) {}
}
if (update_counter % 4 == 0) {
UpdateHardwareTemperatures();
}
if (std::isnan(current_fps) || current_fps <= 0.0) current_fps = 60.0;
if (std::isnan(current_frame_time) || current_frame_time <= 0.0) current_frame_time = 16.67;
if (std::isnan(emulation_speed) || emulation_speed <= 0.0) emulation_speed = 100.0;
if (current_frame_time > 0.0) AddFrameTime(current_frame_time);
fps_color = GetFpsColor(current_fps);
temperature_color = GetTemperatureColor(std::max({cpu_temperature, gpu_temperature, battery_temperature}));
update();
}
void PerformanceOverlay::UpdateHardwareTemperatures() {
cpu_temperature = 0.0f;
gpu_temperature = 0.0f;
cpu_sensor_type.clear();
gpu_sensor_type.clear();
battery_percentage = 0;
battery_temperature = 0.0f;
#if defined(Q_OS_LINUX)
// 1. Read Battery Percentage and Temperature (Steam Deck / Linux Laptops)
QStringList bat_nodes = {QStringLiteral("BAT1"), QStringLiteral("BAT0")};
for (const QString& node : bat_nodes) {
QString base_path = QStringLiteral("/sys/class/power_supply/%1/").arg(node);
QFile cap_file(base_path + QStringLiteral("capacity"));
if (cap_file.open(QIODevice::ReadOnly | QIODevice::Text)) {
battery_percentage = cap_file.readAll().trimmed().toInt();
cap_file.close();
QFile btemp_file(base_path + QStringLiteral("temp"));
if (btemp_file.open(QIODevice::ReadOnly | QIODevice::Text)) {
battery_temperature = btemp_file.readAll().trimmed().toFloat() / 10.0f;
btemp_file.close();
}
break;
}
}
// 2. Read CPU/GPU Temperature via hwmon (Steam Deck APU)
QDir hwmon_dir(QStringLiteral("/sys/class/hwmon/"));
QStringList hwmons = hwmon_dir.entryList({QStringLiteral("hwmon*")}, QDir::Dirs);
for (const QString& h_node : hwmons) {
QFile name_file(hwmon_dir.filePath(h_node + QStringLiteral("/name")));
if (!name_file.open(QIODevice::ReadOnly | QIODevice::Text)) continue;
QString hw_name = QString::fromUtf8(name_file.readAll().trimmed());
name_file.close();
if (hw_name == QStringLiteral("amdgpu") || hw_name == QStringLiteral("k10temp")) {
QFile temp_file(hwmon_dir.filePath(h_node + QStringLiteral("/temp1_input")));
if (temp_file.open(QIODevice::ReadOnly | QIODevice::Text)) {
float temp = temp_file.readAll().trimmed().toFloat() / 1000.0f;
temp_file.close();
if (temp > cpu_temperature) {
cpu_temperature = temp;
gpu_temperature = temp;
cpu_sensor_type = QStringLiteral("APU");
gpu_sensor_type = QStringLiteral("GPU");
}
}
}
}
// 3. Fallback to generic thermal_zones
if (cpu_temperature <= 0.0f) {
QDir thermal_dir(QStringLiteral("/sys/class/thermal/"));
QStringList thermal_zones = thermal_dir.entryList({QStringLiteral("thermal_zone*")}, QDir::Dirs);
for (const QString& zone_name : thermal_zones) {
QFile type_file(thermal_dir.filePath(zone_name + QStringLiteral("/type")));
if (!type_file.open(QIODevice::ReadOnly | QIODevice::Text)) continue;
QString type = QString::fromUtf8(type_file.readAll().trimmed());
type_file.close();
if (type.contains(QStringLiteral("x86_pkg_temp")) || type.contains(QStringLiteral("cpu"))) {
QFile temp_file(thermal_dir.filePath(zone_name + QStringLiteral("/temp")));
if (temp_file.open(QIODevice::ReadOnly | QIODevice::Text)) {
cpu_temperature = temp_file.readAll().trimmed().toFloat() / 1000.0f;
cpu_sensor_type = QStringLiteral("CPU");
temp_file.close();
}
}
}
}
#endif
#if defined(Q_OS_ANDROID)
QJniObject battery_status = QJniObject::callStaticObjectMethod(
"android/content/Context", "registerReceiver",
"(Landroid/content/BroadcastReceiver;Landroid/content/IntentFilter;)Landroid/content/Intent;",
nullptr, new QJniObject("android.content.IntentFilter", "(Ljava/lang/String;)V", "android.intent.action.BATTERY_CHANGED"));
if (battery_status.isValid()) {
int level = battery_status.callMethod<jint>("getIntExtra", "(Ljava/lang/String;I)I",
QJniObject::fromString("level").object<jstring>(), -1);
int scale = battery_status.callMethod<jint>("getIntExtra", "(Ljava/lang/String;I)I",
QJniObject::fromString("scale").object<jstring>(), -1);
int temp_tenths = battery_status.callMethod<jint>("getIntExtra", "(Ljava/lang/String;I)I",
QJniObject::fromString("temperature").object<jstring>(), -1);
if (scale > 0) battery_percentage = (level * 100) / scale;
if (temp_tenths > 0) battery_temperature = static_cast<float>(temp_tenths) / 10.0f;
}
#endif
#if defined(Q_OS_WIN)
HRESULT hres;
IWbemLocator* pLoc = nullptr;
IWbemServices* pSvc = nullptr;
hres = CoCreateInstance(CLSID_WbemLocator, 0, CLSCTX_INPROC_SERVER, IID_IWbemLocator, (LPVOID*)&pLoc);
if (SUCCEEDED(hres)) {
hres = pLoc->ConnectServer(_bstr_t(L"ROOT\\WMI"), NULL, NULL, 0, NULL, 0, 0, &pSvc);
if (SUCCEEDED(hres)) {
hres = CoSetProxyBlanket(pSvc, RPC_C_AUTHN_WINNT, RPC_C_AUTHZ_NONE, NULL,
RPC_C_AUTHN_LEVEL_CALL, RPC_C_IMP_LEVEL_IMPERSONATE, NULL, EOAC_NONE);
if (SUCCEEDED(hres)) {
IEnumWbemClassObject* pEnumerator = nullptr;
hres = pSvc->ExecQuery(bstr_t("WQL"), bstr_t("SELECT * FROM MSAcpi_ThermalZoneTemperature"),
WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY, NULL, &pEnumerator);
if (SUCCEEDED(hres)) {
IWbemClassObject* pclsObj = nullptr;
ULONG uReturn = 0;
while (pEnumerator) {
pEnumerator->Next(WBEM_INFINITE, 1, &pclsObj, &uReturn);
if (uReturn == 0) break;
VARIANT vtProp;
pclsObj->Get(L"CurrentTemperature", 0, &vtProp, 0, 0);
float temp_kelvin = vtProp.uintVal / 10.0f;
cpu_temperature = temp_kelvin - 273.15f;
cpu_sensor_type = QStringLiteral("CPU");
VariantClear(&vtProp);
pclsObj->Release();
}
pEnumerator->Release();
}
}
}
}
if(pSvc) pSvc->Release();
if(pLoc) pLoc->Release();
#endif
}
void PerformanceOverlay::UpdatePosition() {
if (main_window && !has_been_moved) {
QPoint main_window_pos = main_window->mapToGlobal(QPoint(0, 0));
move(main_window_pos.x() + 10, main_window_pos.y() + 10);
}
}
void PerformanceOverlay::DrawPerformanceInfo(QPainter& painter) {
painter.setRenderHint(QPainter::TextAntialiasing, true);
int y_offset = padding;
const int line_height = IsGamescope() ? 16 : 20;
// Draw title
painter.setFont(title_font);
painter.setPen(text_color);
painter.drawText(padding, y_offset + 12, QString::fromUtf8("CITRON"));
int y_offset_right = padding;
const int line_height_right = IsGamescope() ? 14 : 18;
// Draw Temperatures
painter.setFont(small_font);
float core_temp_to_display = std::max(cpu_temperature, gpu_temperature);
if (core_temp_to_display > 0.0f) {
QString core_label = gpu_temperature > cpu_temperature ? gpu_sensor_type : cpu_sensor_type;
QString core_temp_text = QString::fromUtf8("%1:%2°C").arg(core_label).arg(core_temp_to_display, 0, 'f', 0);
painter.setPen(GetTemperatureColor(core_temp_to_display));
int text_width = painter.fontMetrics().horizontalAdvance(core_temp_text);
painter.drawText(width() - padding - text_width, y_offset_right + 12, core_temp_text);
}
y_offset_right += line_height_right;
// Draw Battery info
if (battery_percentage > 0) {
QString batt_text = QString::fromUtf8("Batt:%1%").arg(battery_percentage);
if (battery_temperature > 0.0f) batt_text += QString::fromUtf8("(%1°C)").arg(battery_temperature, 0, 'f', 0);
painter.setPen(text_color);
int text_width = painter.fontMetrics().horizontalAdvance(batt_text);
painter.drawText(width() - padding - text_width, y_offset_right + 12, batt_text);
}
y_offset += line_height + 12;
// Draw FPS
painter.setFont(value_font);
painter.setPen(fps_color);
painter.drawText(padding, y_offset, QString::fromUtf8("%1 FPS").arg(FormatFps(current_fps)));
y_offset += line_height;
// Draw frame time and speed
painter.setFont(small_font);
painter.setPen(text_color);
painter.drawText(padding, y_offset, QString::fromUtf8("Frame:%1 ms").arg(FormatFrameTime(current_frame_time)));
y_offset += line_height - 2;
painter.drawText(padding, y_offset, QString::fromUtf8("Speed:%1%").arg(emulation_speed, 0, 'f', 0));
y_offset += line_height - 2;
// Draw shader building info
if (shaders_building > 0) {
painter.setPen(QColor(255, 152, 0, 255));
painter.drawText(padding, y_offset, QString::fromUtf8("Building:%1").arg(shaders_building));
}
}
void PerformanceOverlay::DrawFrameGraph(QPainter& painter) {
if (frame_times.empty()) return;
const int graph_y = height() - graph_height - padding;
const int graph_width = width() - (padding * 2);
const QRect graph_rect(padding, graph_y, graph_width, graph_height);
painter.fillRect(graph_rect, graph_background_color);
const double min_val = std::max(0.0, min_frame_time - 1.0);
const double max_val = std::max(16.67, max_frame_time + 1.0);
const double range = max_val - min_val;
if (range <= 0.0) return;
// Grid lines
painter.setPen(QPen(QColor(80, 80, 80, 100), 1));
const int grid_lines = 4;
for (int i = 1; i < grid_lines; ++i) {
const int y = graph_y + (graph_height * i) / grid_lines;
painter.drawLine(graph_rect.left(), y, graph_rect.right(), y);
}
// 60 FPS Target line
const int fps60_y = graph_y + graph_height - static_cast<int>((16.67 - min_val) / range * graph_height);
painter.setPen(QPen(QColor(255, 255, 255, 80), 1, Qt::DashLine));
painter.drawLine(graph_rect.left(), fps60_y, graph_rect.right(), fps60_y);
painter.setPen(QPen(graph_line_color, 2));
painter.setBrush(graph_fill_color);
QPainterPath graph_path;
const int point_count = static_cast<int>(frame_times.size());
const double x_step = static_cast<double>(graph_width) / (std::max(1, point_count - 1));
for (int i = 0; i < point_count; ++i) {
const double frame_time = frame_times[i];
const double normalized_y = (frame_time - min_val) / range;
const int x = graph_rect.left() + static_cast<int>(i * x_step);
const int y = graph_y + graph_height - static_cast<int>(normalized_y * graph_height);
if (i == 0) graph_path.moveTo(x, y); else graph_path.lineTo(x, y);
}
graph_path.lineTo(graph_rect.right(), graph_rect.bottom());
graph_path.lineTo(graph_rect.left(), graph_rect.bottom());
graph_path.closeSubpath();
painter.drawPath(graph_path);
painter.setFont(small_font);
painter.setPen(text_color);
// FIXED: Split Min/Avg/Max into multiple lines to prevent squishing at 800p
const QString min_avg_text = QString::fromUtf8("Min:%1ms Avg:%2ms").arg(FormatFrameTime(min_frame_time)).arg(FormatFrameTime(avg_frame_time));
const QString max_text = QString::fromUtf8("Max:%1ms").arg(FormatFrameTime(max_frame_time));
painter.drawText(graph_rect.left(), graph_y - 18, min_avg_text);
painter.drawText(graph_rect.left(), graph_y - 4, max_text);
}
void PerformanceOverlay::AddFrameTime(double frame_time_ms) {
frame_times.push_back(frame_time_ms);
if (frame_times.size() > MAX_FRAME_HISTORY) frame_times.pop_front();
if (!frame_times.empty()) {
min_frame_time = *std::min_element(frame_times.begin(), frame_times.end());
max_frame_time = *std::max_element(frame_times.begin(), frame_times.end());
avg_frame_time = std::accumulate(frame_times.begin(), frame_times.end(), 0.0) / frame_times.size();
}
}
QColor PerformanceOverlay::GetFpsColor(double fps) const {
if (fps >= 55.0) return QColor(76, 175, 80, 255);
if (fps >= 45.0) return QColor(255, 152, 0, 255);
if (fps >= 30.0) return QColor(255, 87, 34, 255);
return QColor(244, 67, 54, 255);
}
QColor PerformanceOverlay::GetTemperatureColor(float temperature) const {
if (temperature > 70.0f) return QColor(244, 67, 54, 255);
if (temperature > 60.0f) return QColor(255, 152, 0, 255);
return QColor(76, 175, 80, 255);
}
QString PerformanceOverlay::FormatFps(double fps) const {
if (std::isnan(fps) || fps < 0.0) return QString::fromUtf8("0.0");
return QString::number(fps, 'f', 1);
}
QString PerformanceOverlay::FormatFrameTime(double frame_time_ms) const {
if (std::isnan(frame_time_ms) || frame_time_ms < 0.0) return QString::fromUtf8("0.00");
return QString::number(frame_time_ms, 'f', 2);
}
void PerformanceOverlay::UpdateTheme() {
if (UISettings::IsDarkTheme()) {
background_color = QColor(20, 20, 20, 200);
border_color = QColor(60, 60, 60, 120);
text_color = QColor(220, 220, 220, 255);
graph_background_color = QColor(40, 40, 40, 100);
} else {
background_color = QColor(245, 245, 245, 220);
border_color = QColor(200, 200, 200, 120);
text_color = QColor(20, 20, 20, 255);
graph_background_color = QColor(220, 220, 220, 100);
}
update();
}