From d407aca30e2701bdf2138ca33d8446631f5b42db Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Tue, 3 Nov 2026 18:35:06 +0530 Subject: [PATCH] HUD and telemetry: report the game's fps and the displayed fps on both display servers The HUD's FPS row and game_exited's avg_fps carry one number, the frames the session counts: on X11 the game's presents to the app's X server (one tick per content update of the tracked window), under gamescope the daemon's onFramePublished upcall, which counts the frames the ICD shim reports as produced (acquired, plus generated under frame generation) and falls back to the daemon's publishes only while no shim report arrives. A game rendering faster than the panel therefore shows numbers the panel can never show (287 for an uncapped Hades II on a 60 Hz panel), and the two servers are compared against different expectations. Report both quantities explicitly: - HUD FPS row: "FPS 60 (287)", the displayed fps then in brackets the game's when the game outruns the panel; "FPS 59.8" otherwise. - game_exited: game_fps (the same value as avg_fps, under an explicit name) and displayed_fps; avg_fps stays for continuity and is the game's fps. - Session metadata: displayed_fps next to avg_fps. - PowerMetrics log line: displayed= next to fps=. The displayed figure is an estimate: the game's one-second readings clamped to the panel's refresh rate. Neither the Vulkan nor the SurfaceControl renderer on X11 reports a per-frame present, and under gamescope the app receives only the produced-frame upcall, so frames the panel dropped below its rate are not seen. A measured displayed count needs a renderer counter on X11 and a separate publish upcall from the daemon under gamescope. - FrameRating.java: the class note on what ticks it per display server; setPanelRefreshHz, displayedFps, getAvgDisplayedFPS. - XServerScreen.kt: the panel's rate handed to FrameRating with the limiter's; game_fps and displayed_fps on game_exited; displayed_fps in the session metadata. - PerformanceHudText.kt / PerformanceHudView.kt: the FPS row; tests. - PerformanceMetricsCollector.kt: displayed= in the log line. --- .../metrics/PerformanceMetricsCollector.kt | 5 ++- .../ui/screen/xserver/XServerScreen.kt | 8 ++++ .../ui/widget/PerformanceHudText.kt | 19 +++++++++ .../ui/widget/PerformanceHudView.kt | 2 +- .../java/com/winlator/widget/FrameRating.java | 39 +++++++++++++++++++ .../ui/widget/PerformanceHudTextTest.kt | 17 ++++++++ 6 files changed, 88 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/powercontrol/metrics/PerformanceMetricsCollector.kt b/app/src/main/java/app/gamenative/powercontrol/metrics/PerformanceMetricsCollector.kt index e620b467e0..b5b2df1b5a 100644 --- a/app/src/main/java/app/gamenative/powercontrol/metrics/PerformanceMetricsCollector.kt +++ b/app/src/main/java/app/gamenative/powercontrol/metrics/PerformanceMetricsCollector.kt @@ -221,8 +221,11 @@ object PerformanceMetricsCollector { sampleCount++ if (sampleCount % LOG_EVERY_N_SAMPLES == 0L) { Timber.tag(TAG).i( - "fps=%.1f p95=%.1fms slow=%d/%d repeated=%s in 10s cpu=%s%%(%s) gpu=%s%% cpuTemp=%s gpuTemp=%s", + // fps is the game's (frames produced, FrameRating's ticks); displayed is it + // clamped to the panel's rate, an estimate of the frames the panel showed. + "fps=%.1f displayed=%.1f p95=%.1fms slow=%d/%d repeated=%s in 10s cpu=%s%%(%s) gpu=%s%% cpuTemp=%s gpuTemp=%s", snapshot.fps, + com.winlator.widget.FrameRating.displayedFps(snapshot.fps), snapshot.frameTimeP95Ms, snapshot.slowFrameCount, snapshot.totalFrameCount, diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 0e2530537b..03895111b1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -844,6 +844,8 @@ fun XServerScreen( fun refreshFpsLimit(view: View?, isX11Display: Boolean) { val refreshHz = detectRefreshRateHz(context, view) + // The ceiling of the displayed-fps estimate, on both display servers. + FrameRating.setPanelRefreshHz(refreshHz.toFloat()) ui.detectedMaxRefreshRateHz = refreshHz * FPS_LIMITER_MAX_MULTIPLIER if (!isX11Display) @@ -5435,7 +5437,12 @@ private fun exit( "game_name" to ContainerUtils.resolveGameName(appId), "game_store" to ContainerUtils.extractGameSourceFromContainerId(appId).name, "session_length" to (frameRating?.sessionLengthSec ?: 0), + // avg_fps is the game's fps (frames it produced; see FrameRating). game_fps repeats it + // under an explicit name, and displayed_fps is the same readings clamped to the panel's + // rate, an estimate of the frames that reached the panel. "avg_fps" to (frameRating?.avgFPS ?: 0.0), + "game_fps" to (frameRating?.avgFPS ?: 0.0), + "displayed_fps" to (frameRating?.avgDisplayedFPS ?: 0.0), "container_config" to container.containerJson, ) + runCatching { PluviaApp.xServerView?.getxServer()?.effectiveScreenInfo @@ -5456,6 +5463,7 @@ private fun exit( // Store session data in container metadata frameRating?.let { rating -> container.putSessionMetadata("avg_fps", rating.avgFPS) + container.putSessionMetadata("displayed_fps", rating.avgDisplayedFPS) container.putSessionMetadata("session_length_sec", rating.sessionLengthSec.toInt()) container.putSessionMetadata("total_frames", rating.totalFrames) container.saveData() diff --git a/app/src/main/java/app/gamenative/ui/widget/PerformanceHudText.kt b/app/src/main/java/app/gamenative/ui/widget/PerformanceHudText.kt index 453762d40a..4bc94ebce4 100644 --- a/app/src/main/java/app/gamenative/ui/widget/PerformanceHudText.kt +++ b/app/src/main/java/app/gamenative/ui/widget/PerformanceHudText.kt @@ -9,6 +9,25 @@ import kotlin.math.roundToInt internal object PerformanceHudText { private const val MISSING = "--" + /** + * FPS row: the frames that reached the panel, then in brackets the frames the game produced + * when the two differ. "FPS 60 (287)" is a game rendering 287 frames a second on a 60 Hz + * panel; "FPS 59.8" is a game at or below the panel's rate, where the two are the same. + * + * [gameFps] is what the session counts on either display server (see FrameRating). The + * displayed figure is an estimate: [gameFps] clamped to [refreshHz]; no renderer reports the + * frames the panel actually latched. With no refresh rate known the row is the game's fps. + */ + fun fps(gameFps: Float, refreshHz: Float): String { + val game = if (gameFps.isFinite()) gameFps.coerceAtLeast(0f) else 0f + val hz = refreshHz.takeIf { it.isFinite() && it > 0f } + return if (hz != null && game > hz + 0.5f) { + String.format(Locale.US, "FPS %d (%d)", hz.roundToInt(), game.roundToInt()) + } else { + String.format(Locale.US, "FPS %.1f", game) + } + } + /** * GPU row. The percent is always the active usage source's reading (sysfs, Mali counters * or the shim); a measured GPU frame time is appended, never substituted: diff --git a/app/src/main/java/app/gamenative/ui/widget/PerformanceHudView.kt b/app/src/main/java/app/gamenative/ui/widget/PerformanceHudView.kt index ff01234c7a..b325b700ae 100644 --- a/app/src/main/java/app/gamenative/ui/widget/PerformanceHudView.kt +++ b/app/src/main/java/app/gamenative/ui/widget/PerformanceHudView.kt @@ -347,7 +347,7 @@ class PerformanceHudView( fpsValue = currentFps, cpuValue = cpuPercent?.toFloat(), gpuValue = gpuPercent?.toFloat(), - fps = String.format(Locale.US, "FPS %.1f", currentFps), + fps = PerformanceHudText.fps(currentFps, refreshHz), cpu = cpuPercent?.let { "CPU $it%" }, gpu = PerformanceHudText.gpu(gpuPercent, gpuFrameMs), gpuCompact = PerformanceHudText.gpu(gpuPercent, gpuFrameMs, compact = true), diff --git a/app/src/main/java/com/winlator/widget/FrameRating.java b/app/src/main/java/com/winlator/widget/FrameRating.java index 2015b5283a..ae266ac8e3 100644 --- a/app/src/main/java/com/winlator/widget/FrameRating.java +++ b/app/src/main/java/com/winlator/widget/FrameRating.java @@ -23,7 +23,37 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +/** + * Counts the frames the game produced, on either display server. + * + * + * + * Neither renderer reports the frames that reached the panel, so the "displayed" figure kept + * here is an estimate: each one-second reading clamped to the panel's refresh rate + * ({@link #setPanelRefreshHz}). A frame the panel dropped below that rate is not seen. + */ public class FrameRating extends FrameLayout implements Runnable { + /** The panel's refresh rate in Hz, 0 while unknown; set from the session's display. */ + private static volatile float panelRefreshHz = 0f; + + /** Record the panel's refresh rate, the ceiling of the displayed-fps estimate. */ + public static void setPanelRefreshHz(float hz) { + panelRefreshHz = (hz > 0f && !Float.isInfinite(hz)) ? hz : 0f; + } + + /** {@code fps} clamped to the panel's refresh rate: what the panel could have shown. */ + public static float displayedFps(float fps) { + float hz = panelRefreshHz; + return hz > 0f ? Math.min(fps, hz) : fps; + } + private long lastTime = 0; private int frameCount = 0; private float lastFPS = 0; @@ -37,6 +67,7 @@ public class FrameRating extends FrameLayout implements Runnable { private int minFPS = Integer.MAX_VALUE; private long lastReadingTime = 0; private long fpsSum = 0; // Sum of all FPS readings for average calculation + private double displayedFpsSum = 0; // Same readings clamped to the panel's rate private long lastFrameTime = 0; private long totalFrames = 0; private long activeMs = 0; @@ -91,6 +122,7 @@ public void update(long time) { int currentFPS = Math.round(lastFPS); readingCount++; fpsSum += currentFPS; + displayedFpsSum += displayedFps(currentFPS); int bucket = (int) ((time - sessionStartTime) / FPS_BUCKET_MS); if (bucket >= 0 && bucket < MAX_FPS_BUCKETS) { bucketFpsSum[bucket] += currentFPS; @@ -129,11 +161,18 @@ public float getCurrentFPS() { return lastFPS; } + /** Average of the one-second game-fps readings. */ public float getAvgFPS() { if (readingCount == 0) return 0; return (float) fpsSum / readingCount; } + /** Average of the same readings clamped to the panel's rate (an estimate, see the class note). */ + public float getAvgDisplayedFPS() { + if (readingCount == 0) return 0; + return (float) (displayedFpsSum / readingCount); + } + public long getTotalFrames() { return totalFrames; } diff --git a/app/src/test/java/app/gamenative/ui/widget/PerformanceHudTextTest.kt b/app/src/test/java/app/gamenative/ui/widget/PerformanceHudTextTest.kt index c08f4c1021..8766360fad 100644 --- a/app/src/test/java/app/gamenative/ui/widget/PerformanceHudTextTest.kt +++ b/app/src/test/java/app/gamenative/ui/widget/PerformanceHudTextTest.kt @@ -7,6 +7,23 @@ import org.junit.Test class PerformanceHudTextTest { + @Test + fun fpsRowShowsDisplayedThenGameWhenTheGameOutrunsThePanel() { + // Hades II uncapped: 287 frames produced, a 60 Hz panel shows at most 60. + assertEquals("FPS 60 (287)", PerformanceHudText.fps(287f, 60f)) + assertEquals("FPS 120 (142)", PerformanceHudText.fps(142.4f, 120f)) + } + + @Test + fun fpsRowIsOneNumberAtOrBelowThePanelRate() { + assertEquals("FPS 59.8", PerformanceHudText.fps(59.8f, 60f)) + assertEquals("FPS 60.3", PerformanceHudText.fps(60.3f, 60f)) + assertEquals("FPS 42.0", PerformanceHudText.fps(42f, 60f)) + // No refresh rate known: the game's fps alone. + assertEquals("FPS 287.0", PerformanceHudText.fps(287f, 0f)) + assertEquals("FPS 0.0", PerformanceHudText.fps(Float.NaN, 60f)) + } + @Test fun gpuRow() { assertEquals("GPU 8%", PerformanceHudText.gpu(8, null))