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))