// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef Q_OS_WIN #include #include #include #pragma comment(lib, "wbemuuid.lib") #endif #ifdef Q_OS_ANDROID #include #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(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("getIntExtra", "(Ljava/lang/String;I)I", QJniObject::fromString("level").object(), -1); int scale = battery_status.callMethod("getIntExtra", "(Ljava/lang/String;I)I", QJniObject::fromString("scale").object(), -1); int temp_tenths = battery_status.callMethod("getIntExtra", "(Ljava/lang/String;I)I", QJniObject::fromString("temperature").object(), -1); if (scale > 0) battery_percentage = (level * 100) / scale; if (temp_tenths > 0) battery_temperature = static_cast(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((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(frame_times.size()); const double x_step = static_cast(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(i * x_step); const int y = graph_y + graph_height - static_cast(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(); }