Skip to content
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ app/release/output-metadata.json
local.properties
app/src/main/assets/recommendations.json

# Locally built gamescope payloads: fetched at runtime unless one is present,
# and never committed — a payload is an artefact of the branch that built it.
app/src/main/assets/gamescope-gamenative-*.tzst

# Proguard folder generated by Eclipse
proguard/

Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/app/gamenative/PrefManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ object PrefManager {

private val DISPLAY_SERVER = stringPreferencesKey("display_server")
var displayServer: String
get() = getPref(DISPLAY_SERVER, Container.DEFAULT_DISPLAY_SERVER)
get() = getPref(DISPLAY_SERVER, DefaultVersion.DISPLAY_SERVER)
set(value) {
setPref(DISPLAY_SERVER, value)
}
Expand Down
35 changes: 29 additions & 6 deletions app/src/main/java/app/gamenative/display/GamescopePayload.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "20261022.13"
const val VERSION = "20261023.3"
const val ARCHIVE = "gamescope-gamenative-$VERSION.tzst"
private const val STAMP = ".payload.version"

Expand All @@ -29,16 +29,39 @@ object GamescopePayload {
val archive = File(filesDir, ARCHIVE)
if (!archive.isFile || archive.length() == 0L) {
archive.delete()
try {
SteamService.fetchFileWithFallback(ARCHIVE, archive, context, onProgress)
} catch (e: Exception) {
archive.delete()
throw e
// A payload shipped inside the APK wins over the download: it is the
// build that was exercised with this app, and it needs no network.
// Only when the asset is absent does the published archive get
// fetched.
if (copyFromAssets(context, archive)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: copyFromAssets runs only when the cached archive is missing or empty. A non-empty archive left by an interrupted prior launch can therefore bypass the APK asset and install a stale or downloaded payload. Check and copy the bundled asset before accepting the cached archive.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/display/GamescopePayload.kt, line 36:

<comment>`copyFromAssets` runs only when the cached archive is missing or empty. A non-empty archive left by an interrupted prior launch can therefore bypass the APK asset and install a stale or downloaded payload. Check and copy the bundled asset before accepting the cached archive.</comment>

<file context>
@@ -29,16 +29,39 @@ object GamescopePayload {
+            // build that was exercised with this app, and it needs no network.
+            // Only when the asset is absent does the published archive get
+            // fetched.
+            if (copyFromAssets(context, archive)) {
+                onProgress(1.0f)
+            } else {
</file context>

onProgress(1.0f)
} else {
try {
SteamService.fetchFileWithFallback(ARCHIVE, archive, context, onProgress)
} catch (e: Exception) {
archive.delete()
throw e
}
}
}
install(archive, filesDir)
}

/** Whether this APK carries the payload, and if so, copy it out to [dest]. */
private fun copyFromAssets(context: Context, dest: File): Boolean {
val assets = context.assets
val present = runCatching { assets.open(ARCHIVE).close() }.isSuccess
if (!present) return false
return runCatching {
assets.open(ARCHIVE).use { input -> dest.outputStream().use { output -> input.copyTo(output) } }
}
.onFailure {
Timber.w(it, "Failed to copy $ARCHIVE from assets")
dest.delete()
}
.isSuccess
Comment on lines +55 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The APK stream is copied directly to the final archive, so interruption can leave a non-empty partial file that the next launch tries to extract; zero-byte assets are also reported as success. Copy to a temporary file, require nonzero output, then atomically rename it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/display/GamescopePayload.kt, line 55:

<comment>The APK stream is copied directly to the final archive, so interruption can leave a non-empty partial file that the next launch tries to extract; zero-byte assets are also reported as success. Copy to a temporary file, require nonzero output, then atomically rename it.</comment>

<file context>
@@ -29,16 +29,39 @@ object GamescopePayload {
+        val assets = context.assets
+        val present = runCatching { assets.open(ARCHIVE).close() }.isSuccess
+        if (!present) return false
+        return runCatching {
+            assets.open(ARCHIVE).use { input -> dest.outputStream().use { output -> input.copyTo(output) } }
+        }
</file context>
Suggested change
return runCatching {
assets.open(ARCHIVE).use { input -> dest.outputStream().use { output -> input.copyTo(output) } }
}
.onFailure {
Timber.w(it, "Failed to copy $ARCHIVE from assets")
dest.delete()
}
.isSuccess
val temp = File("${dest.absolutePath}.part")
return runCatching {
assets.open(ARCHIVE).use { input -> temp.outputStream().use { output -> input.copyTo(output) } }
check(temp.length() > 0L && temp.renameTo(dest))
}
.onFailure {
Timber.w(it, "Failed to copy $ARCHIVE from assets")
temp.delete()
dest.delete()
}
.isSuccess

}

@Synchronized
internal fun install(archive: File, filesDir: File): File {
val target = directory(filesDir)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2423,6 +2423,7 @@ fun XServerScreen(
envVars,
contentsManager,
onExtractFileListener,
gameSession = !bootToContainer,
)
}
if (isGamescopeSession) {
Expand Down Expand Up @@ -5732,6 +5733,7 @@ private suspend fun setupWineSystemFiles(
envVars: EnvVars,
contentsManager: ContentsManager,
onExtractFileListener: OnExtractFileListener?,
gameSession: Boolean,
) {
val imageFs = ImageFs.find(context)
val appVersion = AppUtils.getVersionCode(context).toString()
Expand Down Expand Up @@ -5866,10 +5868,11 @@ private suspend fun setupWineSystemFiles(
cleanupBionicSteamAssets(imageFs)
}

val desktopTheme = container.desktopTheme
if ((desktopTheme + "," + screenInfo) != container.getExtra("desktopTheme")) {
WineThemeManager.apply(context, WineThemeManager.ThemeInfo(desktopTheme), screenInfo)
container.putExtra("desktopTheme", desktopTheme + "," + screenInfo)
val desktopTheme = if (gameSession) WineThemeManager.gameSessionTheme(container.desktopTheme) else container.desktopTheme
val desktopThemeKey = desktopTheme + "," + screenInfo + if (gameSession) ",game" else ""
if (desktopThemeKey != container.getExtra("desktopTheme")) {
WineThemeManager.apply(context, WineThemeManager.ThemeInfo(desktopTheme), screenInfo, !gameSession)
container.putExtra("desktopTheme", desktopThemeKey)
containerDataChanged = true
}

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/app/gamenative/utils/ContainerUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ object ContainerUtils {
DefaultVersion.STEAM_TYPE = Container.STEAM_TYPE_HEADLESS
DefaultVersion.ASYNC_CACHE = "0"
}
DefaultVersion.DISPLAY_SERVER = Container.defaultDisplayServer(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The Adreno display-server default is initialized only from MainActivity, so service-driven container creation can still use the initial X11 value. Initialize the GPU-dependent default from a shared application/service startup path before any container is created.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/utils/ContainerUtils.kt, line 101:

<comment>The Adreno display-server default is initialized only from `MainActivity`, so service-driven container creation can still use the initial X11 value. Initialize the GPU-dependent default from a shared application/service startup path before any container is created.</comment>

<file context>
@@ -98,6 +98,9 @@ object ContainerUtils {
             DefaultVersion.STEAM_TYPE = Container.STEAM_TYPE_HEADLESS
             DefaultVersion.ASYNC_CACHE = "0"
         }
+        DefaultVersion.DISPLAY_SERVER = Container.defaultDisplayServer(
+            GPUInformation.getRenderer(context).orEmpty().contains("adreno", ignoreCase = true),
+        )
</file context>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new display-server default checks Adreno through the GL renderer string, but isAdrenoGPU in the same function uses the Vulkan renderer. Compute val isAdreno once and use it for both defaults so they cannot disagree.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/app/gamenative/utils/ContainerUtils.kt, line 101:

<comment>The new display-server default checks Adreno through the GL renderer string, but `isAdrenoGPU` in the same function uses the Vulkan renderer. Compute `val isAdreno` once and use it for both defaults so they cannot disagree.</comment>

<file context>
@@ -98,6 +98,9 @@ object ContainerUtils {
             DefaultVersion.STEAM_TYPE = Container.STEAM_TYPE_HEADLESS
             DefaultVersion.ASYNC_CACHE = "0"
         }
+        DefaultVersion.DISPLAY_SERVER = Container.defaultDisplayServer(
+            GPUInformation.getRenderer(context).orEmpty().contains("adreno", ignoreCase = true),
+        )
</file context>

GPUInformation.getRenderer(context).orEmpty().contains("adreno", ignoreCase = true),
)
}

fun getGPUCards(context: Context): Map<Int, GpuInfo> {
Expand Down
7 changes: 5 additions & 2 deletions app/src/main/java/com/winlator/container/Container.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ public enum XrControllerMapping {
public static final String DEFAULT_DISPLAY_RENDERER = "vulkan";
public static final String DISPLAY_SERVER_X11 = "x11";
public static final String DISPLAY_SERVER_GAMESCOPE = "gamescope";
public static final String DEFAULT_DISPLAY_SERVER = DISPLAY_SERVER_GAMESCOPE;
public static final String DEFAULT_DDRAWRAPPER = "none";
public static final String DEFAULT_DXWRAPPERCONFIG = "version=" + DefaultVersion.DXVK + ",framerate=0,maxDeviceMemory=0,async=" + DefaultVersion.ASYNC + ",asyncCache=" + DefaultVersion.ASYNC_CACHE + ",vkd3dVersion=" + DefaultVersion.VKD3D + ",vkd3dLevel=12_1" + ",ddrawrapper=" + Container.DEFAULT_DDRAWRAPPER + ",csmt=3" + ",gpuName=NVIDIA GeForce GTX 480" + ",videoMemorySize=2048" + ",strict_shader_math=1" + ",OffscreenRenderingMode=fbo" + ",renderer=gl";;
public static final String DEFAULT_GRAPHICSDRIVERCONFIG = "vulkanVersion=1.3" + ",version=" + DefaultVersion.WRAPPER + ",blacklistedExtensions=" + ",maxDeviceMemory=0" + ",presentMode=mailbox" + ",syncFrame=0" + ",disablePresentWait=0" + ",resourceType=auto" + ",bcnEmulation=auto" + ",bcnEmulationType=compute" + ",bcnEmulationCache=0" + ",gpuName=Device";
Expand Down Expand Up @@ -87,7 +86,7 @@ public enum XrControllerMapping {
private String graphicsDriverConfig = DEFAULT_GRAPHICSDRIVERCONFIG;
private String rendererPresentMode = "fifo";
private String displayRenderer = Container.DEFAULT_DISPLAY_RENDERER;
private String displayServer = Container.DEFAULT_DISPLAY_SERVER;
private String displayServer = DefaultVersion.DISPLAY_SERVER;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This default is overwritten for new containers. loadData runs checkObsoleteOrMissingProperties first, which writes displayServer=x11 whenever the key is missing, and ContainerUtils creates containers with no displayServer key. So new containers on Adreno still get X11 instead of Gamescope. Either pass the resolved DefaultVersion.DISPLAY_SERVER into the data object before createContainerFuture, or limit the X11 backfill to containers that already exist.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/com/winlator/container/Container.java, line 89:

<comment>This default is overwritten for new containers. `loadData` runs `checkObsoleteOrMissingProperties` first, which writes `displayServer=x11` whenever the key is missing, and `ContainerUtils` creates containers with no `displayServer` key. So new containers on Adreno still get X11 instead of Gamescope. Either pass the resolved `DefaultVersion.DISPLAY_SERVER` into the `data` object before `createContainerFuture`, or limit the X11 backfill to containers that already exist.</comment>

<file context>
@@ -87,7 +86,7 @@ public enum XrControllerMapping {
     private String rendererPresentMode = "fifo";
     private String displayRenderer = Container.DEFAULT_DISPLAY_RENDERER;
-    private String displayServer = Container.DEFAULT_DISPLAY_SERVER;
+    private String displayServer = DefaultVersion.DISPLAY_SERVER;
     private int xrRefreshRate = 72;
     private int xrRenderScale = 100;
</file context>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Duplicating a container no longer preserves its display server because ContainerManager omits that property when copying. Copy srcContainer.getDisplayServer() into the duplicate before saving it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/src/main/java/com/winlator/container/Container.java, line 89:

<comment>Duplicating a container no longer preserves its display server because `ContainerManager` omits that property when copying. Copy `srcContainer.getDisplayServer()` into the duplicate before saving it.</comment>

<file context>
@@ -87,7 +86,7 @@ public enum XrControllerMapping {
     private String rendererPresentMode = "fifo";
     private String displayRenderer = Container.DEFAULT_DISPLAY_RENDERER;
-    private String displayServer = Container.DEFAULT_DISPLAY_SERVER;
+    private String displayServer = DefaultVersion.DISPLAY_SERVER;
     private int xrRefreshRate = 72;
     private int xrRenderScale = 100;
</file context>

private int xrRefreshRate = 72;
private int xrRenderScale = 100;
private boolean sfCompatMode = true;
Expand Down Expand Up @@ -309,6 +308,10 @@ public void setGraphicsDriverConfig(String graphicsDriverConfig) {

public void setDisplayServer(String v) { this.displayServer = v; }

public static String defaultDisplayServer(boolean isAdreno) {
return isAdreno ? DISPLAY_SERVER_GAMESCOPE : DISPLAY_SERVER_X11;
}

public static boolean isX11Display(String containerVariant, String displayServer) {
return !BIONIC.equalsIgnoreCase(containerVariant) || !DISPLAY_SERVER_GAMESCOPE.equalsIgnoreCase(displayServer);
}
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/com/winlator/container/ContainerData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ data class ContainerData(
val graphicsDriverConfig: String = "",
val rendererPresentMode: String = "fifo",
val displayRenderer: String = Container.DEFAULT_DISPLAY_RENDERER,
val displayServer: String = Container.DEFAULT_DISPLAY_SERVER,
val displayServer: String = DefaultVersion.DISPLAY_SERVER,
val xrRefreshRate: Int = 72,
val xrRenderScale: Int = 100,
val sfCompatMode: Boolean = true,
Expand Down Expand Up @@ -214,7 +214,7 @@ data class ContainerData(
graphicsDriverConfig = (savedMap["graphicsDriverConfig"] as? String) ?: "",
rendererPresentMode = (savedMap["rendererPresentMode"] as? String) ?: "fifo",
displayRenderer = (savedMap["displayRenderer"] as? String) ?: "vulkan",
displayServer = (savedMap["displayServer"] as? String) ?: Container.DEFAULT_DISPLAY_SERVER,
displayServer = (savedMap["displayServer"] as? String) ?: DefaultVersion.DISPLAY_SERVER,
xrRefreshRate = (savedMap["xrRefreshRate"] as? Int) ?: 72,
xrRenderScale = (savedMap["xrRenderScale"] as? Int) ?: 100,
sfCompatMode = (savedMap["sfCompatMode"] as? Boolean) ?: true,
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/com/winlator/core/DefaultVersion.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ public abstract class DefaultVersion {
public static String WINE_VERSION = com.winlator.core.WineInfo.MAIN_WINE_VERSION.identifier();
public static String ASYNC = "1";
public static String ASYNC_CACHE = "0";
public static String DISPLAY_SERVER = Container.DISPLAY_SERVER_X11;
}
24 changes: 24 additions & 0 deletions app/src/main/java/com/winlator/core/WineThemeManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,22 @@ public ThemeInfo(String value) {
}
}

// Game sessions get a black desktop: gamescope composites Wine's desktop
// surface, so the wallpaper would show through before the game's first frame
// and around its dialogs. The X11 renderers never draw explorer, so it is
// invisible there either way.
public static String gameSessionTheme(String value) {
ThemeInfo info = new ThemeInfo(value);
return info.theme + "," + BackgroundType.COLOR + ",#000000";
}

public static void apply(Context context, ThemeInfo themeInfo, ScreenInfo screenInfo) {
apply(context, themeInfo, screenInfo, true);
}

// The desktop is named "shell", which makes explorer draw its taskbar and
// start menu; game sessions turn that off along with the standalone systray.
public static void apply(Context context, ThemeInfo themeInfo, ScreenInfo screenInfo, boolean enableShell) {
File rootDir = ImageFs.find(context).getRootDir();
File userRegFile = new File(rootDir, ImageFs.WINEPREFIX+"/user.reg");
String background = Color.red(themeInfo.backgroundColor)+" "+Color.green(themeInfo.backgroundColor)+" "+Color.blue(themeInfo.backgroundColor);
Expand All @@ -54,6 +69,15 @@ public static void apply(Context context, ThemeInfo themeInfo, ScreenInfo screen
}
else registryEditor.removeValue("Control Panel\\Desktop", "Wallpaper");

if (enableShell) {
registryEditor.removeValue("Software\\Wine\\Explorer\\Desktops\\shell", "EnableShell");
registryEditor.removeValue("Software\\Wine\\Explorer\\Desktops\\shell", "ShowSystray");
}
else {
registryEditor.setDwordValue("Software\\Wine\\Explorer\\Desktops\\shell", "EnableShell", 0);
registryEditor.setDwordValue("Software\\Wine\\Explorer\\Desktops\\shell", "ShowSystray", 0);
}

if (themeInfo.theme == Theme.LIGHT) {
registryEditor.setStringValue("Control Panel\\Colors", "ActiveBorder", "245 245 245");
registryEditor.setStringValue("Control Panel\\Colors", "ActiveTitle", "96 125 139");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -514,19 +514,7 @@ private void applyGamescopeEnv(Context context, EnvVars envVars, File rootDir) {
envVars.put("LD_PRELOAD", preload.isEmpty()
? gsDir + "/lib/libgndrm_shim.so"
: preload + ":" + gsDir + "/lib/libgndrm_shim.so");
// The shim pulls in libandroid.so, whose OEM deps must bind to system libs, not imagefs's same-named ones:
// Samsung's libsqlite.so needs the BoringSSL libcrypto.so, HyperOS's libjpeg-hyper.so needs libjpeg.so's jsimd_* exports.
String gamescopeLibPath = gsDir + "/lib";
File systemLibDir = new File(context.getFilesDir(), "gamescope_system_libs");
boolean linkedSystemLib = false;
for (String lib : new String[]{"libcrypto.so", "libjpeg.so"}) {
File systemLib = new File("/system/lib64", lib);
if (!systemLib.exists()) continue;
systemLibDir.mkdirs();
FileUtils.symlink(systemLib, new File(systemLibDir, lib));
linkedSystemLib = true;
}
if (linkedSystemLib) gamescopeLibPath = systemLibDir.getPath() + ":" + gamescopeLibPath;
String libPath = envVars.get("LD_LIBRARY_PATH");
envVars.put("LD_LIBRARY_PATH", libPath.isEmpty() ? gamescopeLibPath : gamescopeLibPath + ":" + libPath);
// gamescope execvp's gamescopereaper and wlroots spawns xkbcomp.
Expand Down
Binary file modified app/src/main/jniLibs/arm64-v8a/libgamenative_drm.so
Binary file not shown.
Loading