diff --git a/app/src/main/java/app/gamenative/display/GameNativeDrm.kt b/app/src/main/java/app/gamenative/display/GameNativeDrm.kt index cdffd9b24d..ee8a996c25 100644 --- a/app/src/main/java/app/gamenative/display/GameNativeDrm.kt +++ b/app/src/main/java/app/gamenative/display/GameNativeDrm.kt @@ -33,6 +33,10 @@ object GameNativeDrm { @JvmStatic external fun nativeSetPanelRefreshHz(refreshHz: Int) + /** The scan-out size the daemon emulates its mode at, before [nativeStart]; `0, 0` clears it. */ + @JvmStatic + external fun nativeSetScanoutGeometry(width: Int, height: Int) + /** Start the daemon: create the SurfaceControl layer on [surface], then serve [socketName]. */ @JvmStatic external fun nativeStart(socketName: String, surface: Surface, width: Int, height: Int): Int diff --git a/app/src/main/java/app/gamenative/display/GamescopePayload.kt b/app/src/main/java/app/gamenative/display/GamescopePayload.kt index 8b6f0701fe..7b8056c131 100644 --- a/app/src/main/java/app/gamenative/display/GamescopePayload.kt +++ b/app/src/main/java/app/gamenative/display/GamescopePayload.kt @@ -11,7 +11,7 @@ import java.io.File /** Versioned gamescope payload (gamescope, XWayland, scaling clients), fetched on the first gamescope launch. */ object GamescopePayload { - const val VERSION = "20261030.1" + const val VERSION = "20261030.3" const val ARCHIVE = "gamescope-gamenative-$VERSION.tzst" private const val STAMP = ".payload.version" diff --git a/app/src/main/java/app/gamenative/display/GamescopeState.kt b/app/src/main/java/app/gamenative/display/GamescopeState.kt index f3014d1529..eee3108af7 100644 --- a/app/src/main/java/app/gamenative/display/GamescopeState.kt +++ b/app/src/main/java/app/gamenative/display/GamescopeState.kt @@ -26,15 +26,15 @@ object GamescopeSettings { /** Output resolution the container presents at, as "WxH", [OUTPUT_RESOLUTION_DEVICE] or [OUTPUT_RESOLUTION_OFF]. */ const val EXTRA_OUTPUT_RESOLUTION = "gamescopeOutputResolution" - /** 1080p — the output resolution a session presents at until the container picks another. */ - const val OUTPUT_RESOLUTION_DEFAULT = "1920x1080" - /** `-W`/`-H` follow the container's screen size: the game presents 1:1, nothing is resampled. */ const val OUTPUT_RESOLUTION_OFF = "off" /** `-W`/`-H` follow the panel's real pixel size (the pre-setting behaviour). */ const val OUTPUT_RESOLUTION_DEVICE = "device" + /** The default: no upscale — `-W/-H` follow the container's screen size, so the session presents 1:1. */ + const val OUTPUT_RESOLUTION_DEFAULT = OUTPUT_RESOLUTION_OFF + // Sharpness is stored in the UI's own units: 0..SHARPNESS_STEPS, higher = sharper — the scale the // QuickMenu row shows. const val SHARPNESS_STEPS = 5 @@ -88,6 +88,11 @@ object GamescopeSettings { return " -W ${size[0]} -H ${size[1]}" } + /** The session's scan-out size — [outputResolution] read back as `[w, h]`, or null if unreadable. */ + @JvmStatic + fun scanoutSize(container: Container?): IntArray? = + AppUtils.parseScreenSize(outputResolution(container)) + /** * The session's X socket — gamescope's own Xwayland, whose root window the live scaling-mode * property is set on. diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt index ba0aefe2b4..df4439a0b7 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GraphicsTab.kt @@ -125,8 +125,7 @@ fun GraphicsTabContent(state: ContainerConfigState, default: Boolean = false) { } }, ) - // Upscaling resolution gamescope presents at (-W/-H): Disabled follows the container screen size, Device's - // follows the panel, and the rest are the General tab's resolutions — whose 1920x1080 entry is the default. + // Upscaling resolution: the session's scan-out size (-W/-H and the daemon's emulated mode); Disabled follows the container screen size. if (!isX11Display) { val outputResolutionLabels = stringArrayResource(R.array.screen_size_entries).drop(1).reversed() val outputResolutionValues = listOf( 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 13dff9d07f..b384369ec7 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 @@ -62,8 +62,6 @@ import androidx.compose.runtime.setValue import app.gamenative.MainActivity import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.type import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.ui.Alignment import androidx.compose.ui.input.pointer.PointerIcon @@ -139,7 +137,6 @@ import app.gamenative.ui.component.dialog.SteamControllerBindingEditorDialog import app.gamenative.ui.component.parseBooleanExtra import app.gamenative.ui.component.parsePositiveFpsLimit import app.gamenative.ui.data.PerformanceHudConfig -import app.gamenative.ui.data.PerformanceHudSize import app.gamenative.ui.data.XServerState import app.gamenative.ui.widget.PerformanceHudView import app.gamenative.utils.AssetUtils @@ -254,7 +251,6 @@ import com.winlator.xserver.extensions.PresentExtension import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -311,27 +307,40 @@ private fun initialFpsLimiterTarget(container: Container): Int = parsePositiveFpsLimit(container.getExtra(FPS_LIMITER_TARGET_EXTRA)) ?: DEFAULT_FPS_LIMITER_TARGET_HZ -internal fun detectMaxRefreshRateHz(context: Context, attachedView: View?): Int { +/** + * The panel's refresh rate in Hz: the mode the display is in now, not the fastest it + * supports. The gndrm daemon measures the panel's latch grid against this period and the + * FPS limiter's ceiling is derived from it, so a panel a user holds at 60 Hz counts as 60 + * even when it can do 120; a mode change mid-session is reported again by the display + * listener below. + */ +internal fun detectRefreshRateHz(context: Context, attachedView: View?): Int { val display = attachedView?.display ?: context.display ?: ContextCompat.getSystemService(context, DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY) - - val refreshRate = when { - display == null -> DEFAULT_FPS_LIMITER_MAX_HZ.toFloat() - Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { - val supportedMax = display.supportedModes.maxOfOrNull { it.refreshRate } ?: display.refreshRate - if (supportedMax.isFinite() && supportedMax > 0f) supportedMax else display.refreshRate - } - else -> display.refreshRate - } - - return refreshRate - .takeIf { it.isFinite() && it > 0f } + return display?.refreshRate + ?.takeIf { it.isFinite() && it > 0f } ?.roundToInt() ?.coerceAtLeast(5) ?: DEFAULT_FPS_LIMITER_MAX_HZ } + +/** Re-reports the panel's active refresh rate to the daemon while a session runs. */ +private var panelRefreshListener: Pair? = null + +/** + * Drops the panel-refresh listener. Both session-end paths call this — `exit()` for a finished + * session and the view's `onRelease` for one disposed without it — so it has to tolerate a + * second call and a listener another path has already dropped. + */ +private fun releasePanelRefreshListener() { + panelRefreshListener?.let { (displayManager, listener) -> + displayManager.unregisterDisplayListener(listener) + panelRefreshListener = null + } +} + private data class XServerViewReleaseBinding( val xServerView: XServerRendererView, val windowModificationListener: WindowManager.OnWindowModificationListener, @@ -793,6 +802,33 @@ fun XServerScreen( applyLsfgSettings() } + fun refreshFpsLimit(view: View?, isX11Display: Boolean) { + val refreshHz = detectRefreshRateHz(context, view) + ui.detectedMaxRefreshRateHz = refreshHz * FPS_LIMITER_MAX_MULTIPLIER + + if (!isX11Display) + GameNativeDrm.nativeSetPanelRefreshHz(refreshHz) + } + + /** + * Registers the display listener that re-reports the panel's rate when the display changes mode. + * Called from both the view factory and its update block: a session reusing a cached view skips + * the factory, and would otherwise run without one. + */ + fun ensurePanelRefreshListener() { + if (panelRefreshListener != null) return + val displayManager = ContextCompat.getSystemService(context, DisplayManager::class.java) ?: return + val listener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) {} + override fun onDisplayRemoved(displayId: Int) {} + override fun onDisplayChanged(displayId: Int) { + refreshFpsLimit(ui.xServerView as? View, isX11Display = isX11Display) + } + } + displayManager.registerDisplayListener(listener, Handler(Looper.getMainLooper())) + panelRefreshListener = displayManager to listener + } + LaunchedEffect(ui.xServerView) { // Adaptive-cap steps route through the LSFG limiter; the X-server // limiters must stay at 0 under LSFG. @@ -804,7 +840,7 @@ fun XServerScreen( true } // Scaled here, not in the resolver, which must keep returning the display's real rate. - val detectedMax = detectMaxRefreshRateHz(context, ui.xServerView as? View) * FPS_LIMITER_MAX_MULTIPLIER + val detectedMax = detectRefreshRateHz(context, ui.xServerView as? View) * FPS_LIMITER_MAX_MULTIPLIER ui.detectedMaxRefreshRateHz = detectedMax val clampedTarget = fpsLimiterTarget.coerceAtMost(detectedMax).coerceAtLeast(5) if (clampedTarget != fpsLimiterTarget) { @@ -2073,9 +2109,10 @@ fun XServerScreen( EnvVars(container.envVars).get("GNDRM_SCANOUT_LAYOUT"), ) xServerToUse.setInputEventSink(GamescopeInputSink()) - // Ahead of nativeStart, which fixes the emulated mode's rate from this - // view's surface callback, so the panel's real rate must be set first. - GameNativeDrm.nativeSetPanelRefreshHz(detectMaxRefreshRateHz(context, xServerViewInstance as? View)) + // Ahead of nativeStart: the daemon's mode is the Upscaling Resolution. + GamescopeSettings.scanoutSize(container)?.let { + GameNativeDrm.nativeSetScanoutGeometry(it[0], it[1]) + } ?: GameNativeDrm.nativeSetScanoutGeometry(0, 0) // The presenter opens its frame-generation session on the first // present, from whatever the daemon holds: without this the daemon // has nothing until the first Quick Menu change. @@ -2083,6 +2120,10 @@ fun XServerScreen( } } + // Always refresh FPS Limit, Both X11 and Gamescope should refresh the fps limit when display is changed + refreshFpsLimit(xServerViewInstance as? View, isX11Display = sessionDisplay == SessionDisplay.X11) + ensurePanelRefreshListener() + val xServerView = xServerViewInstance.apply { ui.xServerView = this setFrameRateLimit(if (fpsLimiterEnabled) fpsLimiterTarget else 0) @@ -2988,6 +3029,10 @@ fun XServerScreen( GameNativeDrm.setFrameRating(null) } + // A view released without exit() (the activity-destroyed fallback, a restart) still ends + // the session, so the panel listener goes with it; exit() calls this too, harmlessly. + releasePanelRefreshListener() + val releaseBinding = view.tag as? XServerViewReleaseBinding releaseBinding?.let { binding -> // Remove the WindowManager listener associated with the released AndroidView. @@ -5367,8 +5412,10 @@ private fun exit( } catch (e: Exception) { Timber.e(e, "winHandler.stop() failed during exit") } - // The gndrm daemon (gamescope display mode) is torn down only at session exit: a display power - // cycle retargets the surface in XServerView instead of stopping it. + + // The panel listener is session-wide, so both display types drop it here. + releasePanelRefreshListener() + GameNativeDrm.nativeStop() PluviaApp.shutdownEnvironment() EaLaunchSupport.stop() diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreenState.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreenState.kt index f6e4f14055..36140e976a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreenState.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreenState.kt @@ -74,7 +74,7 @@ internal class XServerScreenState(container: Container, context: Context) { var hasInternalTouchpad by mutableStateOf(false) var hasUpdatedScreenGamepad by mutableStateOf(false) var isPerformanceHudEnabled by mutableStateOf(PrefManager.showFps) - var detectedMaxRefreshRateHz by mutableIntStateOf(detectMaxRefreshRateHz(context, null)) + var detectedMaxRefreshRateHz by mutableIntStateOf(detectRefreshRateHz(context, null)) var performanceHudConfig by mutableStateOf(loadPerformanceHudConfig()) var performanceHudView by mutableStateOf(null) var performanceHudHost by mutableStateOf(null) diff --git a/app/src/main/java/com/winlator/widget/XServerView.java b/app/src/main/java/com/winlator/widget/XServerView.java index be74e0498a..ae8c0eda14 100644 --- a/app/src/main/java/com/winlator/widget/XServerView.java +++ b/app/src/main/java/com/winlator/widget/XServerView.java @@ -73,9 +73,7 @@ public XServerRenderer getRenderer() { @Override public void surfaceCreated(@NonNull SurfaceHolder holder) { if (isGamescopeMode()) { - // Seed from the panel's real size (getWidth/Height may predate layout); surfaceChanged - // replaces it with the real holder size. The container preset is only a last resort: - // it's an internal desktop resolution, and seeding it starts the daemon at 720p. + // The window's size: the daemon's mode only when no scan-out geometry was named. int[] defaultSize = AppUtils.parseScreenSize(PluviaApp.getNativeDisplaySize()); if (defaultSize == null) defaultSize = AppUtils.parseScreenSize(PluviaApp.getDefaultScreenSize()); if (defaultSize == null) { diff --git a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index 293e25cac7..b9a11d1a22 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -553,9 +553,8 @@ private void applyGamescopeEnv(Context context, EnvVars envVars, File rootDir) { if (container != null && container.getGraphicsDriver() != null) envVars.put("GNDRM_DRIVER_TYPE", container.getGraphicsDriver()); - // Same source as gamescope's -W/-H, so the two cannot drift. The nested pair is seeded from the - // same value the -w/-h arguments are formatted from (see gamescopeNestedSize). - int[] scanoutOutput = AppUtils.parseScreenSize(GamescopeSettings.outputResolution(container)); + // Same source as gamescope's -W/-H and the daemon's mode; the panel is the fallback when the resolution is unreadable. + int[] scanoutOutput = GamescopeSettings.scanoutSize(container); if (scanoutOutput == null) scanoutOutput = displaySize(); if (scanoutOutput == null) @@ -750,11 +749,7 @@ private int[] gamescopeNestedSize() { return AppUtils.parseScreenSize(container != null ? container.getScreenSize() : null); } - /** - * The panel's real pixel size, or null if unreadable. This is what scanout needs: the - * container's screen size is an internal desktop resolution, so using it here would make - * gamescope present at 720p (or whatever preset) and resample onto the panel. - */ + /** The panel's real pixel size, or null if unreadable; only the shim seed's last-resort fallback. */ private static int[] displaySize() { int[] nativeSize = AppUtils.parseScreenSize(PluviaApp.getNativeDisplaySize()); if (nativeSize != null) return nativeSize; diff --git a/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so b/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so index 79f6cd490e..40225e6048 100755 Binary files a/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so and b/app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so differ diff --git a/app/src/test/java/app/gamenative/display/GamescopePayloadDownloadTest.kt b/app/src/test/java/app/gamenative/display/GamescopePayloadDownloadTest.kt new file mode 100644 index 0000000000..4ed35ee5ef --- /dev/null +++ b/app/src/test/java/app/gamenative/display/GamescopePayloadDownloadTest.kt @@ -0,0 +1,170 @@ +package app.gamenative.display + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.gamenative.service.SteamService +import app.gamenative.utils.AssetUtils +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +/** + * Whether the gamescope payload the app fetches on its first gamescope launch really is on the + * servers it fetches it from. + * + * The two hosts mirror `SteamService.fetchFileWithFallback`, and the file name comes from + * [GamescopePayload.ARCHIVE] — so bumping [GamescopePayload.VERSION] without publishing that + * archive fails here instead of on a user's first launch. A missing archive, a wrong body or a + * host that stopped mirroring it is a failure; no network at all is a skip. + */ +@RunWith(RobolectricTestRunner::class) +class GamescopePayloadDownloadTest { + + @Test + fun theArchiveIsPublishedOnBothHosts() { + assumeNetwork() + + val responses = HOSTS.map { host -> host to head("$host/${GamescopePayload.ARCHIVE}") } + + responses.forEach { (host, rsp) -> + assertEquals("$host did not serve ${GamescopePayload.ARCHIVE}", 200, rsp.code) + assertTrue( + "$host served a ${rsp.contentType} (${rsp.length} B), not the archive", + rsp.length >= MIN_ARCHIVE_BYTES, + ) + } + assertEquals( + "the two hosts serve different archives", + responses[0].second.length, + responses[1].second.length, + ) + + HOSTS.forEach { host -> + val magic = firstBytes("$host/${GamescopePayload.ARCHIVE}", ZSTD_MAGIC.size) + assertTrue( + "$host did not serve a zstd frame (first bytes ${magic.toHex()})", + magic.contentEquals(ZSTD_MAGIC), + ) + } + } + + @Test + fun theArchiveDownloadsAndInstallsFromTheServer() { + assumeNetwork() + + val context: Context = ApplicationProvider.getApplicationContext() + val archive = File(context.filesDir, GamescopePayload.ARCHIVE) + archive.delete() + AssetUtils.deleteTree(GamescopeSettings.gamescopeDir(context.filesDir)) + + // Downloaded through the app's own fetch, so a host that 404s, truncates or serves a page + // instead of the archive fails here; not via the APK asset, which would hide that. + val progress = mutableListOf() + runBlocking { + SteamService.fetchFile("${HOSTS[0]}/${GamescopePayload.ARCHIVE}", archive) { progress.add(it) } + } + + val published = head("${HOSTS[0]}/${GamescopePayload.ARCHIVE}").length + assertEquals("the download is not the published archive", published, archive.length()) + assertTrue("the download reported no progress", progress.isNotEmpty()) + assertTrue("the download never completed", progress.max() > 0.99f) + + val installed = GamescopePayload.install(archive, context.filesDir) + + assertEquals(GamescopeSettings.gamescopeDir(context.filesDir), installed) + assertTrue( + "the payload carries no bin/gamescope", + File(installed, "bin/gamescope").isFile, + ) + assertEquals( + GamescopePayload.VERSION, + File(installed, ".payload.version").readText().trim(), + ) + assertTrue(GamescopePayload.isReady(context.filesDir)) + assertFalse( + "the archive should not outlive the install", + File(context.filesDir, GamescopePayload.ARCHIVE).exists(), + ) + } + + @Test + fun anInstalledPayloadIsNotDownloadedAgain() { + val context: Context = ApplicationProvider.getApplicationContext() + val dir = GamescopeSettings.gamescopeDir(context.filesDir) + AssetUtils.deleteTree(dir) + // An installed payload: the stamp and the directory the readiness check reads. + File(dir, "bin").mkdirs() + File(dir, ".payload.version").writeText(GamescopePayload.VERSION) + + val progress = mutableListOf() + val installed = runBlocking { GamescopePayload.ensure(context) { progress.add(it) } } + + assertEquals(dir, installed) + assertTrue("a ready payload must not report download progress", progress.isEmpty()) + assertFalse(File(context.filesDir, GamescopePayload.ARCHIVE).exists()) + } + + /// The response of a `HEAD`, with the length the archive claims and the type it was served as. + private data class Head(val code: Int, val length: Long, val contentType: String) + + private fun head(url: String): Head { + val connection = open(url, "HEAD") + try { + return Head( + code = connection.responseCode, + length = connection.getHeaderField("Content-Length")?.toLongOrNull() ?: -1L, + contentType = connection.contentType.orEmpty(), + ) + } finally { + connection.disconnect() + } + } + + private fun firstBytes(url: String, count: Int): ByteArray { + val connection = open(url, "GET", ranged = true) + try { + assertEquals("$url did not serve a range", 206, connection.responseCode) + return connection.inputStream.readNBytes(count) + } finally { + connection.disconnect() + } + } + + private fun open(url: String, method: String, ranged: Boolean = false): HttpURLConnection = + (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + if (ranged) setRequestProperty("Range", "bytes=0-${ZSTD_MAGIC.size - 1}") + instanceFollowRedirects = true + } + + /// Skips the test on a machine with no network, where the check would say nothing. + private fun assumeNetwork() { + val reachable = runCatching { head("${HOSTS[0]}/${GamescopePayload.ARCHIVE}").code }.isSuccess + assumeTrue("No network: the published payload cannot be checked", reachable) + } + + private fun ByteArray.toHex(): String = joinToString(" ") { "%02x".format(it) } + + private companion object { + val HOSTS = listOf( + "https://downloads.gamenative.app", + "https://pub-9fcd5294bd0d4b85a9d73615bf98f3b5.r2.dev", + ) + /// A zstd frame header: the archive is a `.tzst`, and an error page never starts with it. + val ZSTD_MAGIC = byteArrayOf(0x28, 0xB5.toByte(), 0x2F, 0xFD.toByte()) + /// The payload is tens of megabytes; anything far below that is a stub or a page. + const val MIN_ARCHIVE_BYTES = 1L shl 20 + const val CONNECT_TIMEOUT_MS = 8_000 + const val READ_TIMEOUT_MS = 20_000 + } +}