From 15d74f123a80f29658e5d00636b552154f93e464 Mon Sep 17 00:00:00 2001 From: TideGear Date: Thu, 23 Apr 2026 10:41:34 -0700 Subject: [PATCH 01/34] fix: recursive delete in AdrenotoolsManager.extractDriverFromResources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File.delete() is a silent no-op on non-empty directories, so an extraction interrupted mid-stream (OOM, storage hiccup, process kill) leaves partial files behind. The next launch can't clear them, extract-over-stale returns false, and setDriverById falls through to the "Driver not found" branch — which excludes asset-bundled drivers from its installed-list fallback. ADRENOTOOLS_DRIVER_PATH/HOOKS_PATH/NAME never get set, wrapper_icd has nothing to dlopen, and the game renders a black screen with audio while the process appears healthy. Swap both dst.delete() calls for FileUtils.delete(dst), which clears directory contents before unlinking. Toggling the Graphics Driver Version dropdown off and on was the user-visible workaround — it just forced a retry that happened to not get interrupted. --- .../main/java/com/winlator/contents/AdrenotoolsManager.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/winlator/contents/AdrenotoolsManager.java b/app/src/main/java/com/winlator/contents/AdrenotoolsManager.java index cc6d236066..db9a12cecb 100644 --- a/app/src/main/java/com/winlator/contents/AdrenotoolsManager.java +++ b/app/src/main/java/com/winlator/contents/AdrenotoolsManager.java @@ -131,15 +131,17 @@ private boolean extractDriverFromResources(String adrenotoolsDriverId) { boolean hasExtracted; File dst = new File(adrenotoolsContentDir, adrenotoolsDriverId); + // Recursive: File.delete() silently no-ops on non-empty dirs, leaving partial files from + // an interrupted prior extract that subsequent extracts can't cleanly overwrite. if (dst.exists()) - dst.delete(); + FileUtils.delete(dst); dst.mkdirs(); Log.d("AdrenotoolsManager", "Extracting " + src + " to " + dst.getAbsolutePath()); hasExtracted = TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, mContext, src, dst); if (!hasExtracted) - dst.delete(); + FileUtils.delete(dst); return hasExtracted; } From e963f13650f62653cef04886bfbe463bcdab2d3a Mon Sep 17 00:00:00 2001 From: TideGear Date: Thu, 23 Apr 2026 12:25:15 -0700 Subject: [PATCH 02/34] fix: defensive BadIdChoice on null Drawable/Pixmap in DRI3 pixmap creation pixmapFromHardwareBuffer and pixmapFromFd dereferenced the return values of drawableManager.createDrawable / pixmapManager.createPixmap directly. Both return null on resource-ID conflicts (the requested ID was already in use), which produced an opaque NullPointerException at the next line instead of a proper X protocol error. Throw BadIdChoice on null returns so the client sees a meaningful failure and can recover, rather than getting a JVM stacktrace logged into System.err and a half-initialized swapchain. Note: this commit historically also added registerAsOwnerOfResource calls to make the pixmaps client-owned (preventing a leak on disconnect that caused BadIdChoice cascades). That half has been removed pending a soak test post-cascade-fix to determine whether the leak still triggers in normal use or was primarily exposed by cascade-driven Wine client cycling. --- .../java/com/winlator/xserver/extensions/DRI3Extension.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java b/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java index 79a24b90fb..843b2b864d 100644 --- a/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java +++ b/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java @@ -159,8 +159,9 @@ private void pixmapFromHardwareBuffer(XClient client, int pixmapId, short width, try { GPUImage gpuImage = new GPUImage(fd); Drawable drawable = client.xServer.drawableManager.createDrawable(pixmapId, gpuImage.getStride(), height, depth); + if (drawable == null) throw new BadIdChoice(pixmapId); drawable.setTexture(gpuImage); - client.xServer.pixmapManager.createPixmap(drawable); + if (client.xServer.pixmapManager.createPixmap(drawable) == null) throw new BadIdChoice(pixmapId); } finally { XConnectorEpoll.closeFd(fd); @@ -174,10 +175,11 @@ private void pixmapFromFd(XClient client, int pixmapId, short width, short heigh short totalWidth = (short)(stride / 4); Drawable drawable = client.xServer.drawableManager.createDrawable(pixmapId, totalWidth, height, depth); + if (drawable == null) throw new BadIdChoice(pixmapId); drawable.setData(buffer); drawable.setTexture(null); drawable.setOnDestroyListener(onDestroyDrawableListener); - client.xServer.pixmapManager.createPixmap(drawable); + if (client.xServer.pixmapManager.createPixmap(drawable) == null) throw new BadIdChoice(pixmapId); } finally { XConnectorEpoll.closeFd(fd); From 215b5b34f021fb934e6887277a266ac7ace2bcb4 Mon Sep 17 00:00:00 2001 From: TideGear Date: Thu, 23 Apr 2026 15:15:21 -0700 Subject: [PATCH 03/34] fix: cap leaked GLX client windows to prevent OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wine's GLX/DRI3 helper (resourceIDBase 0x5C00000) under real-Steam mode can create blank input-output windows every ~10ms without ever destroying them, exhausting the 512MB Dalvik heap via 1.92MB per Drawable ByteBuffer and aborting in XConnectorEpoll. reapLeakedClientWindows runs at end of createWindow. When the same originClient has >= LEAK_CLIENT_CAP (8) blank-className, same-size input-output windows alive, destroy the oldest. Parent-agnostic — the leaked windows get reparented to a 1x1 orphanage before the check runs. --- .../com/winlator/xserver/WindowManager.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/src/main/java/com/winlator/xserver/WindowManager.java b/app/src/main/java/com/winlator/xserver/WindowManager.java index 83145a1432..ab9ab1e542 100644 --- a/app/src/main/java/com/winlator/xserver/WindowManager.java +++ b/app/src/main/java/com/winlator/xserver/WindowManager.java @@ -1,5 +1,6 @@ package com.winlator.xserver; +import android.util.Log; import android.util.SparseArray; import com.winlator.xconnector.XInputStream; @@ -183,9 +184,43 @@ public Window createWindow(int id, Window parent, short x, short y, short width, windows.put(id, window); parent.addChild(window); triggerOnCreateResourceListener(window); + reapLeakedClientWindows(window); return window; } + private static final int LEAK_CLIENT_CAP = 8; + + private void reapLeakedClientWindows(Window created) { + if (created == rootWindow || !created.isInputOutput()) return; + if (!created.getClassName().isEmpty()) return; + XClient createdClient = created.originClient; + if (createdClient == null) return; + int w = created.getWidth(); + int h = created.getHeight(); + + ArrayList matches = new ArrayList<>(); + for (int i = 0; i < windows.size(); i++) { + Window cand = windows.valueAt(i); + if (cand == null || cand == created || cand == rootWindow) continue; + if (cand.originClient != createdClient) continue; + if (!cand.isInputOutput()) continue; + if (!cand.getClassName().isEmpty()) continue; + if (cand.getWidth() != w || cand.getHeight() != h) continue; + matches.add(cand); + } + if (matches.size() < LEAK_CLIENT_CAP) return; + + matches.sort((a, b) -> Integer.compareUnsigned(a.id, b.id)); + int toReap = matches.size() - (LEAK_CLIENT_CAP - 1); + for (int i = 0; i < toReap && i < matches.size(); i++) { + Window victim = matches.get(i); + Log.w("WindowManager", "reapLeakedClientWindow: wid=" + victim.id + + " parent=" + (victim.getParent() == null ? "null" : Integer.toString(victim.getParent().id)) + + " (cap=" + LEAK_CLIENT_CAP + " matches=" + matches.size() + ")"); + destroyWindow(victim.id); + } + } + private void changeWindowGeometry(Window window, short x, short y, short width, short height) { boolean resized = window.getWidth() != width || window.getHeight() != height; if (resized && window.hasEventListenerFor(Event.RESIZE_REDIRECT)) { From 8d9ab477d64521dd21e65acd500c5d8364613c48 Mon Sep 17 00:00:00 2001 From: TideGear Date: Thu, 23 Apr 2026 15:15:30 -0700 Subject: [PATCH 04/34] fix: skip orphaned Wine GLX leak chain in compositor Blank-className pid=0 windows in the Wine winex11.drv/zink path cover two populations: the game's active GL render target (child of the game's top-level HWND, parent has real className+pid) and the orphan leak chain reparented to a 1x1 blank orphanage window. Only the orphan chain has a blank parent. Filter on that: skip a blank window only when its parent is also blank. The active render target falls through and gets drawn. A broader filter (skip all blank windows) hid the game's output. No filter let fresh leak children at (0,0) paint blank over the game every frame before their reparent. --- .../main/java/com/winlator/renderer/GLRenderer.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/winlator/renderer/GLRenderer.java b/app/src/main/java/com/winlator/renderer/GLRenderer.java index 01d9e09a3a..c2754e39cd 100644 --- a/app/src/main/java/com/winlator/renderer/GLRenderer.java +++ b/app/src/main/java/com/winlator/renderer/GLRenderer.java @@ -358,6 +358,13 @@ private void updateScene() { private void collectRenderableWindows(Window window, int x, int y) { if (!window.attributes.isMapped()) return; if (window != xServer.windowManager.rootWindow) { + if (window.getClassName().isEmpty() && window.getProcessId() == 0) { + Window parent = window.getParent(); + if (parent != null && parent != xServer.windowManager.rootWindow + && parent.getClassName().isEmpty() && parent.getProcessId() == 0) { + return; + } + } boolean viewable = true; if (unviewableWMClasses != null) { @@ -396,7 +403,9 @@ private void collectRenderableWindows(Window window, int x, int y) { renderableWindows.add(new RenderableWindow(window.getContent(), x, y, forceFullscreen)); } - else renderableWindows.add(new RenderableWindow(window.getContent(), x, y)); + else { + renderableWindows.add(new RenderableWindow(window.getContent(), x, y)); + } } } From 05f303ebf68fab6d7d491c7df251b0dd00d3545e Mon Sep 17 00:00:00 2001 From: TideGear Date: Mon, 20 Apr 2026 20:53:57 -0700 Subject: [PATCH 05/34] fix: guard SysVSharedMemory.delete with the shmemories lock SysVSharedMemory.delete(int) mutated the shmemories SparseArray without synchronizing on it, while every other mutator (get, attach, detach, deleteAll) held synchronized(shmemories). delete is invoked from the SysV SHM connector thread (SysVSHMRequestHandler), and detach is invoked from the X11 connector thread (MITSHMExtension), so the two can run concurrently on the same array. When delete ran mid-iteration in detach, SparseArray's internal DELETED sentinel (a bare Object) could surface at valueAt(i), producing: java.lang.ClassCastException: java.lang.Object cannot be cast to com.winlator.sysvshm.SysVSharedMemory$SHMemory at SysVSharedMemory.detach(SysVSharedMemory.java:117) at SHMSegmentManager.detach(SHMSegmentManager.java:26) at MITSHMExtension.detach(MITSHMExtension.java:74) Fix: wrap the body of delete(int) in synchronized(shmemories). The lock is reentrant, so deleteAll (which already holds it while calling delete per-id) is unaffected. Files: app/src/main/java/com/winlator/sysvshm/SysVSharedMemory.java --- .../com/winlator/sysvshm/SysVSharedMemory.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/winlator/sysvshm/SysVSharedMemory.java b/app/src/main/java/com/winlator/sysvshm/SysVSharedMemory.java index 5d9bac76c6..c06b257ab6 100644 --- a/app/src/main/java/com/winlator/sysvshm/SysVSharedMemory.java +++ b/app/src/main/java/com/winlator/sysvshm/SysVSharedMemory.java @@ -80,13 +80,15 @@ public int get(long size) { } public void delete(int shmid) { - SHMemory shmemory = shmemories.get(shmid); - if (shmemory != null) { - if (SHMemory.access$000(shmemory) != -1) { - XConnectorEpoll.closeFd(SHMemory.access$000(shmemory)); - SHMemory.access$002(shmemory, -1); + synchronized (shmemories) { + SHMemory shmemory = shmemories.get(shmid); + if (shmemory != null) { + if (SHMemory.access$000(shmemory) != -1) { + XConnectorEpoll.closeFd(SHMemory.access$000(shmemory)); + SHMemory.access$002(shmemory, -1); + } + shmemories.remove(shmid); } - shmemories.remove(shmid); } } From bff36e527a0da87ec41026798ffcc3cabe139f66 Mon Sep 17 00:00:00 2001 From: TideGear Date: Sat, 18 Apr 2026 23:24:44 -0700 Subject: [PATCH 06/34] fix: real Steam client launch reliability + overlay toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stabilizes the "real Steam client" launch path (`steam.exe -applaunch`) so games like Darkest Dungeon and 868-HACK boot cleanly without the multi-minute "gray Play" reconcile loop, and adds a per-container toggle to disable the Steam overlay. Also hardens cloud sync and GSE→userdata migration against mode switches between emu and real-Steam launches. - `SteamUtils.writeSteamworksCommonManifest` now writes only the depots whose `_CommonRedist//installscript.vdf` is actually present on disk (typically 228985 vcredist 2013 + 228989 vcredist 2022) instead of the full 24 PICS-declared depots. The SKIP check compares against the same present-depot baseline, so a correct manifest survives across launches. - `SteamUtils.createAppManifest` emits a `SharedDepots` block in the child game's acf for any depot whose parent app id is known (via `DepotInfo.depotFromApp`). Declaring shared ownership up front stops Steam from reparenting depots and cascading `Update Queued` onto the child on every launch. - Removed the now-unused `canonical228980SizeOnDisk`, `canonical228980BytesToDownload`, and `canonical228980BytesToStage` constants; size-on-disk is summed from the present depots and bytes-to-download is `0`. Why: writing all 24 depots made Steam prune 22 phantoms every boot (~3 min of gray Play). Writing an empty manifest made Steam try to download ~52 MB, hang `Suspended`, and permanently cascade `Update Queued`. The 2-depot + `SharedDepots` shape is the only combination that survives Steam's reconcile. - New per-container `disableSteamOverlay` flag (`Container.java`, `ContainerData.kt`, `PrefManager.kt`, `strings.xml`). - Toggle surfaced in the container dialog's General tab, visible only when "Launch Real Steam" is enabled (`GeneralTab.kt`). - When enabled, `XServerScreen` exports `DISABLE_VK_LAYER_VALVE_steam_overlay_1=1` and `SteamNoOverlayUIDrawing=1` into the Wine env so the overlay DLL never injects. - `SteamService.beginLaunchApp` / `syncUserFiles` / `closeApp` now take an `isLaunchRealSteam` flag. In real-Steam mode the Goldberg achievement sync and the GSE→Steam userdata migration are skipped so the real client owns its own save state. - `MainViewModel` tracks the last launch mode (`lastSteamMode` extra) and, on real→emu transitions, cleans up artifacts from the previous mode to avoid mixing GSE and real-client save layouts. - `PluviaMain` threads the launch-mode flag through to `syncUserFiles`. - `SteamAutoCloud` refactors the byte-identical detection into a shared lambda and adds a "cache lost but local == remote" branch that silently rehydrates the cache instead of surfacing a spurious conflict prompt. - `MainViewModel` adds `SteamFixDiagnostics` (last mapped window class, game-window-mapped flag, last unmatched window class) and a helper window-class filter (`STEAM_HELPER_WINDOW_CLASSES`) so stall reports distinguish the real game window from Steam's own helper UI. - `XServerScreen` integrates a 60 s stall watchdog and logs window-mapping events for post-mortem analysis of gray-Play hangs. - `SteamTokenLogin`, `PreInstallSteps`, `preInstallSteps/VcRedistStep`, `ContainerUtils`, and `ImageFsInstaller` receive small wiring changes to support the real-Steam-client launch path (vcredist gating, container defaults, ImageFs layout). - [ ] Launch Darkest Dungeon (262060) three times in a row — Play button should auto-dismiss; no multi-minute gray Play. - [ ] Launch 868-HACK — clean boot via real Steam client. - [ ] Switch a container between emu and real-Steam mode; verify saves from the prior mode are not blended into the new one. - [ ] Toggle "Disable Steam Overlay" on a real-Steam container and confirm the overlay DLL does not inject at runtime. - [ ] With a stale cloud cache matching remote, confirm no conflict dialog appears. --- .../main/java/app/gamenative/PrefManager.kt | 7 + .../app/gamenative/service/SteamAutoCloud.kt | 58 +- .../app/gamenative/service/SteamService.kt | 30 +- .../main/java/app/gamenative/ui/PluviaMain.kt | 1 + .../ui/component/dialog/GeneralTab.kt | 9 + .../app/gamenative/ui/model/MainViewModel.kt | 78 +- .../ui/screen/xserver/XServerScreen.kt | 126 +- .../app/gamenative/utils/ContainerUtils.kt | 4 + .../app/gamenative/utils/PreInstallSteps.kt | 13 +- .../app/gamenative/utils/SteamTokenLogin.kt | 48 +- .../java/app/gamenative/utils/SteamUtils.kt | 1087 +++++++++++++++-- .../utils/preInstallSteps/VcRedistStep.kt | 7 + .../com/winlator/container/Container.java | 13 + .../com/winlator/container/ContainerData.kt | 3 + .../xenvironment/ImageFsInstaller.java | 20 +- app/src/main/res/values/strings.xml | 2 + 16 files changed, 1392 insertions(+), 114 deletions(-) diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index ab8ac7d486..c51ecb78b9 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -468,6 +468,13 @@ object PrefManager { setPref(LAUNCH_REAL_STEAM, value) } + private val DISABLE_STEAM_OVERLAY = booleanPreferencesKey("disable_steam_overlay") + var disableSteamOverlay: Boolean + get() = getPref(DISABLE_STEAM_OVERLAY, false) + set(value) { + setPref(DISABLE_STEAM_OVERLAY, value) + } + private val FORCE_DLC = booleanPreferencesKey("force_dlc") var forceDlc: Boolean get() = getPref(FORCE_DLC, false) diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index 3e1c90ddc0..bd0a3665ae 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -842,46 +842,58 @@ object SteamAutoCloud { val hasUncachedLocalFiles = cacheIsAbsentOrEmpty && allLocalUserFiles.isNotEmpty() var rehydratedSilently = false - if (hasUncachedLocalFiles) { - // no cache but local files exist. before declaring conflict, - // check if local state is byte-identical to remote — this is - // the "cache-wiped by destructive migration, nothing actually - // changed" case and should be silent. key by absolute filesystem - // path: cloud stores files as (pathPrefixIndex, basename) while - // local scan stores filename as subdir-relative path with a - // single pattern prefix, so basename-only keys won't match for - // nested files. - // windows paths are case-insensitive; steam cloud and wine may - // disagree on case. lowercase the keys so content-identical - // files compare equal regardless. + + // Shared byte-identical check: local vs remote SHAs keyed by + // absolute path (lowercased to sidestep windows case quirks). + // Used by both the cache-absent branch and the cache-present + // "stale cache, content unchanged" fast-path below. + val localMatchesRemote: () -> Boolean = { val localByPath = allLocalUserFiles.associate { it.getAbsPath(prefixToPath).toString().lowercase() to it.sha } val remoteByPath = appFileListChange.files.associate { getFullFilePath(it, appFileListChange).toString().lowercase() to it.shaFile } - val localMatchesRemote = localByPath.keys == remoteByPath.keys && + localByPath.keys == remoteByPath.keys && localByPath.all { (path, sha) -> sha.contentEquals(remoteByPath[path]) } + } - if (localMatchesRemote) { - Timber.i("Cache absent but local matches remote — rehydrating cache silently") - with(steamInstance) { - db.withTransaction { - fileChangeListsDao.insert(appInfo.id, allLocalUserFiles) - changeNumbersDao.insert(appInfo.id, cloudAppChangeNumber) - } + val rehydrateCacheSilently: suspend (String) -> Unit = { reason -> + Timber.tag("SteamFix").i("Cloud sync: $reason — rehydrating cache silently") + with(steamInstance) { + db.withTransaction { + fileChangeListsDao.insert(appInfo.id, allLocalUserFiles) + changeNumbersDao.insert(appInfo.id, cloudAppChangeNumber) } - syncResult = SyncResult.UpToDate - filesManaged = allLocalUserFiles.size - rehydratedSilently = true + } + syncResult = SyncResult.UpToDate + filesManaged = allLocalUserFiles.size + rehydratedSilently = true + } + + if (hasUncachedLocalFiles) { + // no cache but local files exist. before declaring conflict, + // check if local state is byte-identical to remote — this is + // the "cache-wiped by destructive migration, nothing actually + // changed" case and should be silent. + if (localMatchesRemote()) { + rehydrateCacheSilently("cache absent but local matches remote") } else { hasLocalChanges = true conflictUfsVersion = CURRENT_UFS_PARSE_VERSION remoteTimestamp = appFileListChange.files.map { it.timestamp.time }.maxOrNull() ?: 0L localTimestamp = allLocalUserFiles.map { it.timestamp }.maxOrNull() ?: 0L } + } else if (hasLocalChanges && localMatchesRemote()) { + // SteamFix: cache present but stale (diff-from-cache says + // local changed) yet local is byte-identical to remote. + // Happens after the Steam DLL swap / any path that touches + // userdata without going through the cache writer. Without + // this branch, the user gets a conflict prompt for a save + // that didn't actually change. + rehydrateCacheSilently("cache stale but local matches remote") } if (rehydratedSilently) { diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 95580537e3..f642e2b7e2 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2263,8 +2263,11 @@ class SteamService : Service(), IChallengeUrlChanged { preferredSave: SaveLocation = SaveLocation.None, prefixToPath: (String) -> String, isOffline: Boolean = false, + isLaunchRealSteam: Boolean = false, onProgress: ((message: String, progress: Float) -> Unit)? = null, ): Deferred = parentScope.async { + Timber.tag("SteamFix").i("beginLaunchApp: appId=%d mode=%s offline=%s", appId, if (isLaunchRealSteam) "REAL" else "EMU", isOffline) + SteamUtils.logAppDirInventory(appId, "beginLaunchApp") if (isOffline || !isConnected) { return@async PostSyncInfo(SyncResult.UpToDate) } @@ -2273,6 +2276,10 @@ class SteamService : Service(), IChallengeUrlChanged { return@async PostSyncInfo(SyncResult.InProgress) } + // SteamFix #11: only migrate GSE -> userdata when we'll actually boot + // real Steam. Reverse direction is handled in ensureSteamSettings. + SteamUtils.migrateGSESavesToSteamUserdata(instance?.applicationContext!!, appId, isLaunchRealSteam) + try { val context = instance?.applicationContext ?: return@async PostSyncInfo(SyncResult.UnknownFail) // Migrate GSE Saves to Steam userdata @@ -2359,12 +2366,16 @@ class SteamService : Service(), IChallengeUrlChanged { preferredSave: SaveLocation = SaveLocation.None, parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO), overrideLocalChangeNumber: Long? = null, + isLaunchRealSteam: Boolean = false, ): Deferred = parentScope.async { if (!tryAcquireSync(appId)) { Timber.w("Cannot force sync when sync already in progress for appId=$appId") return@async PostSyncInfo(SyncResult.InProgress) } + // SteamFix #11: only migrate GSE -> userdata in real-Steam mode. + SteamUtils.migrateGSESavesToSteamUserdata(instance?.applicationContext!!, appId, isLaunchRealSteam) + try { val context = instance?.applicationContext ?: return@async PostSyncInfo(SyncResult.UnknownFail) // Migrate GSE Saves to Steam userdata @@ -2415,8 +2426,9 @@ class SteamService : Service(), IChallengeUrlChanged { } } - suspend fun closeApp(context: Context, appId: Int, isOffline: Boolean, prefixToPath: (String) -> String) = withContext(Dispatchers.IO) { + suspend fun closeApp(context: Context, appId: Int, isOffline: Boolean, prefixToPath: (String) -> String, isLaunchRealSteam: Boolean = false) = withContext(Dispatchers.IO) { async { + Timber.tag("SteamFix").i("closeApp: appId=%d mode=%s offline=%s", appId, if (isLaunchRealSteam) "REAL" else "EMU", isOffline) if (isOffline || !isConnected) { return@async } @@ -2427,10 +2439,18 @@ class SteamService : Service(), IChallengeUrlChanged { } try { - try { - syncAchievementsFromGoldberg(context, appId) - } catch (e: Exception) { - Timber.e(e, "Achievement sync failed for appId=$appId, continuing with cloud save sync") + // SteamFix #18: in real-Steam mode, achievements are written by + // real Steam (via the Wine-hosted client) rather than Goldberg. + // Reading the Goldberg dir then is at best a no-op and at worst + // clobbers achievements we don't own. + if (!isLaunchRealSteam) { + try { + syncAchievementsFromGoldberg(context, appId) + } catch (e: Exception) { + Timber.e(e, "Achievement sync failed for appId=$appId, continuing with cloud save sync") + } + } else { + Timber.tag("SteamFix").i("closeApp: skipping Goldberg achievement sync (real-Steam mode)") } val maxAttempts = 3 diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 2d0d5f2c40..a249d19641 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -2027,6 +2027,7 @@ fun preLaunchApp( preferredSave = preferredSave, parentScope = this, isOffline = isOffline, + isLaunchRealSteam = container.isLaunchRealSteam, onProgress = { message, progress -> setLoadingMessage(message) setLoadingProgress(if (progress < 0) -1f else progress) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt index 219d7532c6..ccaaa9fb98 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt @@ -366,6 +366,15 @@ fun GeneralTabContent( state = config.launchRealSteam, onCheckedChange = { state.config.value = config.copy(launchRealSteam = it) }, ) + if (config.launchRealSteam) { + SettingsSwitch( + colors = settingsTileColorsAlt(), + title = { Text(text = stringResource(R.string.disable_steam_overlay)) }, + subtitle = { Text(text = stringResource(R.string.disable_steam_overlay_description)) }, + state = config.disableSteamOverlay, + onCheckedChange = { state.config.value = config.copy(disableSteamOverlay = it) }, + ) + } val steamTypeItems = listOf("Normal", "Light", "Ultra Light") val currentSteamTypeIndex = when (config.steamType.lowercase()) { Container.STEAM_TYPE_LIGHT -> 1 diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index e48fa65075..ebcb9066e8 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -24,6 +24,8 @@ import app.gamenative.ui.enums.ConnectionState import app.gamenative.ui.screen.PluviaScreen import app.gamenative.utils.ContainerUtils import app.gamenative.utils.IntentLaunchManager +import app.gamenative.utils.STEAM_HELPER_WINDOW_CLASSES +import app.gamenative.utils.SteamFixDiagnostics import app.gamenative.utils.SteamUtils import app.gamenative.utils.UpdateInfo import com.materialkolor.PaletteStyle @@ -455,10 +457,27 @@ class MainViewModel @Inject constructor( val container = ContainerUtils.getOrCreateContainer(context, appId) val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) if (gameSource == GameSource.STEAM) { + // SteamFix #19: record the mode this launch is using so we can + // detect mode flips between launches and run #20 cleanup. + val previousMode = container.getExtra("lastSteamMode", "") + val currentMode = if (container.isLaunchRealSteam()) "real" else "emu" + if (previousMode != currentMode) { + Timber.tag("SteamFix").i("launchApp: Steam mode change %s -> %s for appId=%s", previousMode, currentMode, appId) + container.putExtra("lastSteamMode", currentMode) + container.saveData() + // SteamFix #20: on real->emu transitions, GC the ~200 MB + // extracted Steam client we won't need until the next ON + // launch. Extraction is fast on re-entry. + if (previousMode == "real" && currentMode == "emu") { + SteamUtils.cleanupExtractedSteamFiles(context) + } + } if (container.isLaunchRealSteam()) { + Timber.tag("SteamFix").i("launchApp: real-Steam mode, running restoreSteamApi") SteamUtils.restoreSteamApi(context, appId) } else { val offline = _offline.value + Timber.tag("SteamFix").i("launchApp: emulated mode, useLegacyDRM=%s offline=%s", container.isUseLegacyDRM, offline) if (container.isUseLegacyDRM) { SteamUtils.replaceSteamApi(context, appId, offline) } else { @@ -590,9 +609,15 @@ class MainViewModel @Inject constructor( val container = withContext(Dispatchers.IO) { ContainerUtils.getContainer(context, appId) } - SteamService.closeApp(context, gameId, isOffline.value) { prefix -> - PathType.from(prefix).toAbsPath(container, gameId, SteamService.userSteamId!!.accountID) - }.await() + SteamService.closeApp( + context, + gameId, + isOffline.value, + prefixToPath = { prefix -> + PathType.from(prefix).toAbsPath(container, gameId, SteamService.userSteamId!!.accountID) + }, + isLaunchRealSteam = container.isLaunchRealSteam, + ).await() } catch (e: CancellationException) { throw e } catch (t: Throwable) { @@ -618,7 +643,17 @@ class MainViewModel @Inject constructor( gameExe == windowExe } + // SteamFix: remember the class of every mapped window so if the + // stall watchdog fires later, it can name what was on screen at + // that moment instead of saying "no window mapped" vaguely. + SteamFixDiagnostics.lastMappedWindowClass = window.className + if (launchConfig != null) { + // SteamFix: record that the *game* window mapped (not just + // any window) so the stall watchdog can tell "game actually + // launched" apart from "stuck in Steam bootstrapper." + SteamFixDiagnostics.gameWindowMapped = true + SteamFixDiagnostics.gameWindowClass = window.className val steamProcessId = Process.myPid() val processes = mutableListOf() var currentWindow: Window = window @@ -639,6 +674,16 @@ class MainViewModel @Inject constructor( } while (parentWindow != null) val installedBranch = SteamService.getInstalledApp(gameId)?.branch ?: "public" + // SteamFix #22/#23: log the process-tree walk we just did so a + // black-screen report includes which window class was chosen as + // the game and what its parent chain looked like. Under real + // Steam the chain terminates at steam.exe rather than explorer. + val topWindowClass = window.className + val parentClass = window.parent?.className + Timber.tag("SteamFix").i( + "onWindowMapped: appId=%d exe=%s window=%s parent=%s chainDepth=%d", + gameId, launchConfig.executable, topWindowClass, parentClass, processes.size, + ) GameProcessInfo(appId = gameId, branch = installedBranch, processes = processes).let { // Only notify Steam if we're not using real Steam // When launchRealSteam is true, let the real Steam client handle the "game is running" notification @@ -653,9 +698,34 @@ class MainViewModel @Inject constructor( if (!shouldLaunchRealSteam) { SteamService.notifyRunningProcesses(it) } else { - Timber.tag("MainViewModel").i("Skipping Steam process notification - real Steam will handle this") + Timber.tag("SteamFix").i("onWindowMapped: skipping process notification (real Steam owns this)") } } + } else { + // SteamFix #22: we mapped a window but no known launch exe + // matched. Two flavors here: + // (1) it's one of Steam's own helper windows (explorer, + // steam.exe, conhost, overlay, winhandler…). These fire + // constantly during bootstrapping — log at debug. + // (2) it's *something else*. That's the signal we care + // about: login prompt, cloud-conflict dialog, update- + // required modal. Warn, and remember it for the + // stall watchdog. + val cls = window.className + val isHelper = cls in STEAM_HELPER_WINDOW_CLASSES + if (isHelper) { + Timber.tag("SteamFix").d( + "onWindowMapped: Steam helper window mapped class=%s (appId=%d)", + cls, gameId, + ) + } else { + SteamFixDiagnostics.lastUnmatchedWindowClass = cls + SteamFixDiagnostics.unmatchedWindowCount += 1 + Timber.tag("SteamFix").w( + "onWindowMapped: unmatched non-helper window class=%s (appId=%d). Likely a Steam modal (login, cloud conflict, update-required).", + cls, gameId, + ) + } } } } 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 289e2b10d9..3665f7cd7a 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 @@ -105,6 +105,7 @@ import app.gamenative.utils.ExecutableSelectionUtils import app.gamenative.utils.LsfgQuickMenuHelper import app.gamenative.utils.ManifestComponentHelper import app.gamenative.utils.PreInstallSteps +import app.gamenative.utils.SteamFixDiagnostics import app.gamenative.utils.SteamTokenLogin import app.gamenative.utils.SteamUtils import com.posthog.PostHog @@ -168,6 +169,7 @@ import com.winlator.xserver.Window import com.winlator.xserver.WindowManager import com.winlator.xserver.XServer import com.winlator.xserver.extensions.PresentExtension +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -238,6 +240,23 @@ private fun detectMaxRefreshRateHz(context: Context, attachedView: View?): Int { ?: DEFAULT_FPS_LIMITER_MAX_HZ } +/** + * SteamFix #21: flags passed to `steam.exe` when launching through real Steam. + * Kept as a constant so (a) we can override via a per-container extra if we + * ever need to expose it to power users, and (b) the log line right before + * launch is inspectable. + * + * - `-silent`: start without the main window + * - `-vgui`: use the classic UI renderer (more compatible under Wine) + * - `-tcp`: force TCP client so Steam doesn't wait on named-pipe handshake + * - `-nobigpicture -nofriendsui -nochatui -nointro`: suppress optional UIs + */ +private const val STEAM_LAUNCH_FLAGS = "-silent -vgui -tcp -nobigpicture -nofriendsui -nochatui -nointro" + +/** SteamFix #8: if we launched real Steam but the game window hasn't appeared + * in this long, log a loud "likely stall" line so users can grab it. */ +private const val REAL_STEAM_STALL_WATCHDOG_MS = 60_000L + private data class XServerViewReleaseBinding( val xServerView: XServerView, val windowModificationListener: WindowManager.OnWindowModificationListener, @@ -3117,6 +3136,17 @@ private fun setupXEnvironment( envVars.putAll(container.envVars) if (!envVars.has("WINEESYNC")) envVars.put("WINEESYNC", "1") + + // Disable the Steam Vulkan overlay layer via its own disable_environment + // hook when the user has opted out. This is the Khronos-canonical way to + // skip an implicit layer — the loader sees the env var and never + // initializes the layer, which avoids the race where Steam re-extracts + // the stashed layer files during client startup. + if (container.isDisableSteamOverlay) { + envVars.put("DISABLE_VK_LAYER_VALVE_steam_overlay_1", "1") + envVars.put("SteamNoOverlayUIDrawing", "1") + } + val graphicsDriverConfig = KeyValueSet(container.getGraphicsDriverConfig()) if (graphicsDriverConfig.get("version").lowercase(Locale.getDefault()).contains("gen8")) { var tuDebug = envVars.get("TU_DEBUG") @@ -3270,6 +3300,10 @@ private fun setupXEnvironment( // Moved here, as guestProgramLauncherComponent.environment is setup after addComponent() if (container != null) { if (container.isLaunchRealSteam) { + Timber.tag("SteamFix").i( + "SteamTokenLogin: writing config.vdf for login=%s steamId=%s (refreshToken len=%d)", + PrefManager.username, PrefManager.steamUserSteamId64, PrefManager.refreshToken.length, + ) SteamTokenLogin( steamId = PrefManager.steamUserSteamId64.toString(), login = PrefManager.username, @@ -3277,6 +3311,7 @@ private fun setupXEnvironment( imageFs = imageFs, guestProgramLauncherComponent = guestProgramLauncherComponent, ).setupSteamFiles() + Timber.tag("SteamFix").i("SteamTokenLogin: setupSteamFiles complete") } } @@ -3303,7 +3338,11 @@ private fun setupXEnvironment( Timber.i("---------------------------") } - // Request encrypted app ticket for Steam games at launch time + // Request encrypted app ticket for Steam games at launch time. + // SteamFix #16: in real-Steam mode, steam.exe fetches its own session ticket + // through the official libsteam_api path, so we deliberately skip pre-warming + // the GSE-facing ticket cache. Noted in logs so a real-Steam black-screen + // investigation can rule this branch in/out. val isCustomGame = gameSource == GameSource.CUSTOM_GAME val gameIdForTicket = ContainerUtils.extractGameIdFromContainerId(appId) if (!bootToContainer && !isCustomGame && gameIdForTicket != null && !container.isLaunchRealSteam) { @@ -3311,14 +3350,16 @@ private fun setupXEnvironment( try { val ticket = SteamService.instance?.getEncryptedAppTicket(gameIdForTicket) if (ticket != null) { - Timber.i("Successfully retrieved encrypted app ticket for app $gameIdForTicket") + Timber.tag("SteamFix").i("Encrypted app ticket retrieved for app %s", gameIdForTicket) } else { - Timber.w("Failed to retrieve encrypted app ticket for app $gameIdForTicket") + Timber.tag("SteamFix").w("Encrypted app ticket came back null for app %s", gameIdForTicket) } } catch (e: Exception) { - Timber.e(e, "Error requesting encrypted app ticket for app $gameIdForTicket") + Timber.tag("SteamFix").e(e, "Encrypted app ticket request threw for app %s", gameIdForTicket) } } + } else if (container.isLaunchRealSteam) { + Timber.tag("SteamFix").i("Skipping encrypted app-ticket pre-warm for app %s (real Steam owns ticket)", gameIdForTicket) } if (container.wineVersion.lowercase().contains("proton-10") && container.getExtra("xaudioDllsExtracted").isEmpty()) { @@ -3334,6 +3375,11 @@ private fun setupXEnvironment( } try { + Timber.tag("SteamFix").i( + "startEnvironmentComponents: mode=%s steamClientComponentLoaded=%s", + if (container.isLaunchRealSteam) "REAL" else "EMU", + !container.isLaunchRealSteam, + ) environment.startEnvironmentComponents() } catch (e: Exception) { Timber.e(e, "Failed to start environment components, cleaning up") @@ -3345,6 +3391,54 @@ private fun setupXEnvironment( throw e } + // SteamFix #8: watchdog. If we're launching real Steam, log breadcrumbs + // and fire a loud warning after REAL_STEAM_STALL_WATCHDOG_MS if we + // haven't observed a game-window map yet. Gets cancelled by onWindowMapped + // via the global event bus hook elsewhere — worst case it fires once and + // gives the user a precise log line to report. + // SteamFix #17: log that the Steam pipe is expected NOT to be up (we did + // not add SteamClientComponent in real-Steam mode) so a user chasing the + // black screen doesn't assume the pipe is the culprit. + if (container.isLaunchRealSteam) { + // SteamFix: reset diagnostic breadcrumbs so the watchdog reports what + // happened in THIS launch, not leftover state from a prior one. + SteamFixDiagnostics.reset() + Timber.tag("SteamFix").i("real-Steam mode: emulated Steam pipe NOT started (real Steam will own it via steam.exe)") + CoroutineScope(Dispatchers.IO).launch { + try { + delay(REAL_STEAM_STALL_WATCHDOG_MS) + // SteamFix: the game window may have mapped successfully before + // the delay elapsed. If so, emit a short "ok" breadcrumb + // instead of the false-alarm stall warning — previous version + // always warned because later helper windows clobbered the + // `lastMappedClass` field. + if (SteamFixDiagnostics.gameWindowMapped) { + Timber.tag("SteamFix").i( + "STALL WATCHDOG: %d ms elapsed but game window already mapped (class=%s). No stall.", + REAL_STEAM_STALL_WATCHDOG_MS, + SteamFixDiagnostics.gameWindowClass ?: "", + ) + return@launch + } + val lastMapped = SteamFixDiagnostics.lastMappedWindowClass ?: "" + val lastUnmatched = SteamFixDiagnostics.lastUnmatchedWindowClass ?: "" + val unmatchedCount = SteamFixDiagnostics.unmatchedWindowCount + Timber.tag("SteamFix").w( + "STALL WATCHDOG: >%d ms after launch of real Steam for %s, no game window mapped yet. " + + "lastMappedClass=%s lastUnmatchedClass=%s unmatchedCount=%d. " + + "If lastUnmatchedClass is a real window (not a Steam helper), that's the dialog the user needs to click.", + REAL_STEAM_STALL_WATCHDOG_MS, appId, lastMapped, lastUnmatched, unmatchedCount, + ) + } catch (_: CancellationException) { + // expected when the scope is cancelled at exit + } catch (e: Exception) { + Timber.tag("SteamFix").w(e, "stall watchdog failed") + } + } + } else { + Timber.tag("SteamFix").i("emulated mode: SteamClientComponent started as in-process pipe") + } + if (gameSource == GameSource.STEAM) { val gameIdInt = ContainerUtils.extractGameIdFromContainerId(appId) val achAppId = SteamService.cachedAchievementsAppId @@ -3748,8 +3842,8 @@ private fun getWineStartCommand( } else { if (container.isLaunchRealSteam) { // Launch Steam with the applaunch parameter to start the game - "\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" -silent -vgui -tcp " + - "-nobigpicture -nofriendsui -nochatui -nointro -applaunch $gameId" + Timber.tag("SteamFix").i("Building real-Steam launch command for gameId=%s flags=%s", gameId, STEAM_LAUNCH_FLAGS) + "\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" $STEAM_LAUNCH_FLAGS -applaunch $gameId" } else { var executablePath = "" if (container.executablePath.isNotEmpty()) { @@ -4282,7 +4376,27 @@ private fun setupWineSystemFiles( } if (container.isLaunchRealSteam){ + // SteamFix #6: invalidate the Steam extraction when Wine version or + // variant has changed. A stale steam.exe compiled against an older Wine + // ABI is one of the quiet ways the ON toggle goes black-screen. + val steamExtractedKey = "${container.wineVersion}|${container.containerVariant}" + val steamExtractedPrev = container.getExtra("steamExtractedForWine") + val steamExeFile = File(ImageFs.find(context).rootDir.absolutePath, ImageFs.WINEPREFIX + "/drive_c/Program Files (x86)/Steam/steam.exe") + val wineChanged = steamExtractedPrev != steamExtractedKey + if (wineChanged && steamExeFile.exists()) { + Timber.tag("SteamFix").w("Wine change detected (%s -> %s), wiping old Steam install to force re-extract", steamExtractedPrev, steamExtractedKey) + val steamDir = File(ImageFs.find(context).rootDir.absolutePath, ImageFs.WINEPREFIX + "/drive_c/Program Files (x86)/Steam") + // SteamFix: symlink-safe delete. `File.deleteRecursively()` follows + // symlinks, and `steamapps/common/` is a symlink to the + // real game-content directory under GameNative's own Steam dir. A + // plain recursive delete here was deleting every installed game's + // files as a side effect of toggling Wine/Steam Client. + SteamUtils.deleteTreeNoFollowSymlinks(steamDir) + } + Timber.tag("SteamFix").i("extractSteamFiles: wineKey=%s steamExeExists=%s", steamExtractedKey, steamExeFile.exists()) extractSteamFiles(context, container, onExtractFileListener) + container.putExtra("steamExtractedForWine", steamExtractedKey) + containerDataChanged = true } val desktopTheme = container.desktopTheme diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index ca3549a0ac..46b2a0f7d5 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -101,6 +101,7 @@ object ContainerUtils { execArgs = PrefManager.execArgs, showFPS = false, launchRealSteam = PrefManager.launchRealSteam, + disableSteamOverlay = PrefManager.disableSteamOverlay, cpuList = PrefManager.cpuList, cpuListWoW64 = PrefManager.cpuListWoW64, wow64Mode = PrefManager.wow64Mode, @@ -160,6 +161,7 @@ object ContainerUtils { PrefManager.drives = containerData.drives PrefManager.execArgs = containerData.execArgs PrefManager.launchRealSteam = containerData.launchRealSteam + PrefManager.disableSteamOverlay = containerData.disableSteamOverlay PrefManager.cpuList = containerData.cpuList PrefManager.cpuListWoW64 = containerData.cpuListWoW64 PrefManager.wow64Mode = containerData.wow64Mode @@ -273,6 +275,7 @@ object ContainerUtils { executablePath = container.executablePath, showFPS = false, launchRealSteam = container.isLaunchRealSteam, + disableSteamOverlay = container.isDisableSteamOverlay, allowSteamUpdates = container.isAllowSteamUpdates, steamType = container.getSteamType(), cpuList = container.cpuList, @@ -448,6 +451,7 @@ object ContainerUtils { container.executablePath = containerData.executablePath container.isShowFPS = false container.isLaunchRealSteam = containerData.launchRealSteam + container.isDisableSteamOverlay = containerData.disableSteamOverlay container.isAllowSteamUpdates = containerData.allowSteamUpdates container.setSteamType(containerData.steamType) container.cpuList = containerData.cpuList diff --git a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt index c1a76d766e..830cbbaff3 100644 --- a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt +++ b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt @@ -50,7 +50,10 @@ object PreInstallSteps { val gameDir = getGameDir(container) ?: return emptyList() val gameDirPath = gameDir.absolutePath - if (containerVariantChanged) resetMarkers(gameDirPath) + if (containerVariantChanged) { + resetMarkers(gameDirPath) + container.rootDir?.absolutePath?.let { resetMarkers(it) } + } val commands = mutableListOf() @@ -93,6 +96,14 @@ object PreInstallSteps { val gameDir = getGameDir(container) ?: return val gameDirPath = gameDir.absolutePath MarkerUtils.addMarker(gameDirPath, marker) + // Also persist container-scoped prereqs at the Wine prefix root so a + // game reinstall doesn't force a redundant re-run of an installer that + // already landed system-wide. Matches the appliesTo check in VcRedistStep. + if (marker == Marker.VCREDIST_INSTALLED) { + container.rootDir?.absolutePath?.let { containerRoot -> + MarkerUtils.addMarker(containerRoot, marker) + } + } } private fun resetMarkers(gameDirPath: String) { diff --git a/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt b/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt index 9fe796907d..c504b7ea31 100644 --- a/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt +++ b/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt @@ -14,6 +14,10 @@ import `in`.dragonbra.javasteam.types.KeyValue import timber.log.Timber import java.io.File import java.nio.file.Files +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException import java.util.zip.CRC32 import kotlin.io.path.absolutePathString import kotlin.io.path.createDirectories @@ -23,6 +27,12 @@ import kotlin.io.path.exists const val NULL_CHAR = '\u0000' const val TOKEN_EXPIRE_TIME = 86400L // 1 day +// SteamFix #24: cap synchronous Wine invocations. `steam-token.exe` shells out +// through box64 + wine, which on cold boot can wedge forever when Wine's +// prefix is mid-update. 30 s is generous enough for cold boots and still +// prevents an infinite black screen. +private const val WINE_EXEC_TIMEOUT_SECONDS = 30L + class SteamTokenLogin( private val steamId: String, private val login: String, @@ -43,8 +53,26 @@ class SteamTokenLogin( } private fun execCommand(command: String) : String { - return guestProgramLauncherComponent?.execShellCommand(command, false) + val launcher = guestProgramLauncherComponent ?: throw IllegalStateException("GuestProgramLauncherComponent is required for command execution") + // SteamFix #24: bound wall-clock so a stuck wine invocation can't black-screen us. + val executor = Executors.newSingleThreadExecutor { r -> + Thread(r, "SteamTokenLogin-exec").apply { isDaemon = true } + } + return try { + val future = CompletableFuture.supplyAsync({ + launcher.execShellCommand(command, false) + }, executor) + try { + future.get(WINE_EXEC_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } catch (e: TimeoutException) { + future.cancel(true) + Timber.tag("SteamFix").e("wine exec timed out after %ds: %s", WINE_EXEC_TIMEOUT_SECONDS, command) + throw IllegalStateException("wine exec timed out: $command", e) + } + } finally { + executor.shutdownNow() + } } private fun killWineServer() { @@ -178,16 +206,24 @@ class SteamTokenLogin( if (mtbf != null && connectCacheValue != null) { try { val dToken = deobfuscateToken(connectCacheValue.trimEnd(NULL_CHAR), mtbf.toLong()).trimEnd(NULL_CHAR) - if (JWT(dToken).isExpired(TOKEN_EXPIRE_TIME)) { - Timber.tag("SteamTokenLogin").d("Saved JWT expired, overriding config.vdf") - // If the saved JWT is expired, override it + // SteamFix #9: compare the decoded token against the refresh + // token we're about to write. If they diverge, the user + // logged in with a different account or we corrupted the + // value — either way force a rewrite instead of trusting + // the in-prefix value. + val tokenMatches = dToken == token + if (!tokenMatches) { + Timber.tag("SteamFix").w("config.vdf: saved JWT != current refresh token, forcing rewrite") + shouldWriteConfig = true + } else if (JWT(dToken).isExpired(TOKEN_EXPIRE_TIME)) { + Timber.tag("SteamFix").i("config.vdf: saved JWT expired, rewriting") shouldWriteConfig = true } else { - Timber.tag("SteamTokenLogin").d("Saved JWT is not expired, do not override config.vdf") + Timber.tag("SteamFix").d("config.vdf: saved JWT matches + valid, keeping") shouldWriteConfig = false } } catch (_: Exception) { - Timber.tag("SteamTokenLogin").d("Cannot parse saved JWT, overriding config.vdf") + Timber.tag("SteamFix").w("config.vdf: saved JWT unparseable, forcing rewrite") shouldWriteConfig = true } } else { diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 1263c61b81..912d69410f 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -28,6 +28,7 @@ import java.nio.file.Path import java.nio.file.Paths import java.nio.file.StandardCopyOption import java.nio.file.StandardOpenOption +import java.security.MessageDigest import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone @@ -41,6 +42,53 @@ import java.nio.file.attribute.FileTime import java.util.concurrent.TimeUnit import kotlin.io.path.setLastModifiedTime +/** + * Shared SteamFix diagnostics. Written from onWindowMapped so the stall + * watchdog (and any other late-firing diagnostic) can name *what* was on + * screen at the moment it gave up. A null value means "nothing mapped yet." + */ +object SteamFixDiagnostics { + @Volatile var lastMappedWindowClass: String? = null + @Volatile var lastUnmatchedWindowClass: String? = null + @Volatile var unmatchedWindowCount: Int = 0 + + /** + * The window class of the last mapped window that matched a known launch + * config (i.e. the game window itself). Survives later noise from helper + * windows, so the stall watchdog can distinguish "game never appeared" + * from "game appeared, then something else grabbed focus." + */ + @Volatile var gameWindowMapped: Boolean = false + @Volatile var gameWindowClass: String? = null + + fun reset() { + lastMappedWindowClass = null + lastUnmatchedWindowClass = null + unmatchedWindowCount = 0 + gameWindowMapped = false + gameWindowClass = null + } +} + +/** + * Window classes we know are Steam's own bootstrapper/helper windows. + * Seeing these mapped is normal and does not indicate a modal; log at debug. + * Anything else in real-Steam mode is more suspicious (login prompt, cloud + * conflict, update-required dialog) and is worth warning about. + */ +val STEAM_HELPER_WINDOW_CLASSES: Set = setOf( + "", + "explorer.exe", + "steam.exe", + "steamwebhelper.exe", + "conhost.exe", + "winhandler.exe", + "gameoverlayui.exe", + "steamerrorreporter.exe", + "steamerrorreporter64.exe", + "steamservice.exe", +) + object SteamUtils { fun getDownloadBytes(manifest: ManifestInfo?): Long { @@ -148,6 +196,89 @@ object SteamUtils { } } + /** + * SteamFix #7: hash-verify that the steam_api DLL currently on disk matches + * the pipe DLL shipped in assets. An interrupted launch can desync the marker + * (marker says "replaced" but DLLs are actually original, or vice versa). + */ + private fun verifyReplacedState(context: Context, appDirPath: String): Boolean { + return try { + val assetHashes = mutableMapOf() + listOf("steam_api.dll", "steam_api64.dll").forEach { name -> + runCatching { + context.assets.open("steampipe/$name").use { ins -> + assetHashes[name.lowercase()] = sha256OfStream(ins) + } + } + } + var found = false + Paths.get(appDirPath).toFile().walkTopDown().maxDepth(10).forEach { file -> + if (!file.isFile) return@forEach + val n = file.name.lowercase() + if (n == "steam_api.dll" || n == "steam_api64.dll") { + found = true + val expected = assetHashes[n] ?: return@forEach + val actual = sha256OfFile(file) + if (actual != expected) { + Timber.tag("SteamFix").w("DLL marker desync: %s hash mismatch (marker says REPLACED)", file.absolutePath) + return false + } + } + } + if (!found) { + Timber.tag("SteamFix").w("DLL marker desync: no steam_api DLL found at $appDirPath but REPLACED marker present") + return false + } + true + } catch (e: Exception) { + Timber.tag("SteamFix").w(e, "verifyReplacedState failed, treating as desync") + false + } + } + + /** + * SteamFix #7: verify RESTORED marker. Each steam_api DLL must have a .orig + * sibling and match it byte-for-byte. + */ + private fun verifyRestoredState(appDirPath: String): Boolean { + return try { + Paths.get(appDirPath).toFile().walkTopDown().maxDepth(10).forEach { file -> + if (!file.isFile) return@forEach + val n = file.name.lowercase() + if (n == "steam_api.dll" || n == "steam_api64.dll") { + val orig = File(file.parentFile, "${file.name}.orig") + if (!orig.exists()) { + Timber.tag("SteamFix").w("DLL marker desync: %s has no .orig sibling (marker says RESTORED)", file.absolutePath) + return false + } + if (sha256OfFile(file) != sha256OfFile(orig)) { + Timber.tag("SteamFix").w("DLL marker desync: %s != %s.orig (marker says RESTORED)", file.absolutePath, file.name) + return false + } + } + } + true + } catch (e: Exception) { + Timber.tag("SteamFix").w(e, "verifyRestoredState failed, treating as desync") + false + } + } + + private fun sha256OfFile(file: File): String { + file.inputStream().use { return sha256OfStream(it) } + } + + private fun sha256OfStream(input: java.io.InputStream): String { + val md = MessageDigest.getInstance("SHA-256") + val buf = ByteArray(64 * 1024) + while (true) { + val n = input.read(buf) + if (n <= 0) break + md.update(buf, 0, n) + } + return md.digest().joinToString("") { "%02x".format(it) } + } + /** * Replaces any existing `steam_api.dll` or `steam_api64.dll` in the app directory * with our pipe dll stored in assets @@ -155,8 +286,14 @@ object SteamUtils { suspend fun replaceSteamApi(context: Context, appId: String, isOffline: Boolean = false) { val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) val appDirPath = SteamService.getAppDirPath(steamAppId) + Timber.tag("SteamFix").i("replaceSteamApi: appId=%s dir=%s", appId, appDirPath) if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_DLL_REPLACED)) { - return + if (verifyReplacedState(context, appDirPath)) { + Timber.tag("SteamFix").i("replaceSteamApi: marker + hash ok, skipping") + return + } + Timber.tag("SteamFix").w("replaceSteamApi: clearing stale REPLACED marker and re-running swap") + MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) } MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_RESTORED) MarkerUtils.removeMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) @@ -235,6 +372,7 @@ object SteamUtils { generateAchievementsFile(rootPath.resolve("steam_settings"), appId) MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_REPLACED) + logAppDirInventory(ContainerUtils.extractGameIdFromContainerId(appId), "replaceSteamApi.done") } /** @@ -280,7 +418,10 @@ object SteamUtils { // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId, container) + applySteamOverlayPref(context, container) + MarkerUtils.addMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) + logAppDirInventory(steamAppId, "replaceSteamclientDll.done") } fun steamClientFiles() : Array { @@ -313,6 +454,77 @@ object SteamUtils { Timber.i("Finished backupSteamclientFiles for appId: $steamAppId. Backed up $backupCount file(s)") } + // Every Steam-overlay file a running game can pick up. Rename only the + // binaries — leave the Vulkan layer JSON manifests in place so Wine's + // Vulkan loader can parse them, discover the referenced DLL is missing, + // log a warning, and skip the layer cleanly. Renaming the JSONs instead + // can race with loader init and stall early Vulkan startup. + // GameOverlayUI.exe is the in-game UI host (inventory popup, shift-tab + // overlay) and is safe to stash. + private val steamOverlayFiles = arrayOf( + "GameOverlayRenderer.dll", + "GameOverlayRenderer64.dll", + "SteamOverlayVulkanLayer.dll", + "SteamOverlayVulkanLayer64.dll", + "GameOverlayUI.exe", + ) + + /** + * Apply the container's disableSteamOverlay setting. Rename-based: when + * enabled, stash overlay files with a `.disabled` suffix; when disabled, + * un-stash. Idempotent on both sides, and survives Steam's re-extraction + * of client files (a fresh client install won't touch the stashed copies). + * Stashes the D3D renderer DLLs, the Vulkan overlay layer DLLs, and the + * overlay UI process. Also restores any Vulkan layer manifest JSONs an + * older build had stashed (see legacyStashedFiles). + */ + // Files an older build stashed that we no longer want to keep hidden. + // Always restore these if a `.disabled` copy exists — covers upgrade from + // the previous overlay-disable logic that renamed the Vulkan layer JSONs. + private val legacyStashedFiles = arrayOf( + "SteamOverlayVulkanLayer.json", + "SteamOverlayVulkanLayer64.json", + ) + + fun applySteamOverlayPref(context: Context, container: com.winlator.container.Container) { + val steamDir = File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam") + + var legacyRestored = 0 + legacyStashedFiles.forEach { name -> + val live = File(steamDir, name) + val stashedFile = File(steamDir, "$name.disabled") + if (stashedFile.exists() && !live.exists()) { + if (stashedFile.renameTo(live)) legacyRestored++ + } + } + if (legacyRestored > 0) { + Timber.tag("SteamFix").i("applySteamOverlayPref: restored %d legacy-stashed file(s)", legacyRestored) + } + + if (container.isDisableSteamOverlay) { + var stashed = 0 + steamOverlayFiles.forEach { name -> + val live = File(steamDir, name) + val stashedFile = File(steamDir, "$name.disabled") + if (live.exists()) { + if (stashedFile.exists()) stashedFile.delete() + if (live.renameTo(stashedFile)) stashed++ + } + } + Timber.tag("SteamFix").i("applySteamOverlayPref: disabled, stashed %d overlay file(s)", stashed) + } else { + var restored = 0 + steamOverlayFiles.forEach { name -> + val live = File(steamDir, name) + val stashedFile = File(steamDir, "$name.disabled") + if (stashedFile.exists() && !live.exists()) { + if (stashedFile.renameTo(live)) restored++ + } + } + if (restored > 0) Timber.tag("SteamFix").i("applySteamOverlayPref: enabled, restored %d overlay file(s)", restored) + } + } + fun restoreSteamclientFiles(context: Context, steamAppId: Int) { val imageFs = ImageFs.find(context) @@ -380,15 +592,95 @@ object SteamUtils { """.trimIndent() } + /** + * Resolve the ColdClient-style path "steamapps\common\\" + * case-insensitively against the on-disk Linux filesystem. Inner files may + * have been installed with different casing (Steam → on-disk) and Linux + * case-sensitivity turns what Windows treats as a match into a + * "file not found" error from ColdClientLoader. + * + * Returns the actual-casing relative path (using forward slashes) when all + * components exist on disk. Returns null when a component is missing, + * which is the "install is actually broken" case rather than a casing + * mismatch. Logs at SteamFix level when casing differs so the logcat shows + * exactly which component was wrong. + */ + internal fun resolveOnDiskCasing( + steamDir: File, + gameName: String, + executablePath: String, + ): String? { + val relComponents = buildList { + add("steamapps") + add("common") + add(gameName) + executablePath.replace("\\", "/").split("/").filter { it.isNotEmpty() }.forEach { add(it) } + } + var cursor: File = steamDir + val resolved = mutableListOf() + for ((idx, requested) in relComponents.withIndex()) { + val exact = File(cursor, requested) + val child: File? = if (exact.exists()) { + exact + } else { + cursor.listFiles()?.firstOrNull { it.name.equals(requested, ignoreCase = true) } + } + if (child == null) { + Timber.tag("SteamFix").w( + "ColdClient exe resolve MISS: component %d (%s) not found under %s. siblings=%s", + idx, requested, cursor.absolutePath, + cursor.listFiles()?.joinToString(", ") { it.name } ?: "", + ) + return null + } + if (child.name != requested) { + Timber.tag("SteamFix").w( + "ColdClient exe resolve CASE MISMATCH: requested '%s' on-disk '%s' at depth %d (%s)", + requested, child.name, idx, cursor.absolutePath, + ) + } + resolved += child.name + cursor = child + } + return resolved.joinToString("/") + } + internal fun writeColdClientIni(steamAppId: Int, container: Container, launchInfo: LaunchInfo? = null) { val gameName = getAppDirName(getAppInfoOf(steamAppId)) val workingDir = launchInfo?.workingDir val iniFile = File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/ColdClientLoader.ini") iniFile.parentFile?.mkdirs() + + // SteamFix: resolve the "steamapps\common\\" path against + // the actual on-disk filesystem before we bake it into the INI. If any + // component's casing differs we log it; if the exe is genuinely missing + // we still write the INI (so ColdClient's own error surfaces as before) + // but the logcat now shows exactly which path component went wrong. + val steamDir = File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam") + val resolved = resolveOnDiskCasing(steamDir, gameName, container.executablePath) + val (effectiveGameName, effectiveExe) = if (resolved != null) { + val parts = resolved.split("/") + // Components 0,1 are steamapps/common; 2 is gameName; rest is exe path. + val diskGameName = parts.getOrNull(2) ?: gameName + val diskExe = parts.drop(3).joinToString("/") + if (diskGameName != gameName || diskExe.replace("/", "\\") != container.executablePath.replace("/", "\\")) { + Timber.tag("SteamFix").i( + "ColdClient INI using on-disk casing: gameName='%s' (requested '%s'), exe='%s' (requested '%s')", + diskGameName, gameName, diskExe, container.executablePath, + ) + } + diskGameName to diskExe + } else { + Timber.tag("SteamFix").w( + "ColdClient INI: falling back to requested casing because resolve failed. ColdClientLoader will likely report 'couldn't find the requested exe file'.", + ) + gameName to container.executablePath + } + iniFile.writeText( generateColdClientIni( - gameName = gameName, - executablePath = container.executablePath, + gameName = effectiveGameName, + executablePath = effectiveExe, exeCommandLine = container.execArgs, steamAppId = steamAppId, workingDir = workingDir, @@ -555,10 +847,10 @@ object SteamUtils { */ private fun createAppManifest(context: Context, steamAppId: Int) { try { - Timber.i("Attempting to createAppManifest for appId: $steamAppId") + Timber.tag("SteamFix").i("createAppManifest: begin for appId=$steamAppId") val appInfo = SteamService.getAppInfoOf(steamAppId) if (appInfo == null) { - Timber.w("No app info found for appId: $steamAppId") + Timber.tag("SteamFix").w("createAppManifest ABORT: no SteamApp info for appId=$steamAppId. Steam will treat the game as not installed and gray out Play.") return } @@ -581,15 +873,42 @@ object SteamUtils { val gameName = gameDir.name val sizeOnDisk = calculateDirectorySize(gameDir) - // Create symlink from Steam common directory to actual game directory - val steamGameLink = File(commonDir, gameName) - if (!steamGameLink.exists()) { - Files.createSymbolicLink(steamGameLink.toPath(), gameDir.toPath()) - Timber.i("Created symlink from ${steamGameLink.absolutePath} to ${gameDir.absolutePath}") + // Resolve the installdir Steam expects. Real steam.exe -applaunch looks + // the game up via steamapps/common/, NOT the on-disk folder + // name, so the primary symlink must match the manifest's installdir. + val actualInstallDir = appInfo.config.installDir.ifEmpty { gameName } + Timber.tag("SteamFix").i( + "createAppManifest: installDir pre-check appId=%d appInfo.installDir='%s' gameDir.name='%s' actualInstallDir='%s' match=%s", + steamAppId, appInfo.config.installDir, gameName, actualInstallDir, + appInfo.config.installDir.equals(gameName, ignoreCase = false), + ) + + val primaryLink = File(commonDir, actualInstallDir) + createSteamCommonLink(primaryLink, gameDir) + // Keep a fallback alias under the on-disk folder name for code paths + // that still resolve via gameDir.name (WineUtils, legacy helpers). + if (actualInstallDir != gameName) { + createSteamCommonLink(File(commonDir, gameName), gameDir) + } + try { + val linkPath = primaryLink.toPath() + val isSymlink = java.nio.file.Files.isSymbolicLink(linkPath) + val targetReadback = if (isSymlink) java.nio.file.Files.readSymbolicLink(linkPath).toString() else "(not a symlink)" + val targetExists = primaryLink.exists() + Timber.tag("SteamFix").i( + "createAppManifest: symlink readback appId=%d link=%s isSymlink=%s target=%s resolvedExists=%s", + steamAppId, primaryLink.absolutePath, isSymlink, targetReadback, targetExists, + ) + } catch (e: Exception) { + Timber.tag("SteamFix").w(e, "createAppManifest: symlink readback failed for %s", primaryLink.absolutePath) } val installedBranch = SteamService.getInstalledApp(steamAppId)?.branch ?: "public" val buildId = (appInfo.branches[installedBranch] ?: appInfo.branches["public"])?.buildId ?: 0L + if (buildId == 0L) { + Timber.tag("SteamFix").w("createAppManifest ABORT: appId=$steamAppId buildid unresolvable (branch=$installedBranch, known branches=${appInfo.branches.keys}). Zero buildid makes Steam force an update and gray out Play.") + return + } val downloadableDepots = SteamService.getDownloadableDepots(steamAppId) val regularDepots = mutableMapOf() @@ -609,6 +928,53 @@ object SteamUtils { // Find the main content depot (owner) - typically the one with the lowest ID that has content val mainDepotId = regularDepots.keys.minOrNull() + // LastOwner is how Steam attributes the install to a signed-in account. + // Leaving it zero makes cloud-sync/ownership checks resolve against a + // non-existent user, which is one of the stalls that leaves Play disabled. + val lastOwner = SteamService.userSteamId?.convertToUInt64()?.toString() ?: "0" + if (lastOwner == "0") { + Timber.tag("SteamFix").w("createAppManifest WARN: appId=$steamAppId LastOwner=0 — no signed-in SteamID. Cloud sync and ownership checks may stall.") + } + + // Pre-resolve depot manifests so we can bail before writing a broken ACF. + val regularDepotManifests = regularDepots.mapValues { (_, depotInfo) -> + depotInfo.manifests[installedBranch] + ?: depotInfo.manifests["public"] + ?: depotInfo.manifests.values.firstOrNull() + } + val brokenDepotIds = regularDepotManifests.filter { (_, m) -> m == null || m.gid == 0L }.keys + if (brokenDepotIds.isNotEmpty()) { + Timber.tag("SteamFix").w("createAppManifest ABORT: appId=$steamAppId depot(s) $brokenDepotIds have no resolvable manifest GID for branch=$installedBranch. Zero depot GID triggers Steam's 'Update Required' loop and disables Play.") + return + } + + // SteamFix #28: if PICS gave us depots but none resolved with a GID, every + // game-declared depot landed in `sharedDepots` and our ACF would be written + // with an empty InstalledDepots block — Steam then flips Update Required + // on the game itself. Bail out instead of overwriting a previously-good acf. + if (regularDepots.isEmpty()) { + val existing = File(steamappsDir, "appmanifest_$steamAppId.acf") + Timber.tag("SteamFix").w( + "createAppManifest ABORT: appId=%d regularDepots empty (downloadableDepots=%s sharedDepots=%s branch=%s). Keeping existing acf=%s (exists=%s) to avoid regressing Steam into Update Required.", + steamAppId, + downloadableDepots.keys, + sharedDepots.keys, + installedBranch, + existing.absolutePath, + existing.exists(), + ) + // Still write 228980's manifest — it's independent of the child's + // state and we just resolved fresh GIDs for it. + writeSteamworksCommonManifest(steamappsDir, commonDir, lastOwner) + return + } + + Timber.tag("SteamFix").i( + "createAppManifest: appId=%d branch=%s downloadableDepots=%s regular=%s shared=%s buildid=%d", + steamAppId, installedBranch, + downloadableDepots.keys, regularDepots.keys, sharedDepots.keys, buildId, + ) + // Create ACF content val acfContent = buildString { appendLine("\"AppState\"") @@ -616,40 +982,78 @@ object SteamUtils { appendLine("\t\"appid\"\t\t\"$steamAppId\"") appendLine("\t\"Universe\"\t\t\"1\"") appendLine("\t\"name\"\t\t\"${escapeString(appInfo.name)}\"") - appendLine("\t\"StateFlags\"\t\t\"4\"") // 4 = fully installed + // StateFlags = 4 = fully installed. SteamFix: we intentionally do not + // set 2 (update required) / 8 (update pending) / 16 (validating) — if + // Steam thinks an update is needed, it will flip these bits itself on + // next launch; we just don't claim authority over them. + appendLine("\t\"StateFlags\"\t\t\"4\"") appendLine("\t\"LastUpdated\"\t\t\"${System.currentTimeMillis() / 1000}\"") appendLine("\t\"SizeOnDisk\"\t\t\"$sizeOnDisk\"") appendLine("\t\"buildid\"\t\t\"$buildId\"") + // SteamFix #14: TargetBuildID matches buildid so Steam doesn't think + // it's mid-update. UpdateResult "0" = last update succeeded. + appendLine("\t\"TargetBuildID\"\t\t\"$buildId\"") + appendLine("\t\"UpdateResult\"\t\t\"0\"") + appendLine("\t\"AppType\"\t\t\"Game\"") + // SteamFix #26: write branch so invalidate-on-branch-change is + // observable in logcat and so Steam reflects the selected branch. + appendLine("\t\"betakey\"\t\t\"${if (installedBranch == "public") "" else escapeString(installedBranch)}\"") - // Use the actual install directory name - val actualInstallDir = appInfo.config.installDir.ifEmpty { gameName } appendLine("\t\"installdir\"\t\t\"${escapeString(actualInstallDir)}\"") - appendLine("\t\"LastOwner\"\t\t\"0\"") + appendLine("\t\"LastOwner\"\t\t\"$lastOwner\"") appendLine("\t\"BytesToDownload\"\t\t\"0\"") appendLine("\t\"BytesDownloaded\"\t\t\"0\"") appendLine("\t\"AutoUpdateBehavior\"\t\t\"0\"") appendLine("\t\"AllowOtherDownloadsWhileRunning\"\t\t\"0\"") appendLine("\t\"ScheduledAutoUpdate\"\t\t\"0\"") - // Add InstalledDepots section (only regular depots with actual manifests) if (regularDepots.isNotEmpty()) { appendLine("\t\"InstalledDepots\"") appendLine("\t{") - regularDepots.forEach { (depotId, depotInfo) -> - val manifest = depotInfo.manifests[installedBranch] - ?: depotInfo.manifests["public"] - ?: depotInfo.manifests.values.firstOrNull() + regularDepots.forEach { (depotId, _) -> + val manifest = regularDepotManifests[depotId]!! appendLine("\t\t\"$depotId\"") appendLine("\t\t{") - appendLine("\t\t\t\"manifest\"\t\t\"${manifest?.gid ?: "0"}\"") - appendLine("\t\t\t\"size\"\t\t\"${manifest?.size ?: 0}\"") + appendLine("\t\t\t\"manifest\"\t\t\"${manifest.gid}\"") + appendLine("\t\t\t\"size\"\t\t\"${manifest.size}\"") appendLine("\t\t}") } appendLine("\t}") } - appendLine("\t\"UserConfig\" { \"language\" \"english\" }") + // Declare shared-redist depots (228985/228989 etc.) as + // SharedDepots owned by their parent app (e.g. 228980). Without + // this block Steam loads the child's acf, notices via PICS that + // these depots are "really" owned by 228980, logs + // `config changed : removed depots` + `Dependency added: parent + // 228980, child `, and flips 228980 → `Update Required` → + // cascades `Fully Installed,Update Queued,` onto us. That is + // the gray-Play state. Declaring the ownership explicitly tells + // Steam the link is already satisfied, so no recategorization / + // queue flip happens. + if (sharedDepots.isNotEmpty()) { + appendLine("\t\"SharedDepots\"") + appendLine("\t{") + sharedDepots.forEach { (depotId, info) -> + appendLine("\t\t\"$depotId\"\t\t\"${info.depotFromApp}\"") + } + appendLine("\t}") + } + + // SteamFix #13: cloud_enabled="0" is a deliberate choice (Option B). + // GameNative already runs SteamAutoCloud.syncUserFiles around every + // launch with a signed JWT, so letting the Wine-hosted Steam client + // also sync would race and occasionally blow away saves. Leaving it + // disabled here is what keeps the ON toggle from corrupting data. + // If we ever expose a user-facing "let real Steam manage cloud" + // setting, this line becomes the toggle point. + appendLine("\t\"UserConfig\"") + appendLine("\t{") + appendLine("\t\t\"language\"\t\t\"english\"") + appendLine("\t\t\"cloud_enabled\"\t\t\"0\"") + appendLine("\t\t\"BetaKey\"\t\t\"${if (installedBranch == "public") "" else escapeString(installedBranch)}\"") + appendLine("\t}") appendLine("\t\"MountedConfig\" { \"language\" \"english\" }") appendLine("}") @@ -659,35 +1063,221 @@ object SteamUtils { val acfFile = File(steamappsDir, "appmanifest_$steamAppId.acf") acfFile.writeText(acfContent) - Timber.i("Created ACF manifest for ${appInfo.name} at ${acfFile.absolutePath}") - - // Create separate ACF for Steamworks Common Redistributables if we have shared depots - if (sharedDepots.isNotEmpty()) { - val steamworksAcfContent = buildString { - appendLine("\"AppState\"") - appendLine("{") - appendLine("\t\"appid\"\t\t\"228980\"") - appendLine("\t\"Universe\"\t\t\"1\"") - appendLine("\t\"name\"\t\t\"Steamworks Common Redistributables\"") - appendLine("\t\"StateFlags\"\t\t\"4\"") - appendLine("\t\"installdir\"\t\t\"Steamworks Shared\"") - appendLine("\t\"buildid\"\t\t\"1\"") - - appendLine("\t\"BytesToDownload\"\t\t\"0\"") - appendLine("\t\"BytesDownloaded\"\t\t\"0\"") - appendLine("}") + Timber.tag("SteamFix").i("createAppManifest OK: appId=$steamAppId name=${appInfo.name} installdir=$actualInstallDir buildid=$buildId branch=$installedBranch depots=${regularDepots.keys}") + + // SteamFix #27: always write a real appmanifest_228980.acf for Steamworks + // Common Redistributables. The dependency is declared server-side in + // Steam's PICS metadata, not in the child game's own depot list, so + // `sharedDepots` on the child is empty for affected games (e.g. Shiren). + // Without a valid 228980 manifest, Steam flips "Update Required" on the + // shared dep, queues a download it can't complete, and leaves the child + // stuck in "Update Queued" — which presents as a gray Play button. + // writeSteamworksCommonManifest is a no-op if PICS has no AppInfo for 228980. + writeSteamworksCommonManifest(steamappsDir, commonDir, lastOwner) + + } catch (e: Exception) { + Timber.e(e, "Failed to create ACF manifest for appId $steamAppId") + } + } + + private data class CanonicalDepot( + val id: Int, + val manifestGid: Long, + val size: Long, + val installScript: String, + ) + + // Buildid + depot GIDs sourced verbatim from a canonical PC install of 228980. + // Steam treats a manifest with this exact shape as "nothing to do" — no + // reconfigure, no "config changed : removed depots", no scheduler backoff. + private val canonical228980Depots: List = listOf( + CanonicalDepot(228981, 7613356809904826842L, 5884085L, "_CommonRedist\\\\vcredist\\\\2005\\\\installscript.vdf"), + CanonicalDepot(228982, 6413394087650432851L, 9688647L, "_CommonRedist\\\\vcredist\\\\2008\\\\installscript.vdf"), + CanonicalDepot(228983, 8124929965194586177L, 19265607L, "_CommonRedist\\\\vcredist\\\\2010\\\\installscript.vdf"), + CanonicalDepot(228984, 2547553897526095397L, 13742505L, "_CommonRedist\\\\vcredist\\\\2012\\\\installscript.vdf"), + CanonicalDepot(228985, 3966345552745568756L, 13699237L, "_CommonRedist\\\\vcredist\\\\2013\\\\installscript.vdf"), + CanonicalDepot(228986, 8782296191957114623L, 29759921L, "_CommonRedist\\\\vcredist\\\\2015\\\\installscript.vdf"), + CanonicalDepot(228987, 4302102680580581867L, 29664201L, "_CommonRedist\\\\vcredist\\\\2017\\\\installscript.vdf"), + CanonicalDepot(228988, 6645201662696499616L, 29212173L, "_CommonRedist\\\\vcredist\\\\2019\\\\installscript.vdf"), + CanonicalDepot(228989, 3514306556860204959L, 39590283L, "_CommonRedist\\\\vcredist\\\\2022\\\\installscript.vdf"), + CanonicalDepot(228990, 1829726630299308803L, 102931551L, "_CommonRedist\\\\DirectX\\\\Jun2010\\\\installscript.vdf"), + CanonicalDepot(229000, 4622705914179893434L, 242743889L, "_CommonRedist\\\\DotNet\\\\3.5\\\\installscript.vdf"), + CanonicalDepot(229001, 4049573910112143457L, 267964564L, "_CommonRedist\\\\DotNet\\\\3.5 Client Profile\\\\installscript.vdf"), + CanonicalDepot(229002, 7260605429366465749L, 50450161L, "_CommonRedist\\\\DotNet\\\\4.0\\\\installscript.vdf"), + CanonicalDepot(229003, 8740933542064151477L, 43001447L, "_CommonRedist\\\\DotNet\\\\4.0 Client Profile\\\\installscript.vdf"), + CanonicalDepot(229004, 5220958916987797232L, 70000464L, "_CommonRedist\\\\DotNet\\\\4.5.2\\\\installscript.vdf"), + CanonicalDepot(229005, 7992454656023763365L, 62009092L, "_CommonRedist\\\\DotNet\\\\4.6\\\\installscript.vdf"), + CanonicalDepot(229006, 1784011429307107530L, 83944258L, "_CommonRedist\\\\DotNet\\\\4.7\\\\installscript.vdf"), + CanonicalDepot(229007, 4477590687906973371L, 117381405L, "_CommonRedist\\\\DotNet\\\\4.8\\\\installscript.vdf"), + CanonicalDepot(229011, 392351049714934122L, 7672416L, "_CommonRedist\\\\XNA\\\\3.1\\\\installscript.vdf"), + CanonicalDepot(229012, 4353723233161159493L, 7061608L, "_CommonRedist\\\\XNA\\\\4.0\\\\installscript.vdf"), + CanonicalDepot(229020, 5799761707845834510L, 810085L, "_CommonRedist\\\\OpenAL\\\\2.0.7.0\\\\installscript.vdf"), + CanonicalDepot(229030, 1043465440436835055L, 51790718L, "_CommonRedist\\\\PhysX\\\\8.09.04\\\\installscript.vdf"), + CanonicalDepot(229031, 7746630274301172884L, 26729083L, "_CommonRedist\\\\PhysX\\\\9.12.1031\\\\installscript.vdf"), + CanonicalDepot(229032, 3616495131483866412L, 41178235L, "_CommonRedist\\\\PhysX\\\\9.13.1220\\\\installscript.vdf"), + ) + + private val canonical228980BuildId = 19222509L + + /** + * SteamFix #35: write a canonical appmanifest_228980.acf that mirrors the + * exact shape Steam writes on a real PC after a clean commit — all 24 + * Steamworks-Common-Redist depots, matching InstallScripts block, PC- + * matching byte counters. Steam treats this as "nothing to do" on every + * launch regardless of which child game is launching, breaking the + * per-launch "Updating…" loop we saw with per-game filtered subsets. + * + * Background: earlier approaches (SteamFix #31/33/34) wrote only the + * depot subset the currently-launching game declared via + * `DepotInfo.depotFromApp == 228980`. Steam's in-memory mount state + * tracks what it last satisfied; since we SIGKILL the wine prefix on + * Exit Game (GuestProgramLauncherComponent.java:74), Steam never flushes + * its post-reconfigure view to disk. Next boot: disk ≠ memory → + * reconfigure → "Updating Steamworks Common…" window → gray Play. + * The canonical baseline removes the mismatch surface entirely. + */ + private fun writeSteamworksCommonManifest( + steamappsDir: File, + commonDir: File, + lastOwner: String, + ) { + val sharedAppId = 228980 + val staleAcf = File(steamappsDir, "appmanifest_$sharedAppId.acf") + val sharedAppInfo = SteamService.getAppInfoOf(sharedAppId) + if (sharedAppInfo == null) { + if (staleAcf.exists() && staleAcf.delete()) { + Timber.tag("SteamFix").i( + "writeSteamworksCommonManifest: removed stale %s (no PICS info for 228980)", + staleAcf.absolutePath, + ) + } + Timber.tag("SteamFix").w( + "writeSteamworksCommonManifest ABORT: PICS has no AppInfo for 228980 — " + + "Steam will queue 228980 for update and gray out Play for the child game." + ) + return + } + + val sharedBuildId = sharedAppInfo.branches["public"]?.buildId + ?: sharedAppInfo.branches.values.firstOrNull()?.buildId + ?: canonical228980BuildId + + // Declare only the depots whose `installscript.vdf` is present on + // disk. An empty manifest made Steam say + // `update prefetch finished : 52086224 bytes to download` and try to + // download the missing content — the download gets suspended mid-run + // and leaves 228980 `Suspended`, which cascades `Update Queued` onto + // the child indefinitely. The 2-depot present-only shape has + // `0 bytes to download`, so Steam's reconcile is a ~1-second no-op. + // Matches the shape Steam itself writes after its first successful + // reconcile. + val sharedRedistDirForSkip = File(commonDir, "Steamworks Shared") + val presentDepots = canonical228980Depots.filter { d -> + File(sharedRedistDirForSkip, d.installScript.replace("\\\\", "/").replace("\\", "/")).isFile + } + val presentDepotIds = presentDepots.map { it.id }.toSet() + if (staleAcf.isFile) { + val existingBuildId = parseAcfBuildId(staleAcf) + val existingDepots = parseAcfInstalledDepotIds(staleAcf) + val existingScripts = parseAcfInstallScriptDepotIds(staleAcf) + val depotsMatch = existingDepots == presentDepotIds + val buildIdMatch = existingBuildId == sharedBuildId + val scriptsMatch = existingScripts == presentDepotIds + if (depotsMatch && buildIdMatch && scriptsMatch) { + val updateResult = parseAcfUpdateResult(staleAcf) + if (updateResult == 0L) { + Timber.tag("SteamFix").i( + "writeSteamworksCommonManifest SKIP: %s matches present-depot baseline (buildid=%d, %d depots, UpdateResult=0)", + staleAcf.absolutePath, sharedBuildId, presentDepotIds.size, + ) + return } + if (staleAcf.delete()) { + Timber.tag("SteamFix").w( + "writeSteamworksCommonManifest: deleted present-shaped %s with UpdateResult=%d; rewriting", + staleAcf.absolutePath, updateResult, + ) + } + } else { + Timber.tag("SteamFix").i( + "writeSteamworksCommonManifest: existing %s differs from present baseline (buildid=%d vs %d, %d vs %d depots, %d vs %d scripts); rewriting", + staleAcf.absolutePath, existingBuildId, sharedBuildId, + existingDepots.size, presentDepotIds.size, + existingScripts.size, presentDepotIds.size, + ) + } + } - // Write Steamworks ACF file - val steamworksAcfFile = File(steamappsDir, "appmanifest_228980.acf") - steamworksAcfFile.writeText(steamworksAcfContent) + val sharedInstallDir = "Steamworks Shared" + val sharedCommonDir = File(commonDir, sharedInstallDir) + if (!sharedCommonDir.exists()) { + sharedCommonDir.mkdirs() + } - Timber.i("Created Steamworks Common Redistributables ACF manifest at ${steamworksAcfFile.absolutePath}") + val launcherPath = "C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe" + + val acfContent = buildString { + appendLine("\"AppState\"") + appendLine("{") + appendLine("\t\"appid\"\t\t\"$sharedAppId\"") + appendLine("\t\"Universe\"\t\t\"1\"") + appendLine("\t\"LauncherPath\"\t\t\"$launcherPath\"") + appendLine("\t\"name\"\t\t\"${escapeString(sharedAppInfo.name.ifBlank { "Steamworks Common Redistributables" })}\"") + appendLine("\t\"StateFlags\"\t\t\"4\"") + appendLine("\t\"installdir\"\t\t\"$sharedInstallDir\"") + appendLine("\t\"LastUpdated\"\t\t\"${System.currentTimeMillis() / 1000}\"") + appendLine("\t\"LastPlayed\"\t\t\"0\"") + val presentSizeOnDisk = presentDepots.sumOf { it.size } + appendLine("\t\"SizeOnDisk\"\t\t\"$presentSizeOnDisk\"") + appendLine("\t\"StagingSize\"\t\t\"0\"") + appendLine("\t\"buildid\"\t\t\"$sharedBuildId\"") + appendLine("\t\"LastOwner\"\t\t\"$lastOwner\"") + appendLine("\t\"DownloadType\"\t\t\"1\"") + appendLine("\t\"UpdateResult\"\t\t\"0\"") + appendLine("\t\"BytesToDownload\"\t\t\"0\"") + appendLine("\t\"BytesDownloaded\"\t\t\"0\"") + appendLine("\t\"BytesToStage\"\t\t\"0\"") + appendLine("\t\"BytesStaged\"\t\t\"0\"") + appendLine("\t\"TargetBuildID\"\t\t\"$sharedBuildId\"") + appendLine("\t\"AutoUpdateBehavior\"\t\t\"0\"") + appendLine("\t\"AllowOtherDownloadsWhileRunning\"\t\t\"0\"") + appendLine("\t\"ScheduledAutoUpdate\"\t\t\"0\"") + + appendLine("\t\"InstalledDepots\"") + appendLine("\t{") + presentDepots.forEach { d -> + appendLine("\t\t\"${d.id}\"") + appendLine("\t\t{") + appendLine("\t\t\t\"manifest\"\t\t\"${d.manifestGid}\"") + appendLine("\t\t\t\"size\"\t\t\"${d.size}\"") + appendLine("\t\t}") } + appendLine("\t}") - } catch (e: Exception) { - Timber.e(e, "Failed to create ACF manifest for appId $steamAppId") + appendLine("\t\"InstallScripts\"") + appendLine("\t{") + presentDepots.forEach { d -> + appendLine("\t\t\"${d.id}\"\t\t\"${d.installScript}\"") + } + appendLine("\t}") + + appendLine("\t\"UserConfig\"") + appendLine("\t{") + appendLine("\t\t\"BetaKey\"\t\t\"public\"") + appendLine("\t}") + appendLine("\t\"MountedConfig\"") + appendLine("\t{") + appendLine("\t\t\"BetaKey\"\t\t\"public\"") + appendLine("\t}") + appendLine("}") } + + staleAcf.writeText(acfContent) + + Timber.tag("SteamFix").i( + "writeSteamworksCommonManifest OK: wrote present-depot %s buildid=%d (%d depots) lastOwner=%s", + staleAcf.absolutePath, sharedBuildId, presentDepots.size, lastOwner, + ) } private fun escapeString(input: String?): String { @@ -695,6 +1285,127 @@ object SteamUtils { return input.replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") } + private val acfBuildIdRegex = Regex("\"buildid\"\\s*\"(\\d+)\"") + private val acfUpdateResultRegex = Regex("\"UpdateResult\"\\s*\"(\\d+)\"") + + // Matches `"" { "manifest" "" ... }` entries inside the + // InstalledDepots block of our own acf output. Tolerant of whitespace / + // newlines; depot ids are ints, manifest GIDs can be long-valued. + private val acfInstalledDepotEntryRegex = Regex( + "\"(\\d+)\"\\s*\\{\\s*\"manifest\"\\s*\"\\d+\"", + RegexOption.DOT_MATCHES_ALL, + ) + + private fun parseAcfBuildId(acf: File): Long { + return try { + val match = acfBuildIdRegex.find(acf.readText()) ?: return 0L + match.groupValues[1].toLongOrNull() ?: 0L + } catch (_: Exception) { + 0L + } + } + + private fun parseAcfUpdateResult(acf: File): Long { + return try { + val match = acfUpdateResultRegex.find(acf.readText()) ?: return 0L + match.groupValues[1].toLongOrNull() ?: 0L + } catch (_: Exception) { + 0L + } + } + + private fun parseAcfInstalledDepotIds(acf: File): Set { + return try { + val text = acf.readText() + val start = text.indexOf("\"InstalledDepots\"") + if (start < 0) return emptySet() + val braceOpen = text.indexOf('{', start) + if (braceOpen < 0) return emptySet() + // Find the matching closing brace for InstalledDepots. + var depth = 0 + var braceClose = -1 + for (i in braceOpen until text.length) { + when (text[i]) { + '{' -> depth++ + '}' -> { + depth-- + if (depth == 0) { braceClose = i; break } + } + } + } + if (braceClose < 0) return emptySet() + val section = text.substring(braceOpen, braceClose) + acfInstalledDepotEntryRegex.findAll(section) + .mapNotNull { it.groupValues[1].toIntOrNull() } + .toSet() + } catch (_: Exception) { + emptySet() + } + } + + private fun parseAcfInstallScriptDepotIds(acf: File): Set { + return try { + val text = acf.readText() + val start = text.indexOf("\"InstallScripts\"") + if (start < 0) return emptySet() + val braceOpen = text.indexOf('{', start) + if (braceOpen < 0) return emptySet() + var depth = 0 + var braceClose = -1 + for (i in braceOpen until text.length) { + when (text[i]) { + '{' -> depth++ + '}' -> { + depth-- + if (depth == 0) { braceClose = i; break } + } + } + } + if (braceClose < 0) return emptySet() + val section = text.substring(braceOpen, braceClose) + Regex("\"(\\d+)\"\\s*\"[^\"]*installscript\\.vdf\"", RegexOption.IGNORE_CASE) + .findAll(section) + .mapNotNull { it.groupValues[1].toIntOrNull() } + .toSet() + } catch (_: Exception) { + emptySet() + } + } + + /** + * SteamFix #12: Create a link for `steamapps/common/`. Try + * NIO `createSymbolicLink` first, then fall back to a junction-style + * directory with a sentinel (if the underlying filesystem rejects + * symlinks — some Android external storage mounts do). We log loudly + * so this shows up as a breadcrumb rather than a silent launch failure. + */ + private fun createSteamCommonLink(link: File, target: File) { + if (link.exists()) { + Timber.tag("SteamFix").d("common link already present: %s", link.absolutePath) + return + } + val parent = link.parentFile + if (parent != null && !parent.exists()) parent.mkdirs() + try { + Files.createSymbolicLink(link.toPath(), target.toPath()) + Timber.tag("SteamFix").i("created symlink %s -> %s", link.absolutePath, target.absolutePath) + return + } catch (e: Exception) { + Timber.tag("SteamFix").w(e, "createSymbolicLink failed for %s, falling back to directory + redirect file", link.absolutePath) + } + // Fallback: create a real directory containing a sentinel file so Steam at least + // sees the folder exist. This won't let Steam find the EXE, but it prevents a + // null-dir crash and gives us a diagnostic marker to search for in logcat. + try { + if (link.mkdirs()) { + File(link, ".steamfix_symlink_failed").writeText(target.absolutePath) + Timber.tag("SteamFix").w("wrote fallback dir+sentinel at %s (pointing to %s)", link.absolutePath, target.absolutePath) + } + } catch (e2: Exception) { + Timber.tag("SteamFix").e(e2, "fallback directory creation failed for %s", link.absolutePath) + } + } + private fun calculateDirectorySize(directory: File): Long { if (!directory.exists() || !directory.isDirectory()) { return 0L @@ -720,7 +1431,7 @@ object SteamUtils { */ fun restoreSteamApi(context: Context, appId: String) { - Timber.i("Starting restoreSteamApi for appId: ${appId}") + Timber.tag("SteamFix").i("restoreSteamApi starting for appId=%s", appId) val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) val imageFs = ImageFs.find(context) val container = ContainerUtils.getOrCreateContainer(context, appId) @@ -736,33 +1447,78 @@ object SteamUtils { skipFirstTimeSteamSetup(imageFs.rootDir) val appDirPath = SteamService.getAppDirPath(steamAppId) - if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_DLL_RESTORED)) { - return - } - MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) - MarkerUtils.removeMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) - Timber.i("Checking directory: $appDirPath") - autoLoginUserChanges(imageFs) - setupLightweightSteamConfig(imageFs, SteamService.userSteamId!!.accountID.toString()) + val needsDllRestore = if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_DLL_RESTORED)) { + if (verifyRestoredState(appDirPath)) { + Timber.tag("SteamFix").i("restoreSteamApi: DLL marker + hash ok, skipping DLL copy") + false + } else { + Timber.tag("SteamFix").w("restoreSteamApi: clearing stale RESTORED marker and re-running restore") + MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_RESTORED) + true + } + } else true - putBackSteamDlls(appDirPath) + if (needsDllRestore) { + MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) + MarkerUtils.removeMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) + Timber.tag("SteamFix").i("restoreSteamApi: running DLL restore in %s", appDirPath) - Timber.i("Finished restoreSteamApi for appId: ${appId}") + autoLoginUserChanges(imageFs) + setupLightweightSteamConfig(imageFs, SteamService.userSteamId!!.accountID.toString()) - // Restore original executable if it exists (for real Steam mode) - restoreOriginalExecutable(context, steamAppId) + putBackSteamDlls(appDirPath) - // Restore original steamclient.dll files if they exist - restoreSteamclientFiles(context, steamAppId) + // Restore original executable if it exists (for real Steam mode) + restoreOriginalExecutable(context, steamAppId) - // Create Steam ACF manifest for real Steam compatibility + // Restore original steamclient.dll files if they exist + restoreSteamclientFiles(context, steamAppId) + + MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_RESTORED) + } + + applySteamOverlayPref(context, container) + + // SteamFix #25/#26: always refresh manifest + symlinks on every real-Steam + // launch. Multi-account contamination and branch changes otherwise leave + // stale LastOwner / buildid / installdir until the DLL marker is cleared. createAppManifest(context, steamAppId) // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId, container) - MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_RESTORED) + // SteamFix: sanity check the install. If the dir has only dot-prefixed + // metadata sidecars and _CommonRedist (no actual game .exe anywhere), + // Steam will happily "launch" the game but ColdClientLoader or + // steam.exe -applaunch will fail because the exe isn't on disk. Make + // that obvious in logcat instead of surfacing as a silent black screen. + try { + val appDir = File(SteamService.getAppDirPath(steamAppId)) + if (appDir.isDirectory) { + val topLevel = appDir.listFiles()?.map { it.name } ?: emptyList() + val hasAnyExe = appDir.walkTopDown() + .maxDepth(4) + .any { it.isFile && it.name.endsWith(".exe", ignoreCase = true) } + if (!hasAnyExe) { + Timber.tag("SteamFix").w( + "Install INCOMPLETE for appId=%s: no .exe found under %s within 4 levels. topLevel=%s. " + + "GameNative's download_complete marker is present but the real game files aren't. Re-verify / re-download from library.", + appId, appDir.absolutePath, topLevel, + ) + } + } else { + Timber.tag("SteamFix").w( + "Install MISSING for appId=%s: %s is not a directory.", + appId, appDir.absolutePath, + ) + } + } catch (e: Exception) { + Timber.tag("SteamFix").w(e, "install sanity check failed for appId=%s", appId) + } + + Timber.tag("SteamFix").i("restoreSteamApi finished for appId=%s", appId) + logAppDirInventory(ContainerUtils.extractGameIdFromContainerId(appId), "restoreSteamApi.done") } fun findSteamApiDllRootFile(file: File, depth: Int): File? { @@ -864,11 +1620,15 @@ object SteamUtils { } /** - * Migrates save files from GSE Saves directory to Steam userdata directory. - * This function copies all files from the GSE saves location to the proper Steam userdata - * location and then removes the original GSE directory to complete the migration. + * SteamFix #11: Only copy GSE saves into Steam userdata when the next launch + * is actually real Steam. Running this in OFF mode moved GSE's own saves out + * from under Goldberg. Caller threads `isLaunchRealSteam` through. */ - fun migrateGSESavesToSteamUserdata(context: Context, appId: Int) { + fun migrateGSESavesToSteamUserdata(context: Context, appId: Int, isLaunchRealSteam: Boolean = true) { + if (!isLaunchRealSteam) { + Timber.tag("SteamFix").d("migrateGSESavesToSteamUserdata: skipping for appId=%d (not real-Steam mode)", appId) + return + } val imageFs = ImageFs.find(context) val accountId = SteamService.userSteamId?.accountID?.toInt() ?: PrefManager.steamUserAccountId.takeIf { it != 0 } @@ -952,6 +1712,194 @@ object SteamUtils { Timber.tag("migrateGSESavesToSteamUserdata").i("Migration completed for appId=$appId. Migrated $migratedCount file(s)") } + /** + * SteamFix #10: reverse migration. When a user toggles Launch Steam Client + * OFF after an ON session, saves that were copied into Steam/userdata need + * to move back so Goldberg (which reads from GSE Saves) can find them. + * Only runs when entering OFF mode, since the opposite direction is + * handled by [migrateGSESavesToSteamUserdata]. + */ + fun migrateSteamUserdataToGSESaves(context: Context, appId: Int, isLaunchRealSteam: Boolean = false) { + if (isLaunchRealSteam) { + Timber.tag("SteamFix").d("migrateSteamUserdataToGSESaves: skipping for appId=%d (real-Steam mode)", appId) + return + } + val imageFs = ImageFs.find(context) + val accountId = SteamService.userSteamId?.accountID?.toInt() + ?: PrefManager.steamUserAccountId.takeIf { it != 0 } + + if (accountId == null) { + Timber.tag("SteamFix").w("migrateSteamUserdataToGSESaves: no Steam account ID available") + return + } + + val steamUserdataDir = File( + imageFs.rootDir, + "${ImageFs.WINEPREFIX}/drive_c/Program Files (x86)/Steam/userdata/$accountId/$appId" + ) + val gseDir = File( + imageFs.rootDir, + "${ImageFs.WINEPREFIX}/drive_c/users/xuser/AppData/Roaming/GSE Saves/$appId" + ) + + fun isDirectoryEmpty(file: File): Boolean { + return file.isDirectory && file.list()?.isEmpty() ?: true + } + + if ( + !steamUserdataDir.exists() || + !steamUserdataDir.isDirectory || + isDirectoryEmpty(steamUserdataDir) + ) { + Timber.tag("SteamFix").d("migrateSteamUserdataToGSESaves: no userdata to migrate for appId=%d", appId) + return + } + + Timber.tag("SteamFix").i("migrateSteamUserdataToGSESaves: starting migration for appId=%d", appId) + + if (!gseDir.exists()) { + try { + Files.createDirectories(gseDir.toPath()) + } catch (e: IOException) { + Timber.tag("SteamFix").e(e, "migrateSteamUserdataToGSESaves: failed to create GSE Saves dir") + return + } + } + + var migratedCount = 0 + var migrationFailed = false + + steamUserdataDir.walkTopDown() + .filter { it.isFile } + .forEach { file -> + val relativePath = steamUserdataDir.toPath().relativize(file.toPath()) + val targetFile = gseDir.toPath().resolve(relativePath) + try { + Files.createDirectories(targetFile.parent) + val ts = file.lastModified() + Files.move( + file.toPath(), + targetFile, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + targetFile.setLastModifiedTime(FileTime.fromMillis(ts)) + migratedCount++ + } catch (e: Exception) { + migrationFailed = true + Timber.tag("SteamFix").w(e, "migrateSteamUserdataToGSESaves: failed to migrate %s", file.name) + } + } + + if (!migrationFailed) { + steamUserdataDir.deleteRecursively() + } + + Timber.tag("SteamFix").i("migrateSteamUserdataToGSESaves: completed appId=%d, files=%d", appId, migratedCount) + } + + /** + * SteamFix #20: one-shot cleanup of the ~200 MB extracted Steam binaries + * when we're confident no real-Steam launch is pending. Caller owns the + * "is this safe right now" decision (e.g. after confirming OFF toggle). + */ + /** + * Symlink-safe recursive delete. Kotlin's `File.deleteRecursively()` follows + * symbolic links to directories and deletes the files on the *other* side, + * which is catastrophic when the tree contains `steamapps/common/` + * symlinks pointing at the real game-files directory on the GameNative side. + * + * Java's NIO `Files.walkFileTree` does NOT follow symlinks by default — a + * symlink to a directory is visited as a file, and `Files.delete` on it + * unlinks the symlink without touching the target. This is the behaviour we + * want: tear down the extracted Steam prefix but leave the actual game + * content the symlinks point to alone. + */ + /** + * Emit a one-line inventory of a Steam game's install directory so we can + * correlate "where did my game go?" reports against launch / mode-switch / + * variant-reset events. Tagged `SteamFix` so it shows up in the same + * logcat filter as the rest of the diagnostics. + */ + fun logAppDirInventory(appId: Int, phase: String) { + try { + val path = SteamService.getAppDirPath(appId) + val dir = File(path) + if (!dir.exists()) { + Timber.tag("SteamFix").w("inventory[%s] appId=%d path=%s MISSING", phase, appId, path) + return + } + var fileCount = 0 + var exeCount = 0 + var totalBytes = 0L + val exeList = mutableListOf() + try { + dir.walkTopDown().maxDepth(4).forEach { f -> + if (f.isFile) { + fileCount++ + totalBytes += f.length() + if (f.name.endsWith(".exe", ignoreCase = true)) { + exeCount++ + if (exeList.size < 8) exeList += f.relativeTo(dir).path + } + } + } + } catch (_: Exception) { /* best-effort walk */ } + val appInfo = SteamService.getAppInfoOf(appId) + val configuredExe = appInfo?.config?.launch + ?.firstOrNull { it.executable.isNotBlank() }?.executable.orEmpty() + val expectedExeRel = configuredExe.replace("\\", "/").trimStart('/') + val expectedExeFile = if (expectedExeRel.isNotBlank()) File(dir, expectedExeRel) else null + val expectedExePresent = expectedExeFile?.exists() == true + Timber.tag("SteamFix").i( + "inventory[%s] appId=%d path=%s files=%d exes=%d bytes=%d expectedExe=%s present=%s exes=%s", + phase, appId, path, fileCount, exeCount, totalBytes, + expectedExeRel.ifBlank { "(unset)" }, expectedExePresent, exeList, + ) + } catch (e: Exception) { + Timber.tag("SteamFix").w(e, "inventory[%s] appId=%d FAILED", phase, appId) + } + } + + internal fun deleteTreeNoFollowSymlinks(root: File) { + if (!root.exists() && !java.nio.file.Files.isSymbolicLink(root.toPath())) return + val rootPath = root.toPath() + java.nio.file.Files.walkFileTree(rootPath, object : java.nio.file.SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: java.nio.file.attribute.BasicFileAttributes): java.nio.file.FileVisitResult { + java.nio.file.Files.deleteIfExists(file) + return java.nio.file.FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): java.nio.file.FileVisitResult { + // Broken symlink or permission issue — unlink and keep going. + try { java.nio.file.Files.deleteIfExists(file) } catch (_: Exception) {} + return java.nio.file.FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): java.nio.file.FileVisitResult { + java.nio.file.Files.deleteIfExists(dir) + return java.nio.file.FileVisitResult.CONTINUE + } + }) + } + + fun cleanupExtractedSteamFiles(context: Context) { + try { + val imageFs = ImageFs.find(context) + val steamDir = File(imageFs.rootDir, ImageFs.WINEPREFIX + "/drive_c/Program Files (x86)/Steam") + if (!steamDir.exists()) return + val steamExe = File(steamDir, "steam.exe") + if (!steamExe.exists()) return + Timber.tag("SteamFix").i( + "cleanupExtractedSteamFiles: removing %s (symlink-safe walk)", + steamDir.absolutePath, + ) + deleteTreeNoFollowSymlinks(steamDir) + } catch (e: Exception) { + Timber.tag("SteamFix").w(e, "cleanupExtractedSteamFiles failed") + } + } + /** * Sibling folder "steam_settings" + empty "offline.txt" file, no-ops if they already exist. */ @@ -1010,8 +1958,11 @@ object SteamUtils { appendLine("ticket=$ticketBase64") } - // Migrate GSE Saves to Steam userdata - migrateGSESavesToSteamUserdata(context, steamAppId) + // SteamFix #10/#11: in OFF mode (ensureSteamSettings only runs in the + // emulated-Steam launch path) we must not move GSE saves into Steam's + // userdata — that's the ON-mode direction. Instead, pull any leftover + // userdata back into GSE Saves so Goldberg can find it. + migrateSteamUserdataToGSESaves(context, steamAppId, isLaunchRealSteam = false) // Add [user::saves] section val steamUserDataPath = "C:\\Program Files (x86)\\Steam\\userdata\\$accountId" diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt index 9b319b0a63..cd4c89c41b 100644 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt @@ -53,6 +53,13 @@ object VcRedistStep : PreInstallStep { gameSource: GameSource, gameDirPath: String, ): Boolean { + // vcredist installs system-wide into the Wine prefix, not per-game. Check a + // container-root marker first so reinstalling the game (which wipes the game + // dir) doesn't force a redundant re-run + wineserver kill on every launch. + val containerRoot = container.rootDir?.absolutePath + if (containerRoot != null && MarkerUtils.hasMarker(containerRoot, Marker.VCREDIST_INSTALLED)) { + return false + } return !MarkerUtils.hasMarker(gameDirPath, Marker.VCREDIST_INSTALLED) } diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java index d11d68adca..d6bb2a2a60 100644 --- a/app/src/main/java/com/winlator/container/Container.java +++ b/app/src/main/java/com/winlator/container/Container.java @@ -85,6 +85,7 @@ public enum XrControllerMapping { private String wineVersion = WineInfo.MAIN_WINE_VERSION.identifier(); private boolean showFPS; private boolean launchRealSteam; + private boolean disableSteamOverlay; private boolean allowSteamUpdates; private boolean wow64Mode = true; private boolean needsUnpacking = true; @@ -325,6 +326,14 @@ public void setLaunchRealSteam(boolean launchRealSteam) { this.launchRealSteam = launchRealSteam; } + public boolean isDisableSteamOverlay() { + return disableSteamOverlay; + } + + public void setDisableSteamOverlay(boolean disableSteamOverlay) { + this.disableSteamOverlay = disableSteamOverlay; + } + public boolean isAllowSteamUpdates() { return allowSteamUpdates; } @@ -652,6 +661,7 @@ public void saveData() { data.put("drives", drives); data.put("showFPS", showFPS); data.put("launchRealSteam", launchRealSteam); + data.put("disableSteamOverlay", disableSteamOverlay); data.put("allowSteamUpdates", allowSteamUpdates); data.put("inputType", inputType); data.put("dinputMapperType", dinputMapperType); @@ -771,6 +781,9 @@ public void loadData(JSONObject data) throws JSONException { case "launchRealSteam" : setLaunchRealSteam(data.getBoolean(key)); break; + case "disableSteamOverlay" : + setDisableSteamOverlay(data.getBoolean(key)); + break; case "allowSteamUpdates" : setAllowSteamUpdates(data.getBoolean(key)); break; diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index d05dcb5a74..5130365bbc 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -25,6 +25,7 @@ data class ContainerData( val installPath: String = "", val showFPS: Boolean = false, val launchRealSteam: Boolean = false, + val disableSteamOverlay: Boolean = false, val allowSteamUpdates: Boolean = false, val steamType: String = "normal", val cpuList: String = Container.getFallbackCPUList(), @@ -118,6 +119,7 @@ data class ContainerData( "installPath" to state.installPath, "showFPS" to state.showFPS, "launchRealSteam" to state.launchRealSteam, + "disableSteamOverlay" to state.disableSteamOverlay, "allowSteamUpdates" to state.allowSteamUpdates, "steamType" to state.steamType, "cpuList" to state.cpuList, @@ -181,6 +183,7 @@ data class ContainerData( installPath = savedMap["installPath"] as String, showFPS = savedMap["showFPS"] as Boolean, launchRealSteam = savedMap["launchRealSteam"] as Boolean, + disableSteamOverlay = (savedMap["disableSteamOverlay"] as? Boolean) ?: false, allowSteamUpdates = savedMap["allowSteamUpdates"] as Boolean, steamType = (savedMap["steamType"] as? String) ?: "normal", cpuList = savedMap["cpuList"] as String, diff --git a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java index 84b05aa633..ca00143e6f 100644 --- a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java +++ b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java @@ -215,7 +215,16 @@ public static Future installIfNeededFuture(final Context context, Asset Log.w("ImageFsInstaller", "Failed to migrate legacy directories before installation."); return Executors.newSingleThreadExecutor().submit(() -> false); } - if (!imageFs.isValid() || imageFs.getVersion() < LATEST_VERSION || !imageFs.getVariant().equals(container.getContainerVariant())) { + boolean valid = imageFs.isValid(); + int version = imageFs.getVersion(); + String currentVariant = imageFs.getVariant(); + String requestedVariant = container.getContainerVariant(); + if (!valid || version < LATEST_VERSION || !currentVariant.equals(requestedVariant)) { + android.util.Log.i("SteamFix", + "ImageFsInstaller.installIfNeeded: REINSTALL valid=" + valid + + " version=" + version + "/" + LATEST_VERSION + + " variant=" + currentVariant + "->" + requestedVariant + + " (this will unlink steamapps/common/ symlinks)"); Log.d("ImageFsInstaller", "Installing image from assets"); return installFromAssetsFuture( context, @@ -277,6 +286,14 @@ private static void clearOptDir(Context context, File optDir) { } private static void clearRootDir(Context context, File rootDir) { + // SteamFix diag: this runs when imagefs is invalid/out-of-date OR when the + // user toggles container variant (glibc <-> bionic). It wipes everything + // under rootDir except home/ and opt/, which means any symlinks placed by + // createAppManifest (steamapps/common/ -> /data/.../Steam/...) get + // unlinked. Real on-disk game content is NOT followed (FileUtils.delete + // is symlink-safe), but the manifest/link pair is gone. Tag this so the + // same logcat filter catches it. + android.util.Log.i("SteamFix", "ImageFsInstaller.clearRootDir: wiping " + rootDir.getAbsolutePath() + " (variant/imgVersion reset — symlinks under wineprefix will be unlinked)"); if (rootDir.isDirectory()) { File[] files = rootDir.listFiles(); if (files != null) { @@ -298,6 +315,7 @@ private static void clearRootDir(Context context, File rootDir) { } } else rootDir.mkdirs(); + android.util.Log.i("SteamFix", "ImageFsInstaller.clearRootDir: done"); } public static void generateCompactContainerPattern(final Context context, AssetManager assetManager) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e96128cb7..333447cb77 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -673,6 +673,8 @@ Disable cloud save sync for this game and keep saves on device only Launch Steam Client (Beta) Reduces performance and slows down launch\nAllows online play and fixes DRM and controller issues\nNot all games work + Disable Steam Overlay + Skip injecting gameoverlayrenderer64.dll\nAchievements still unlock but popups won\'t appear\nMay fix crashes on Unity/Vulkan games Steam Offline Mode Launch Steam games in offline mode Achievement Unlocked From 963d799971c1d4f63afb953aaecafa3377382141 Mon Sep 17 00:00:00 2001 From: TideGear Date: Sun, 19 Apr 2026 00:21:47 -0700 Subject: [PATCH 07/34] fix: suppress per-launch vcredist reinstall + DLL marker false desyncs Three fixes to real-Steam-client launch path, all in SteamUtils.kt: 1. applySteamInstallScriptShim (new): writes HKLM\Software\Valve\Steam\Apps and \InstallScripts entries for appId, 228980, and all 24 canonical 228980 depots with Installed=1 / Run=1. This stops Steam from re-running bundled vc_redist.x86.exe / vc_redist.x64.exe / DXSETUP.exe on every launch, which was racing against the game's own MSVC loader and (on Unity titles) triggering UnityCrashHandler. Called from restoreSteamApi after createAppManifest. 2. verifyRestoredState: no longer requires a .orig sibling next to steam_api*.dll. Games with useLegacyDRM=false never go through replaceSteamApi and so never produce a .orig, which caused the old check to log "DLL marker desync" on every launch and needlessly re-copy pipe DLLs. Now compares the on-disk DLL hash against the pipe asset hash: match = still pipe (bad), mismatch = restored (good). 3. createAppManifest regularDepots-empty path: downgraded from W to I when the existing appmanifest already has a valid buildId + depots + UpdateResult=0. The PICS "regularDepots empty" result is a transient refresh flake and not actionable when the acf is otherwise healthy. Validated on Shiren (2178480) and Darkest Dungeon (262060): shim writes 300 reg entries, vcredist windows no longer appear on 2nd+ launches, DLL hash check logs "DLL marker + hash ok, skipping DLL copy" instead of desync warnings. --- .../java/app/gamenative/utils/SteamUtils.kt | 147 +++++++++++++++--- 1 file changed, 127 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 912d69410f..5d61486489 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -237,22 +237,35 @@ object SteamUtils { } /** - * SteamFix #7: verify RESTORED marker. Each steam_api DLL must have a .orig - * sibling and match it byte-for-byte. + * SteamFix #7: verify RESTORED marker. A restored DLL is defined as "NOT our + * pipe DLL" — we compare each on-disk `steam_api*.dll` against the pipe DLL + * shipped in assets. If the hash matches the pipe, the DLL is still replaced + * and the marker is lying. If the hash differs, the DLL is either the game's + * original (never replaced) or successfully put back from .orig; either way + * RESTORED is a truthful state. The .orig sibling is optional — games whose + * emu-mode path never called replaceSteamApi (e.g. non-legacy-DRM games like + * Shiren) never have a .orig, and that is fine. */ - private fun verifyRestoredState(appDirPath: String): Boolean { + private fun verifyRestoredState(context: Context, appDirPath: String): Boolean { return try { + val assetHashes = mutableMapOf() + listOf("steam_api.dll", "steam_api64.dll").forEach { name -> + runCatching { + context.assets.open("steampipe/$name").use { ins -> + assetHashes[name.lowercase()] = sha256OfStream(ins) + } + } + } Paths.get(appDirPath).toFile().walkTopDown().maxDepth(10).forEach { file -> if (!file.isFile) return@forEach val n = file.name.lowercase() if (n == "steam_api.dll" || n == "steam_api64.dll") { - val orig = File(file.parentFile, "${file.name}.orig") - if (!orig.exists()) { - Timber.tag("SteamFix").w("DLL marker desync: %s has no .orig sibling (marker says RESTORED)", file.absolutePath) - return false - } - if (sha256OfFile(file) != sha256OfFile(orig)) { - Timber.tag("SteamFix").w("DLL marker desync: %s != %s.orig (marker says RESTORED)", file.absolutePath, file.name) + val pipeHash = assetHashes[n] ?: return@forEach + if (sha256OfFile(file) == pipeHash) { + Timber.tag("SteamFix").w( + "DLL marker desync: %s is still the pipe DLL (marker says RESTORED)", + file.absolutePath, + ) return false } } @@ -279,6 +292,81 @@ object SteamUtils { return md.digest().joinToString("") { "%02x".format(it) } } + /** + * SteamFix: tell the Wine-hosted Steam client that the Steamworks Common + * Redistributables (228980) plus each canonical child depot's install + * script has already executed, so `steam.exe -applaunch` does not + * re-launch the bundled `vc_redist.x86.exe` / `vc_redist.x64.exe` / + * `DXSETUP.exe` installers on every boot. Those installers race the + * Unity MSVC runtime (we've seen the resulting crash on Shiren, appId + * 2178480) and also grab focus away from the game window. + * + * Steam checks these registry paths before running an InstallScript: + * - HKLM\Software\Valve\Steam\Apps\ (app registry) + * - HKLM\Software\Wow6432Node\Valve\Steam\Apps\ (32-bit view) + * - HKLM\Software\Valve\Steam\Apps\\Depots\ (per-depot) + * - HKLM\Software\Valve\Steam\InstallScripts\ (script tracker) + * + * We set `Installed=1` and `Run=1` across all of them. Writes go to + * `system.reg` (HKLM) via WineRegistryEditor. Idempotent: if a key + * already has the value, the editor short-circuits. Safe to fail — the + * worst outcome is that Steam runs the installer again (status quo). + */ + fun applySteamInstallScriptShim(context: Context, steamAppId: Int) { + try { + val imageFs = ImageFs.find(context) + val systemRegFile = File(imageFs.wineprefix, "system.reg") + if (!systemRegFile.isFile) { + Timber.tag("SteamFix").w( + "applySteamInstallScriptShim: system.reg missing at %s — skipping", + systemRegFile.absolutePath, + ) + return + } + + val appKeys = listOf( + "Software\\Valve\\Steam\\Apps\\228980", + "Software\\Wow6432Node\\Valve\\Steam\\Apps\\228980", + "Software\\Valve\\Steam\\Apps\\$steamAppId", + "Software\\Wow6432Node\\Valve\\Steam\\Apps\\$steamAppId", + ) + val depotIds = canonical228980Depots.map { it.id } + var writes = 0 + + WineRegistryEditor(systemRegFile).use { reg -> + reg.setCreateKeyIfNotExist(true) + appKeys.forEach { key -> + reg.setDwordValue(key, "Installed", 1) + reg.setDwordValue(key, "Updating", 0) + reg.setDwordValue(key, "Running", 0) + writes += 3 + } + depotIds.forEach { depotId -> + val perDepotKeys = listOf( + "Software\\Valve\\Steam\\Apps\\228980\\Depots\\$depotId", + "Software\\Wow6432Node\\Valve\\Steam\\Apps\\228980\\Depots\\$depotId", + "Software\\Valve\\Steam\\Apps\\$steamAppId\\Depots\\$depotId", + "Software\\Wow6432Node\\Valve\\Steam\\Apps\\$steamAppId\\Depots\\$depotId", + "Software\\Valve\\Steam\\InstallScripts\\$depotId", + "Software\\Wow6432Node\\Valve\\Steam\\InstallScripts\\$depotId", + ) + perDepotKeys.forEach { key -> + reg.setDwordValue(key, "Installed", 1) + reg.setDwordValue(key, "Run", 1) + writes += 2 + } + } + } + + Timber.tag("SteamFix").i( + "applySteamInstallScriptShim: wrote %d reg entries for appId=%d + 228980 (%d canonical depots)", + writes, steamAppId, depotIds.size, + ) + } catch (e: Exception) { + Timber.tag("SteamFix").w(e, "applySteamInstallScriptShim failed for appId=%d", steamAppId) + } + } + /** * Replaces any existing `steam_api.dll` or `steam_api64.dll` in the app directory * with our pipe dll stored in assets @@ -954,15 +1042,28 @@ object SteamUtils { // on the game itself. Bail out instead of overwriting a previously-good acf. if (regularDepots.isEmpty()) { val existing = File(steamappsDir, "appmanifest_$steamAppId.acf") - Timber.tag("SteamFix").w( - "createAppManifest ABORT: appId=%d regularDepots empty (downloadableDepots=%s sharedDepots=%s branch=%s). Keeping existing acf=%s (exists=%s) to avoid regressing Steam into Update Required.", - steamAppId, - downloadableDepots.keys, - sharedDepots.keys, - installedBranch, - existing.absolutePath, - existing.exists(), - ) + val existingBuildId = if (existing.isFile) parseAcfBuildId(existing) else 0L + val existingDepotCount = if (existing.isFile) parseAcfInstalledDepotIds(existing).size else 0 + val hasValidExisting = existing.isFile && existingBuildId > 0L && existingDepotCount > 0 + if (hasValidExisting) { + // Transient PICS flake (partial depot list). The existing acf is + // structurally sound and will continue to serve Steam — quiet info, + // not a scary warn. + Timber.tag("SteamFix").i( + "createAppManifest: PICS returned empty regularDepots for appId=%d (downloadable=%s shared=%s). Existing acf is valid (buildid=%d, %d depots); keeping.", + steamAppId, downloadableDepots.keys, sharedDepots.keys, existingBuildId, existingDepotCount, + ) + } else { + Timber.tag("SteamFix").w( + "createAppManifest ABORT: appId=%d regularDepots empty (downloadableDepots=%s sharedDepots=%s branch=%s) and no valid existing acf=%s (exists=%s). Steam will likely gray Play until PICS returns full data.", + steamAppId, + downloadableDepots.keys, + sharedDepots.keys, + installedBranch, + existing.absolutePath, + existing.exists(), + ) + } // Still write 228980's manifest — it's independent of the child's // state and we just resolved fresh GIDs for it. writeSteamworksCommonManifest(steamappsDir, commonDir, lastOwner) @@ -1449,7 +1550,7 @@ object SteamUtils { val appDirPath = SteamService.getAppDirPath(steamAppId) val needsDllRestore = if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_DLL_RESTORED)) { - if (verifyRestoredState(appDirPath)) { + if (verifyRestoredState(context, appDirPath)) { Timber.tag("SteamFix").i("restoreSteamApi: DLL marker + hash ok, skipping DLL copy") false } else { @@ -1485,6 +1586,12 @@ object SteamUtils { // stale LastOwner / buildid / installdir until the DLL marker is cleared. createAppManifest(context, steamAppId) + // SteamFix #36: suppress per-launch vcredist/DirectX installer invocations + // by claiming all 228980 install scripts have already run. See doc on + // applySteamInstallScriptShim — fixes the Unity crash on Shiren where + // Steam was racing VC_redist.x64.exe against the game's MSVC loader. + applySteamInstallScriptShim(context, steamAppId) + // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId, container) From cdc1d80bed022692fb9b51a874c14fdfcd84b722 Mon Sep 17 00:00:00 2001 From: TideGear Date: Mon, 20 Apr 2026 17:52:19 -0700 Subject: [PATCH 08/34] refactor: PICS-driven shared depots + retire SteamFix diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real-Steam launch path is stable across DD / 868-HACK / Shotgun King / Baba Is You — fold in the remaining structural fixes and strip the investigation-era diagnostic scaffolding that was left behind. SteamUtils.kt: - Replace hardcoded canonical228980Depots table with a runtime PICS resolver driven by the child acf's SharedDepots. Fixes permanent "gray Play" on games whose shared-redist depot set diverges from DD's (was silently writing InstalledDepots{} + BytesToDownload=52MB on those). - Add SHA-256 verify-and-reheal to STEAM_DLL_REPLACED / STEAM_DLL_RESTORED markers in replaceSteamApi/restoreSteamApi so a stale marker can no longer lie about on-disk state (2nd-launch black-screen repro). - Add validateAcfShape() self-check: after writing any child or 228980 manifest, log Timber.e on shapes known to cause gray Play (Update Required bit set, BytesToDownload > 0, InstalledDepots empty when it shouldn't be). Catches future regressions loudly instead of silently. Diagnostic cleanup: - Drop SteamFixDiagnostics object, STEAM_HELPER_WINDOW_CLASSES, REAL_STEAM_STALL_WATCHDOG_MS, and all Timber.tag("SteamFix") log lines across SteamUtils, SteamService, SteamAutoCloud, SteamTokenLogin, MainViewModel, XServerScreen, ImageFsInstaller. - Rewrite the comments that referenced the investigation's numbered SteamFix list (#8, #9, #11, #16–#24) so they stand on their own. UX default: - Default disableSteamOverlay to true in Container.java, ContainerData.kt, and PrefManager.kt. Overlay-on is an opt-in now. Housekeeping: - .gitignore: exclude .claude/ session state. - Drop tracked .claude/scheduled_tasks.lock. --- .../main/java/app/gamenative/PrefManager.kt | 2 +- .../app/gamenative/service/SteamAutoCloud.kt | 16 +- .../app/gamenative/service/SteamService.kt | 15 +- .../app/gamenative/ui/model/MainViewModel.kt | 60 +- .../ui/screen/xserver/XServerScreen.kt | 116 +-- .../app/gamenative/utils/SteamTokenLogin.kt | 29 +- .../java/app/gamenative/utils/SteamUtils.kt | 917 +++++------------- .../com/winlator/container/Container.java | 2 +- .../com/winlator/container/ContainerData.kt | 4 +- .../xenvironment/ImageFsInstaller.java | 14 - 10 files changed, 302 insertions(+), 873 deletions(-) diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index c51ecb78b9..e758750f95 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -470,7 +470,7 @@ object PrefManager { private val DISABLE_STEAM_OVERLAY = booleanPreferencesKey("disable_steam_overlay") var disableSteamOverlay: Boolean - get() = getPref(DISABLE_STEAM_OVERLAY, false) + get() = getPref(DISABLE_STEAM_OVERLAY, true) set(value) { setPref(DISABLE_STEAM_OVERLAY, value) } diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index bd0a3665ae..59b39f397e 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -860,8 +860,7 @@ object SteamAutoCloud { } } - val rehydrateCacheSilently: suspend (String) -> Unit = { reason -> - Timber.tag("SteamFix").i("Cloud sync: $reason — rehydrating cache silently") + val rehydrateCacheSilently: suspend () -> Unit = { with(steamInstance) { db.withTransaction { fileChangeListsDao.insert(appInfo.id, allLocalUserFiles) @@ -879,7 +878,7 @@ object SteamAutoCloud { // the "cache-wiped by destructive migration, nothing actually // changed" case and should be silent. if (localMatchesRemote()) { - rehydrateCacheSilently("cache absent but local matches remote") + rehydrateCacheSilently() } else { hasLocalChanges = true conflictUfsVersion = CURRENT_UFS_PARSE_VERSION @@ -887,13 +886,10 @@ object SteamAutoCloud { localTimestamp = allLocalUserFiles.map { it.timestamp }.maxOrNull() ?: 0L } } else if (hasLocalChanges && localMatchesRemote()) { - // SteamFix: cache present but stale (diff-from-cache says - // local changed) yet local is byte-identical to remote. - // Happens after the Steam DLL swap / any path that touches - // userdata without going through the cache writer. Without - // this branch, the user gets a conflict prompt for a save - // that didn't actually change. - rehydrateCacheSilently("cache stale but local matches remote") + // Cache flagged local-changed but bytes match remote (e.g. after + // the DLL swap touched userdata outside the cache writer). Rehydrate + // silently instead of showing a bogus conflict prompt. + rehydrateCacheSilently() } if (rehydratedSilently) { diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index f642e2b7e2..2f0697a30d 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2266,8 +2266,6 @@ class SteamService : Service(), IChallengeUrlChanged { isLaunchRealSteam: Boolean = false, onProgress: ((message: String, progress: Float) -> Unit)? = null, ): Deferred = parentScope.async { - Timber.tag("SteamFix").i("beginLaunchApp: appId=%d mode=%s offline=%s", appId, if (isLaunchRealSteam) "REAL" else "EMU", isOffline) - SteamUtils.logAppDirInventory(appId, "beginLaunchApp") if (isOffline || !isConnected) { return@async PostSyncInfo(SyncResult.UpToDate) } @@ -2276,8 +2274,7 @@ class SteamService : Service(), IChallengeUrlChanged { return@async PostSyncInfo(SyncResult.InProgress) } - // SteamFix #11: only migrate GSE -> userdata when we'll actually boot - // real Steam. Reverse direction is handled in ensureSteamSettings. + // Only migrate GSE -> userdata when booting real Steam; reverse direction lives in ensureSteamSettings. SteamUtils.migrateGSESavesToSteamUserdata(instance?.applicationContext!!, appId, isLaunchRealSteam) try { @@ -2373,7 +2370,6 @@ class SteamService : Service(), IChallengeUrlChanged { return@async PostSyncInfo(SyncResult.InProgress) } - // SteamFix #11: only migrate GSE -> userdata in real-Steam mode. SteamUtils.migrateGSESavesToSteamUserdata(instance?.applicationContext!!, appId, isLaunchRealSteam) try { @@ -2428,7 +2424,6 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun closeApp(context: Context, appId: Int, isOffline: Boolean, prefixToPath: (String) -> String, isLaunchRealSteam: Boolean = false) = withContext(Dispatchers.IO) { async { - Timber.tag("SteamFix").i("closeApp: appId=%d mode=%s offline=%s", appId, if (isLaunchRealSteam) "REAL" else "EMU", isOffline) if (isOffline || !isConnected) { return@async } @@ -2439,18 +2434,14 @@ class SteamService : Service(), IChallengeUrlChanged { } try { - // SteamFix #18: in real-Steam mode, achievements are written by - // real Steam (via the Wine-hosted client) rather than Goldberg. - // Reading the Goldberg dir then is at best a no-op and at worst - // clobbers achievements we don't own. + // Real-Steam mode writes achievements via the Wine-hosted client, + // so skip Goldberg sync to avoid clobbering them. if (!isLaunchRealSteam) { try { syncAchievementsFromGoldberg(context, appId) } catch (e: Exception) { Timber.e(e, "Achievement sync failed for appId=$appId, continuing with cloud save sync") } - } else { - Timber.tag("SteamFix").i("closeApp: skipping Goldberg achievement sync (real-Steam mode)") } val maxAttempts = 3 diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index ebcb9066e8..dd62f4eac3 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -24,8 +24,6 @@ import app.gamenative.ui.enums.ConnectionState import app.gamenative.ui.screen.PluviaScreen import app.gamenative.utils.ContainerUtils import app.gamenative.utils.IntentLaunchManager -import app.gamenative.utils.STEAM_HELPER_WINDOW_CLASSES -import app.gamenative.utils.SteamFixDiagnostics import app.gamenative.utils.SteamUtils import app.gamenative.utils.UpdateInfo import com.materialkolor.PaletteStyle @@ -457,27 +455,22 @@ class MainViewModel @Inject constructor( val container = ContainerUtils.getOrCreateContainer(context, appId) val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) if (gameSource == GameSource.STEAM) { - // SteamFix #19: record the mode this launch is using so we can - // detect mode flips between launches and run #20 cleanup. + // On real->emu transition, GC the ~200 MB extracted Steam client + // we won't need until the next real-Steam launch. Re-extraction + // on the next transition is fast. val previousMode = container.getExtra("lastSteamMode", "") val currentMode = if (container.isLaunchRealSteam()) "real" else "emu" if (previousMode != currentMode) { - Timber.tag("SteamFix").i("launchApp: Steam mode change %s -> %s for appId=%s", previousMode, currentMode, appId) container.putExtra("lastSteamMode", currentMode) container.saveData() - // SteamFix #20: on real->emu transitions, GC the ~200 MB - // extracted Steam client we won't need until the next ON - // launch. Extraction is fast on re-entry. if (previousMode == "real" && currentMode == "emu") { SteamUtils.cleanupExtractedSteamFiles(context) } } if (container.isLaunchRealSteam()) { - Timber.tag("SteamFix").i("launchApp: real-Steam mode, running restoreSteamApi") SteamUtils.restoreSteamApi(context, appId) } else { val offline = _offline.value - Timber.tag("SteamFix").i("launchApp: emulated mode, useLegacyDRM=%s offline=%s", container.isUseLegacyDRM, offline) if (container.isUseLegacyDRM) { SteamUtils.replaceSteamApi(context, appId, offline) } else { @@ -643,17 +636,7 @@ class MainViewModel @Inject constructor( gameExe == windowExe } - // SteamFix: remember the class of every mapped window so if the - // stall watchdog fires later, it can name what was on screen at - // that moment instead of saying "no window mapped" vaguely. - SteamFixDiagnostics.lastMappedWindowClass = window.className - if (launchConfig != null) { - // SteamFix: record that the *game* window mapped (not just - // any window) so the stall watchdog can tell "game actually - // launched" apart from "stuck in Steam bootstrapper." - SteamFixDiagnostics.gameWindowMapped = true - SteamFixDiagnostics.gameWindowClass = window.className val steamProcessId = Process.myPid() val processes = mutableListOf() var currentWindow: Window = window @@ -674,16 +657,6 @@ class MainViewModel @Inject constructor( } while (parentWindow != null) val installedBranch = SteamService.getInstalledApp(gameId)?.branch ?: "public" - // SteamFix #22/#23: log the process-tree walk we just did so a - // black-screen report includes which window class was chosen as - // the game and what its parent chain looked like. Under real - // Steam the chain terminates at steam.exe rather than explorer. - val topWindowClass = window.className - val parentClass = window.parent?.className - Timber.tag("SteamFix").i( - "onWindowMapped: appId=%d exe=%s window=%s parent=%s chainDepth=%d", - gameId, launchConfig.executable, topWindowClass, parentClass, processes.size, - ) GameProcessInfo(appId = gameId, branch = installedBranch, processes = processes).let { // Only notify Steam if we're not using real Steam // When launchRealSteam is true, let the real Steam client handle the "game is running" notification @@ -698,34 +671,9 @@ class MainViewModel @Inject constructor( if (!shouldLaunchRealSteam) { SteamService.notifyRunningProcesses(it) } else { - Timber.tag("SteamFix").i("onWindowMapped: skipping process notification (real Steam owns this)") + Timber.tag("MainViewModel").i("Skipping Steam process notification - real Steam will handle this") } } - } else { - // SteamFix #22: we mapped a window but no known launch exe - // matched. Two flavors here: - // (1) it's one of Steam's own helper windows (explorer, - // steam.exe, conhost, overlay, winhandler…). These fire - // constantly during bootstrapping — log at debug. - // (2) it's *something else*. That's the signal we care - // about: login prompt, cloud-conflict dialog, update- - // required modal. Warn, and remember it for the - // stall watchdog. - val cls = window.className - val isHelper = cls in STEAM_HELPER_WINDOW_CLASSES - if (isHelper) { - Timber.tag("SteamFix").d( - "onWindowMapped: Steam helper window mapped class=%s (appId=%d)", - cls, gameId, - ) - } else { - SteamFixDiagnostics.lastUnmatchedWindowClass = cls - SteamFixDiagnostics.unmatchedWindowCount += 1 - Timber.tag("SteamFix").w( - "onWindowMapped: unmatched non-helper window class=%s (appId=%d). Likely a Steam modal (login, cloud conflict, update-required).", - cls, gameId, - ) - } } } } 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 3665f7cd7a..02fac1ca67 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 @@ -105,7 +105,6 @@ import app.gamenative.utils.ExecutableSelectionUtils import app.gamenative.utils.LsfgQuickMenuHelper import app.gamenative.utils.ManifestComponentHelper import app.gamenative.utils.PreInstallSteps -import app.gamenative.utils.SteamFixDiagnostics import app.gamenative.utils.SteamTokenLogin import app.gamenative.utils.SteamUtils import com.posthog.PostHog @@ -169,7 +168,6 @@ import com.winlator.xserver.Window import com.winlator.xserver.WindowManager import com.winlator.xserver.XServer import com.winlator.xserver.extensions.PresentExtension -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -240,23 +238,11 @@ private fun detectMaxRefreshRateHz(context: Context, attachedView: View?): Int { ?: DEFAULT_FPS_LIMITER_MAX_HZ } -/** - * SteamFix #21: flags passed to `steam.exe` when launching through real Steam. - * Kept as a constant so (a) we can override via a per-container extra if we - * ever need to expose it to power users, and (b) the log line right before - * launch is inspectable. - * - * - `-silent`: start without the main window - * - `-vgui`: use the classic UI renderer (more compatible under Wine) - * - `-tcp`: force TCP client so Steam doesn't wait on named-pipe handshake - * - `-nobigpicture -nofriendsui -nochatui -nointro`: suppress optional UIs - */ +// Flags passed to steam.exe in real-Steam launch mode. +// -silent: start without main window; -vgui: classic UI renderer (more compatible under Wine); +// -tcp: avoid named-pipe handshake wait; -nobigpicture/-nofriendsui/-nochatui/-nointro: suppress optional UIs. private const val STEAM_LAUNCH_FLAGS = "-silent -vgui -tcp -nobigpicture -nofriendsui -nochatui -nointro" -/** SteamFix #8: if we launched real Steam but the game window hasn't appeared - * in this long, log a loud "likely stall" line so users can grab it. */ -private const val REAL_STEAM_STALL_WATCHDOG_MS = 60_000L - private data class XServerViewReleaseBinding( val xServerView: XServerView, val windowModificationListener: WindowManager.OnWindowModificationListener, @@ -3300,10 +3286,6 @@ private fun setupXEnvironment( // Moved here, as guestProgramLauncherComponent.environment is setup after addComponent() if (container != null) { if (container.isLaunchRealSteam) { - Timber.tag("SteamFix").i( - "SteamTokenLogin: writing config.vdf for login=%s steamId=%s (refreshToken len=%d)", - PrefManager.username, PrefManager.steamUserSteamId64, PrefManager.refreshToken.length, - ) SteamTokenLogin( steamId = PrefManager.steamUserSteamId64.toString(), login = PrefManager.username, @@ -3311,7 +3293,6 @@ private fun setupXEnvironment( imageFs = imageFs, guestProgramLauncherComponent = guestProgramLauncherComponent, ).setupSteamFiles() - Timber.tag("SteamFix").i("SteamTokenLogin: setupSteamFiles complete") } } @@ -3338,28 +3319,18 @@ private fun setupXEnvironment( Timber.i("---------------------------") } - // Request encrypted app ticket for Steam games at launch time. - // SteamFix #16: in real-Steam mode, steam.exe fetches its own session ticket - // through the official libsteam_api path, so we deliberately skip pre-warming - // the GSE-facing ticket cache. Noted in logs so a real-Steam black-screen - // investigation can rule this branch in/out. + // In real-Steam mode steam.exe fetches its own session ticket via libsteam_api, + // so pre-warming the GSE-facing ticket cache is unnecessary. val isCustomGame = gameSource == GameSource.CUSTOM_GAME val gameIdForTicket = ContainerUtils.extractGameIdFromContainerId(appId) if (!bootToContainer && !isCustomGame && gameIdForTicket != null && !container.isLaunchRealSteam) { CoroutineScope(Dispatchers.IO).launch { try { - val ticket = SteamService.instance?.getEncryptedAppTicket(gameIdForTicket) - if (ticket != null) { - Timber.tag("SteamFix").i("Encrypted app ticket retrieved for app %s", gameIdForTicket) - } else { - Timber.tag("SteamFix").w("Encrypted app ticket came back null for app %s", gameIdForTicket) - } + SteamService.instance?.getEncryptedAppTicket(gameIdForTicket) } catch (e: Exception) { - Timber.tag("SteamFix").e(e, "Encrypted app ticket request threw for app %s", gameIdForTicket) + Timber.e(e, "Encrypted app ticket request failed for app $gameIdForTicket") } } - } else if (container.isLaunchRealSteam) { - Timber.tag("SteamFix").i("Skipping encrypted app-ticket pre-warm for app %s (real Steam owns ticket)", gameIdForTicket) } if (container.wineVersion.lowercase().contains("proton-10") && container.getExtra("xaudioDllsExtracted").isEmpty()) { @@ -3375,11 +3346,6 @@ private fun setupXEnvironment( } try { - Timber.tag("SteamFix").i( - "startEnvironmentComponents: mode=%s steamClientComponentLoaded=%s", - if (container.isLaunchRealSteam) "REAL" else "EMU", - !container.isLaunchRealSteam, - ) environment.startEnvironmentComponents() } catch (e: Exception) { Timber.e(e, "Failed to start environment components, cleaning up") @@ -3391,54 +3357,6 @@ private fun setupXEnvironment( throw e } - // SteamFix #8: watchdog. If we're launching real Steam, log breadcrumbs - // and fire a loud warning after REAL_STEAM_STALL_WATCHDOG_MS if we - // haven't observed a game-window map yet. Gets cancelled by onWindowMapped - // via the global event bus hook elsewhere — worst case it fires once and - // gives the user a precise log line to report. - // SteamFix #17: log that the Steam pipe is expected NOT to be up (we did - // not add SteamClientComponent in real-Steam mode) so a user chasing the - // black screen doesn't assume the pipe is the culprit. - if (container.isLaunchRealSteam) { - // SteamFix: reset diagnostic breadcrumbs so the watchdog reports what - // happened in THIS launch, not leftover state from a prior one. - SteamFixDiagnostics.reset() - Timber.tag("SteamFix").i("real-Steam mode: emulated Steam pipe NOT started (real Steam will own it via steam.exe)") - CoroutineScope(Dispatchers.IO).launch { - try { - delay(REAL_STEAM_STALL_WATCHDOG_MS) - // SteamFix: the game window may have mapped successfully before - // the delay elapsed. If so, emit a short "ok" breadcrumb - // instead of the false-alarm stall warning — previous version - // always warned because later helper windows clobbered the - // `lastMappedClass` field. - if (SteamFixDiagnostics.gameWindowMapped) { - Timber.tag("SteamFix").i( - "STALL WATCHDOG: %d ms elapsed but game window already mapped (class=%s). No stall.", - REAL_STEAM_STALL_WATCHDOG_MS, - SteamFixDiagnostics.gameWindowClass ?: "", - ) - return@launch - } - val lastMapped = SteamFixDiagnostics.lastMappedWindowClass ?: "" - val lastUnmatched = SteamFixDiagnostics.lastUnmatchedWindowClass ?: "" - val unmatchedCount = SteamFixDiagnostics.unmatchedWindowCount - Timber.tag("SteamFix").w( - "STALL WATCHDOG: >%d ms after launch of real Steam for %s, no game window mapped yet. " + - "lastMappedClass=%s lastUnmatchedClass=%s unmatchedCount=%d. " + - "If lastUnmatchedClass is a real window (not a Steam helper), that's the dialog the user needs to click.", - REAL_STEAM_STALL_WATCHDOG_MS, appId, lastMapped, lastUnmatched, unmatchedCount, - ) - } catch (_: CancellationException) { - // expected when the scope is cancelled at exit - } catch (e: Exception) { - Timber.tag("SteamFix").w(e, "stall watchdog failed") - } - } - } else { - Timber.tag("SteamFix").i("emulated mode: SteamClientComponent started as in-process pipe") - } - if (gameSource == GameSource.STEAM) { val gameIdInt = ContainerUtils.extractGameIdFromContainerId(appId) val achAppId = SteamService.cachedAchievementsAppId @@ -3841,8 +3759,6 @@ private fun getWineStartCommand( "\"wfm.exe\"" } else { if (container.isLaunchRealSteam) { - // Launch Steam with the applaunch parameter to start the game - Timber.tag("SteamFix").i("Building real-Steam launch command for gameId=%s flags=%s", gameId, STEAM_LAUNCH_FLAGS) "\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" $STEAM_LAUNCH_FLAGS -applaunch $gameId" } else { var executablePath = "" @@ -4376,24 +4292,18 @@ private fun setupWineSystemFiles( } if (container.isLaunchRealSteam){ - // SteamFix #6: invalidate the Steam extraction when Wine version or - // variant has changed. A stale steam.exe compiled against an older Wine - // ABI is one of the quiet ways the ON toggle goes black-screen. + // A stale steam.exe compiled against an older Wine ABI silently black-screens + // on launch, so invalidate the extraction when Wine version or variant changes. val steamExtractedKey = "${container.wineVersion}|${container.containerVariant}" val steamExtractedPrev = container.getExtra("steamExtractedForWine") val steamExeFile = File(ImageFs.find(context).rootDir.absolutePath, ImageFs.WINEPREFIX + "/drive_c/Program Files (x86)/Steam/steam.exe") - val wineChanged = steamExtractedPrev != steamExtractedKey - if (wineChanged && steamExeFile.exists()) { - Timber.tag("SteamFix").w("Wine change detected (%s -> %s), wiping old Steam install to force re-extract", steamExtractedPrev, steamExtractedKey) + if (steamExtractedPrev != steamExtractedKey && steamExeFile.exists()) { + // Symlink-safe delete: steamapps/common/ symlinks point at + // GameNative's own Steam dir, and a following recursive delete would wipe + // every installed game's files. val steamDir = File(ImageFs.find(context).rootDir.absolutePath, ImageFs.WINEPREFIX + "/drive_c/Program Files (x86)/Steam") - // SteamFix: symlink-safe delete. `File.deleteRecursively()` follows - // symlinks, and `steamapps/common/` is a symlink to the - // real game-content directory under GameNative's own Steam dir. A - // plain recursive delete here was deleting every installed game's - // files as a side effect of toggling Wine/Steam Client. SteamUtils.deleteTreeNoFollowSymlinks(steamDir) } - Timber.tag("SteamFix").i("extractSteamFiles: wineKey=%s steamExeExists=%s", steamExtractedKey, steamExeFile.exists()) extractSteamFiles(context, container, onExtractFileListener) container.putExtra("steamExtractedForWine", steamExtractedKey) containerDataChanged = true diff --git a/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt b/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt index c504b7ea31..d64330ddfd 100644 --- a/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt +++ b/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt @@ -27,10 +27,8 @@ import kotlin.io.path.exists const val NULL_CHAR = '\u0000' const val TOKEN_EXPIRE_TIME = 86400L // 1 day -// SteamFix #24: cap synchronous Wine invocations. `steam-token.exe` shells out -// through box64 + wine, which on cold boot can wedge forever when Wine's -// prefix is mid-update. 30 s is generous enough for cold boots and still -// prevents an infinite black screen. +// Bound synchronous Wine invocations — steam-token.exe can wedge forever on cold +// boot when the Wine prefix is mid-update, which otherwise black-screens the app. private const val WINE_EXEC_TIMEOUT_SECONDS = 30L class SteamTokenLogin( @@ -55,7 +53,6 @@ class SteamTokenLogin( private fun execCommand(command: String) : String { val launcher = guestProgramLauncherComponent ?: throw IllegalStateException("GuestProgramLauncherComponent is required for command execution") - // SteamFix #24: bound wall-clock so a stuck wine invocation can't black-screen us. val executor = Executors.newSingleThreadExecutor { r -> Thread(r, "SteamTokenLogin-exec").apply { isDaemon = true } } @@ -67,7 +64,7 @@ class SteamTokenLogin( future.get(WINE_EXEC_TIMEOUT_SECONDS, TimeUnit.SECONDS) } catch (e: TimeoutException) { future.cancel(true) - Timber.tag("SteamFix").e("wine exec timed out after %ds: %s", WINE_EXEC_TIMEOUT_SECONDS, command) + Timber.tag("SteamTokenLogin").e("wine exec timed out after %ds: %s", WINE_EXEC_TIMEOUT_SECONDS, command) throw IllegalStateException("wine exec timed out: $command", e) } } finally { @@ -206,24 +203,10 @@ class SteamTokenLogin( if (mtbf != null && connectCacheValue != null) { try { val dToken = deobfuscateToken(connectCacheValue.trimEnd(NULL_CHAR), mtbf.toLong()).trimEnd(NULL_CHAR) - // SteamFix #9: compare the decoded token against the refresh - // token we're about to write. If they diverge, the user - // logged in with a different account or we corrupted the - // value — either way force a rewrite instead of trusting - // the in-prefix value. - val tokenMatches = dToken == token - if (!tokenMatches) { - Timber.tag("SteamFix").w("config.vdf: saved JWT != current refresh token, forcing rewrite") - shouldWriteConfig = true - } else if (JWT(dToken).isExpired(TOKEN_EXPIRE_TIME)) { - Timber.tag("SteamFix").i("config.vdf: saved JWT expired, rewriting") - shouldWriteConfig = true - } else { - Timber.tag("SteamFix").d("config.vdf: saved JWT matches + valid, keeping") - shouldWriteConfig = false - } + // If the stored token diverges from the current refresh token + // (different account, or corrupted value), force a rewrite. + shouldWriteConfig = dToken != token || JWT(dToken).isExpired(TOKEN_EXPIRE_TIME) } catch (_: Exception) { - Timber.tag("SteamFix").w("config.vdf: saved JWT unparseable, forcing rewrite") shouldWriteConfig = true } } else { diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 5d61486489..a8fc75f749 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -42,53 +42,6 @@ import java.nio.file.attribute.FileTime import java.util.concurrent.TimeUnit import kotlin.io.path.setLastModifiedTime -/** - * Shared SteamFix diagnostics. Written from onWindowMapped so the stall - * watchdog (and any other late-firing diagnostic) can name *what* was on - * screen at the moment it gave up. A null value means "nothing mapped yet." - */ -object SteamFixDiagnostics { - @Volatile var lastMappedWindowClass: String? = null - @Volatile var lastUnmatchedWindowClass: String? = null - @Volatile var unmatchedWindowCount: Int = 0 - - /** - * The window class of the last mapped window that matched a known launch - * config (i.e. the game window itself). Survives later noise from helper - * windows, so the stall watchdog can distinguish "game never appeared" - * from "game appeared, then something else grabbed focus." - */ - @Volatile var gameWindowMapped: Boolean = false - @Volatile var gameWindowClass: String? = null - - fun reset() { - lastMappedWindowClass = null - lastUnmatchedWindowClass = null - unmatchedWindowCount = 0 - gameWindowMapped = false - gameWindowClass = null - } -} - -/** - * Window classes we know are Steam's own bootstrapper/helper windows. - * Seeing these mapped is normal and does not indicate a modal; log at debug. - * Anything else in real-Steam mode is more suspicious (login prompt, cloud - * conflict, update-required dialog) and is worth warning about. - */ -val STEAM_HELPER_WINDOW_CLASSES: Set = setOf( - "", - "explorer.exe", - "steam.exe", - "steamwebhelper.exe", - "conhost.exe", - "winhandler.exe", - "gameoverlayui.exe", - "steamerrorreporter.exe", - "steamerrorreporter64.exe", - "steamservice.exe", -) - object SteamUtils { fun getDownloadBytes(manifest: ManifestInfo?): Long { @@ -197,20 +150,72 @@ object SteamUtils { } /** - * SteamFix #7: hash-verify that the steam_api DLL currently on disk matches - * the pipe DLL shipped in assets. An interrupted launch can desync the marker - * (marker says "replaced" but DLLs are actually original, or vice versa). + * Mark the Steamworks Common Redistributables (228980) install scripts as + * already executed, so `steam.exe -applaunch` does not re-launch the bundled + * vcredist / DXSETUP installers on every boot (which can race the game's + * MSVC loader and steal window focus). Writes Installed=1/Run=1 under the + * HKLM keys Steam consults before invoking an InstallScript. */ - private fun verifyReplacedState(context: Context, appDirPath: String): Boolean { - return try { - val assetHashes = mutableMapOf() - listOf("steam_api.dll", "steam_api64.dll").forEach { name -> - runCatching { - context.assets.open("steampipe/$name").use { ins -> - assetHashes[name.lowercase()] = sha256OfStream(ins) + fun applySteamInstallScriptShim(context: Context, steamAppId: Int) { + try { + val imageFs = ImageFs.find(context) + val systemRegFile = File(imageFs.wineprefix, "system.reg") + if (!systemRegFile.isFile) return + + val appKeys = listOf( + "Software\\Valve\\Steam\\Apps\\228980", + "Software\\Wow6432Node\\Valve\\Steam\\Apps\\228980", + "Software\\Valve\\Steam\\Apps\\$steamAppId", + "Software\\Wow6432Node\\Valve\\Steam\\Apps\\$steamAppId", + ) + val depotIds = sharedDepotInstallScripts.keys + + WineRegistryEditor(systemRegFile).use { reg -> + reg.setCreateKeyIfNotExist(true) + appKeys.forEach { key -> + reg.setDwordValue(key, "Installed", 1) + reg.setDwordValue(key, "Updating", 0) + reg.setDwordValue(key, "Running", 0) + } + depotIds.forEach { depotId -> + val perDepotKeys = listOf( + "Software\\Valve\\Steam\\Apps\\228980\\Depots\\$depotId", + "Software\\Wow6432Node\\Valve\\Steam\\Apps\\228980\\Depots\\$depotId", + "Software\\Valve\\Steam\\Apps\\$steamAppId\\Depots\\$depotId", + "Software\\Wow6432Node\\Valve\\Steam\\Apps\\$steamAppId\\Depots\\$depotId", + "Software\\Valve\\Steam\\InstallScripts\\$depotId", + "Software\\Wow6432Node\\Valve\\Steam\\InstallScripts\\$depotId", + ) + perDepotKeys.forEach { key -> + reg.setDwordValue(key, "Installed", 1) + reg.setDwordValue(key, "Run", 1) } } } + } catch (e: Exception) { + Timber.w(e, "applySteamInstallScriptShim failed for appId=%d", steamAppId) + } + } + + // Hash-verify the steam_api DLL on disk against the pipe DLL shipped in assets. + // An interrupted launch can desync markers from reality (marker says REPLACED but + // the DLL is the game's original, or vice versa). Returns true if the marker's + // claim matches disk. Any IO/hash failure is treated as desync so we reheal. + private fun pipeDllHashes(context: Context): Map { + val out = mutableMapOf() + listOf("steam_api.dll", "steam_api64.dll").forEach { name -> + runCatching { + context.assets.open("steampipe/$name").use { ins -> + out[name.lowercase()] = sha256OfStream(ins) + } + } + } + return out + } + + private fun verifyReplacedState(context: Context, appDirPath: String): Boolean { + return try { + val assetHashes = pipeDllHashes(context) var found = false Paths.get(appDirPath).toFile().walkTopDown().maxDepth(10).forEach { file -> if (!file.isFile) return@forEach @@ -218,68 +223,45 @@ object SteamUtils { if (n == "steam_api.dll" || n == "steam_api64.dll") { found = true val expected = assetHashes[n] ?: return@forEach - val actual = sha256OfFile(file) - if (actual != expected) { - Timber.tag("SteamFix").w("DLL marker desync: %s hash mismatch (marker says REPLACED)", file.absolutePath) + if (sha256OfFile(file) != expected) { + Timber.w("DLL marker desync: %s hash mismatch (marker says REPLACED)", file.absolutePath) return false } } } if (!found) { - Timber.tag("SteamFix").w("DLL marker desync: no steam_api DLL found at $appDirPath but REPLACED marker present") + Timber.w("DLL marker desync: no steam_api DLL found under %s but REPLACED marker present", appDirPath) return false } true } catch (e: Exception) { - Timber.tag("SteamFix").w(e, "verifyReplacedState failed, treating as desync") + Timber.w(e, "verifyReplacedState failed, treating as desync") false } } - /** - * SteamFix #7: verify RESTORED marker. A restored DLL is defined as "NOT our - * pipe DLL" — we compare each on-disk `steam_api*.dll` against the pipe DLL - * shipped in assets. If the hash matches the pipe, the DLL is still replaced - * and the marker is lying. If the hash differs, the DLL is either the game's - * original (never replaced) or successfully put back from .orig; either way - * RESTORED is a truthful state. The .orig sibling is optional — games whose - * emu-mode path never called replaceSteamApi (e.g. non-legacy-DRM games like - * Shiren) never have a .orig, and that is fine. - */ private fun verifyRestoredState(context: Context, appDirPath: String): Boolean { return try { - val assetHashes = mutableMapOf() - listOf("steam_api.dll", "steam_api64.dll").forEach { name -> - runCatching { - context.assets.open("steampipe/$name").use { ins -> - assetHashes[name.lowercase()] = sha256OfStream(ins) - } - } - } + val assetHashes = pipeDllHashes(context) Paths.get(appDirPath).toFile().walkTopDown().maxDepth(10).forEach { file -> if (!file.isFile) return@forEach val n = file.name.lowercase() if (n == "steam_api.dll" || n == "steam_api64.dll") { val pipeHash = assetHashes[n] ?: return@forEach if (sha256OfFile(file) == pipeHash) { - Timber.tag("SteamFix").w( - "DLL marker desync: %s is still the pipe DLL (marker says RESTORED)", - file.absolutePath, - ) + Timber.w("DLL marker desync: %s is still the pipe DLL (marker says RESTORED)", file.absolutePath) return false } } } true } catch (e: Exception) { - Timber.tag("SteamFix").w(e, "verifyRestoredState failed, treating as desync") + Timber.w(e, "verifyRestoredState failed, treating as desync") false } } - private fun sha256OfFile(file: File): String { - file.inputStream().use { return sha256OfStream(it) } - } + private fun sha256OfFile(file: File): String = file.inputStream().use { sha256OfStream(it) } private fun sha256OfStream(input: java.io.InputStream): String { val md = MessageDigest.getInstance("SHA-256") @@ -292,81 +274,6 @@ object SteamUtils { return md.digest().joinToString("") { "%02x".format(it) } } - /** - * SteamFix: tell the Wine-hosted Steam client that the Steamworks Common - * Redistributables (228980) plus each canonical child depot's install - * script has already executed, so `steam.exe -applaunch` does not - * re-launch the bundled `vc_redist.x86.exe` / `vc_redist.x64.exe` / - * `DXSETUP.exe` installers on every boot. Those installers race the - * Unity MSVC runtime (we've seen the resulting crash on Shiren, appId - * 2178480) and also grab focus away from the game window. - * - * Steam checks these registry paths before running an InstallScript: - * - HKLM\Software\Valve\Steam\Apps\ (app registry) - * - HKLM\Software\Wow6432Node\Valve\Steam\Apps\ (32-bit view) - * - HKLM\Software\Valve\Steam\Apps\\Depots\ (per-depot) - * - HKLM\Software\Valve\Steam\InstallScripts\ (script tracker) - * - * We set `Installed=1` and `Run=1` across all of them. Writes go to - * `system.reg` (HKLM) via WineRegistryEditor. Idempotent: if a key - * already has the value, the editor short-circuits. Safe to fail — the - * worst outcome is that Steam runs the installer again (status quo). - */ - fun applySteamInstallScriptShim(context: Context, steamAppId: Int) { - try { - val imageFs = ImageFs.find(context) - val systemRegFile = File(imageFs.wineprefix, "system.reg") - if (!systemRegFile.isFile) { - Timber.tag("SteamFix").w( - "applySteamInstallScriptShim: system.reg missing at %s — skipping", - systemRegFile.absolutePath, - ) - return - } - - val appKeys = listOf( - "Software\\Valve\\Steam\\Apps\\228980", - "Software\\Wow6432Node\\Valve\\Steam\\Apps\\228980", - "Software\\Valve\\Steam\\Apps\\$steamAppId", - "Software\\Wow6432Node\\Valve\\Steam\\Apps\\$steamAppId", - ) - val depotIds = canonical228980Depots.map { it.id } - var writes = 0 - - WineRegistryEditor(systemRegFile).use { reg -> - reg.setCreateKeyIfNotExist(true) - appKeys.forEach { key -> - reg.setDwordValue(key, "Installed", 1) - reg.setDwordValue(key, "Updating", 0) - reg.setDwordValue(key, "Running", 0) - writes += 3 - } - depotIds.forEach { depotId -> - val perDepotKeys = listOf( - "Software\\Valve\\Steam\\Apps\\228980\\Depots\\$depotId", - "Software\\Wow6432Node\\Valve\\Steam\\Apps\\228980\\Depots\\$depotId", - "Software\\Valve\\Steam\\Apps\\$steamAppId\\Depots\\$depotId", - "Software\\Wow6432Node\\Valve\\Steam\\Apps\\$steamAppId\\Depots\\$depotId", - "Software\\Valve\\Steam\\InstallScripts\\$depotId", - "Software\\Wow6432Node\\Valve\\Steam\\InstallScripts\\$depotId", - ) - perDepotKeys.forEach { key -> - reg.setDwordValue(key, "Installed", 1) - reg.setDwordValue(key, "Run", 1) - writes += 2 - } - } - } - - Timber.tag("SteamFix").i( - "applySteamInstallScriptShim: wrote %d reg entries for appId=%d + 228980 (%d canonical depots)", - writes, steamAppId, depotIds.size, - ) - } catch (e: Exception) { - Timber.tag("SteamFix").w(e, "applySteamInstallScriptShim failed for appId=%d", steamAppId) - } - } - /** * Replaces any existing `steam_api.dll` or `steam_api64.dll` in the app directory * with our pipe dll stored in assets @@ -374,13 +281,10 @@ object SteamUtils { suspend fun replaceSteamApi(context: Context, appId: String, isOffline: Boolean = false) { val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) val appDirPath = SteamService.getAppDirPath(steamAppId) - Timber.tag("SteamFix").i("replaceSteamApi: appId=%s dir=%s", appId, appDirPath) if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_DLL_REPLACED)) { if (verifyReplacedState(context, appDirPath)) { - Timber.tag("SteamFix").i("replaceSteamApi: marker + hash ok, skipping") return } - Timber.tag("SteamFix").w("replaceSteamApi: clearing stale REPLACED marker and re-running swap") MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) } MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_RESTORED) @@ -460,7 +364,6 @@ object SteamUtils { generateAchievementsFile(rootPath.resolve("steam_settings"), appId) MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_REPLACED) - logAppDirInventory(ContainerUtils.extractGameIdFromContainerId(appId), "replaceSteamApi.done") } /** @@ -506,10 +409,9 @@ object SteamUtils { // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId, container) - applySteamOverlayPref(context, container) + restoreLegacyStashedOverlayFiles(container) MarkerUtils.addMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) - logAppDirInventory(steamAppId, "replaceSteamclientDll.done") } fun steamClientFiles() : Array { @@ -542,74 +444,28 @@ object SteamUtils { Timber.i("Finished backupSteamclientFiles for appId: $steamAppId. Backed up $backupCount file(s)") } - // Every Steam-overlay file a running game can pick up. Rename only the - // binaries — leave the Vulkan layer JSON manifests in place so Wine's - // Vulkan loader can parse them, discover the referenced DLL is missing, - // log a warning, and skip the layer cleanly. Renaming the JSONs instead - // can race with loader init and stall early Vulkan startup. - // GameOverlayUI.exe is the in-game UI host (inventory popup, shift-tab - // overlay) and is safe to stash. - private val steamOverlayFiles = arrayOf( + // Upgrade helper: a prior build disabled the Steam overlay by renaming + // overlay binaries (and Vulkan layer JSONs) to `.disabled`. The overlay + // disable is now done through Khronos env vars at launch time, so any + // leftover `.disabled` stashes need to be restored or the overlay stays + // broken even with the toggle off. Runs once per launch; a no-op once + // clean. Safe to delete after a release or two. + private val legacyStashedOverlayFiles = arrayOf( "GameOverlayRenderer.dll", "GameOverlayRenderer64.dll", "SteamOverlayVulkanLayer.dll", "SteamOverlayVulkanLayer64.dll", "GameOverlayUI.exe", - ) - - /** - * Apply the container's disableSteamOverlay setting. Rename-based: when - * enabled, stash overlay files with a `.disabled` suffix; when disabled, - * un-stash. Idempotent on both sides, and survives Steam's re-extraction - * of client files (a fresh client install won't touch the stashed copies). - * Stashes the D3D renderer DLLs, the Vulkan overlay layer DLLs, and the - * overlay UI process. Also restores any Vulkan layer manifest JSONs an - * older build had stashed (see legacyStashedFiles). - */ - // Files an older build stashed that we no longer want to keep hidden. - // Always restore these if a `.disabled` copy exists — covers upgrade from - // the previous overlay-disable logic that renamed the Vulkan layer JSONs. - private val legacyStashedFiles = arrayOf( "SteamOverlayVulkanLayer.json", "SteamOverlayVulkanLayer64.json", ) - fun applySteamOverlayPref(context: Context, container: com.winlator.container.Container) { + private fun restoreLegacyStashedOverlayFiles(container: com.winlator.container.Container) { val steamDir = File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam") - - var legacyRestored = 0 - legacyStashedFiles.forEach { name -> + legacyStashedOverlayFiles.forEach { name -> val live = File(steamDir, name) - val stashedFile = File(steamDir, "$name.disabled") - if (stashedFile.exists() && !live.exists()) { - if (stashedFile.renameTo(live)) legacyRestored++ - } - } - if (legacyRestored > 0) { - Timber.tag("SteamFix").i("applySteamOverlayPref: restored %d legacy-stashed file(s)", legacyRestored) - } - - if (container.isDisableSteamOverlay) { - var stashed = 0 - steamOverlayFiles.forEach { name -> - val live = File(steamDir, name) - val stashedFile = File(steamDir, "$name.disabled") - if (live.exists()) { - if (stashedFile.exists()) stashedFile.delete() - if (live.renameTo(stashedFile)) stashed++ - } - } - Timber.tag("SteamFix").i("applySteamOverlayPref: disabled, stashed %d overlay file(s)", stashed) - } else { - var restored = 0 - steamOverlayFiles.forEach { name -> - val live = File(steamDir, name) - val stashedFile = File(steamDir, "$name.disabled") - if (stashedFile.exists() && !live.exists()) { - if (stashedFile.renameTo(live)) restored++ - } - } - if (restored > 0) Timber.tag("SteamFix").i("applySteamOverlayPref: enabled, restored %d overlay file(s)", restored) + val stashed = File(steamDir, "$name.disabled") + if (stashed.exists() && !live.exists()) stashed.renameTo(live) } } @@ -680,95 +536,15 @@ object SteamUtils { """.trimIndent() } - /** - * Resolve the ColdClient-style path "steamapps\common\\" - * case-insensitively against the on-disk Linux filesystem. Inner files may - * have been installed with different casing (Steam → on-disk) and Linux - * case-sensitivity turns what Windows treats as a match into a - * "file not found" error from ColdClientLoader. - * - * Returns the actual-casing relative path (using forward slashes) when all - * components exist on disk. Returns null when a component is missing, - * which is the "install is actually broken" case rather than a casing - * mismatch. Logs at SteamFix level when casing differs so the logcat shows - * exactly which component was wrong. - */ - internal fun resolveOnDiskCasing( - steamDir: File, - gameName: String, - executablePath: String, - ): String? { - val relComponents = buildList { - add("steamapps") - add("common") - add(gameName) - executablePath.replace("\\", "/").split("/").filter { it.isNotEmpty() }.forEach { add(it) } - } - var cursor: File = steamDir - val resolved = mutableListOf() - for ((idx, requested) in relComponents.withIndex()) { - val exact = File(cursor, requested) - val child: File? = if (exact.exists()) { - exact - } else { - cursor.listFiles()?.firstOrNull { it.name.equals(requested, ignoreCase = true) } - } - if (child == null) { - Timber.tag("SteamFix").w( - "ColdClient exe resolve MISS: component %d (%s) not found under %s. siblings=%s", - idx, requested, cursor.absolutePath, - cursor.listFiles()?.joinToString(", ") { it.name } ?: "", - ) - return null - } - if (child.name != requested) { - Timber.tag("SteamFix").w( - "ColdClient exe resolve CASE MISMATCH: requested '%s' on-disk '%s' at depth %d (%s)", - requested, child.name, idx, cursor.absolutePath, - ) - } - resolved += child.name - cursor = child - } - return resolved.joinToString("/") - } - internal fun writeColdClientIni(steamAppId: Int, container: Container, launchInfo: LaunchInfo? = null) { val gameName = getAppDirName(getAppInfoOf(steamAppId)) val workingDir = launchInfo?.workingDir val iniFile = File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/ColdClientLoader.ini") iniFile.parentFile?.mkdirs() - - // SteamFix: resolve the "steamapps\common\\" path against - // the actual on-disk filesystem before we bake it into the INI. If any - // component's casing differs we log it; if the exe is genuinely missing - // we still write the INI (so ColdClient's own error surfaces as before) - // but the logcat now shows exactly which path component went wrong. - val steamDir = File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam") - val resolved = resolveOnDiskCasing(steamDir, gameName, container.executablePath) - val (effectiveGameName, effectiveExe) = if (resolved != null) { - val parts = resolved.split("/") - // Components 0,1 are steamapps/common; 2 is gameName; rest is exe path. - val diskGameName = parts.getOrNull(2) ?: gameName - val diskExe = parts.drop(3).joinToString("/") - if (diskGameName != gameName || diskExe.replace("/", "\\") != container.executablePath.replace("/", "\\")) { - Timber.tag("SteamFix").i( - "ColdClient INI using on-disk casing: gameName='%s' (requested '%s'), exe='%s' (requested '%s')", - diskGameName, gameName, diskExe, container.executablePath, - ) - } - diskGameName to diskExe - } else { - Timber.tag("SteamFix").w( - "ColdClient INI: falling back to requested casing because resolve failed. ColdClientLoader will likely report 'couldn't find the requested exe file'.", - ) - gameName to container.executablePath - } - iniFile.writeText( generateColdClientIni( - gameName = effectiveGameName, - executablePath = effectiveExe, + gameName = gameName, + executablePath = container.executablePath, exeCommandLine = container.execArgs, steamAppId = steamAppId, workingDir = workingDir, @@ -935,10 +711,9 @@ object SteamUtils { */ private fun createAppManifest(context: Context, steamAppId: Int) { try { - Timber.tag("SteamFix").i("createAppManifest: begin for appId=$steamAppId") val appInfo = SteamService.getAppInfoOf(steamAppId) if (appInfo == null) { - Timber.tag("SteamFix").w("createAppManifest ABORT: no SteamApp info for appId=$steamAppId. Steam will treat the game as not installed and gray out Play.") + Timber.w("createAppManifest: no SteamApp info for appId=$steamAppId — Steam will gray Play") return } @@ -965,12 +740,6 @@ object SteamUtils { // the game up via steamapps/common/, NOT the on-disk folder // name, so the primary symlink must match the manifest's installdir. val actualInstallDir = appInfo.config.installDir.ifEmpty { gameName } - Timber.tag("SteamFix").i( - "createAppManifest: installDir pre-check appId=%d appInfo.installDir='%s' gameDir.name='%s' actualInstallDir='%s' match=%s", - steamAppId, appInfo.config.installDir, gameName, actualInstallDir, - appInfo.config.installDir.equals(gameName, ignoreCase = false), - ) - val primaryLink = File(commonDir, actualInstallDir) createSteamCommonLink(primaryLink, gameDir) // Keep a fallback alias under the on-disk folder name for code paths @@ -978,23 +747,11 @@ object SteamUtils { if (actualInstallDir != gameName) { createSteamCommonLink(File(commonDir, gameName), gameDir) } - try { - val linkPath = primaryLink.toPath() - val isSymlink = java.nio.file.Files.isSymbolicLink(linkPath) - val targetReadback = if (isSymlink) java.nio.file.Files.readSymbolicLink(linkPath).toString() else "(not a symlink)" - val targetExists = primaryLink.exists() - Timber.tag("SteamFix").i( - "createAppManifest: symlink readback appId=%d link=%s isSymlink=%s target=%s resolvedExists=%s", - steamAppId, primaryLink.absolutePath, isSymlink, targetReadback, targetExists, - ) - } catch (e: Exception) { - Timber.tag("SteamFix").w(e, "createAppManifest: symlink readback failed for %s", primaryLink.absolutePath) - } val installedBranch = SteamService.getInstalledApp(steamAppId)?.branch ?: "public" val buildId = (appInfo.branches[installedBranch] ?: appInfo.branches["public"])?.buildId ?: 0L if (buildId == 0L) { - Timber.tag("SteamFix").w("createAppManifest ABORT: appId=$steamAppId buildid unresolvable (branch=$installedBranch, known branches=${appInfo.branches.keys}). Zero buildid makes Steam force an update and gray out Play.") + Timber.w("createAppManifest: unresolvable buildid for appId=$steamAppId branch=$installedBranch") return } val downloadableDepots = SteamService.getDownloadableDepots(steamAppId) @@ -1021,7 +778,7 @@ object SteamUtils { // non-existent user, which is one of the stalls that leaves Play disabled. val lastOwner = SteamService.userSteamId?.convertToUInt64()?.toString() ?: "0" if (lastOwner == "0") { - Timber.tag("SteamFix").w("createAppManifest WARN: appId=$steamAppId LastOwner=0 — no signed-in SteamID. Cloud sync and ownership checks may stall.") + Timber.w("createAppManifest: appId=$steamAppId LastOwner=0 (no signed-in SteamID) — cloud/ownership checks may stall") } // Pre-resolve depot manifests so we can bail before writing a broken ACF. @@ -1032,50 +789,28 @@ object SteamUtils { } val brokenDepotIds = regularDepotManifests.filter { (_, m) -> m == null || m.gid == 0L }.keys if (brokenDepotIds.isNotEmpty()) { - Timber.tag("SteamFix").w("createAppManifest ABORT: appId=$steamAppId depot(s) $brokenDepotIds have no resolvable manifest GID for branch=$installedBranch. Zero depot GID triggers Steam's 'Update Required' loop and disables Play.") + Timber.w("createAppManifest: appId=$steamAppId depot(s) $brokenDepotIds have no resolvable manifest GID for branch=$installedBranch — skipping (would trigger Update Required)") return } - // SteamFix #28: if PICS gave us depots but none resolved with a GID, every - // game-declared depot landed in `sharedDepots` and our ACF would be written - // with an empty InstalledDepots block — Steam then flips Update Required - // on the game itself. Bail out instead of overwriting a previously-good acf. + // If PICS returned no resolvable regular depots, don't overwrite a previously-good + // acf with an empty InstalledDepots block (Steam would flip Update Required on it). if (regularDepots.isEmpty()) { val existing = File(steamappsDir, "appmanifest_$steamAppId.acf") val existingBuildId = if (existing.isFile) parseAcfBuildId(existing) else 0L val existingDepotCount = if (existing.isFile) parseAcfInstalledDepotIds(existing).size else 0 val hasValidExisting = existing.isFile && existingBuildId > 0L && existingDepotCount > 0 - if (hasValidExisting) { - // Transient PICS flake (partial depot list). The existing acf is - // structurally sound and will continue to serve Steam — quiet info, - // not a scary warn. - Timber.tag("SteamFix").i( - "createAppManifest: PICS returned empty regularDepots for appId=%d (downloadable=%s shared=%s). Existing acf is valid (buildid=%d, %d depots); keeping.", - steamAppId, downloadableDepots.keys, sharedDepots.keys, existingBuildId, existingDepotCount, - ) - } else { - Timber.tag("SteamFix").w( - "createAppManifest ABORT: appId=%d regularDepots empty (downloadableDepots=%s sharedDepots=%s branch=%s) and no valid existing acf=%s (exists=%s). Steam will likely gray Play until PICS returns full data.", + if (!hasValidExisting) { + Timber.w( + "createAppManifest: appId=%d has no regular depots and no valid existing acf — Steam will likely gray Play until PICS returns full data", steamAppId, - downloadableDepots.keys, - sharedDepots.keys, - installedBranch, - existing.absolutePath, - existing.exists(), ) } - // Still write 228980's manifest — it's independent of the child's - // state and we just resolved fresh GIDs for it. - writeSteamworksCommonManifest(steamappsDir, commonDir, lastOwner) + // Still refresh 228980's manifest — it's independent of the child's state. + writeSteamworksCommonManifest(steamappsDir, commonDir, lastOwner, sharedDepots.keys) return } - Timber.tag("SteamFix").i( - "createAppManifest: appId=%d branch=%s downloadableDepots=%s regular=%s shared=%s buildid=%d", - steamAppId, installedBranch, - downloadableDepots.keys, regularDepots.keys, sharedDepots.keys, buildId, - ) - // Create ACF content val acfContent = buildString { appendLine("\"AppState\"") @@ -1083,21 +818,16 @@ object SteamUtils { appendLine("\t\"appid\"\t\t\"$steamAppId\"") appendLine("\t\"Universe\"\t\t\"1\"") appendLine("\t\"name\"\t\t\"${escapeString(appInfo.name)}\"") - // StateFlags = 4 = fully installed. SteamFix: we intentionally do not - // set 2 (update required) / 8 (update pending) / 16 (validating) — if - // Steam thinks an update is needed, it will flip these bits itself on - // next launch; we just don't claim authority over them. + // StateFlags = 4 (fully installed). Deliberately omit 2/8/16 — Steam + // flips those itself if it decides an update is needed. appendLine("\t\"StateFlags\"\t\t\"4\"") appendLine("\t\"LastUpdated\"\t\t\"${System.currentTimeMillis() / 1000}\"") appendLine("\t\"SizeOnDisk\"\t\t\"$sizeOnDisk\"") appendLine("\t\"buildid\"\t\t\"$buildId\"") - // SteamFix #14: TargetBuildID matches buildid so Steam doesn't think - // it's mid-update. UpdateResult "0" = last update succeeded. + // TargetBuildID = buildId and UpdateResult=0 so Steam doesn't think it's mid-update. appendLine("\t\"TargetBuildID\"\t\t\"$buildId\"") appendLine("\t\"UpdateResult\"\t\t\"0\"") appendLine("\t\"AppType\"\t\t\"Game\"") - // SteamFix #26: write branch so invalidate-on-branch-change is - // observable in logcat and so Steam reflects the selected branch. appendLine("\t\"betakey\"\t\t\"${if (installedBranch == "public") "" else escapeString(installedBranch)}\"") appendLine("\t\"installdir\"\t\t\"${escapeString(actualInstallDir)}\"") @@ -1142,13 +872,9 @@ object SteamUtils { appendLine("\t}") } - // SteamFix #13: cloud_enabled="0" is a deliberate choice (Option B). - // GameNative already runs SteamAutoCloud.syncUserFiles around every - // launch with a signed JWT, so letting the Wine-hosted Steam client - // also sync would race and occasionally blow away saves. Leaving it - // disabled here is what keeps the ON toggle from corrupting data. - // If we ever expose a user-facing "let real Steam manage cloud" - // setting, this line becomes the toggle point. + // cloud_enabled="0" deliberately: GameNative already runs SteamAutoCloud.syncUserFiles + // around every launch, and letting the Wine-hosted Steam client also sync races it + // and occasionally blows away saves. appendLine("\t\"UserConfig\"") appendLine("\t{") appendLine("\t\t\"language\"\t\t\"english\"") @@ -1164,148 +890,153 @@ object SteamUtils { val acfFile = File(steamappsDir, "appmanifest_$steamAppId.acf") acfFile.writeText(acfContent) - Timber.tag("SteamFix").i("createAppManifest OK: appId=$steamAppId name=${appInfo.name} installdir=$actualInstallDir buildid=$buildId branch=$installedBranch depots=${regularDepots.keys}") - - // SteamFix #27: always write a real appmanifest_228980.acf for Steamworks - // Common Redistributables. The dependency is declared server-side in - // Steam's PICS metadata, not in the child game's own depot list, so - // `sharedDepots` on the child is empty for affected games (e.g. Shiren). - // Without a valid 228980 manifest, Steam flips "Update Required" on the - // shared dep, queues a download it can't complete, and leaves the child - // stuck in "Update Queued" — which presents as a gray Play button. + // Always write a real appmanifest_228980.acf for Steamworks Common + // Redistributables — Steam's PICS metadata declares this dependency + // server-side (not in the child's depot list), and without a valid + // 228980 manifest the child gets stuck in Update Queued / gray Play. // writeSteamworksCommonManifest is a no-op if PICS has no AppInfo for 228980. - writeSteamworksCommonManifest(steamappsDir, commonDir, lastOwner) + writeSteamworksCommonManifest(steamappsDir, commonDir, lastOwner, sharedDepots.keys) + + // Self-check: assert the acfs we just wrote are in the shape that + // avoids gray Play. Catches silent regressions in the writers + any + // case where Steam immediately rewrites the file between our write + // and the next launch. + validateAcfShape(acfFile, expectDepotCount = regularDepots.size, label = "child $steamAppId") + if (sharedDepots.isNotEmpty()) { + validateAcfShape( + File(steamappsDir, "appmanifest_228980.acf"), + expectDepotCount = -1, + label = "shared 228980", + ) + } } catch (e: Exception) { Timber.e(e, "Failed to create ACF manifest for appId $steamAppId") } } - private data class CanonicalDepot( + private data class ResolvedSharedDepot( val id: Int, val manifestGid: Long, val size: Long, val installScript: String, ) - // Buildid + depot GIDs sourced verbatim from a canonical PC install of 228980. - // Steam treats a manifest with this exact shape as "nothing to do" — no - // reconfigure, no "config changed : removed depots", no scheduler backoff. - private val canonical228980Depots: List = listOf( - CanonicalDepot(228981, 7613356809904826842L, 5884085L, "_CommonRedist\\\\vcredist\\\\2005\\\\installscript.vdf"), - CanonicalDepot(228982, 6413394087650432851L, 9688647L, "_CommonRedist\\\\vcredist\\\\2008\\\\installscript.vdf"), - CanonicalDepot(228983, 8124929965194586177L, 19265607L, "_CommonRedist\\\\vcredist\\\\2010\\\\installscript.vdf"), - CanonicalDepot(228984, 2547553897526095397L, 13742505L, "_CommonRedist\\\\vcredist\\\\2012\\\\installscript.vdf"), - CanonicalDepot(228985, 3966345552745568756L, 13699237L, "_CommonRedist\\\\vcredist\\\\2013\\\\installscript.vdf"), - CanonicalDepot(228986, 8782296191957114623L, 29759921L, "_CommonRedist\\\\vcredist\\\\2015\\\\installscript.vdf"), - CanonicalDepot(228987, 4302102680580581867L, 29664201L, "_CommonRedist\\\\vcredist\\\\2017\\\\installscript.vdf"), - CanonicalDepot(228988, 6645201662696499616L, 29212173L, "_CommonRedist\\\\vcredist\\\\2019\\\\installscript.vdf"), - CanonicalDepot(228989, 3514306556860204959L, 39590283L, "_CommonRedist\\\\vcredist\\\\2022\\\\installscript.vdf"), - CanonicalDepot(228990, 1829726630299308803L, 102931551L, "_CommonRedist\\\\DirectX\\\\Jun2010\\\\installscript.vdf"), - CanonicalDepot(229000, 4622705914179893434L, 242743889L, "_CommonRedist\\\\DotNet\\\\3.5\\\\installscript.vdf"), - CanonicalDepot(229001, 4049573910112143457L, 267964564L, "_CommonRedist\\\\DotNet\\\\3.5 Client Profile\\\\installscript.vdf"), - CanonicalDepot(229002, 7260605429366465749L, 50450161L, "_CommonRedist\\\\DotNet\\\\4.0\\\\installscript.vdf"), - CanonicalDepot(229003, 8740933542064151477L, 43001447L, "_CommonRedist\\\\DotNet\\\\4.0 Client Profile\\\\installscript.vdf"), - CanonicalDepot(229004, 5220958916987797232L, 70000464L, "_CommonRedist\\\\DotNet\\\\4.5.2\\\\installscript.vdf"), - CanonicalDepot(229005, 7992454656023763365L, 62009092L, "_CommonRedist\\\\DotNet\\\\4.6\\\\installscript.vdf"), - CanonicalDepot(229006, 1784011429307107530L, 83944258L, "_CommonRedist\\\\DotNet\\\\4.7\\\\installscript.vdf"), - CanonicalDepot(229007, 4477590687906973371L, 117381405L, "_CommonRedist\\\\DotNet\\\\4.8\\\\installscript.vdf"), - CanonicalDepot(229011, 392351049714934122L, 7672416L, "_CommonRedist\\\\XNA\\\\3.1\\\\installscript.vdf"), - CanonicalDepot(229012, 4353723233161159493L, 7061608L, "_CommonRedist\\\\XNA\\\\4.0\\\\installscript.vdf"), - CanonicalDepot(229020, 5799761707845834510L, 810085L, "_CommonRedist\\\\OpenAL\\\\2.0.7.0\\\\installscript.vdf"), - CanonicalDepot(229030, 1043465440436835055L, 51790718L, "_CommonRedist\\\\PhysX\\\\8.09.04\\\\installscript.vdf"), - CanonicalDepot(229031, 7746630274301172884L, 26729083L, "_CommonRedist\\\\PhysX\\\\9.12.1031\\\\installscript.vdf"), - CanonicalDepot(229032, 3616495131483866412L, 41178235L, "_CommonRedist\\\\PhysX\\\\9.13.1220\\\\installscript.vdf"), + // Static installscript paths by depot id — Steam convention, stable across + // PICS churn. Only used to populate the acf's InstallScripts block; manifest + // GID / size are always pulled from PICS at runtime. Missing entries here + // just mean the depot is included in InstalledDepots without an + // InstallScripts mapping, which is fine because applySteamInstallScriptShim + // writes HKLM registry keys that mark every installscript Run=1 anyway. + private val sharedDepotInstallScripts: Map = mapOf( + 228981 to "_CommonRedist\\\\vcredist\\\\2005\\\\installscript.vdf", + 228982 to "_CommonRedist\\\\vcredist\\\\2008\\\\installscript.vdf", + 228983 to "_CommonRedist\\\\vcredist\\\\2010\\\\installscript.vdf", + 228984 to "_CommonRedist\\\\vcredist\\\\2012\\\\installscript.vdf", + 228985 to "_CommonRedist\\\\vcredist\\\\2013\\\\installscript.vdf", + 228986 to "_CommonRedist\\\\vcredist\\\\2015\\\\installscript.vdf", + 228987 to "_CommonRedist\\\\vcredist\\\\2017\\\\installscript.vdf", + 228988 to "_CommonRedist\\\\vcredist\\\\2019\\\\installscript.vdf", + 228989 to "_CommonRedist\\\\vcredist\\\\2022\\\\installscript.vdf", + 228990 to "_CommonRedist\\\\DirectX\\\\Jun2010\\\\installscript.vdf", + 229000 to "_CommonRedist\\\\DotNet\\\\3.5\\\\installscript.vdf", + 229001 to "_CommonRedist\\\\DotNet\\\\3.5 Client Profile\\\\installscript.vdf", + 229002 to "_CommonRedist\\\\DotNet\\\\4.0\\\\installscript.vdf", + 229003 to "_CommonRedist\\\\DotNet\\\\4.0 Client Profile\\\\installscript.vdf", + 229004 to "_CommonRedist\\\\DotNet\\\\4.5.2\\\\installscript.vdf", + 229005 to "_CommonRedist\\\\DotNet\\\\4.6\\\\installscript.vdf", + 229006 to "_CommonRedist\\\\DotNet\\\\4.7\\\\installscript.vdf", + 229007 to "_CommonRedist\\\\DotNet\\\\4.8\\\\installscript.vdf", + 229011 to "_CommonRedist\\\\XNA\\\\3.1\\\\installscript.vdf", + 229012 to "_CommonRedist\\\\XNA\\\\4.0\\\\installscript.vdf", + 229020 to "_CommonRedist\\\\OpenAL\\\\2.0.7.0\\\\installscript.vdf", + 229030 to "_CommonRedist\\\\PhysX\\\\8.09.04\\\\installscript.vdf", + 229031 to "_CommonRedist\\\\PhysX\\\\9.12.1031\\\\installscript.vdf", + 229032 to "_CommonRedist\\\\PhysX\\\\9.13.1220\\\\installscript.vdf", ) - private val canonical228980BuildId = 19222509L + // Resolve the depots that 228980 owns for this child by intersecting the + // child's PICS-declared SharedDepots with 228980's own PICS depot list, + // pulling manifest GID + size from PICS. This replaces the old approach of + // hardcoding a depot table and filtering by installscript.vdf presence — + // that broke for games (DD, others) that ship a half-empty _CommonRedist. + private fun resolveSharedDepotsForChild( + sharedAppId: Int, + childSharedDepotIds: Set, + ): List { + val sharedAppInfo = SteamService.getAppInfoOf(sharedAppId) ?: return emptyList() + return childSharedDepotIds.mapNotNull { depotId -> + val depot = sharedAppInfo.depots[depotId] ?: return@mapNotNull null + val manifest = depot.manifests["public"] + ?: depot.manifests.values.firstOrNull() + ?: return@mapNotNull null + if (manifest.gid == 0L) return@mapNotNull null + ResolvedSharedDepot( + id = depotId, + manifestGid = manifest.gid, + size = manifest.size, + installScript = sharedDepotInstallScripts[depotId] ?: "", + ) + } + } /** - * SteamFix #35: write a canonical appmanifest_228980.acf that mirrors the - * exact shape Steam writes on a real PC after a clean commit — all 24 - * Steamworks-Common-Redist depots, matching InstallScripts block, PC- - * matching byte counters. Steam treats this as "nothing to do" on every - * launch regardless of which child game is launching, breaking the - * per-launch "Updating…" loop we saw with per-game filtered subsets. - * - * Background: earlier approaches (SteamFix #31/33/34) wrote only the - * depot subset the currently-launching game declared via - * `DepotInfo.depotFromApp == 228980`. Steam's in-memory mount state - * tracks what it last satisfied; since we SIGKILL the wine prefix on - * Exit Game (GuestProgramLauncherComponent.java:74), Steam never flushes - * its post-reconfigure view to disk. Next boot: disk ≠ memory → - * reconfigure → "Updating Steamworks Common…" window → gray Play. - * The canonical baseline removes the mismatch surface entirely. + * Write appmanifest_228980.acf for Steamworks Common Redistributables + * declaring exactly the depots whose installscript.vdf is present on disk. + * This "0 bytes to download" shape makes Steam's reconcile a ~1s no-op; an + * empty or mismatched manifest otherwise leaves 228980 stuck in Update + * Queued and cascades gray Play onto the child game. */ private fun writeSteamworksCommonManifest( steamappsDir: File, commonDir: File, lastOwner: String, + childSharedDepotIds: Set = emptySet(), ) { val sharedAppId = 228980 val staleAcf = File(steamappsDir, "appmanifest_$sharedAppId.acf") val sharedAppInfo = SteamService.getAppInfoOf(sharedAppId) if (sharedAppInfo == null) { - if (staleAcf.exists() && staleAcf.delete()) { - Timber.tag("SteamFix").i( - "writeSteamworksCommonManifest: removed stale %s (no PICS info for 228980)", - staleAcf.absolutePath, - ) - } - Timber.tag("SteamFix").w( - "writeSteamworksCommonManifest ABORT: PICS has no AppInfo for 228980 — " + - "Steam will queue 228980 for update and gray out Play for the child game." - ) + if (staleAcf.exists()) staleAcf.delete() + Timber.w("writeSteamworksCommonManifest: PICS has no AppInfo for 228980 — Steam will gray Play") return } val sharedBuildId = sharedAppInfo.branches["public"]?.buildId ?: sharedAppInfo.branches.values.firstOrNull()?.buildId - ?: canonical228980BuildId - - // Declare only the depots whose `installscript.vdf` is present on - // disk. An empty manifest made Steam say - // `update prefetch finished : 52086224 bytes to download` and try to - // download the missing content — the download gets suspended mid-run - // and leaves 228980 `Suspended`, which cascades `Update Queued` onto - // the child indefinitely. The 2-depot present-only shape has - // `0 bytes to download`, so Steam's reconcile is a ~1-second no-op. - // Matches the shape Steam itself writes after its first successful - // reconcile. - val sharedRedistDirForSkip = File(commonDir, "Steamworks Shared") - val presentDepots = canonical228980Depots.filter { d -> - File(sharedRedistDirForSkip, d.installScript.replace("\\\\", "/").replace("\\", "/")).isFile + ?: 0L + if (sharedBuildId == 0L) { + Timber.w("writeSteamworksCommonManifest: 228980 PICS has no buildid — skipping") + return + } + + // Source-of-truth: PICS. Resolve exactly the depots this child declared + // as owned by 228980, pulling manifest GIDs + sizes from PICS itself. + // No dependency on installscript.vdf files on disk — that's what + // trapped us in the empty-InstalledDepots → gray Play state for games + // like DD that ship a half-empty _CommonRedist skeleton. + val presentDepots = resolveSharedDepotsForChild(sharedAppId, childSharedDepotIds) + if (presentDepots.isEmpty()) { + if (staleAcf.exists()) staleAcf.delete() + Timber.w( + "writeSteamworksCommonManifest: no 228980 depots resolvable from PICS for child shared=%s — deleting stale acf", + childSharedDepotIds, + ) + return } val presentDepotIds = presentDepots.map { it.id }.toSet() + val presentScriptDepotIds = presentDepots.filter { it.installScript.isNotEmpty() }.map { it.id }.toSet() if (staleAcf.isFile) { val existingBuildId = parseAcfBuildId(staleAcf) val existingDepots = parseAcfInstalledDepotIds(staleAcf) val existingScripts = parseAcfInstallScriptDepotIds(staleAcf) val depotsMatch = existingDepots == presentDepotIds val buildIdMatch = existingBuildId == sharedBuildId - val scriptsMatch = existingScripts == presentDepotIds + val scriptsMatch = existingScripts == presentScriptDepotIds if (depotsMatch && buildIdMatch && scriptsMatch) { val updateResult = parseAcfUpdateResult(staleAcf) - if (updateResult == 0L) { - Timber.tag("SteamFix").i( - "writeSteamworksCommonManifest SKIP: %s matches present-depot baseline (buildid=%d, %d depots, UpdateResult=0)", - staleAcf.absolutePath, sharedBuildId, presentDepotIds.size, - ) - return - } - if (staleAcf.delete()) { - Timber.tag("SteamFix").w( - "writeSteamworksCommonManifest: deleted present-shaped %s with UpdateResult=%d; rewriting", - staleAcf.absolutePath, updateResult, - ) - } - } else { - Timber.tag("SteamFix").i( - "writeSteamworksCommonManifest: existing %s differs from present baseline (buildid=%d vs %d, %d vs %d depots, %d vs %d scripts); rewriting", - staleAcf.absolutePath, existingBuildId, sharedBuildId, - existingDepots.size, presentDepotIds.size, - existingScripts.size, presentDepotIds.size, - ) + if (updateResult == 0L) return + staleAcf.delete() } } @@ -1357,7 +1088,7 @@ object SteamUtils { appendLine("\t\"InstallScripts\"") appendLine("\t{") - presentDepots.forEach { d -> + presentDepots.filter { it.installScript.isNotEmpty() }.forEach { d -> appendLine("\t\t\"${d.id}\"\t\t\"${d.installScript}\"") } appendLine("\t}") @@ -1374,11 +1105,6 @@ object SteamUtils { } staleAcf.writeText(acfContent) - - Timber.tag("SteamFix").i( - "writeSteamworksCommonManifest OK: wrote present-depot %s buildid=%d (%d depots) lastOwner=%s", - staleAcf.absolutePath, sharedBuildId, presentDepots.size, lastOwner, - ) } private fun escapeString(input: String?): String { @@ -1473,40 +1199,50 @@ object SteamUtils { } } - /** - * SteamFix #12: Create a link for `steamapps/common/`. Try - * NIO `createSymbolicLink` first, then fall back to a junction-style - * directory with a sentinel (if the underlying filesystem rejects - * symlinks — some Android external storage mounts do). We log loudly - * so this shows up as a breadcrumb rather than a silent launch failure. - */ - private fun createSteamCommonLink(link: File, target: File) { - if (link.exists()) { - Timber.tag("SteamFix").d("common link already present: %s", link.absolutePath) + private val acfStateFlagsRegex = Regex("\"StateFlags\"\\s*\"(\\d+)\"") + private val acfBytesToDownloadRegex = Regex("\"BytesToDownload\"\\s*\"(\\d+)\"") + + // Post-write self-check. Flags the known-bad shapes that cause gray Play: + // - missing file + // - StateFlags has Update Required (bit 2) set + // - BytesToDownload > 0 (Steam thinks it needs to fetch something) + // - InstalledDepots block empty (unless the caller explicitly expects 0) + // Logs ERROR instead of throwing so a regression is visible in logcat + // without breaking the launch flow — the launch was already going to fail + // at the gray Play step anyway, and a loud ERROR beats a silent hang. + private fun validateAcfShape(acf: File, expectDepotCount: Int, label: String) { + if (!acf.isFile) { + Timber.e("ACF self-check FAIL [%s]: %s missing after write", label, acf.absolutePath) return } - val parent = link.parentFile - if (parent != null && !parent.exists()) parent.mkdirs() - try { - Files.createSymbolicLink(link.toPath(), target.toPath()) - Timber.tag("SteamFix").i("created symlink %s -> %s", link.absolutePath, target.absolutePath) + val text = runCatching { acf.readText() }.getOrNull() + if (text == null) { + Timber.e("ACF self-check FAIL [%s]: %s unreadable", label, acf.absolutePath) return - } catch (e: Exception) { - Timber.tag("SteamFix").w(e, "createSymbolicLink failed for %s, falling back to directory + redirect file", link.absolutePath) } - // Fallback: create a real directory containing a sentinel file so Steam at least - // sees the folder exist. This won't let Steam find the EXE, but it prevents a - // null-dir crash and gives us a diagnostic marker to search for in logcat. - try { - if (link.mkdirs()) { - File(link, ".steamfix_symlink_failed").writeText(target.absolutePath) - Timber.tag("SteamFix").w("wrote fallback dir+sentinel at %s (pointing to %s)", link.absolutePath, target.absolutePath) - } - } catch (e2: Exception) { - Timber.tag("SteamFix").e(e2, "fallback directory creation failed for %s", link.absolutePath) + val stateFlags = acfStateFlagsRegex.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: -1L + val bytesToDownload = acfBytesToDownloadRegex.find(text)?.groupValues?.get(1)?.toLongOrNull() ?: -1L + val installedDepots = parseAcfInstalledDepotIds(acf) + val problems = mutableListOf() + if (stateFlags < 0) problems += "StateFlags missing" + else if (stateFlags and 0x2L != 0L) problems += "StateFlags=$stateFlags has Update Required bit" + if (bytesToDownload > 0L) problems += "BytesToDownload=$bytesToDownload" + if (expectDepotCount >= 0 && installedDepots.size != expectDepotCount) { + problems += "InstalledDepots count=${installedDepots.size} expected=$expectDepotCount" + } else if (expectDepotCount < 0 && installedDepots.isEmpty()) { + problems += "InstalledDepots empty" + } + if (problems.isNotEmpty()) { + Timber.e("ACF self-check FAIL [%s] %s: %s", label, acf.name, problems.joinToString("; ")) } } + private fun createSteamCommonLink(link: File, target: File) { + if (link.exists()) return + link.parentFile?.mkdirs() + Files.createSymbolicLink(link.toPath(), target.toPath()) + } + private fun calculateDirectorySize(directory: File): Long { if (!directory.exists() || !directory.isDirectory()) { return 0L @@ -1531,8 +1267,6 @@ object SteamUtils { * if they exist. Does not error if backup files are not found. */ fun restoreSteamApi(context: Context, appId: String) { - - Timber.tag("SteamFix").i("restoreSteamApi starting for appId=%s", appId) val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) val imageFs = ImageFs.find(context) val container = ContainerUtils.getOrCreateContainer(context, appId) @@ -1549,83 +1283,35 @@ object SteamUtils { skipFirstTimeSteamSetup(imageFs.rootDir) val appDirPath = SteamService.getAppDirPath(steamAppId) - val needsDllRestore = if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_DLL_RESTORED)) { - if (verifyRestoredState(context, appDirPath)) { - Timber.tag("SteamFix").i("restoreSteamApi: DLL marker + hash ok, skipping DLL copy") - false - } else { - Timber.tag("SteamFix").w("restoreSteamApi: clearing stale RESTORED marker and re-running restore") - MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_RESTORED) - true - } - } else true - - if (needsDllRestore) { + val restoredMarkerPresent = MarkerUtils.hasMarker(appDirPath, Marker.STEAM_DLL_RESTORED) + val needsRestore = !restoredMarkerPresent || !verifyRestoredState(context, appDirPath) + if (needsRestore) { + MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_RESTORED) MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) MarkerUtils.removeMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) - Timber.tag("SteamFix").i("restoreSteamApi: running DLL restore in %s", appDirPath) autoLoginUserChanges(imageFs) setupLightweightSteamConfig(imageFs, SteamService.userSteamId!!.accountID.toString()) putBackSteamDlls(appDirPath) - - // Restore original executable if it exists (for real Steam mode) restoreOriginalExecutable(context, steamAppId) - - // Restore original steamclient.dll files if they exist restoreSteamclientFiles(context, steamAppId) MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_RESTORED) } - applySteamOverlayPref(context, container) + restoreLegacyStashedOverlayFiles(container) - // SteamFix #25/#26: always refresh manifest + symlinks on every real-Steam - // launch. Multi-account contamination and branch changes otherwise leave - // stale LastOwner / buildid / installdir until the DLL marker is cleared. + // Always refresh the manifest + symlinks: multi-account switches and branch + // changes otherwise leave stale LastOwner/buildid/installdir behind. createAppManifest(context, steamAppId) - // SteamFix #36: suppress per-launch vcredist/DirectX installer invocations - // by claiming all 228980 install scripts have already run. See doc on - // applySteamInstallScriptShim — fixes the Unity crash on Shiren where - // Steam was racing VC_redist.x64.exe against the game's MSVC loader. + // Mark 228980 install scripts as already run so Steam doesn't re-launch + // vcredist/DirectX installers on every boot (they race the game's MSVC loader). applySteamInstallScriptShim(context, steamAppId) // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId, container) - - // SteamFix: sanity check the install. If the dir has only dot-prefixed - // metadata sidecars and _CommonRedist (no actual game .exe anywhere), - // Steam will happily "launch" the game but ColdClientLoader or - // steam.exe -applaunch will fail because the exe isn't on disk. Make - // that obvious in logcat instead of surfacing as a silent black screen. - try { - val appDir = File(SteamService.getAppDirPath(steamAppId)) - if (appDir.isDirectory) { - val topLevel = appDir.listFiles()?.map { it.name } ?: emptyList() - val hasAnyExe = appDir.walkTopDown() - .maxDepth(4) - .any { it.isFile && it.name.endsWith(".exe", ignoreCase = true) } - if (!hasAnyExe) { - Timber.tag("SteamFix").w( - "Install INCOMPLETE for appId=%s: no .exe found under %s within 4 levels. topLevel=%s. " + - "GameNative's download_complete marker is present but the real game files aren't. Re-verify / re-download from library.", - appId, appDir.absolutePath, topLevel, - ) - } - } else { - Timber.tag("SteamFix").w( - "Install MISSING for appId=%s: %s is not a directory.", - appId, appDir.absolutePath, - ) - } - } catch (e: Exception) { - Timber.tag("SteamFix").w(e, "install sanity check failed for appId=%s", appId) - } - - Timber.tag("SteamFix").i("restoreSteamApi finished for appId=%s", appId) - logAppDirInventory(ContainerUtils.extractGameIdFromContainerId(appId), "restoreSteamApi.done") } fun findSteamApiDllRootFile(file: File, depth: Int): File? { @@ -1727,15 +1413,11 @@ object SteamUtils { } /** - * SteamFix #11: Only copy GSE saves into Steam userdata when the next launch - * is actually real Steam. Running this in OFF mode moved GSE's own saves out - * from under Goldberg. Caller threads `isLaunchRealSteam` through. + * Copy GSE saves into Steam userdata only when the next launch is real Steam. + * Running this in OFF mode would move GSE's own saves out from under Goldberg. */ fun migrateGSESavesToSteamUserdata(context: Context, appId: Int, isLaunchRealSteam: Boolean = true) { - if (!isLaunchRealSteam) { - Timber.tag("SteamFix").d("migrateGSESavesToSteamUserdata: skipping for appId=%d (not real-Steam mode)", appId) - return - } + if (!isLaunchRealSteam) return val imageFs = ImageFs.find(context) val accountId = SteamService.userSteamId?.accountID?.toInt() ?: PrefManager.steamUserAccountId.takeIf { it != 0 } @@ -1820,23 +1502,18 @@ object SteamUtils { } /** - * SteamFix #10: reverse migration. When a user toggles Launch Steam Client - * OFF after an ON session, saves that were copied into Steam/userdata need - * to move back so Goldberg (which reads from GSE Saves) can find them. - * Only runs when entering OFF mode, since the opposite direction is - * handled by [migrateGSESavesToSteamUserdata]. + * Reverse of [migrateGSESavesToSteamUserdata]: when a user toggles Launch + * Steam Client OFF, move saves from Steam/userdata back to GSE Saves so + * Goldberg can find them. */ fun migrateSteamUserdataToGSESaves(context: Context, appId: Int, isLaunchRealSteam: Boolean = false) { - if (isLaunchRealSteam) { - Timber.tag("SteamFix").d("migrateSteamUserdataToGSESaves: skipping for appId=%d (real-Steam mode)", appId) - return - } + if (isLaunchRealSteam) return val imageFs = ImageFs.find(context) val accountId = SteamService.userSteamId?.accountID?.toInt() ?: PrefManager.steamUserAccountId.takeIf { it != 0 } if (accountId == null) { - Timber.tag("SteamFix").w("migrateSteamUserdataToGSESaves: no Steam account ID available") + Timber.w("migrateSteamUserdataToGSESaves: no Steam account ID available") return } @@ -1858,17 +1535,14 @@ object SteamUtils { !steamUserdataDir.isDirectory || isDirectoryEmpty(steamUserdataDir) ) { - Timber.tag("SteamFix").d("migrateSteamUserdataToGSESaves: no userdata to migrate for appId=%d", appId) return } - Timber.tag("SteamFix").i("migrateSteamUserdataToGSESaves: starting migration for appId=%d", appId) - if (!gseDir.exists()) { try { Files.createDirectories(gseDir.toPath()) } catch (e: IOException) { - Timber.tag("SteamFix").e(e, "migrateSteamUserdataToGSESaves: failed to create GSE Saves dir") + Timber.e(e, "migrateSteamUserdataToGSESaves: failed to create GSE Saves dir") return } } @@ -1894,22 +1568,15 @@ object SteamUtils { migratedCount++ } catch (e: Exception) { migrationFailed = true - Timber.tag("SteamFix").w(e, "migrateSteamUserdataToGSESaves: failed to migrate %s", file.name) + Timber.w(e, "migrateSteamUserdataToGSESaves: failed to migrate %s", file.name) } } if (!migrationFailed) { steamUserdataDir.deleteRecursively() } - - Timber.tag("SteamFix").i("migrateSteamUserdataToGSESaves: completed appId=%d, files=%d", appId, migratedCount) } - /** - * SteamFix #20: one-shot cleanup of the ~200 MB extracted Steam binaries - * when we're confident no real-Steam launch is pending. Caller owns the - * "is this safe right now" decision (e.g. after confirming OFF toggle). - */ /** * Symlink-safe recursive delete. Kotlin's `File.deleteRecursively()` follows * symbolic links to directories and deletes the files on the *other* side, @@ -1922,52 +1589,6 @@ object SteamUtils { * want: tear down the extracted Steam prefix but leave the actual game * content the symlinks point to alone. */ - /** - * Emit a one-line inventory of a Steam game's install directory so we can - * correlate "where did my game go?" reports against launch / mode-switch / - * variant-reset events. Tagged `SteamFix` so it shows up in the same - * logcat filter as the rest of the diagnostics. - */ - fun logAppDirInventory(appId: Int, phase: String) { - try { - val path = SteamService.getAppDirPath(appId) - val dir = File(path) - if (!dir.exists()) { - Timber.tag("SteamFix").w("inventory[%s] appId=%d path=%s MISSING", phase, appId, path) - return - } - var fileCount = 0 - var exeCount = 0 - var totalBytes = 0L - val exeList = mutableListOf() - try { - dir.walkTopDown().maxDepth(4).forEach { f -> - if (f.isFile) { - fileCount++ - totalBytes += f.length() - if (f.name.endsWith(".exe", ignoreCase = true)) { - exeCount++ - if (exeList.size < 8) exeList += f.relativeTo(dir).path - } - } - } - } catch (_: Exception) { /* best-effort walk */ } - val appInfo = SteamService.getAppInfoOf(appId) - val configuredExe = appInfo?.config?.launch - ?.firstOrNull { it.executable.isNotBlank() }?.executable.orEmpty() - val expectedExeRel = configuredExe.replace("\\", "/").trimStart('/') - val expectedExeFile = if (expectedExeRel.isNotBlank()) File(dir, expectedExeRel) else null - val expectedExePresent = expectedExeFile?.exists() == true - Timber.tag("SteamFix").i( - "inventory[%s] appId=%d path=%s files=%d exes=%d bytes=%d expectedExe=%s present=%s exes=%s", - phase, appId, path, fileCount, exeCount, totalBytes, - expectedExeRel.ifBlank { "(unset)" }, expectedExePresent, exeList, - ) - } catch (e: Exception) { - Timber.tag("SteamFix").w(e, "inventory[%s] appId=%d FAILED", phase, appId) - } - } - internal fun deleteTreeNoFollowSymlinks(root: File) { if (!root.exists() && !java.nio.file.Files.isSymbolicLink(root.toPath())) return val rootPath = root.toPath() @@ -1997,13 +1618,9 @@ object SteamUtils { if (!steamDir.exists()) return val steamExe = File(steamDir, "steam.exe") if (!steamExe.exists()) return - Timber.tag("SteamFix").i( - "cleanupExtractedSteamFiles: removing %s (symlink-safe walk)", - steamDir.absolutePath, - ) deleteTreeNoFollowSymlinks(steamDir) } catch (e: Exception) { - Timber.tag("SteamFix").w(e, "cleanupExtractedSteamFiles failed") + Timber.w(e, "cleanupExtractedSteamFiles failed") } } @@ -2065,10 +1682,8 @@ object SteamUtils { appendLine("ticket=$ticketBase64") } - // SteamFix #10/#11: in OFF mode (ensureSteamSettings only runs in the - // emulated-Steam launch path) we must not move GSE saves into Steam's - // userdata — that's the ON-mode direction. Instead, pull any leftover - // userdata back into GSE Saves so Goldberg can find it. + // ensureSteamSettings only runs in the emulated-Steam launch path, so + // pull any leftover userdata back into GSE Saves for Goldberg. migrateSteamUserdataToGSESaves(context, steamAppId, isLaunchRealSteam = false) // Add [user::saves] section diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java index d6bb2a2a60..79c1903e4b 100644 --- a/app/src/main/java/com/winlator/container/Container.java +++ b/app/src/main/java/com/winlator/container/Container.java @@ -85,7 +85,7 @@ public enum XrControllerMapping { private String wineVersion = WineInfo.MAIN_WINE_VERSION.identifier(); private boolean showFPS; private boolean launchRealSteam; - private boolean disableSteamOverlay; + private boolean disableSteamOverlay = true; private boolean allowSteamUpdates; private boolean wow64Mode = true; private boolean needsUnpacking = true; diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index 5130365bbc..81a4db3246 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -25,7 +25,7 @@ data class ContainerData( val installPath: String = "", val showFPS: Boolean = false, val launchRealSteam: Boolean = false, - val disableSteamOverlay: Boolean = false, + val disableSteamOverlay: Boolean = true, val allowSteamUpdates: Boolean = false, val steamType: String = "normal", val cpuList: String = Container.getFallbackCPUList(), @@ -183,7 +183,7 @@ data class ContainerData( installPath = savedMap["installPath"] as String, showFPS = savedMap["showFPS"] as Boolean, launchRealSteam = savedMap["launchRealSteam"] as Boolean, - disableSteamOverlay = (savedMap["disableSteamOverlay"] as? Boolean) ?: false, + disableSteamOverlay = (savedMap["disableSteamOverlay"] as? Boolean) ?: true, allowSteamUpdates = savedMap["allowSteamUpdates"] as Boolean, steamType = (savedMap["steamType"] as? String) ?: "normal", cpuList = savedMap["cpuList"] as String, diff --git a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java index ca00143e6f..8c05723b56 100644 --- a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java +++ b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java @@ -220,11 +220,6 @@ public static Future installIfNeededFuture(final Context context, Asset String currentVariant = imageFs.getVariant(); String requestedVariant = container.getContainerVariant(); if (!valid || version < LATEST_VERSION || !currentVariant.equals(requestedVariant)) { - android.util.Log.i("SteamFix", - "ImageFsInstaller.installIfNeeded: REINSTALL valid=" + valid - + " version=" + version + "/" + LATEST_VERSION - + " variant=" + currentVariant + "->" + requestedVariant - + " (this will unlink steamapps/common/ symlinks)"); Log.d("ImageFsInstaller", "Installing image from assets"); return installFromAssetsFuture( context, @@ -286,14 +281,6 @@ private static void clearOptDir(Context context, File optDir) { } private static void clearRootDir(Context context, File rootDir) { - // SteamFix diag: this runs when imagefs is invalid/out-of-date OR when the - // user toggles container variant (glibc <-> bionic). It wipes everything - // under rootDir except home/ and opt/, which means any symlinks placed by - // createAppManifest (steamapps/common/ -> /data/.../Steam/...) get - // unlinked. Real on-disk game content is NOT followed (FileUtils.delete - // is symlink-safe), but the manifest/link pair is gone. Tag this so the - // same logcat filter catches it. - android.util.Log.i("SteamFix", "ImageFsInstaller.clearRootDir: wiping " + rootDir.getAbsolutePath() + " (variant/imgVersion reset — symlinks under wineprefix will be unlinked)"); if (rootDir.isDirectory()) { File[] files = rootDir.listFiles(); if (files != null) { @@ -315,7 +302,6 @@ private static void clearRootDir(Context context, File rootDir) { } } else rootDir.mkdirs(); - android.util.Log.i("SteamFix", "ImageFsInstaller.clearRootDir: done"); } public static void generateCompactContainerPattern(final Context context, AssetManager assetManager) { From 5a17944dfa0ff92bad3e168bfce654b0ae13c8fd Mon Sep 17 00:00:00 2001 From: TideGear Date: Mon, 20 Apr 2026 20:31:15 -0700 Subject: [PATCH 09/34] fix: real-Steam cloud sync race, graceful exit, pending-upload filter Addresses three related issues seen when launching games in real-Steam mode. 1. Suppress Wine-hosted Steam client's cloud sync (Option B) The Wine-hosted Steam client was racing GameNative's SteamKit cloud sync on every launch/exit. Per-app gates now disable the Wine client's cloud path in userdata//config/localconfig.vdf (cloud_enabled=0, cce=0) for both the existing-file and new-file branches, with a setOrReplaceKey helper to avoid duplicates. 2. Graceful red-exit with 5s grace window The quick-menu EXIT_GAME action previously went straight to a SIGKILL of the Wine process tree, giving games no chance to flush saves. It now sends WM_CLOSE to the game exe (and to steam.exe when real-Steam mode is on), waits 5s, then proceeds with the existing exit path. A second tap within the grace window cancels the wait and force-quits. 3. Filter localhost pending-operations in real-Steam mode Steam's server reports the Wine-hosted client's self-registered session as machineName="localhost" with UploadPending, causing a spurious "Pending Upload" dialog before every real-Steam launch. beginLaunchApp now filters pendingRemoteOperations by machineName="localhost" when isLaunchRealSteam is true. Genuine entries from other devices still surface the dialog, and the kickPlayingSession path is unaffected. 4. Fix inverted uploadsRequired flag on exit sync signalAppExitSyncDone was passing uploadsRequired = (... == false), producing impossible states like "upload succeeded but wasn't required". Flipped to == true so Steam's server gets a truthful signal and stops holding stale pending markers. Also: ignore .claude/ session state directory. --- .../app/gamenative/service/SteamService.kt | 15 +++++- .../ui/screen/xserver/XServerScreen.kt | 49 ++++++++++++++----- .../java/app/gamenative/utils/SteamUtils.kt | 17 +++++++ 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 2f0697a30d..c18b195856 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2313,7 +2313,7 @@ class SteamService : Service(), IChallengeUrlChanged { EOSType.AndroidUnknown, ) - val pendingRemoteOperations = steamCloud.signalAppLaunchIntent( + val rawPending = steamCloud.signalAppLaunchIntent( appId = appId, clientId = clientId, machineName = SteamUtils.getMachineName(steamInstance), @@ -2321,6 +2321,17 @@ class SteamService : Service(), IChallengeUrlChanged { osType = EOSType.AndroidUnknown, ).await() + // The Wine-hosted Steam client registers with Steam's cloud + // service as "localhost" and leaves stale pending/session + // markers. Filter those out in real-Steam mode so we don't + // surface spurious dialogs or kick our own launch — genuine + // entries from other devices still flow through. + val pendingRemoteOperations = if (isLaunchRealSteam) { + rawPending.filterNot { it.machineName.equals("localhost", ignoreCase = true) } + } else { + rawPending + } + if (pendingRemoteOperations.isNotEmpty() && !ignorePendingOperations) { syncResult = PostSyncInfo( syncResult = SyncResult.PendingOperations, @@ -2464,7 +2475,7 @@ class SteamService : Service(), IChallengeUrlChanged { appId = appId, clientId = clientId, uploadsCompleted = postSyncInfo?.uploadsCompleted == true, - uploadsRequired = postSyncInfo?.uploadsRequired == false, + uploadsRequired = postSyncInfo?.uploadsRequired == true, ) } } 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 02fac1ca67..0b0dc1a8b6 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 @@ -238,6 +238,12 @@ private fun detectMaxRefreshRateHz(context: Context, attachedView: View?): Int { ?: DEFAULT_FPS_LIMITER_MAX_HZ } +// Red-exit grace window: we first ask the game (and Steam, if real-Steam mode) +// to shut down cleanly via WM_CLOSE, then tear down Wine. Games that flush saves +// on clean exit need this to avoid data loss. Second tap on the exit button +// bypasses the grace and force-quits. +private const val GRACEFUL_EXIT_GRACE_MS = 5_000L + // Flags passed to steam.exe in real-Steam launch mode. // -silent: start without main window; -vgui: classic UI renderer (more compatible under Wine); // -tcp: avoid named-pipe handshake wait; -nobigpicture/-nofriendsui/-nochatui/-nointro: suppress optional UIs. @@ -442,6 +448,7 @@ fun XServerScreen( var win32AppWorkarounds: Win32AppWorkarounds? by remember { mutableStateOf(null) } var physicalControllerHandler: PhysicalControllerHandler? by remember { mutableStateOf(null) } var exitWatchJob: Job? by remember { mutableStateOf(null) } + var gracefulExitJob: Job? by remember { mutableStateOf(null) } DisposableEffect(Unit) { onDispose { @@ -1219,17 +1226,37 @@ fun XServerScreen( } QuickMenuAction.EXIT_GAME -> { - PostHog.capture( - event = "game_closed", - properties = mapOf( - "game_name" to ContainerUtils.resolveGameName(appId), - "game_store" to ContainerUtils.extractGameSourceFromContainerId(appId).name, - ), - ) - imeInputReceiver?.hideKeyboard() - // Resume processes before exiting so they can receive SIGTERM cleanly. - forceResumeIfSuspended() - exit(xServerView!!.getxServer().winHandler, frameRating, currentAppInfo, container, appId, onExit, navigateBack) + val winHandler = xServerView!!.getxServer().winHandler + val activeJob = gracefulExitJob + if (activeJob?.isActive == true) { + Timber.i("EXIT_GAME: second tap during grace window — force quitting") + activeJob.cancel() + gracefulExitJob = null + exit(winHandler, frameRating, currentAppInfo, container, appId, onExit, navigateBack) + } else { + PostHog.capture( + event = "game_closed", + properties = mapOf( + "game_name" to ContainerUtils.resolveGameName(appId), + "game_store" to ContainerUtils.extractGameSourceFromContainerId(appId).name, + ), + ) + imeInputReceiver?.hideKeyboard() + // Resume processes before exiting so they can receive SIGTERM cleanly. + forceResumeIfSuspended() + val gameExe = extractExecutableBasename(container.executablePath) + val shutdownSteam = container.isLaunchRealSteam + gracefulExitJob = CoroutineScope(Dispatchers.Main).launch { + try { + if (gameExe.isNotEmpty()) winHandler.killProcess(gameExe) + if (shutdownSteam) winHandler.killProcess("steam.exe") + delay(GRACEFUL_EXIT_GRACE_MS) + exit(winHandler, frameRating, currentAppInfo, container, appId, onExit, navigateBack) + } catch (_: kotlinx.coroutines.CancellationException) { + // Force-quit path already called exit(). + } + } + } true } diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index a8fc75f749..df6e6b5f7e 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -2013,6 +2013,12 @@ object SteamUtils { app.children.add(KeyValue("LaunchOptions", exeCommandLine)) } + // Suppress the Wine-hosted Steam client's per-app cloud sync. GameNative owns + // cloud via its own SteamKit connection around every launch; letting the client + // sync in parallel races us and produces spurious Save Conflict popups. + setOrReplaceKey(app, "cloud_enabled", "0") + setOrReplaceKey(app, "cce", "0") + vdfData.saveToFile(localConfigFile, false) } else { val vdfData = KeyValue(name = "UserLocalConfigStore") @@ -2024,6 +2030,8 @@ object SteamUtils { val app = KeyValue(appId) app.children.add(option) + app.children.add(KeyValue("cloud_enabled", "0")) + app.children.add(KeyValue("cce", "0")) apps.children.add(app) steam.children.add(apps) valve.children.add(steam) @@ -2077,6 +2085,15 @@ object SteamUtils { } } + private fun setOrReplaceKey(parent: KeyValue, name: String, value: String) { + val existing = parent.children.firstOrNull { it.name == name } + if (existing != null) { + existing.value = value + } else { + parent.children.add(KeyValue(name, value)) + } + } + fun getSteamId64(): Long? { return SteamService.userSteamId?.convertToUInt64()?.toLong() ?: PrefManager.steamUserSteamId64.takeIf { it != 0L } From 711a050f21f51dbd27797ff3bc87203a546e2611 Mon Sep 17 00:00:00 2001 From: TideGear Date: Mon, 20 Apr 2026 21:21:54 -0700 Subject: [PATCH 10/34] fix: graceful-exit job leak, unowned SharedDepots, narrow shim catch Three audit findings from the Fix-Steam-Client branch review: - XServerScreen: cancel gracefulExitJob in DisposableEffect.onDispose. If the screen disposed during the 5s force-quit grace window, the coroutine leaked (only exitWatchJob was cancelled). - SteamUtils.createAppManifest: filter shared depots whose depotFromApp is 0 before writing the SharedDepots block. Writing `"" "0"` told Steam the depot belonged to a nonexistent app 0, which could re-trigger the PICS reconcile / gray-Play cascade this branch was built to prevent. Unowned shared depots are now logged and omitted. - SteamUtils.applySteamInstallScriptShim: narrow the blanket `catch (Exception)` to IOException + SecurityException so programming errors (NPE, ISE) surface instead of being silently swallowed. --- .../ui/screen/xserver/XServerScreen.kt | 3 +++ .../java/app/gamenative/utils/SteamUtils.kt | 22 +++++++++++++++---- app/src/main/res/values/strings.xml | 1 + 3 files changed, 22 insertions(+), 4 deletions(-) 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 0b0dc1a8b6..e7ba77bc1e 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 @@ -456,6 +456,8 @@ fun XServerScreen( physicalControllerHandler = null exitWatchJob?.cancel() exitWatchJob = null + gracefulExitJob?.cancel() + gracefulExitJob = null } } var isKeyboardVisible = false @@ -1244,6 +1246,7 @@ fun XServerScreen( imeInputReceiver?.hideKeyboard() // Resume processes before exiting so they can receive SIGTERM cleanly. forceResumeIfSuspended() + SnackbarManager.show(context.getString(R.string.exit_graceful_toast)) val gameExe = extractExecutableBasename(container.executablePath) val shutdownSteam = container.isLaunchRealSteam gracefulExitJob = CoroutineScope(Dispatchers.Main).launch { diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index df6e6b5f7e..567e85b625 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -192,8 +192,10 @@ object SteamUtils { } } } - } catch (e: Exception) { - Timber.w(e, "applySteamInstallScriptShim failed for appId=%d", steamAppId) + } catch (e: IOException) { + Timber.w(e, "applySteamInstallScriptShim: registry IO failed for appId=%d", steamAppId) + } catch (e: SecurityException) { + Timber.w(e, "applySteamInstallScriptShim: registry access denied for appId=%d", steamAppId) } } @@ -863,14 +865,26 @@ object SteamUtils { // the gray-Play state. Declaring the ownership explicitly tells // Steam the link is already satisfied, so no recategorization / // queue flip happens. - if (sharedDepots.isNotEmpty()) { + // Only depots with a real owning app (depotFromApp != 0) belong in SharedDepots. + // Writing "" "0" tells Steam the depot is owned by app 0, which + // makes PICS reconcile on every boot and can re-trigger the gray-Play cascade. + val ownedSharedDepots = sharedDepots.filterValues { it.depotFromApp != 0 } + if (ownedSharedDepots.isNotEmpty()) { appendLine("\t\"SharedDepots\"") appendLine("\t{") - sharedDepots.forEach { (depotId, info) -> + ownedSharedDepots.forEach { (depotId, info) -> appendLine("\t\t\"$depotId\"\t\t\"${info.depotFromApp}\"") } appendLine("\t}") } + val unownedShared = sharedDepots.keys - ownedSharedDepots.keys + if (unownedShared.isNotEmpty()) { + Timber.w( + "createAppManifest: appId=%d shared depots %s have no owner (depotFromApp=0) — omitting from SharedDepots", + steamAppId, + unownedShared, + ) + } // cloud_enabled="0" deliberately: GameNative already runs SteamAutoCloud.syncUserFiles // around every launch, and letting the Wine-hosted Steam client also sync races it diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 333447cb77..64d9622a97 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -216,6 +216,7 @@ Logs Exit Exit Game + Closing game… tap Exit again to force quit Keyboard Resume Game Extra From f392600a9488616ef4beda455e06a7065e4ed186 Mon Sep 17 00:00:00 2001 From: TideGear Date: Mon, 20 Apr 2026 23:22:10 -0700 Subject: [PATCH 11/34] fix: clear lingering localhost session on real-Steam exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In real-Steam mode the Wine-hosted Steam client logs into Steam's servers and registers its own AppSessionActive under machineName "localhost" (the container's hostname). With cloud_enabled=0 set on the Wine client's localconfig.vdf (Option B from the earlier real-Steam sync-race fix), the Wine client never signals upload state for its localhost session, so Steam's server parks it in UploadPending forever. Desktop Steam then reads that on next launch and shows "Cloud Out of Date — you played on localhost, upload not started" even after GameNative's own sync completed cleanly. After GameNative's post-exit SteamKit sync calls signalAppExitSyncDone, also call kickPlayingSession() to clear any lingering session for this user on Steam's server. The game is already exited at this point, so kicking is safe and only affects the stale Wine-client entry that would otherwise trigger the desktop dialog. --- .../java/app/gamenative/service/SteamService.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index c18b195856..f43dd106f6 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2477,6 +2477,19 @@ class SteamService : Service(), IChallengeUrlChanged { uploadsCompleted = postSyncInfo?.uploadsCompleted == true, uploadsRequired = postSyncInfo?.uploadsRequired == true, ) + + // In real-Steam mode the Wine-hosted Steam client registered its + // own AppSessionActive under machineName="localhost". With + // cloud_enabled=0 it never signals upload state, so Steam's + // server parks that session in UploadPending forever and + // desktop Steam shows a "Cloud Out of Date" / "played on + // localhost, upload not started" dialog on next launch. Game + // is already exited here, so kick any lingering session to + // clear the server-side state. + if (isLaunchRealSteam) { + runCatching { steamInstance._steamUser?.kickPlayingSession() } + .onFailure { Timber.w(it, "kickPlayingSession after real-Steam exit failed") } + } } } } From e91f95017152ec69c3a8f09a791eacb5223aa37e Mon Sep 17 00:00:00 2001 From: TideGear Date: Tue, 21 Apr 2026 21:44:56 -0700 Subject: [PATCH 12/34] diag: log Steam binary fingerprint at launch, write steam.cfg in both modes Reconstructing the timeline of a broken Steam dir from ls -la mtimes wasted hours this session. A Timber line per launch with size+mtime of steam.exe, steamclient.dll, steamclient64.dll will surface any unexpected change the moment it happens. Also lift the steam.cfg writer out of restoreSteamApi (emu-only path) into an ensureSteamCfg helper called from the real-Steam extractSteamFiles path too, so update-inhibit keys land on fresh Wine-prefix seeds regardless of launch mode. --- .../ui/screen/xserver/XServerScreen.kt | 2 ++ .../java/app/gamenative/utils/SteamUtils.kt | 36 +++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) 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 e7ba77bc1e..0ed699b63c 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 @@ -4335,6 +4335,8 @@ private fun setupWineSystemFiles( SteamUtils.deleteTreeNoFollowSymlinks(steamDir) } extractSteamFiles(context, container, onExtractFileListener) + SteamUtils.ensureSteamCfg(ImageFs.find(context)) + SteamUtils.logSteamBinaryFingerprint(ImageFs.find(context), "prepareContainer:realSteam") container.putExtra("steamExtractedForWine", steamExtractedKey) containerDataChanged = true } diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 567e85b625..48989a9813 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -1276,6 +1276,34 @@ object SteamUtils { return size } + /** Writes steam.cfg with update-inhibit keys if missing. Both real-Steam and emu paths need it present. */ + fun ensureSteamCfg(imageFs: ImageFs) { + val cfgFile = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/steam.cfg") + if (cfgFile.exists()) return + try { + cfgFile.parentFile?.mkdirs() + Files.createFile(cfgFile.toPath()) + cfgFile.writeText("BootStrapperInhibitAll=Enable\nBootStrapperForceSelfUpdate=False") + } catch (e: Exception) { + Timber.w(e, "ensureSteamCfg: failed to write steam.cfg") + } + } + + /** Logs size + mtime of the Steam binaries whose unexpected change breaks launches. Diagnostic-only. */ + fun logSteamBinaryFingerprint(imageFs: ImageFs, tag: String) { + try { + val steamDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply { timeZone = TimeZone.getDefault() } + val parts = listOf("steam.exe", "steamclient.dll", "steamclient64.dll").map { name -> + val f = File(steamDir, name) + if (f.exists()) "$name=${f.length()}@${fmt.format(f.lastModified())}" else "$name=MISSING" + } + Timber.i("SteamBinaryFingerprint[$tag] ${parts.joinToString(" ")}") + } catch (e: Exception) { + Timber.w(e, "logSteamBinaryFingerprint[$tag] failed") + } + } + /** * Restores the original steam_api.dll and steam_api64.dll files from their .orig backups * if they exist. Does not error if backup files are not found. @@ -1284,12 +1312,8 @@ object SteamUtils { val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) val imageFs = ImageFs.find(context) val container = ContainerUtils.getOrCreateContainer(context, appId) - val cfgFile = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/steam.cfg") - if (!cfgFile.exists()){ - cfgFile.parentFile?.mkdirs() - Files.createFile(cfgFile.toPath()) - cfgFile.writeText("BootStrapperInhibitAll=Enable\nBootStrapperForceSelfUpdate=False") - } + ensureSteamCfg(imageFs) + logSteamBinaryFingerprint(imageFs, "restoreSteamApi:emu:$steamAppId") // Update or modify localconfig.vdf updateOrModifyLocalConfig(imageFs, container, steamAppId.toString(), SteamService.userSteamId!!.accountID.toString()) From 4dea44a2eb13c733fe72f26596bc0dfd22c2a9d1 Mon Sep 17 00:00:00 2001 From: TideGear Date: Tue, 21 Apr 2026 22:20:05 -0700 Subject: [PATCH 13/34] fix: skip localhost AppLaunchIntent in real-Steam mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-Steam launches stalled after cloud sync because Steam's server saw a pending cloud operation from machine 'localhost' and refused to start the game (BYieldingAppLaunchIntent returned 1 pending remote operations). The phantom operation came from GameNative's own SteamKit client: on every beginLaunchApp it called signalAppLaunchIntent(machineName= 'localhost'), which registers a launch-intent server-side. In emulation mode this is correct — GameNative IS the client running the game. In real-Steam mode it's harmful — the Wine-hosted Steam client performs its own BYieldingAppLaunchIntent, sees the localhost entry we just created, treats it as a conflicting session, and sits forever waiting for it to clear. The earlier ef5b5b59 fix filtered localhost entries out of our local view of pendingRemoteOperations but never stopped creating them. This change skips the RPC entirely when isLaunchRealSteam=true, so the server never records the phantom operation in the first place. Verified by cloud_log.txt on a stuck Baba Is You (appid 736260) launch: [AppID 736260] BYieldingAppLaunchIntent returned 1 pending remote operations: Operation '3' from machine 'localhost' after which Steam never attempted to launch the game. --- .../app/gamenative/service/SteamService.kt | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index f43dd106f6..6bf6db3251 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2307,25 +2307,38 @@ class SteamService : Service(), IChallengeUrlChanged { if (info.syncResult == SyncResult.Success || info.syncResult == SyncResult.UpToDate) { Timber.i( - "Signaling app launch:\n\tappId: %d\n\tclientId: %s\n\tosType: %s", + "Signaling app launch:\n\tappId: %d\n\tclientId: %s\n\tosType: %s\n\tisLaunchRealSteam: %s", appId, PrefManager.clientId, EOSType.AndroidUnknown, + isLaunchRealSteam, ) - val rawPending = steamCloud.signalAppLaunchIntent( - appId = appId, - clientId = clientId, - machineName = SteamUtils.getMachineName(steamInstance), - ignorePendingOperations = ignorePendingOperations, - osType = EOSType.AndroidUnknown, - ).await() - - // The Wine-hosted Steam client registers with Steam's cloud - // service as "localhost" and leaves stale pending/session - // markers. Filter those out in real-Steam mode so we don't - // surface spurious dialogs or kick our own launch — genuine - // entries from other devices still flow through. + // In real-Steam mode, the Wine-hosted Steam client (running as + // machineName="localhost") performs its own BYieldingAppLaunchIntent + // once it starts. If we also signal launch intent from our SteamKit + // client here, the server records a pending operation from machine + // "localhost" that the Wine-hosted client then observes as a + // conflicting session and refuses to launch the game. Skip the RPC + // entirely on the real-Steam path so the server never sees a + // localhost launch intent from us. + val rawPending = if (isLaunchRealSteam) { + emptyList() + } else { + steamCloud.signalAppLaunchIntent( + appId = appId, + clientId = clientId, + machineName = SteamUtils.getMachineName(steamInstance), + ignorePendingOperations = ignorePendingOperations, + osType = EOSType.AndroidUnknown, + ).await() + } + + // Defence in depth: even when the RPC above fires in emulation + // mode, a stale localhost entry from a prior real-Steam session + // could still surface here. Strip those so we never surface + // spurious dialogs or kick our own launch — genuine entries + // from other devices still flow through. val pendingRemoteOperations = if (isLaunchRealSteam) { rawPending.filterNot { it.machineName.equals("localhost", ignoreCase = true) } } else { From bf759e5eff47c64f63ab2f3f283b0ed764c4cce7 Mon Sep 17 00:00:00 2001 From: TideGear Date: Tue, 21 Apr 2026 23:00:55 -0700 Subject: [PATCH 14/34] fix: harden beginLaunchApp against leaked AppSessionActive phantoms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defense-in-depth additions to SteamService.beginLaunchApp that close gaps where a pending AppSessionActive op on Valve's servers can survive a bad launch and block future launches in either mode. 1. Proactive kick before real-Steam launch. When isLaunchRealSteam=true, call kickPlayingSession() up front. Wine-Steam establishes its own session via BYieldingAppLaunchIntent once it boots, so wiping any stale orphaned entry first costs nothing and clears phantoms left by a prior emulation crash that never reached the exit-cleanup path. 2. Finally-block cleanup on aborted emulation launches. Track whether we registered a launch intent via signalAppLaunchIntent, and in the finally block kick the session if the intent was registered but the flow didn't end in Success/UpToDate (PendingOperations dialog dismissed, exception partway through, caller gave up). Stops leaked phantoms from blocking subsequent launches; the successful-launch- then-exit case is still handled by closeApp as before. Motivating incident: Dead Cells black-screened after the fe0f3138 localhost-skip fix because the phantom was under device name, not "localhost" — from an earlier emulation launch that errored mid-flight. Manual reset via desktop Steam cleared it. These hardening steps close that loop automatically. --- .../app/gamenative/service/SteamService.kt | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 6bf6db3251..aedfd0eeab 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2277,13 +2277,19 @@ class SteamService : Service(), IChallengeUrlChanged { // Only migrate GSE -> userdata when booting real Steam; reverse direction lives in ensureSteamSettings. SteamUtils.migrateGSESavesToSteamUserdata(instance?.applicationContext!!, appId, isLaunchRealSteam) - try { - val context = instance?.applicationContext ?: return@async PostSyncInfo(SyncResult.UnknownFail) - // Migrate GSE Saves to Steam userdata - SteamUtils.migrateGSESavesToSteamUserdata(context, appId) - - var syncResult = PostSyncInfo(SyncResult.UnknownFail) + var syncResult = PostSyncInfo(SyncResult.UnknownFail) + var launchIntentRegistered = false + + // Wine-hosted Steam establishes its own session via BYieldingAppLaunchIntent, + // so clearing server-side state here wipes orphan phantoms from prior aborted + // launches (emulation crash, killed mid-sync) without disrupting anything we're + // about to do — we don't signal an intent in real-Steam mode anyway. + if (isLaunchRealSteam) { + runCatching { instance?._steamUser?.kickPlayingSession() } + .onFailure { Timber.w(it, "Proactive kickPlayingSession before real-Steam launch failed") } + } + try { val maxAttempts = 3 for (attempt in 1..maxAttempts) { try { @@ -2331,7 +2337,7 @@ class SteamService : Service(), IChallengeUrlChanged { machineName = SteamUtils.getMachineName(steamInstance), ignorePendingOperations = ignorePendingOperations, osType = EOSType.AndroidUnknown, - ).await() + ).await().also { launchIntentRegistered = true } } // Defence in depth: even when the RPC above fires in emulation @@ -2377,6 +2383,18 @@ class SteamService : Service(), IChallengeUrlChanged { return@async syncResult } finally { + // Emulation-mode cleanup: if we registered a launch intent but didn't end + // in a clean sync-ready state (pending ops returned, exception partway + // through, caller dismissed the dialog), the server-side pending op would + // otherwise linger and block future launches in either mode. Kick to clean + // up; the existing closeApp path handles the successful-launch-then-exit case. + if (launchIntentRegistered && + syncResult.syncResult != SyncResult.Success && + syncResult.syncResult != SyncResult.UpToDate + ) { + runCatching { instance?._steamUser?.kickPlayingSession() } + .onFailure { Timber.w(it, "kickPlayingSession cleanup after aborted launch failed") } + } releaseSync(appId) } } From 0324c16b21900d9d66b52897a4361ee365c7c634 Mon Sep 17 00:00:00 2001 From: TideGear Date: Wed, 22 Apr 2026 21:11:31 -0700 Subject: [PATCH 15/34] fix: auto-clear same-device phantom pending ops in beginLaunchApp When every pending remote operation returned by signalAppLaunchIntent is from our own machine name, treat them as self-phantoms from a prior session that died without cleaning up (Android-killed process, upload in-flight at force-close). Kick any stale AppSessionActive and re-signal with ignorePendingOperations=true in-flight, then proceed silently. Cross-device conflicts (different machine name) still surface the dialog so genuine "someone else is uploading from your PC" cases are preserved. Closes the last repro path where the user had to manually launch in emulation mode with "Play Anyway" to clear a stuck Pending Upload dialog before the next real-Steam launch would work. The proactive kick added in 79c112cd only clears AppSessionActive; this handles UploadPending and the other upload-side markers that kickPlayingSession doesn't release, matching what the manual workaround was doing. --- .../app/gamenative/service/SteamService.kt | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index aedfd0eeab..71a4768979 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2345,12 +2345,50 @@ class SteamService : Service(), IChallengeUrlChanged { // could still surface here. Strip those so we never surface // spurious dialogs or kick our own launch — genuine entries // from other devices still flow through. - val pendingRemoteOperations = if (isLaunchRealSteam) { + var pendingRemoteOperations = if (isLaunchRealSteam) { rawPending.filterNot { it.machineName.equals("localhost", ignoreCase = true) } } else { rawPending } + // Self-phantom auto-clear: when every pending op is from our own + // machine name (device was killed mid-session / mid-upload and the + // server-side markers never got released), kick any stale + // AppSessionActive and re-signal with ignorePendingOperations=true. + // This is what the user's emulation-mode workaround does manually; + // automating it avoids the spurious "Pending Upload" dialog that + // blocks the next launch. Cross-device conflicts (different machine + // name) still surface the dialog for genuine review. + val ourMachineName = SteamUtils.getMachineName(steamInstance) + val allSelfPhantoms = pendingRemoteOperations.isNotEmpty() && + pendingRemoteOperations.all { + it.machineName.equals(ourMachineName, ignoreCase = true) + } + if (allSelfPhantoms && !ignorePendingOperations) { + Timber.i( + "All ${pendingRemoteOperations.size} pending op(s) are self-phantoms from \"$ourMachineName\" (${pendingRemoteOperations.joinToString { it.operation.name }}); kicking and retrying silently", + ) + if (pendingRemoteOperations.any { + it.operation == ECloudPendingRemoteOperation.k_ECloudPendingRemoteOperationAppSessionActive + } + ) { + runCatching { steamInstance._steamUser?.kickPlayingSession() } + .onFailure { Timber.w(it, "Self-phantom AppSessionActive kick failed") } + } + pendingRemoteOperations = runCatching { + steamCloud.signalAppLaunchIntent( + appId = appId, + clientId = clientId, + machineName = ourMachineName, + ignorePendingOperations = true, + osType = EOSType.AndroidUnknown, + ).await().also { launchIntentRegistered = true } + }.getOrElse { + Timber.w(it, "Self-phantom retry signalAppLaunchIntent failed; falling back to original list") + pendingRemoteOperations + } + } + if (pendingRemoteOperations.isNotEmpty() && !ignorePendingOperations) { syncResult = PostSyncInfo( syncResult = SyncResult.PendingOperations, From 06501d8e51d656e7a9341a9e25b23ef8c2ad0531 Mon Sep 17 00:00:00 2001 From: TideGear Date: Thu, 23 Apr 2026 01:01:27 -0700 Subject: [PATCH 16/34] fix: clear cloud phantoms before real-Steam launch, drop -silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two real-Steam launch stalls that manifest as black screens after cloud sync: 1. Cloud-conflict dialog hidden by -silent STEAM_LAUNCH_FLAGS passed -silent to steam.exe, which not only hides the main window but also suppresses the cloud-conflict resolution dialog. Games with save-sync conflicts (or pending uploads) sat at a black screen forever with no way to resolve them. -silent was being injected from two sources; both are removed: - app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt - app/src/main/assets/box86_64/lightsteam.box64rc - app/src/main/assets/box86_64/ultralightsteam.box64rc (the box64rc WINEARGS= entry was shadowing the Kotlin change) 2. localhost pending-operation phantoms Wine-Steam's own BYieldingAppLaunchIntent was observing stale UploadPending / UploadInProgress markers from a prior Wine-Steam session (registered under machineName="localhost") that was killed mid-cycle, and stalling the launch. The existing proactive kickPlayingSession() only clears AppSessionActive; upload markers survive it. SteamService.beginLaunchApp now performs a full phantom-clearing cycle in real-Steam mode before Wine-Steam is spawned: - kickPlayingSession() — clears AppSessionActive - signalAppLaunchIntent(ignore=true) — server-side "Play Anyway" dismissal that also clears UploadPending / UploadInProgress regardless of origin (self or stale localhost) - signalAppExitSyncDone() — cleanly closes the session so Wine-Steam's subsequent BYieldingAppLaunchIntent sees no GN-side session Mirrors the existing self-phantom auto-clear technique, but runs it proactively in the real-Steam path where signalAppLaunchIntent is otherwise skipped entirely. Also in SteamUtils.kt: 3. Shared-helper manifest writer refactor createAppManifest now delegates to writeAllSharedAppManifests (data-driven over a sharedHelperApps list) instead of the single- purpose writeSteamworksCommonManifest. Lets future helper apps plug in without touching the child-manifest path. 228980 is the only entry for now; 241100 was tried, caused a 20s Steam reconcile loop during gameplay, and is listed in retiredSharedHelperAppIds so leftover acfs from the buggy build get cleaned up on next launch. validateAcfShape now loops over the helper list rather than hard-coding one appId. Verified - Shotgun King (1972440) launched cleanly with the new phantom-clearing cycle after black-screening before the fix; cloud_log.txt confirmed the "Operation '3' from machine 'localhost'" pending op no longer appears on retry. --- .../main/assets/box86_64/lightsteam.box64rc | 2 +- .../assets/box86_64/ultralightsteam.box64rc | 2 +- .../app/gamenative/service/SteamService.kt | 38 ++++- .../ui/screen/xserver/XServerScreen.kt | 6 +- .../java/app/gamenative/utils/SteamUtils.kt | 148 ++++++++++++------ 5 files changed, 141 insertions(+), 55 deletions(-) diff --git a/app/src/main/assets/box86_64/lightsteam.box64rc b/app/src/main/assets/box86_64/lightsteam.box64rc index 2e2bc46807..7b45ddba99 100644 --- a/app/src/main/assets/box86_64/lightsteam.box64rc +++ b/app/src/main/assets/box86_64/lightsteam.box64rc @@ -1,5 +1,5 @@ [steam.exe] -BOX64_ENV=WINEARGS=-silent -no-browser -noreactlogin -no-dwrite -no-cef-sandbox -nooverlay -nobigpicture -nofriendsui -noshaders -novid -noverifyfiles -nointro -skipstreamingdrivers -norepairfiles -nohltv -nofasthtml -nocrashmonitor -no-shared-textures -disablehighdpi -cef-single-process -cef-in-process-gpu -single_core -cef-disable-d3d11 -cef-disable-sandbox -disable-winh264 -vrdisable -cef-disable-breakpad -cef-disable-gpu -cef-disable-hang-timeouts -cef-disable-seccomp-sandbox -cef-disable-extensions -cef-disable-remote-fonts -cef-enable-media-stream -cef-disable-accelerated-video-decode +BOX64_ENV=WINEARGS=-no-browser -noreactlogin -no-dwrite -no-cef-sandbox -nooverlay -nobigpicture -nofriendsui -noshaders -novid -noverifyfiles -nointro -skipstreamingdrivers -norepairfiles -nohltv -nofasthtml -nocrashmonitor -no-shared-textures -disablehighdpi -cef-single-process -cef-in-process-gpu -single_core -cef-disable-d3d11 -cef-disable-sandbox -disable-winh264 -vrdisable -cef-disable-breakpad -cef-disable-gpu -cef-disable-hang-timeouts -cef-disable-seccomp-sandbox -cef-disable-extensions -cef-disable-remote-fonts -cef-enable-media-stream -cef-disable-accelerated-video-decode BOX64_ENV1=WINEDLLOVERRIDES=gameoverlayui,gameoverlayui64.exe=d [steamwebhelper.exe] diff --git a/app/src/main/assets/box86_64/ultralightsteam.box64rc b/app/src/main/assets/box86_64/ultralightsteam.box64rc index 1f3bec4629..2e84ec3d1a 100644 --- a/app/src/main/assets/box86_64/ultralightsteam.box64rc +++ b/app/src/main/assets/box86_64/ultralightsteam.box64rc @@ -1,5 +1,5 @@ [steam.exe] -BOX64_ENV=WINEARGS=-no-browser -noreactlogin -silent -no-dwrite -no-cef-sandbox -nooverlay -nofriendsui -nobigpicture -noshaders -novid -noverifyfiles -nointro -skipstreamingdrivers -norepairfiles -nohltv -nofasthtml -nocrashmonitor -no-shared-textures -disablehighdpi -cef-single-process -cef-in-process-gpu -single_core -cef-disable-d3d11 -cef-disable-sandbox -disable-winh264 -vrdisable -cef-disable-breakpad -cef-disable-gpu -cef-disable-hang-timeouts -cef-disable-seccomp-sandbox -cef-disable-gpu-compositing -cef-disable-extensions -cef-disable-remote-fonts -cef-enable-media-stream -cef-disable-accelerated-video-decode +BOX64_ENV=WINEARGS=-no-browser -noreactlogin -no-dwrite -no-cef-sandbox -nooverlay -nofriendsui -nobigpicture -noshaders -novid -noverifyfiles -nointro -skipstreamingdrivers -norepairfiles -nohltv -nofasthtml -nocrashmonitor -no-shared-textures -disablehighdpi -cef-single-process -cef-in-process-gpu -single_core -cef-disable-d3d11 -cef-disable-sandbox -disable-winh264 -vrdisable -cef-disable-breakpad -cef-disable-gpu -cef-disable-hang-timeouts -cef-disable-seccomp-sandbox -cef-disable-gpu-compositing -cef-disable-extensions -cef-disable-remote-fonts -cef-enable-media-stream -cef-disable-accelerated-video-decode BOX64_ENV1=WINEDLLOVERRIDES=gameoverlayui,gameoverlayui64.exe=d [steamwebhelper.exe] diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 71a4768979..780d839e9c 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2280,13 +2280,45 @@ class SteamService : Service(), IChallengeUrlChanged { var syncResult = PostSyncInfo(SyncResult.UnknownFail) var launchIntentRegistered = false - // Wine-hosted Steam establishes its own session via BYieldingAppLaunchIntent, - // so clearing server-side state here wipes orphan phantoms from prior aborted - // launches (emulation crash, killed mid-sync) without disrupting anything we're + // Wine-hosted Steam establishes its own session via BYieldingAppLaunchIntent + // under machineName="localhost", so clearing server-side state here wipes + // orphan phantoms from prior aborted launches without disrupting anything we're // about to do — we don't signal an intent in real-Steam mode anyway. + // + // kickPlayingSession alone only clears AppSessionActive; UploadPending / + // UploadInProgress markers survive it and will be observed by Wine-Steam's + // subsequent BYieldingAppLaunchIntent, stalling the launch at a black screen + // (cloud-conflict dialog hidden behind suppressed UI). To also clear upload + // markers — including the "localhost" flavor left by a prior Wine-Steam + // session that was killed mid-cycle — open a launch intent with + // ignorePendingOperations=true (the server-side "Play Anyway" dismissal) and + // immediately exit cleanly so no GameNative-side session lingers. if (isLaunchRealSteam) { runCatching { instance?._steamUser?.kickPlayingSession() } .onFailure { Timber.w(it, "Proactive kickPlayingSession before real-Steam launch failed") } + + val steamInstance = instance + val steamCloud = steamInstance?._steamCloud + val proactiveClientId = PrefManager.clientId + if (steamInstance != null && steamCloud != null && proactiveClientId != null) { + runCatching { + steamCloud.signalAppLaunchIntent( + appId = appId, + clientId = proactiveClientId, + machineName = SteamUtils.getMachineName(steamInstance), + ignorePendingOperations = true, + osType = EOSType.AndroidUnknown, + ).await() + steamCloud.signalAppExitSyncDone( + appId = appId, + clientId = proactiveClientId, + uploadsCompleted = true, + uploadsRequired = false, + ) + }.onFailure { + Timber.w(it, "Proactive phantom-clearing cycle before real-Steam launch failed") + } + } } try { 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 0ed699b63c..d91c09782b 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 @@ -245,9 +245,11 @@ private fun detectMaxRefreshRateHz(context: Context, attachedView: View?): Int { private const val GRACEFUL_EXIT_GRACE_MS = 5_000L // Flags passed to steam.exe in real-Steam launch mode. -// -silent: start without main window; -vgui: classic UI renderer (more compatible under Wine); +// -vgui: classic UI renderer (more compatible under Wine); // -tcp: avoid named-pipe handshake wait; -nobigpicture/-nofriendsui/-nochatui/-nointro: suppress optional UIs. -private const val STEAM_LAUNCH_FLAGS = "-silent -vgui -tcp -nobigpicture -nofriendsui -nochatui -nointro" +// -silent was dropped intentionally: it suppressed Steam's cloud-conflict resolution dialog, +// causing silent launch hangs on save-sync conflicts. The Steam main window shows briefly at launch. +private const val STEAM_LAUNCH_FLAGS = "-vgui -tcp -nobigpicture -nofriendsui -nochatui -nointro" private data class XServerViewReleaseBinding( val xServerView: XServerView, diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 48989a9813..bccddca801 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -808,8 +808,8 @@ object SteamUtils { steamAppId, ) } - // Still refresh 228980's manifest — it's independent of the child's state. - writeSteamworksCommonManifest(steamappsDir, commonDir, lastOwner, sharedDepots.keys) + // Still refresh shared helper manifests (228980, 241100) — they're independent of the child's state. + writeAllSharedAppManifests(steamappsDir, commonDir, lastOwner, sharedDepots.keys) return } @@ -904,24 +904,28 @@ object SteamUtils { val acfFile = File(steamappsDir, "appmanifest_$steamAppId.acf") acfFile.writeText(acfContent) - // Always write a real appmanifest_228980.acf for Steamworks Common - // Redistributables — Steam's PICS metadata declares this dependency - // server-side (not in the child's depot list), and without a valid - // 228980 manifest the child gets stuck in Update Queued / gray Play. - // writeSteamworksCommonManifest is a no-op if PICS has no AppInfo for 228980. - writeSteamworksCommonManifest(steamappsDir, commonDir, lastOwner, sharedDepots.keys) + // Always refresh shared helper manifests: 228980 (Steamworks Common + // Redistributables — referenced via child's SharedDepots) and 241100 + // (Steam Controller Configs — auto-installed by Steam for any game + // using Steam Input, regardless of child declaration). Without valid + // manifests for these, the child gets stuck in Update Queued / gray + // Play. The writers are no-ops when PICS has no AppInfo for a helper. + writeAllSharedAppManifests(steamappsDir, commonDir, lastOwner, sharedDepots.keys) // Self-check: assert the acfs we just wrote are in the shape that // avoids gray Play. Catches silent regressions in the writers + any // case where Steam immediately rewrites the file between our write // and the next launch. validateAcfShape(acfFile, expectDepotCount = regularDepots.size, label = "child $steamAppId") - if (sharedDepots.isNotEmpty()) { - validateAcfShape( - File(steamappsDir, "appmanifest_228980.acf"), - expectDepotCount = -1, - label = "shared 228980", - ) + sharedHelperApps.forEach { helper -> + val helperAcf = File(steamappsDir, "appmanifest_${helper.appId}.acf") + if (helperAcf.isFile) { + validateAcfShape( + helperAcf, + expectDepotCount = -1, + label = "shared ${helper.appId}", + ) + } } } catch (e: Exception) { @@ -969,17 +973,19 @@ object SteamUtils { 229032 to "_CommonRedist\\\\PhysX\\\\9.13.1220\\\\installscript.vdf", ) - // Resolve the depots that 228980 owns for this child by intersecting the - // child's PICS-declared SharedDepots with 228980's own PICS depot list, - // pulling manifest GID + size from PICS. This replaces the old approach of - // hardcoding a depot table and filtering by installscript.vdf presence — - // that broke for games (DD, others) that ship a half-empty _CommonRedist. - private fun resolveSharedDepotsForChild( + // Resolve depots for a shared parent app (228980, 241100, etc.) by pulling + // manifest GID + size from the parent's own PICS entry. If depotFilter is + // non-null, only those depot IDs are kept (used when a child game declares + // its own SharedDepots list, e.g. 228980's 228985/228989). If null, every + // depot PICS lists for the parent is included (used for apps Steam + // auto-installs regardless of child declaration, e.g. 241100 Steam Input). + private fun resolveSharedAppDepots( sharedAppId: Int, - childSharedDepotIds: Set, + depotFilter: Set?, ): List { val sharedAppInfo = SteamService.getAppInfoOf(sharedAppId) ?: return emptyList() - return childSharedDepotIds.mapNotNull { depotId -> + val depotIds = depotFilter ?: sharedAppInfo.depots.keys + return depotIds.mapNotNull { depotId -> val depot = sharedAppInfo.depots[depotId] ?: return@mapNotNull null val manifest = depot.manifests["public"] ?: depot.manifests.values.firstOrNull() @@ -994,25 +1000,50 @@ object SteamUtils { } } + // Shared helper apps that Steam will try to update before launching any + // game unless we pre-declare them as installed. 228980 (Steamworks Common + // Redistributables) is referenced via the child's PICS SharedDepots block + // (228985/228989 for VC++ redist). Without a valid acf for it, Steam + // cascades Update Queued onto the child and grays out Play. + // + // 241100 (Steam Input configs) was tried here too but caused a worse bug: + // Steam's scheduler re-runs `Reconfiguring → Staging → Committing (0 files)` + // every 20s indefinitely while the game is running, because its content + // lives at workshop/content/241100 (not under common/) and our synthetic + // acf makes Steam think it should exist. Left pre-fix behavior alone: 241100 + // reconciles once and stops. + private data class SharedHelperApp( + val appId: Int, + val installDir: String, + // If null: include all depots PICS lists for this app. + // If set: only include depots that appear in both PICS and the + // provided set (currently only 228980 uses the child-declared set). + val useChildDeclaredDepots: Boolean, + ) + + private val sharedHelperApps = listOf( + SharedHelperApp(appId = 228980, installDir = "Steamworks Shared", useChildDeclaredDepots = true), + ) + /** - * Write appmanifest_228980.acf for Steamworks Common Redistributables - * declaring exactly the depots whose installscript.vdf is present on disk. - * This "0 bytes to download" shape makes Steam's reconcile a ~1s no-op; an - * empty or mismatched manifest otherwise leaves 228980 stuck in Update - * Queued and cascades gray Play onto the child game. + * Write appmanifest_.acf for a shared helper app. The + * "0 bytes to download" shape makes Steam's reconcile a ~1s no-op; an + * empty or mismatched manifest otherwise leaves the helper stuck in + * Update Queued and cascades gray Play onto the child game. */ - private fun writeSteamworksCommonManifest( + private fun writeSharedAppManifest( steamappsDir: File, commonDir: File, lastOwner: String, - childSharedDepotIds: Set = emptySet(), + helper: SharedHelperApp, + childSharedDepotIds: Set, ) { - val sharedAppId = 228980 + val sharedAppId = helper.appId val staleAcf = File(steamappsDir, "appmanifest_$sharedAppId.acf") val sharedAppInfo = SteamService.getAppInfoOf(sharedAppId) if (sharedAppInfo == null) { if (staleAcf.exists()) staleAcf.delete() - Timber.w("writeSteamworksCommonManifest: PICS has no AppInfo for 228980 — Steam will gray Play") + Timber.w("writeSharedAppManifest: PICS has no AppInfo for $sharedAppId — Steam may gray Play") return } @@ -1020,21 +1051,17 @@ object SteamUtils { ?: sharedAppInfo.branches.values.firstOrNull()?.buildId ?: 0L if (sharedBuildId == 0L) { - Timber.w("writeSteamworksCommonManifest: 228980 PICS has no buildid — skipping") + Timber.w("writeSharedAppManifest: $sharedAppId PICS has no buildid — skipping") return } - // Source-of-truth: PICS. Resolve exactly the depots this child declared - // as owned by 228980, pulling manifest GIDs + sizes from PICS itself. - // No dependency on installscript.vdf files on disk — that's what - // trapped us in the empty-InstalledDepots → gray Play state for games - // like DD that ship a half-empty _CommonRedist skeleton. - val presentDepots = resolveSharedDepotsForChild(sharedAppId, childSharedDepotIds) + val depotFilter = if (helper.useChildDeclaredDepots) childSharedDepotIds else null + val presentDepots = resolveSharedAppDepots(sharedAppId, depotFilter) if (presentDepots.isEmpty()) { if (staleAcf.exists()) staleAcf.delete() Timber.w( - "writeSteamworksCommonManifest: no 228980 depots resolvable from PICS for child shared=%s — deleting stale acf", - childSharedDepotIds, + "writeSharedAppManifest: no $sharedAppId depots resolvable from PICS (filter=%s) — deleting stale acf", + depotFilter, ) return } @@ -1054,7 +1081,7 @@ object SteamUtils { } } - val sharedInstallDir = "Steamworks Shared" + val sharedInstallDir = helper.installDir val sharedCommonDir = File(commonDir, sharedInstallDir) if (!sharedCommonDir.exists()) { sharedCommonDir.mkdirs() @@ -1068,9 +1095,9 @@ object SteamUtils { appendLine("\t\"appid\"\t\t\"$sharedAppId\"") appendLine("\t\"Universe\"\t\t\"1\"") appendLine("\t\"LauncherPath\"\t\t\"$launcherPath\"") - appendLine("\t\"name\"\t\t\"${escapeString(sharedAppInfo.name.ifBlank { "Steamworks Common Redistributables" })}\"") + appendLine("\t\"name\"\t\t\"${escapeString(sharedAppInfo.name.ifBlank { "App $sharedAppId" })}\"") appendLine("\t\"StateFlags\"\t\t\"4\"") - appendLine("\t\"installdir\"\t\t\"$sharedInstallDir\"") + appendLine("\t\"installdir\"\t\t\"${escapeString(sharedInstallDir)}\"") appendLine("\t\"LastUpdated\"\t\t\"${System.currentTimeMillis() / 1000}\"") appendLine("\t\"LastPlayed\"\t\t\"0\"") val presentSizeOnDisk = presentDepots.sumOf { it.size } @@ -1100,12 +1127,15 @@ object SteamUtils { } appendLine("\t}") - appendLine("\t\"InstallScripts\"") - appendLine("\t{") - presentDepots.filter { it.installScript.isNotEmpty() }.forEach { d -> - appendLine("\t\t\"${d.id}\"\t\t\"${d.installScript}\"") + val scriptedDepots = presentDepots.filter { it.installScript.isNotEmpty() } + if (scriptedDepots.isNotEmpty()) { + appendLine("\t\"InstallScripts\"") + appendLine("\t{") + scriptedDepots.forEach { d -> + appendLine("\t\t\"${d.id}\"\t\t\"${d.installScript}\"") + } + appendLine("\t}") } - appendLine("\t}") appendLine("\t\"UserConfig\"") appendLine("\t{") @@ -1121,6 +1151,28 @@ object SteamUtils { staleAcf.writeText(acfContent) } + // AppIds that an older build of GameNative wrote synthetic manifests for, + // but which have since been removed from sharedHelperApps. Delete any + // leftover acfs so Steam doesn't keep reconciling phantom installs. + private val retiredSharedHelperAppIds = setOf(241100) + + private fun writeAllSharedAppManifests( + steamappsDir: File, + commonDir: File, + lastOwner: String, + childSharedDepotIds: Set, + ) { + retiredSharedHelperAppIds.forEach { retiredId -> + val stale = File(steamappsDir, "appmanifest_$retiredId.acf") + if (stale.isFile && stale.delete()) { + Timber.i("writeAllSharedAppManifests: removed stale appmanifest_%d.acf from prior build", retiredId) + } + } + sharedHelperApps.forEach { helper -> + writeSharedAppManifest(steamappsDir, commonDir, lastOwner, helper, childSharedDepotIds) + } + } + private fun escapeString(input: String?): String { if (input == null) return "" return input.replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") From 5e50e0eeda3f4d83bc12eeaeae4c63ee81cb4f9c Mon Sep 17 00:00:00 2001 From: TideGear Date: Thu, 23 Apr 2026 01:26:58 -0700 Subject: [PATCH 17/34] fix: probe for cloud phantoms before dismissing, don't corrupt ChangeNumber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior proactive phantom-clear always ran the full signalAppLaunchIntent(ignore=true) + signalAppExitSyncDone( uploadsCompleted=true) cycle on every real-Steam launch. On a clean launch with no phantom present this lied to Steam's cloud that a session had finished with uploads completed, which advanced the server-side ChangeNumber and caused Wine-Steam to "forget" steam_autocloud.vdf on the next launch. Games with Steamworks cloud integration (observed on 868-HACK) then exited with code 1 a few seconds after launch. Probe first: bare signalAppLaunchIntent with ignorePendingOperations= false returns pending ops without dismissing them. Only run the dismissal cycle when the probe actually reports a phantom. When we do run it, use honest signalAppExitSyncDone(uploadsCompleted=false, uploadsRequired=false) — we didn't upload anything. Always kickPlayingSession at the end to release the AppSessionActive the probe RPC registered under our machineName, so Wine-Steam's subsequent localhost intent doesn't see a conflicting session from us. Shotgun King phantom-clearing path still works (the probe detects the phantom and runs the dismissal); clean launches no longer touch ChangeNumber. --- .../app/gamenative/service/SteamService.kt | 58 +++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 780d839e9c..b9b32b29dd 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2285,14 +2285,14 @@ class SteamService : Service(), IChallengeUrlChanged { // orphan phantoms from prior aborted launches without disrupting anything we're // about to do — we don't signal an intent in real-Steam mode anyway. // - // kickPlayingSession alone only clears AppSessionActive; UploadPending / - // UploadInProgress markers survive it and will be observed by Wine-Steam's - // subsequent BYieldingAppLaunchIntent, stalling the launch at a black screen - // (cloud-conflict dialog hidden behind suppressed UI). To also clear upload - // markers — including the "localhost" flavor left by a prior Wine-Steam - // session that was killed mid-cycle — open a launch intent with - // ignorePendingOperations=true (the server-side "Play Anyway" dismissal) and - // immediately exit cleanly so no GameNative-side session lingers. + // Probe first: a bare signalAppLaunchIntent with ignorePendingOperations=false + // returns the list of pending ops without dismissing them. Only run the full + // dismissal cycle if the server actually reports a phantom, so we don't + // advance the cloud ChangeNumber on clean launches. A prior iteration always + // ran signalAppExitSyncDone(uploadsCompleted=true) proactively, which lied to + // the server that we completed uploads and reset cloud ChangeNumber to 0 — + // Wine-Steam then "forgot" steam_autocloud.vdf and games with Steamworks + // cloud integration (e.g. 868-HACK) exited with code 1. if (isLaunchRealSteam) { runCatching { instance?._steamUser?.kickPlayingSession() } .onFailure { Timber.w(it, "Proactive kickPlayingSession before real-Steam launch failed") } @@ -2301,23 +2301,45 @@ class SteamService : Service(), IChallengeUrlChanged { val steamCloud = steamInstance?._steamCloud val proactiveClientId = PrefManager.clientId if (steamInstance != null && steamCloud != null && proactiveClientId != null) { + val ourMachineName = SteamUtils.getMachineName(steamInstance) runCatching { - steamCloud.signalAppLaunchIntent( + val probed = steamCloud.signalAppLaunchIntent( appId = appId, clientId = proactiveClientId, - machineName = SteamUtils.getMachineName(steamInstance), - ignorePendingOperations = true, + machineName = ourMachineName, + ignorePendingOperations = false, osType = EOSType.AndroidUnknown, ).await() - steamCloud.signalAppExitSyncDone( - appId = appId, - clientId = proactiveClientId, - uploadsCompleted = true, - uploadsRequired = false, - ) + + if (probed.isNotEmpty()) { + Timber.i( + "Proactive real-Steam probe found %d pending op(s) (%s); dismissing phantom", + probed.size, + probed.joinToString { "${it.machineName}:${it.operation.name}" }, + ) + steamCloud.signalAppLaunchIntent( + appId = appId, + clientId = proactiveClientId, + machineName = ourMachineName, + ignorePendingOperations = true, + osType = EOSType.AndroidUnknown, + ).await() + steamCloud.signalAppExitSyncDone( + appId = appId, + clientId = proactiveClientId, + uploadsCompleted = false, + uploadsRequired = false, + ) + } }.onFailure { - Timber.w(it, "Proactive phantom-clearing cycle before real-Steam launch failed") + Timber.w(it, "Proactive phantom-clearing probe before real-Steam launch failed") } + + // Release the AppSessionActive the probe RPC registered under our + // machineName so Wine-Steam's subsequent localhost intent doesn't see + // a conflicting session from us. + runCatching { steamInstance._steamUser?.kickPlayingSession() } + .onFailure { Timber.w(it, "Probe-session kick after proactive launch intent failed") } } } From 20728903fffe589b25197a5a7ba21b6a1b18437a Mon Sep 17 00:00:00 2001 From: TideGear Date: Thu, 23 Apr 2026 15:35:05 -0700 Subject: [PATCH 18/34] fix: graceful Steam shutdown on red exit in real-Steam mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The red exit button was hard-killing steam.exe, which prevented Steam from flushing config/localconfig, installscript completion markers, and sending its own clean-shutdown IPC to the running game. Next launch of the same game would see the prefix in an incomplete state and re-run the game's vcredist/DirectX installscripts. Replace killProcess with `steam.exe -shutdown` — Steam's own graceful exit. Poll listProcesses at 1Hz to detect when steam.exe is gone, then proceed with the normal exit sequence. Cap the wait at 15s and show a modal asking "Keep waiting" or "Force quit" if Steam is still winding down (cloud saves, workshop sync, etc.). The dialog is a Compose overlay and does not pause emulation. Also adds a WinHandler.exec(filename, parameters) overload so callers can pass paths containing spaces (the existing single-string exec splits on the first space, which mangles "C:\Program Files (x86)\..." invocations). --- .../ui/screen/xserver/XServerScreen.kt | 96 ++++++++++++++++++- .../com/winlator/winhandler/WinHandler.java | 8 +- app/src/main/res/values/strings.xml | 5 + 3 files changed, 104 insertions(+), 5 deletions(-) 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 d91c09782b..dfb593b964 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 @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -244,6 +245,13 @@ private fun detectMaxRefreshRateHz(context: Context, attachedView: View?): Int { // bypasses the grace and force-quits. private const val GRACEFUL_EXIT_GRACE_MS = 5_000L +// Real-Steam mode: after issuing `steam.exe -shutdown`, wait this long for +// Steam to exit cleanly before escalating to a user prompt. Steam flushes +// config/install markers during shutdown and sends its own graceful quit IPC +// to the running game, so a hard kill before this completes causes next-launch +// redist-reinstall and lost config/cloud state. +private const val STEAM_SHUTDOWN_WAIT_MS = 15_000L + // Flags passed to steam.exe in real-Steam launch mode. // -vgui: classic UI renderer (more compatible under Wine); // -tcp: avoid named-pipe handshake wait; -nobigpicture/-nofriendsui/-nochatui/-nointro: suppress optional UIs. @@ -339,6 +347,35 @@ private suspend fun requestWineProcessSnapshot(winHandler: WinHandler): List?): Boolean { + if (snapshot == null) return true // snapshot failed — assume alive so we keep waiting + return snapshot.any { normalizeProcessName(it.name) == "steam" } +} + +private suspend fun awaitSteamShutdown( + winHandler: WinHandler, + showEscalationDialog: () -> CompletableDeferred, + onEscalationResolved: () -> Unit, +) { + while (true) { + val deadline = System.currentTimeMillis() + STEAM_SHUTDOWN_WAIT_MS + while (System.currentTimeMillis() < deadline) { + delay(EXIT_PROCESS_POLL_INTERVAL_MS) + if (!isSteamExeAlive(requestWineProcessSnapshot(winHandler))) return + } + val resolver = showEscalationDialog() + val keepWaiting = try { + resolver.await() + } finally { + onEscalationResolved() + } + if (!keepWaiting) { + winHandler.killProcess("steam.exe") + return + } + } +} + // TODO logs in composables are 'unstable' which can cause recomposition (performance issues) @Composable @@ -451,6 +488,7 @@ fun XServerScreen( var physicalControllerHandler: PhysicalControllerHandler? by remember { mutableStateOf(null) } var exitWatchJob: Job? by remember { mutableStateOf(null) } var gracefulExitJob: Job? by remember { mutableStateOf(null) } + var steamShutdownDialogResolver by remember { mutableStateOf?>(null) } DisposableEffect(Unit) { onDispose { @@ -460,6 +498,8 @@ fun XServerScreen( exitWatchJob = null gracefulExitJob?.cancel() gracefulExitJob = null + steamShutdownDialogResolver?.let { if (!it.isCompleted) it.cancel() } + steamShutdownDialogResolver = null } } var isKeyboardVisible = false @@ -1248,17 +1288,43 @@ fun XServerScreen( imeInputReceiver?.hideKeyboard() // Resume processes before exiting so they can receive SIGTERM cleanly. forceResumeIfSuspended() - SnackbarManager.show(context.getString(R.string.exit_graceful_toast)) val gameExe = extractExecutableBasename(container.executablePath) val shutdownSteam = container.isLaunchRealSteam + SnackbarManager.show( + context.getString( + if (shutdownSteam) R.string.exit_steam_shutdown_toast + else R.string.exit_graceful_toast, + ), + ) gracefulExitJob = CoroutineScope(Dispatchers.Main).launch { try { - if (gameExe.isNotEmpty()) winHandler.killProcess(gameExe) - if (shutdownSteam) winHandler.killProcess("steam.exe") - delay(GRACEFUL_EXIT_GRACE_MS) + if (shutdownSteam) { + // Let Steam shut the game down via its own IPC (gives + // the game's Steam API a clean quit signal, then Steam + // flushes config/install markers). + winHandler.exec( + "C:\\Program Files (x86)\\Steam\\steam.exe", + "-shutdown", + ) + awaitSteamShutdown( + winHandler, + showEscalationDialog = { + val resolver = CompletableDeferred() + steamShutdownDialogResolver = resolver + resolver + }, + onEscalationResolved = { steamShutdownDialogResolver = null }, + ) + } else { + if (gameExe.isNotEmpty()) winHandler.killProcess(gameExe) + delay(GRACEFUL_EXIT_GRACE_MS) + } exit(winHandler, frameRating, currentAppInfo, container, appId, onExit, navigateBack) } catch (_: kotlinx.coroutines.CancellationException) { // Force-quit path already called exit(). + } finally { + steamShutdownDialogResolver?.let { if (!it.isCompleted) it.cancel() } + steamShutdownDialogResolver = null } } } @@ -2510,6 +2576,28 @@ fun XServerScreen( } } + steamShutdownDialogResolver?.let { resolver -> + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.exit_steam_still_running_title)) }, + text = { Text(stringResource(R.string.exit_steam_still_running_message)) }, + confirmButton = { + TextButton(onClick = { + if (!resolver.isCompleted) resolver.complete(true) + }) { + Text(stringResource(R.string.exit_keep_waiting)) + } + }, + dismissButton = { + TextButton(onClick = { + if (!resolver.isCompleted) resolver.complete(false) + }) { + Text(stringResource(R.string.exit_force_quit)) + } + }, + ) + } + // Element Editor Dialog if (showElementEditor && elementToEdit != null && PluviaApp.inputControlsView != null) { app.gamenative.ui.component.dialog.ElementEditorDialog( diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index e0228ea004..e4b6f904b9 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -197,9 +197,15 @@ public void exec(String command) { String[] cmdList = command2.split(" ", 2); final String filename = cmdList[0]; final String parameters = cmdList.length > 1 ? cmdList[1] : ""; + exec(filename, parameters); + } + + public void exec(final String filename, final String parameters) { + if (filename == null || filename.isEmpty()) return; + final String params = parameters == null ? "" : parameters; addAction(() -> { byte[] filenameBytes = filename.getBytes(); - byte[] parametersBytes = parameters.getBytes(); + byte[] parametersBytes = params.getBytes(); this.sendData.rewind(); this.sendData.put(RequestCodes.EXEC); this.sendData.putInt(filenameBytes.length + parametersBytes.length + 8); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64d9622a97..28894f959f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -217,6 +217,11 @@ Exit Exit Game Closing game… tap Exit again to force quit + Waiting for Steam to shut down cleanly… + Steam is still shutting down + Steam hasn\'t finished yet. Wait longer to avoid losing progress or config, or force quit now. + Keep waiting + Force quit Keyboard Resume Game Extra From 5bf84590501ee60f74cde8a122c8d5aae7e073cb Mon Sep 17 00:00:00 2001 From: TideGear Date: Thu, 23 Apr 2026 22:23:23 -0700 Subject: [PATCH 19/34] fix: bridge SDK-cloud saves to Wine-Steam for Dead Cells Pluvia's SteamAutoCloud owns cloud in both modes with Wine-Steam cloudenabled=0 + -no-browser. That left SDK-cloud games reading stale local state because Wine-Steam's ISteamRemoteStorage had no index and some games read saves from // rather than //remote/. SteamAutoCloud: - writeRemoteCacheVdf called after every sync path (downloadUserFiles, upload, rehydrate, no-change) so Wine-Steam's SDK sees cloud files. - rebaseToAutoCloud redirects SteamUserData-rooted cloud entries to the matching saveFilePattern target for Auto-Cloud games whose uploads were misrooted. - Skip the SteamUserData catch-all scan for Auto-Cloud apps so ghost SteamUserData files don't get re-uploaded forever. SteamUtils: - RemoteCacheFile + writeRemoteCacheVdf writer. - sdkCloudSaveMirrors registry + mirrorSdkCloudRemoteToSave / mirrorSdkCloudSaveToRemote (seeded with 588650 -> "save"). - writeSharedConfigCloudDisabled mirrors cloudenabled=0 into sharedconfig.vdf; both "cloudenabled" and "cloud_enabled" written, last_sync_state / autocloud bookkeeping cleared. - purgePhantomAppUserdata removes userdata/// so a prior synthetic appmanifest can't leave an AutoCloud watcher behind that blocks steam.exe -shutdown. SteamService: - beginLaunchApp calls mirrorSdkCloudRemoteToSave after a successful sync so downloaded bytes reach the install dir before the game runs. - closeApp calls mirrorSdkCloudSaveToRemote before the upload sync so progress in /save/ reaches remote/ before Pluvia uploads. XServerScreen: - steam.exe launched with -no-browser (webhelper is a documented cause of shutdown hangs under Wine). - STEAM_SHUTDOWN_WAIT_MS 15s -> 20s. - purgePhantomAppUserdata("241100") on real-Steam container prep. SteamAppScreen: thread isLaunchRealSteam through forceSyncUserFiles so manual cloud sync uses the right GSE <-> SteamUserData migration path. FileUtils: extract matchesGlob helper; drop per-file log spam from findFiles / findFilesRecursive. --- .../app/gamenative/service/SteamAutoCloud.kt | 155 +++++++++--- .../app/gamenative/service/SteamService.kt | 29 +++ .../library/appscreen/SteamAppScreen.kt | 2 + .../ui/screen/xserver/XServerScreen.kt | 9 +- .../java/app/gamenative/utils/FileUtils.kt | 51 ++-- .../java/app/gamenative/utils/SteamUtils.kt | 226 +++++++++++++++++- 6 files changed, 396 insertions(+), 76 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index 59b39f397e..e7fe063add 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -18,6 +18,7 @@ import app.gamenative.utils.CURRENT_UFS_PARSE_VERSION import app.gamenative.utils.FileUtils import app.gamenative.utils.Net import app.gamenative.utils.SteamUtils +import java.io.File import `in`.dragonbra.javasteam.enums.EResult import `in`.dragonbra.javasteam.steam.handlers.steamcloud.AppFileChangeList import `in`.dragonbra.javasteam.steam.handlers.steamcloud.AppFileInfo @@ -122,6 +123,16 @@ object SteamAutoCloud { .filter { it.uploadRoot != it.root } .associate { "%${it.uploadRoot.name}%" to it.root.name } + // Auto-Cloud games declare saveFilePatterns rooted outside SteamUserData (usually + // GameInstall or WinAppData*). Their cloud payloads are sometimes surfaced without a + // prefix — either because the cloud entry was uploaded by an old Pluvia build under + // SteamUserData, or because Steam's getAppFileListChange returned an empty prefix list. + // Either way, naively writing those files to //remote/ hides them + // from the game, which reads from the pattern's real location. Rebase to the matching + // saveFilePattern target so the game sees them. + val autoCloudPatterns: List = appInfo.ufs.saveFilePatterns + .filter { it.root.isWindows && it.root != PathType.SteamUserData } + // Full-prefix remap for patterns where addPath shifts the local subfolder relative to // the cloud path. E.g. cloud "%GameInstall%saves" must land at "/MyGame/saves", // not "/saves" — root-only replacement can't express this. @@ -202,6 +213,48 @@ object SteamAutoCloud { Paths.get(getFilePrefix(file, fileList), file.filename).pathString } + val steamUserDataBase = prefixToPath(PathType.SteamUserData.name) + + // Build remotecache.vdf entries from the post-sync local scan so Wine-Steam's + // ISteamRemoteStorage indexes the files for SDK-cloud games. With cloudenabled=0 + // Wine-Steam doesn't rescan remote/ on startup — it trusts this index. + val writeRemoteCache: (Map>, Long) -> Unit = { localMap, changeNumber -> + val entries = localMap.values.flatten() + .filter { it.root == PathType.SteamUserData } + .mapNotNull { file -> + val absPath = file.getAbsPath(prefixToPath) + try { + if (!Files.exists(absPath)) return@mapNotNull null + SteamUtils.RemoteCacheFile( + relativePath = file.filename, + size = Files.size(absPath), + timeSeconds = file.timestamp / 1000, + shaHex = file.sha.joinToString("") { "%02x".format(it) }, + ) + } catch (e: Exception) { + Timber.w(e, "Skipping remotecache entry for ${file.filename}") + null + } + } + if (entries.isNotEmpty()) { + SteamUtils.writeRemoteCacheVdf( + remoteDir = File(steamUserDataBase), + appId = appInfo.id, + changeNumber = changeNumber, + files = entries, + ) + } + } + + val rebaseToAutoCloud: (String) -> Path? = rebase@{ filename -> + if (autoCloudPatterns.isEmpty()) return@rebase null + val baseName = Paths.get(filename).fileName?.toString() ?: return@rebase null + val match = autoCloudPatterns.firstOrNull { p -> + FileUtils.matchesGlob(baseName, p.pattern) + } ?: return@rebase null + Paths.get(prefixToPath(match.root.name), match.substitutedPath, filename) + } + val getFullFilePath: (AppFileInfo, AppFileChangeList) -> Path = getFullFilePath@{ file, fileList -> val gameInstallPrefix = "%${PathType.GameInstall.name}%" if (file.filename.startsWith(gameInstallPrefix)) { @@ -221,12 +274,23 @@ object SteamAutoCloud { val convertedPrefixes = convertPrefixes(fileList) - if (file.pathPrefixIndex < fileList.pathPrefixes.size) { + val defaultPath = if (file.pathPrefixIndex < fileList.pathPrefixes.size) { Paths.get(convertedPrefixes[file.pathPrefixIndex], file.filename) } else { // if the file does not reference any prefix then we need to set it to the default path Paths.get(prefixToPath(PathType.DEFAULT.name), file.filename) } + + // If the download target landed under SteamUserData//remote/ but this app + // uses Auto-Cloud at a non-SteamUserData root, redirect to the matching pattern + // location so the game actually reads the downloaded save. + if (defaultPath.toString().startsWith(steamUserDataBase)) { + rebaseToAutoCloud(file.filename)?.let { rebased -> + Timber.i("Rebasing SteamUserData-rooted cloud file ${file.filename} to $rebased") + return@getFullFilePath rebased + } + } + defaultPath } val getFilesDiff: (List, List) -> Pair = { currentFiles, oldFiles -> @@ -323,42 +387,48 @@ object SteamAutoCloud { } } - // Scan SteamUserData root recursively (depth 5) - val rootType = PathType.SteamUserData - val basePath = Paths.get(prefixToPath(rootType.toString())) - - Timber.i("Scanning $basePath recursively (depth 5) under ${rootType.name}") - - val files = FileUtils.findFilesRecursive( - rootPath = basePath, - pattern = "*", - maxDepth = 5, - ).map { - val sha = streamingShaHash(it) - - val relativePath = basePath.relativize(it).pathString - - Timber.i("Found ${it.pathString}\n\tin %${rootType.name}%\n\twith sha [${sha.joinToString(", ")}]") - - // Store relative path in filename; empty path component - UserFileInfo( - root = rootType, - path = "", - filename = relativePath, - timestamp = Files.getLastModifiedTime(it).toMillis(), - sha = sha, - cloudRoot = rootType, - cloudPath = "" - ) - }.collect(Collectors.toList()) - - Timber.i("Found ${files.size} file(s) in $basePath") + // SDK-cloud games (games that call ISteamRemoteStorage directly) keep their + // files in SteamUserData//remote/. For those we need a catch-all scan + // since there's no saveFilePattern to drive it. Skip this scan for Auto-Cloud + // games — the files there are ghosts of misrooted uploads, and rescanning them + // would re-upload them under SteamUserData and perpetuate the bug. + if (autoCloudPatterns.isEmpty()) { + val rootType = PathType.SteamUserData + val basePath = Paths.get(prefixToPath(rootType.toString())) + + Timber.i("Scanning $basePath recursively (depth 5) under ${rootType.name}") + + val files = FileUtils.findFilesRecursive( + rootPath = basePath, + pattern = "*", + maxDepth = 5, + ).map { + val sha = streamingShaHash(it) + + val relativePath = basePath.relativize(it).pathString + + Timber.i("Found ${it.pathString}\n\tin %${rootType.name}%\n\twith sha [${sha.joinToString(", ")}]") + + // Store relative path in filename; empty path component + UserFileInfo( + root = rootType, + path = "", + filename = relativePath, + timestamp = Files.getLastModifiedTime(it).toMillis(), + sha = sha, + cloudRoot = rootType, + cloudPath = "" + ) + }.collect(Collectors.toList()) - mapOf(Paths.get("%${rootType.name}%").pathString to files) + Timber.i("Found ${files.size} file(s) in $basePath") - if (files.isNotEmpty()) { - val prefixKey = "%${rootType.name}%" - result.getOrPut(prefixKey) { mutableListOf() }.addAll(files) + if (files.isNotEmpty()) { + val prefixKey = "%${rootType.name}%" + result.getOrPut(prefixKey) { mutableListOf() }.addAll(files) + } + } else { + Timber.i("Skipping SteamUserData scan for Auto-Cloud app ${appInfo.id} (${autoCloudPatterns.size} pattern(s))") } result @@ -785,6 +855,8 @@ object SteamAutoCloud { } } + writeRemoteCache(updatedLocalFiles, cloudAppChangeNumber) + return@async null } } @@ -819,6 +891,10 @@ object SteamAutoCloud { changeNumbersDao.insert(appInfo.id, uploadResult.appChangeNumber) } } + writeRemoteCache( + allLocalUserFiles.groupBy { "%${it.root.name}%" }, + uploadResult.appChangeNumber, + ) } else { syncResult = SyncResult.UpdateFail } @@ -867,6 +943,10 @@ object SteamAutoCloud { changeNumbersDao.insert(appInfo.id, cloudAppChangeNumber) } } + writeRemoteCache( + allLocalUserFiles.groupBy { "%${it.root.name}%" }, + cloudAppChangeNumber, + ) syncResult = SyncResult.UpToDate filesManaged = allLocalUserFiles.size rehydratedSilently = true @@ -949,6 +1029,11 @@ object SteamAutoCloud { Timber.i("No local changes and no new cloud user files, doing nothing...") syncResult = SyncResult.UpToDate + + // Ensure Wine-Steam's remotecache.vdf is present even when the sync + // is a no-op: on first run after this migration the file may be + // missing for an SDK-cloud app whose cache was already in sync. + writeRemoteCache(localUserFilesMap, cloudAppChangeNumber) } }.inWholeMicroseconds } else { diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index b9b32b29dd..818d6788d4 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2344,6 +2344,16 @@ class SteamService : Service(), IChallengeUrlChanged { } try { + // Pluvia is the sole cloud client in both modes: Wine-Steam has + // cloudenabled=0 written to localconfig.vdf AND sharedconfig.vdf and + // is launched with -no-browser so it performs no cloud I/O. Running + // Pluvia's AutoCloud on launch is required in real-Steam mode so users + // get fresh saves from other devices before the Wine-hosted game loads + // them — skipping this on launch caused Dead Cells to boot into an + // empty save. The original "save conflict" dialog that motivated a + // skip was driven by a ChangeNumber race between Wine-Steam and Pluvia + // both writing cloud state; with Wine-Steam's cloud fully suppressed + // there is no second writer to race with. val maxAttempts = 3 for (attempt in 1..maxAttempts) { try { @@ -2366,6 +2376,15 @@ class SteamService : Service(), IChallengeUrlChanged { syncResult = info if (info.syncResult == SyncResult.Success || info.syncResult == SyncResult.UpToDate) { + // Bridge SDK-cloud games whose on-disk save dir differs + // from //remote/ (e.g. Dead Cells reads + // /save/). Desktop Steam reconciles these via + // ISteamRemoteStorage internally; with cloudenabled=0 that + // path is dead, so mirror remote/ -> save/ ourselves. + steamInstance.applicationContext?.let { ctx -> + SteamUtils.mirrorSdkCloudRemoteToSave(ctx, appId) + } + Timber.i( "Signaling app launch:\n\tappId: %d\n\tclientId: %s\n\tosType: %s\n\tisLaunchRealSteam: %s", appId, @@ -2578,6 +2597,16 @@ class SteamService : Service(), IChallengeUrlChanged { } } + // Reverse of the pre-launch mirror: copy the game's on-disk save + // files (e.g. Dead Cells's /save/) back into + // //remote/ so the subsequent SteamAutoCloud + // upload sees the player's progress. + try { + SteamUtils.mirrorSdkCloudSaveToRemote(context, appId) + } catch (e: Exception) { + Timber.w(e, "SDK cloud save->remote mirror failed for appId=$appId") + } + val maxAttempts = 3 for (attempt in 1..maxAttempts) { try { diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index 55cc5f9d47..607fc73e07 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt @@ -812,6 +812,7 @@ class SteamAppScreen : BaseAppScreen() { val syncResult = SteamService.forceSyncUserFiles( appId = gameId, prefixToPath = prefixToPath, + isLaunchRealSteam = container.isLaunchRealSteam, ).await() when (syncResult.syncResult) { @@ -1110,6 +1111,7 @@ class SteamAppScreen : BaseAppScreen() { appId = gameId, prefixToPath = prefixToPath, overrideLocalChangeNumber = -1, + isLaunchRealSteam = container.isLaunchRealSteam, ).await() } else { SnackbarManager.show(context.getString(R.string.steam_not_logged_in)) 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 dfb593b964..30679a695e 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 @@ -250,14 +250,18 @@ private const val GRACEFUL_EXIT_GRACE_MS = 5_000L // config/install markers during shutdown and sends its own graceful quit IPC // to the running game, so a hard kill before this completes causes next-launch // redist-reinstall and lost config/cloud state. -private const val STEAM_SHUTDOWN_WAIT_MS = 15_000L +private const val STEAM_SHUTDOWN_WAIT_MS = 20_000L // Flags passed to steam.exe in real-Steam launch mode. // -vgui: classic UI renderer (more compatible under Wine); // -tcp: avoid named-pipe handshake wait; -nobigpicture/-nofriendsui/-nochatui/-nointro: suppress optional UIs. +// -no-browser: skip starting steamwebhelper.exe. Under Wine the webhelper is a +// documented cause of indefinite `steam.exe -shutdown` hangs (WineHQ #29066, +// ValveSoftware/steam-for-linux #9575) because it ignores WM_CLOSE and keeps the +// client alive. We don't need the overlay or store UI for real-Steam launches. // -silent was dropped intentionally: it suppressed Steam's cloud-conflict resolution dialog, // causing silent launch hangs on save-sync conflicts. The Steam main window shows briefly at launch. -private const val STEAM_LAUNCH_FLAGS = "-vgui -tcp -nobigpicture -nofriendsui -nochatui -nointro" +private const val STEAM_LAUNCH_FLAGS = "-vgui -tcp -no-browser -nobigpicture -nofriendsui -nochatui -nointro" private data class XServerViewReleaseBinding( val xServerView: XServerView, @@ -4426,6 +4430,7 @@ private fun setupWineSystemFiles( } extractSteamFiles(context, container, onExtractFileListener) SteamUtils.ensureSteamCfg(ImageFs.find(context)) + SteamUtils.purgePhantomAppUserdata(ImageFs.find(context), "241100") SteamUtils.logSteamBinaryFingerprint(ImageFs.find(context), "prepareContainer:realSteam") container.putExtra("steamExtractedForWine", steamExtractedKey) containerDataChanged = true diff --git a/app/src/main/java/app/gamenative/utils/FileUtils.kt b/app/src/main/java/app/gamenative/utils/FileUtils.kt index 7ad8339fa8..0eeae9cebb 100644 --- a/app/src/main/java/app/gamenative/utils/FileUtils.kt +++ b/app/src/main/java/app/gamenative/utils/FileUtils.kt @@ -133,24 +133,26 @@ object FileUtils { } } - fun findFiles(rootPath: Path, pattern: String, includeDirectories: Boolean = false): Stream { + fun matchesGlob(fileName: String, pattern: String): Boolean { + if (pattern.isEmpty() || pattern == "*") return true val patternParts = pattern.split("*").filter { it.isNotEmpty() } - Timber.i("$pattern -> $patternParts") + var startIndex = 0 + for (part in patternParts) { + val index = fileName.indexOf(part, startIndex, ignoreCase = true) + if (index < 0) return false + startIndex = index + part.length + } + return true + } + + fun findFiles(rootPath: Path, pattern: String, includeDirectories: Boolean = false): Stream { + Timber.i("findFiles pattern=$pattern") if (!Files.exists(rootPath)) return emptyList().stream() return Files.list(rootPath).filter { path -> if (path.isDirectory() && !includeDirectories) { false } else { - val fileName = path.name - Timber.i("Checking $fileName for pattern $pattern") - var startIndex = 0 - !patternParts.map { - val index = fileName.indexOf(it, startIndex, ignoreCase = true) - if (index >= 0) { - startIndex = index + it.length - } - index - }.any { it < 0 } + matchesGlob(path.name, pattern) } } } @@ -161,36 +163,19 @@ object FileUtils { maxDepth: Int = -1, includeDirectories: Boolean = false, ): Stream { - val patternParts = pattern.split("*").filter { it.isNotEmpty() } - Timber.i("$pattern -> $patternParts (recursive, depth=$maxDepth)") + Timber.i("findFilesRecursive pattern=$pattern depth=$maxDepth") if (!Files.exists(rootPath)) return emptyList().stream() val results = mutableListOf() - - fun matches(fileName: String): Boolean { - var startIndex = 0 - for (part in patternParts) { - val index = fileName.indexOf(part, startIndex, ignoreCase = true) - if (index < 0) return false - startIndex = index + part.length - } - return true - } - walkThroughPath(rootPath, maxDepth) { path -> if (path.isDirectory()) { - if (includeDirectories && matches(path.name)) { - results.add(path) - } - } else { - val fileName = path.name - Timber.i("Checking $fileName for pattern $pattern (recursive)") - if (matches(fileName)) { + if (includeDirectories && matchesGlob(path.name, pattern)) { results.add(path) } + } else if (matchesGlob(path.name, pattern)) { + results.add(path) } } - return results.stream() } diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index bccddca801..01e4d2b02b 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -9,6 +9,7 @@ import app.gamenative.data.LaunchInfo import app.gamenative.data.ManifestInfo import app.gamenative.data.SteamApp import app.gamenative.enums.Marker +import app.gamenative.enums.PathType import app.gamenative.enums.SpecialGameSaveMapping import app.gamenative.service.SteamService import app.gamenative.service.SteamService.Companion.getAppDirName @@ -886,9 +887,11 @@ object SteamUtils { ) } - // cloud_enabled="0" deliberately: GameNative already runs SteamAutoCloud.syncUserFiles - // around every launch, and letting the Wine-hosted Steam client also sync races it - // and occasionally blows away saves. + // cloud_enabled="0" in both modes: Pluvia's SteamAutoCloud owns cloud sync + // (runs on app close + manual "Cloud Sync" button; skipped only on launch in + // real-Steam mode to avoid a launch-time conflict dialog). Letting Wine-Steam + // also sync in real-Steam mode re-introduced the Steam Input (241100) AutoCloud + // watcher and blocked graceful steam.exe -shutdown on cloud upload — hence 0. appendLine("\t\"UserConfig\"") appendLine("\t{") appendLine("\t\t\"language\"\t\t\"english\"") @@ -1714,6 +1717,31 @@ object SteamUtils { } } + /** + * Removes Steam userdata for a phantom AppID across every logged-in user. When a + * synthetic appmanifest_.acf was written in a past session, Steam registers + * an AutoCloud watch for that app and on shutdown blocks exit until every + * watched file (78 Steam Controller configs for 241100) round-trips the cloud. + * Deleting userdata/// breaks the association so + * shutdown completes promptly for subsequent real-Steam launches. + */ + fun purgePhantomAppUserdata(imageFs: ImageFs, phantomAppId: String) { + try { + val userdataRoot = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/userdata") + if (!userdataRoot.isDirectory) return + val victims = userdataRoot.listFiles { f -> f.isDirectory }?.mapNotNull { userDir -> + File(userDir, phantomAppId).takeIf { it.exists() } + }.orEmpty() + if (victims.isEmpty()) return + victims.forEach { dir -> + deleteTreeNoFollowSymlinks(dir) + Timber.i("purgePhantomAppUserdata: removed ${dir.absolutePath}") + } + } catch (e: Exception) { + Timber.w(e, "purgePhantomAppUserdata[$phantomAppId] failed") + } + } + /** * Sibling folder "steam_settings" + empty "offline.txt" file, no-ops if they already exist. */ @@ -2103,12 +2131,29 @@ object SteamUtils { app.children.add(KeyValue("LaunchOptions", exeCommandLine)) } - // Suppress the Wine-hosted Steam client's per-app cloud sync. GameNative owns - // cloud via its own SteamKit connection around every launch; letting the client - // sync in parallel races us and produces spurious Save Conflict popups. + // Suppress Wine-Steam's per-app cloud sync. Pluvia's SteamAutoCloud handles + // cloud (runs on closeApp + manual sync); letting Wine-Steam also sync + // blocked graceful shutdown on cloud upload and resurrected the Steam Input + // (241100) AutoCloud watcher that purgePhantomAppUserdata works around. + // + // "cloudenabled" (no underscore) is the canonical per-app key documented in + // community reverse-engineering (l3laze/Steam-Data, l3laze/SteamConfig) and + // is what the Steam GUI writes when the user unchecks cloud for a game. + // "cloud_enabled" (with underscore) is retained as belt-and-suspenders in + // case a client build reads the underscored variant. + setOrReplaceKey(app, "cloudenabled", "0") setOrReplaceKey(app, "cloud_enabled", "0") setOrReplaceKey(app, "cce", "0") + // Wipe AutoCloud reconciliation state left over from a prior run where + // cloud_enabled was "1". Wine-Steam uses last_sync_state + autocloud + // lastlaunch/lastexit to decide whether to show "Synchronizing cloud + // saves" on startup and block shutdown on upload; flipping cloud_enabled + // to "0" alone doesn't clear those, so the client keeps reconciling + // ghosts on every launch. Pluvia's SteamAutoCloud owns cloud now, so + // Wine-Steam shouldn't retain any sync bookkeeping for this app. + app.children.removeAll { it.name == "last_sync_state" || it.name == "autocloud" } + vdfData.saveToFile(localConfigFile, false) } else { val vdfData = KeyValue(name = "UserLocalConfigStore") @@ -2120,6 +2165,7 @@ object SteamUtils { val app = KeyValue(appId) app.children.add(option) + app.children.add(KeyValue("cloudenabled", "0")) app.children.add(KeyValue("cloud_enabled", "0")) app.children.add(KeyValue("cce", "0")) apps.children.add(app) @@ -2131,6 +2177,15 @@ object SteamUtils { vdfData.saveToFile(localConfigFile, false) } + // Mirror the per-app cloud-off state into sharedconfig.vdf. Steam keeps + // two per-app cloud flags: one in localconfig.vdf (UserLocalConfigStore) + // and one in sharedconfig.vdf (UserRoamingConfigStore/Software/Valve/Steam/Apps/). + // Different client versions read the roaming vs. local copy at different + // points in the login flow, so writing only to localconfig leaves a live + // "cloudenabled=1" (or absent=default-on) in sharedconfig that still drives + // the "Synchronizing cloud saves" dialog on startup. + writeSharedConfigCloudDisabled(steamPath, steamUserId64, appId) + val userLanguage = container.language val steamappsDir = File(steamPath, "steamapps") val appManifestFile = File(steamappsDir, "appmanifest_$appId.acf") @@ -2175,6 +2230,91 @@ object SteamUtils { } } + private fun writeSharedConfigCloudDisabled(steamPath: File, steamUserId64: String, appId: String) { + try { + val sharedConfigFile = File(steamPath, "userdata/$steamUserId64/7/remote/sharedconfig.vdf") + sharedConfigFile.parentFile?.mkdirs() + + val root = if (sharedConfigFile.exists()) { + val content = FileUtils.readFileAsString(sharedConfigFile.absolutePath) + KeyValue.loadFromString(content!!) ?: KeyValue(name = "UserRoamingConfigStore") + } else { + KeyValue(name = "UserRoamingConfigStore") + } + + val software = getOrCreateChild(root, "Software") + val valve = getOrCreateChild(software, "Valve") + val steam = getOrCreateChild(valve, "Steam") + // Both casings appear in the wild ("Apps" vs. "apps"); prefer "Apps" per + // the documented UserRoamingConfigStore schema but don't duplicate if the + // lowercase variant already exists in the file Steam wrote. + val apps = steam.children.firstOrNull { it.name.equals("Apps", ignoreCase = true) } + ?: KeyValue("Apps").also { steam.children.add(it) } + val app = getOrCreateChild(apps, appId) + setOrReplaceKey(app, "cloudenabled", "0") + + root.saveToFile(sharedConfigFile, false) + } catch (e: Exception) { + Timber.w(e, "Failed to write cloudenabled=0 to sharedconfig.vdf for appId=$appId") + } + } + + data class RemoteCacheFile( + val relativePath: String, + val size: Long, + val timeSeconds: Long, + val shaHex: String, + ) + + /** + * Writes ///remotecache.vdf so Wine-Steam's ISteamRemoteStorage + * reports the cloud files to the game. With cloudenabled=0, Wine-Steam does not + * rescan the remote/ directory on startup; it trusts remotecache.vdf. Without this + * write, SDK-cloud games (e.g. Dead Cells) see FileExists=false on their saves and + * load a blank state even though the files are on disk. + */ + fun writeRemoteCacheVdf( + remoteDir: File, + appId: Int, + changeNumber: Long, + files: List, + ) { + try { + val userAppDir = remoteDir.parentFile ?: return + if (!userAppDir.exists()) userAppDir.mkdirs() + val vdfFile = File(userAppDir, "remotecache.vdf") + + val root = KeyValue(appId.toString()) + root.children.add(KeyValue("ChangeNumber", changeNumber.toString())) + root.children.add(KeyValue("ostype", "0")) + + for (entry in files) { + val key = entry.relativePath.replace('/', '\\') + val node = KeyValue(key) + node.children.add(KeyValue("root", "0")) + node.children.add(KeyValue("size", entry.size.toString())) + node.children.add(KeyValue("localtime", entry.timeSeconds.toString())) + node.children.add(KeyValue("time", entry.timeSeconds.toString())) + node.children.add(KeyValue("remotetime", entry.timeSeconds.toString())) + node.children.add(KeyValue("sha", entry.shaHex)) + node.children.add(KeyValue("syncstate", "1")) + node.children.add(KeyValue("persiststate", "0")) + node.children.add(KeyValue("platformstosync2", "-1")) + root.children.add(node) + } + + root.saveToFile(vdfFile, false) + Timber.i("Wrote remotecache.vdf for appId=$appId (${files.size} file(s), ChangeNumber=$changeNumber)") + } catch (e: Exception) { + Timber.w(e, "Failed to write remotecache.vdf for appId=$appId") + } + } + + private fun getOrCreateChild(parent: KeyValue, name: String): KeyValue { + return parent.children.firstOrNull { it.name == name } + ?: KeyValue(name).also { parent.children.add(it) } + } + private fun setOrReplaceKey(parent: KeyValue, name: String, value: String) { val existing = parent.children.firstOrNull { it.name == name } if (existing != null) { @@ -2248,6 +2388,80 @@ object SteamUtils { } } + /** + * SDK-cloud games whose on-disk save location differs from the SDK's + * //remote/ directory. Value = install-relative subdir + * the game reads/writes. Desktop Steam reconciles the two internally; + * with our cloudenabled=0 setup that path is dead, so we bridge it. + */ + private val sdkCloudSaveMirrors: Map = mapOf( + 588650 to "save", // Dead Cells + ) + + private fun sdkCloudRemoteDir(context: Context, appId: Int): File? { + val accountId = SteamService.userSteamId?.accountID?.toLong() ?: return null + return File(PathType.SteamUserData.toAbsPath(context, appId, accountId)) + } + + private fun sdkCloudGameSaveDir(context: Context, appId: Int): File? { + val sub = sdkCloudSaveMirrors[appId] ?: return null + return File(SteamService.getAppDirPath(appId), sub) + } + + fun mirrorSdkCloudRemoteToSave(context: Context, appId: Int) { + val gameSaveDir = sdkCloudGameSaveDir(context, appId) ?: return + val remoteDir = sdkCloudRemoteDir(context, appId) ?: return + + if (!remoteDir.exists()) { + Timber.i("mirrorSdkCloudRemoteToSave: remote/ missing for appId=$appId") + return + } + if (!gameSaveDir.exists()) gameSaveDir.mkdirs() + + remoteDir.listFiles()?.forEach { src -> + if (!src.isFile) return@forEach + val dst = File(gameSaveDir, src.name) + try { + Files.copy( + src.toPath(), + dst.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.COPY_ATTRIBUTES, + ) + Timber.i("SDK cloud mirror remote->save appId=$appId: ${src.name} (${src.length()} bytes)") + } catch (e: Exception) { + Timber.w(e, "Failed to mirror ${src.name} remote->save appId=$appId") + } + } + } + + fun mirrorSdkCloudSaveToRemote(context: Context, appId: Int) { + val gameSaveDir = sdkCloudGameSaveDir(context, appId) ?: return + val remoteDir = sdkCloudRemoteDir(context, appId) ?: return + + if (!gameSaveDir.exists()) return + if (!remoteDir.exists()) remoteDir.mkdirs() + + gameSaveDir.listFiles()?.forEach { src -> + if (!src.isFile) return@forEach + // Skip local-only artifacts (e.g. Dead Cells writes backup-YYYY-MM-DD-N.zip snapshots + // alongside saves; those aren't cloud-synced). + if (src.name.startsWith("backup-") && src.name.endsWith(".zip")) return@forEach + val dst = File(remoteDir, src.name) + try { + Files.copy( + src.toPath(), + dst.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.COPY_ATTRIBUTES, + ) + Timber.i("SDK cloud mirror save->remote appId=$appId: ${src.name} (${src.length()} bytes)") + } catch (e: Exception) { + Timber.w(e, "Failed to mirror ${src.name} save->remote appId=$appId") + } + } + } + fun generateAchievementsFile(dllPath: Path, appId: String) { if (!SteamService.isLoggedIn) { Timber.w("Skipping achievements generation for $appId — Steam not logged in") From 9c0c5f2802ef576be8af18576e161715f120d2c1 Mon Sep 17 00:00:00 2001 From: TideGear Date: Fri, 24 Apr 2026 16:20:57 -0700 Subject: [PATCH 20/34] feat: SDK cloud save bridge with Ludusavi + launch-time prompt Adds per-container support for "Pattern B" SDK-cloud games (Dead Cells-style) that read saves from a subdirectory of their install directory rather than from //remote/. Replaces the hardcoded one-entry registry with the Ludusavi save-path manifest (~1944 Steam games matching Pattern B). Container setting: - New sdkCloudSaveSubdir field on Container/ContainerData, persisted through JSON and Compose Saver. Empty = bridge disabled. - "Cloud Save Bridge" section in GeneralTab (visible when Launch Steam Client is on) with text field + Use Recommended / Detect / Clear buttons, confirmation dialog on first activation, and validation rejecting path separators, .., and drive letters. Ludusavi integration (utils/LudusaviRegistry.kt): - Fetches manifest.yaml from mtkennerly/ludusavi-manifest via the existing OkHttp client. Streaming line-parser avoids the OOM that SnakeYAML's eager map load caused on the 5 MB file. - Filters to Steam-IDed entries with / save paths tagged "save" and applicable to Windows (or no OS filter). - Writes ~190 KB filtered JSON to filesDir/ludusavi_pattern_b.json, 7-day TTL, falls back to stale disk cache on fetch failure. - Primed in background at PluviaApp.onCreate so Use Recommended and the launch-time prompt are instant after the first session. Launch-time prompt: - preLaunchApp runs a Pattern B check (Ludusavi match AND no saveFilePatterns in PICS UFS) before cloud sync. Match fires an SDK_CLOUD_BRIDGE_SUGGESTION dialog with Enable / Skip / Don't ask again. Catches users who install with real-Steam default and never touch settings. - Don't-ask-again persists per-container as extraData.sdkCloudBridgePromptDismissed. Runtime mirror (utils/SteamUtils.kt): - sdkCloudGameSaveDir now reads the user-configured subdir only; no implicit fallback, to avoid REPLACE_EXISTING copies into a guessed dir that might be wrong. - detectSdkCloudSaveSubdir resolves paths via the container's own rootDir rather than the global xuser symlink so it works even when a different container is activated. Removed: - Bundled assets/sdk_cloud_save_bridge.json (single hardcoded entry); Ludusavi covers it plus ~1943 others. Cosmetic: replaced "Pluvia" with "GameNative" in comments/strings written on this branch. PluviaApp/PluviaTheme/PluviaPreferences class names and storage keys left alone. --- app/src/main/java/app/gamenative/PluviaApp.kt | 10 + .../app/gamenative/service/SteamAutoCloud.kt | 2 +- .../app/gamenative/service/SteamService.kt | 6 +- .../main/java/app/gamenative/ui/PluviaMain.kt | 92 ++++++ .../component/dialog/ContainerConfigDialog.kt | 2 + .../component/dialog/ContainerConfigState.kt | 3 + .../ui/component/dialog/GeneralTab.kt | 176 +++++++++++ .../app/gamenative/ui/enums/DialogType.kt | 1 + .../screen/library/appscreen/BaseAppScreen.kt | 1 + .../app/gamenative/utils/ContainerUtils.kt | 2 + .../app/gamenative/utils/LudusaviRegistry.kt | 284 ++++++++++++++++++ .../java/app/gamenative/utils/SteamUtils.kt | 133 +++++++- .../com/winlator/container/Container.java | 13 + .../com/winlator/container/ContainerData.kt | 5 + app/src/main/res/values/strings.xml | 23 ++ 15 files changed, 738 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt diff --git a/app/src/main/java/app/gamenative/PluviaApp.kt b/app/src/main/java/app/gamenative/PluviaApp.kt index cd34458303..a07b0cc2a5 100644 --- a/app/src/main/java/app/gamenative/PluviaApp.kt +++ b/app/src/main/java/app/gamenative/PluviaApp.kt @@ -13,6 +13,7 @@ import app.gamenative.service.DownloadService import app.gamenative.service.SteamService import app.gamenative.utils.ContainerMigrator import app.gamenative.utils.IntentLaunchManager +import app.gamenative.utils.LudusaviRegistry import app.gamenative.utils.PlayIntegrity import java.io.File import javax.inject.Inject @@ -87,6 +88,15 @@ class PluviaApp : SplitCompatApplication() { ) } + // Prime the Ludusavi save-path registry in the background. The loader itself + // checks cache freshness (7-day TTL) and only hits the network when stale — so + // the SDK Cloud Save Bridge "Use Recommended" button and the launch-time prompt + // are both instant for users once the cache is populated. + appScope.launch { + runCatching { LudusaviRegistry.primeCache(applicationContext) } + .onFailure { Timber.w(it, "Background Ludusavi registry refresh failed") } + } + // Clear any stale temporary config overrides from previous app sessions try { IntentLaunchManager.clearAllTemporaryOverrides() diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index e7fe063add..b5ad234794 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -125,7 +125,7 @@ object SteamAutoCloud { // Auto-Cloud games declare saveFilePatterns rooted outside SteamUserData (usually // GameInstall or WinAppData*). Their cloud payloads are sometimes surfaced without a - // prefix — either because the cloud entry was uploaded by an old Pluvia build under + // prefix — either because the cloud entry was uploaded by an old GameNative build under // SteamUserData, or because Steam's getAppFileListChange returned an empty prefix list. // Either way, naively writing those files to //remote/ hides them // from the game, which reads from the pattern's real location. Rebase to the matching diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 818d6788d4..8ebbc2d791 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2344,14 +2344,14 @@ class SteamService : Service(), IChallengeUrlChanged { } try { - // Pluvia is the sole cloud client in both modes: Wine-Steam has + // GameNative is the sole cloud client in both modes: Wine-Steam has // cloudenabled=0 written to localconfig.vdf AND sharedconfig.vdf and // is launched with -no-browser so it performs no cloud I/O. Running - // Pluvia's AutoCloud on launch is required in real-Steam mode so users + // GameNative's AutoCloud on launch is required in real-Steam mode so users // get fresh saves from other devices before the Wine-hosted game loads // them — skipping this on launch caused Dead Cells to boot into an // empty save. The original "save conflict" dialog that motivated a - // skip was driven by a ChangeNumber race between Wine-Steam and Pluvia + // skip was driven by a ChangeNumber race between Wine-Steam and GameNative // both writing cloud state; with Wine-Steam's cloud fully suppressed // there is no second writer to race with. val maxAttempts = 3 diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index a249d19641..dee33dfbca 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -90,6 +90,7 @@ import app.gamenative.ui.theme.PluviaTheme import app.gamenative.ui.util.SnackbarManager import app.gamenative.utils.BestConfigService import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.SteamUtils import app.gamenative.utils.PlatformAuthUtils import app.gamenative.utils.CustomGameScanner import app.gamenative.utils.ManifestInstaller @@ -790,6 +791,61 @@ fun PluviaMain( } } + DialogType.SDK_CLOUD_BRIDGE_SUGGESTION -> { + val relaunch = { + preLaunchApp( + context = context, + appId = state.launchedAppId, + skipBridgePrompt = true, + setLoadingDialogVisible = viewModel::setLoadingDialogVisible, + setLoadingProgress = viewModel::setLoadingDialogProgress, + setLoadingMessage = viewModel::setLoadingDialogMessage, + setMessageDialogState = setMessageDialogState, + onSuccess = viewModel::launchApp, + isOffline = viewModel.isOffline.value, + ) + } + onConfirmClick = { + // Enable: write Ludusavi's suggested subdir, then continue the launch. + msgDialogState = MessageDialogState(false) + CoroutineScope(Dispatchers.IO).launch { + val gameId = ContainerUtils.extractGameIdFromContainerId(state.launchedAppId) + val rec = runCatching { + SteamUtils.getRecommendedSdkCloudSaveSubdirAsync(context, gameId) + }.getOrNull() + if (rec != null) { + runCatching { + val container = ContainerUtils.getContainer(context, state.launchedAppId) + container.sdkCloudSaveSubdir = rec.subdir + container.saveData() + }.onFailure { Timber.w(it, "Failed to persist sdkCloudSaveSubdir=${rec.subdir}") } + } + withContext(Dispatchers.Main) { relaunch() } + } + } + onDismissClick = { + // Skip this time — continue launch without setting the field. + msgDialogState = MessageDialogState(false) + relaunch() + } + onActionClick = { + // Don't ask again for this game. + msgDialogState = MessageDialogState(false) + CoroutineScope(Dispatchers.IO).launch { + runCatching { + val container = ContainerUtils.getContainer(context, state.launchedAppId) + container.putExtra("sdkCloudBridgePromptDismissed", "1") + container.saveData() + }.onFailure { Timber.w(it, "Failed to persist sdkCloudBridgePromptDismissed") } + withContext(Dispatchers.Main) { relaunch() } + } + } + onDismissRequest = { + msgDialogState = MessageDialogState(false) + relaunch() + } + } + DialogType.SYNC_CONFLICT -> { onConfirmClick = { preLaunchApp( @@ -1520,6 +1576,7 @@ fun preLaunchApp( preferredSave: SaveLocation = SaveLocation.None, useTemporaryOverride: Boolean = false, skipCloudSync: Boolean = false, + skipBridgePrompt: Boolean = false, setLoadingDialogVisible: (Boolean) -> Unit, setLoadingProgress: (Float) -> Unit, setLoadingMessage: (String) -> Unit, @@ -1576,6 +1633,41 @@ fun preLaunchApp( return@launch } + // First-launch suggestion for Pattern B SDK-cloud games (e.g. Dead Cells). + // Fires only when real-Steam is on, the subdir is blank, the user hasn't dismissed, + // Ludusavi knows this game, AND it has no Auto-Cloud saveFilePatterns in PICS UFS. + // That intersection is a reliable Pattern B signal and keeps false positives low. + if (!skipBridgePrompt && + gameSource == GameSource.STEAM && + container.isLaunchRealSteam && + container.sdkCloudSaveSubdir.isBlank() && + container.getExtra("sdkCloudBridgePromptDismissed", "") != "1" + ) { + val rec = runCatching { + SteamUtils.shouldSuggestSdkCloudBridge(context, gameId) + }.getOrNull() + if (rec != null) { + Timber.i("Pattern B bridge prompt for appId=$gameId (\"${rec.name}\" -> \"${rec.subdir}\")") + setLoadingDialogVisible(false) + setMessageDialogState( + MessageDialogState( + visible = true, + type = DialogType.SDK_CLOUD_BRIDGE_SUGGESTION, + title = context.getString(R.string.sdk_cloud_bridge_prompt_title), + message = context.getString( + R.string.sdk_cloud_bridge_prompt_message, + rec.name.ifEmpty { gameId.toString() }, + rec.subdir, + ), + confirmBtnText = context.getString(R.string.sdk_cloud_bridge_prompt_enable), + dismissBtnText = context.getString(R.string.sdk_cloud_bridge_prompt_skip), + actionBtnText = context.getString(R.string.sdk_cloud_bridge_prompt_dont_ask), + ), + ) + return@launch + } + } + // When "Open container" is used we boot to desktop/file manager only — skip executable check if (!bootToContainer) { // Verify we have a launch executable for all platforms before proceeding (fail fast, avoid black screen) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index 419894e1d6..1d13206cc0 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -143,6 +143,7 @@ fun ContainerConfigDialog( default: Boolean = false, title: String, initialConfig: ContainerData = ContainerData(), + appId: Int? = null, onDismissRequest: () -> Unit, onSave: (ContainerData) -> Unit, ) { @@ -1070,6 +1071,7 @@ fun ContainerConfigDialog( applyScreenSizeToConfig = applyScreenSizeToConfig, vkd3dForcedVersion = { vkd3dForcedVersion() }, currentDxvkContext = { currentDxvkContext() }, + appId = appId, ) LoadingDialog( diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt index 533468ff12..7773afd0f2 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt @@ -136,4 +136,7 @@ class ContainerConfigState( val applyScreenSizeToConfig: () -> Unit, val vkd3dForcedVersion: () -> String, val currentDxvkContext: () -> ManifestComponentHelper.DxvkContext, + /** Steam appId for this container, or null if not a Steam container. Used by + * per-game UI like the SDK-cloud save subdir detect button. */ + val appId: Int? = null, ) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt index ccaaa9fb98..efc3b3d0e4 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt @@ -5,10 +5,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -16,8 +19,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -31,6 +38,7 @@ import androidx.compose.ui.unit.sp import app.gamenative.R import app.gamenative.ui.component.NoExtractOutlinedTextField import app.gamenative.ui.component.settings.SettingsListDropdown +import app.gamenative.utils.SteamUtils import com.alorma.compose.settings.ui.SettingsSwitch import app.gamenative.ui.theme.settingsTileColors import app.gamenative.ui.theme.settingsTileColorsAlt @@ -41,6 +49,7 @@ import com.winlator.core.DefaultVersion import com.winlator.core.KeyValueSet import com.winlator.core.StringUtils import com.winlator.contents.ContentProfile +import androidx.compose.ui.platform.LocalContext import java.util.Locale @Composable @@ -374,6 +383,7 @@ fun GeneralTabContent( state = config.disableSteamOverlay, onCheckedChange = { state.config.value = config.copy(disableSteamOverlay = it) }, ) + SdkCloudSaveSubdirField(state = state, config = config) } val steamTypeItems = listOf("Normal", "Light", "Ultra Light") val currentSteamTypeIndex = when (config.steamType.lowercase()) { @@ -412,3 +422,169 @@ fun GeneralTabContent( ) } } + +/** + * Install-relative save subdir for Pattern B SDK-cloud games (game reads saves from + * its install dir while cloud state lives in SteamUserData//remote/). Empty + * disables the mirror. "Use Recommended" pulls from the Ludusavi manifest. + */ +@Composable +private fun SdkCloudSaveSubdirField( + state: ContainerConfigState, + config: ContainerData, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var showConfirmDialog by rememberSaveable { mutableStateOf(false) } + var pendingValue by rememberSaveable { mutableStateOf("") } + var detectMessage by rememberSaveable { mutableStateOf(null) } + // `remember` not `rememberSaveable`: if we're disposed mid-fetch the coroutine + // cancels, and a persisted `true` would leave the button permanently disabled on + // recomposition. Transient state should reset on restart. + var recommendLoading by remember { mutableStateOf(false) } + val current = config.sdkCloudSaveSubdir + val currentIsValid = current.isEmpty() || SteamUtils.isValidSdkCloudSubdir(current) + val errorText = if (!currentIsValid) { + stringResource(R.string.sdk_cloud_save_subdir_invalid) + } else null + + Text( + text = stringResource(R.string.sdk_cloud_save_subdir_section), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 2.dp), + ) + Text( + text = stringResource(R.string.sdk_cloud_save_subdir_section_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) + + NoExtractOutlinedTextField( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + value = current, + onValueChange = { raw -> + val trimmed = raw.trim() + val wasBlank = current.isBlank() + val nowNonBlank = trimmed.isNotEmpty() + if (wasBlank && nowNonBlank) { + // First activation for this container — confirm before committing. + pendingValue = trimmed + showConfirmDialog = true + } else { + state.config.value = config.copy(sdkCloudSaveSubdir = trimmed) + } + }, + label = { Text(text = stringResource(R.string.sdk_cloud_save_subdir_label)) }, + placeholder = { Text(text = stringResource(R.string.sdk_cloud_save_subdir_placeholder)) }, + supportingText = { + Text( + text = errorText + ?: detectMessage + ?: stringResource(R.string.sdk_cloud_save_subdir_description), + ) + }, + isError = !currentIsValid, + singleLine = true, + ) + + val appId = state.appId + if (appId != null) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + enabled = !recommendLoading, + onClick = { + recommendLoading = true + detectMessage = context.getString(R.string.sdk_cloud_save_subdir_recommended_loading) + scope.launch { + val rec = runCatching { + SteamUtils.getRecommendedSdkCloudSaveSubdirAsync(context, appId) + }.getOrNull() + recommendLoading = false + if (rec != null) { + detectMessage = context.getString( + R.string.sdk_cloud_save_subdir_recommended, + rec.name.ifEmpty { appId.toString() }, + rec.subdir, + ) + if (current.isBlank()) { + pendingValue = rec.subdir + showConfirmDialog = true + } + } else { + detectMessage = context.getString(R.string.sdk_cloud_save_subdir_recommended_none) + } + } + }, + ) { + if (recommendLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(text = stringResource(R.string.sdk_cloud_save_subdir_recommended_button)) + } + Spacer(modifier = Modifier.width(8.dp)) + TextButton(onClick = { + scope.launch { + // File.listFiles / isDirectory on main thread can ANR on slow storage. + val detected = withContext(Dispatchers.IO) { + runCatching { + SteamUtils.detectSdkCloudSaveSubdir(context, appId) + }.getOrNull() + } + detectMessage = if (detected != null) { + context.getString(R.string.sdk_cloud_save_subdir_detected, detected) + } else { + context.getString(R.string.sdk_cloud_save_subdir_detect_none) + } + if (detected != null && current.isBlank()) { + pendingValue = detected + showConfirmDialog = true + } + } + }) { + Text(text = stringResource(R.string.sdk_cloud_save_subdir_detect)) + } + if (current.isNotEmpty()) { + Spacer(modifier = Modifier.width(8.dp)) + TextButton(onClick = { + state.config.value = config.copy(sdkCloudSaveSubdir = "") + detectMessage = null + }) { + Text(text = stringResource(R.string.sdk_cloud_save_subdir_clear)) + } + } + } + } + + if (showConfirmDialog) { + AlertDialog( + onDismissRequest = { showConfirmDialog = false }, + title = { Text(text = stringResource(R.string.sdk_cloud_save_subdir_confirm_title)) }, + text = { + Text(text = stringResource(R.string.sdk_cloud_save_subdir_confirm_body, pendingValue)) + }, + confirmButton = { + TextButton(onClick = { + if (SteamUtils.isValidSdkCloudSubdir(pendingValue)) { + state.config.value = config.copy(sdkCloudSaveSubdir = pendingValue) + } + showConfirmDialog = false + }) { + Text(text = stringResource(R.string.sdk_cloud_save_subdir_confirm_accept)) + } + }, + dismissButton = { + TextButton(onClick = { showConfirmDialog = false }) { + Text(text = stringResource(R.string.sdk_cloud_save_subdir_confirm_cancel)) + } + }, + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/enums/DialogType.kt b/app/src/main/java/app/gamenative/ui/enums/DialogType.kt index bb5da85c26..69c3b11cc1 100644 --- a/app/src/main/java/app/gamenative/ui/enums/DialogType.kt +++ b/app/src/main/java/app/gamenative/ui/enums/DialogType.kt @@ -35,6 +35,7 @@ enum class DialogType(val icon: ImageVector? = null) { APP_UPDATE, EXECUTABLE_NOT_FOUND, WORKSHOP_UPDATE_PROMPT, + SDK_CLOUD_BRIDGE_SUGGESTION, NONE, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index b9cd561732..3905f0290b 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -1224,6 +1224,7 @@ abstract class BaseAppScreen { ContainerConfigDialog( title = "${displayInfo.name} Config", initialConfig = containerData, + appId = libraryItem.gameId.takeIf { libraryItem.gameSource == GameSource.STEAM }, onDismissRequest = { showConfigDialog = false }, onSave = { saveContainerConfig(context, libraryItem, it) diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 46b2a0f7d5..3fecb76f85 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -276,6 +276,7 @@ object ContainerUtils { showFPS = false, launchRealSteam = container.isLaunchRealSteam, disableSteamOverlay = container.isDisableSteamOverlay, + sdkCloudSaveSubdir = container.sdkCloudSaveSubdir, allowSteamUpdates = container.isAllowSteamUpdates, steamType = container.getSteamType(), cpuList = container.cpuList, @@ -452,6 +453,7 @@ object ContainerUtils { container.isShowFPS = false container.isLaunchRealSteam = containerData.launchRealSteam container.isDisableSteamOverlay = containerData.disableSteamOverlay + container.sdkCloudSaveSubdir = containerData.sdkCloudSaveSubdir container.isAllowSteamUpdates = containerData.allowSteamUpdates container.setSteamType(containerData.steamType) container.cpuList = containerData.cpuList diff --git a/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt b/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt new file mode 100644 index 0000000000..745a6c83d6 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt @@ -0,0 +1,284 @@ +package app.gamenative.utils + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Fetches and caches the community-maintained + * [Ludusavi save manifest](https://github.com/mtkennerly/ludusavi-manifest), filtering + * to "Pattern B" Steam games — those whose Steamworks SDK cloud saves live inside their + * install directory rather than under //remote/. + * + * Used by the "Use Recommended" button in the SDK Cloud Save Bridge container setting + * and by the first-launch prompt in preLaunchApp. + * + * Flow on lookup: + * 1. Check in-memory cache → return if present. + * 2. Read disk cache JSON → if mtime < CACHE_TTL_MS old, parse and use. + * 3. Else fetch upstream YAML, filter to Pattern B Steam entries, write disk cache. + * 4. On fetch failure, fall back to stale disk cache if present; otherwise return null. + * + * Disk cache format is a filtered JSON (~190 KB) — much cheaper to re-parse than the + * full 5 MB YAML on every cold start. + */ +object LudusaviRegistry { + + private const val MANIFEST_URL = "https://raw.githubusercontent.com/mtkennerly/ludusavi-manifest/master/data/manifest.yaml" + private const val CACHE_FILE = "ludusavi_pattern_b.json" + private val CACHE_TTL_MS = TimeUnit.DAYS.toMillis(7) + + @Volatile + private var memoryCache: Map? = null + + /** + * Returns the recommended save subdir for [appId] from the Ludusavi manifest, or null + * if the game isn't listed or the registry can't be loaded. Network-bound on cold cache. + * Safe to call from a coroutine; performs disk I/O and HTTPS on Dispatchers.IO. + */ + suspend fun lookup(context: Context, appId: Int): SteamUtils.SdkCloudSaveRecommendation? = withContext(Dispatchers.IO) { + ensureLoaded(context)?.get(appId) + } + + /** + * Priming call used at app start. Populates the cache (fetching if stale) without + * looking up a specific game. Equivalent in effect to calling [lookup] with any + * never-matching appId. + */ + suspend fun primeCache(context: Context) = withContext(Dispatchers.IO) { + ensureLoaded(context) + Unit + } + + private suspend fun ensureLoaded(context: Context): Map? { + memoryCache?.let { return it } + + val cacheFile = File(context.filesDir, CACHE_FILE) + if (cacheFile.isFile && (System.currentTimeMillis() - cacheFile.lastModified()) < CACHE_TTL_MS) { + runCatching { parseCacheJson(cacheFile.readText()) } + .onSuccess { parsed -> + memoryCache = parsed + Timber.i("LudusaviRegistry: loaded ${parsed.size} Pattern B entries from disk cache") + return parsed + } + .onFailure { Timber.w(it, "LudusaviRegistry: disk cache parse failed; will refetch") } + } + + val fetched = fetchAndFilter() ?: run { + // Fall back to a stale disk cache if fetch failed and one exists + if (cacheFile.isFile) { + runCatching { parseCacheJson(cacheFile.readText()) } + .onSuccess { + memoryCache = it + Timber.w("LudusaviRegistry: using stale disk cache (${it.size} entries) after fetch failure") + return it + } + } + return null + } + + runCatching { + cacheFile.writeText(serializeCacheJson(fetched)) + }.onFailure { Timber.w(it, "LudusaviRegistry: failed to write disk cache") } + memoryCache = fetched + Timber.i("LudusaviRegistry: fetched ${fetched.size} Pattern B entries from upstream manifest") + return fetched + } + + private fun fetchAndFilter(): Map? { + // No explicit Accept-Encoding: OkHttp handles gzip transparently when we don't + // set the header ourselves. Manually setting it disables auto-decompress and + // hands us raw gzipped bytes, which then fail YAML parse with 0x1F at offset 0. + val request = Request.Builder() + .url(MANIFEST_URL) + .build() + + val body = runCatching { + Net.http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Timber.w("LudusaviRegistry: manifest fetch HTTP ${response.code}") + return null + } + response.body?.string() + } + }.getOrElse { + Timber.w(it, "LudusaviRegistry: manifest fetch failed") + return null + } ?: return null + + return runCatching { parseManifestYaml(body) }.getOrElse { + Timber.w(it, "LudusaviRegistry: YAML parse failed") + null + } + } + + /** + * Streaming line-based parser. SnakeYAML's eager Map load OOMs on the + * 5 MB manifest with ~5000 game entries on a 512 MB Dalvik heap. We only need a flat + * `appId -> subdir` map — finalize each game as we encounter the next top-level key + * so memory stays O(1) per game. + * + * Indentation contract (consistent in Ludusavi's manifest): + * - column 0: game name, ends with `:` + * - column 2: section (`files`, `steam`, `installDir`, ...) + * - column 4: path key under `files`, or property like `id` under `steam` + * - column 6: property under a file path (`tags`, `when`) + * - column 8: list item (`- save`, `- os: windows`) + */ + private fun parseManifestYaml(yamlText: String): Map { + val out = mutableMapOf() + + var gameName: String? = null + var steamId: Int? = null + var saveSubdir: String? = null + + var section: String? = null + var subSection: String? = null + + var currentPath: String? = null + var currentPathHasSaveTag = false + var currentPathAppliesWindows = true + + fun finalizePath() { + val path = currentPath + if (path != null && saveSubdir == null && currentPathHasSaveTag && currentPathAppliesWindows) { + if (path.startsWith("/")) { + val first = path.removePrefix("/").replace('\\', '/').substringBefore('/') + if (first.isNotEmpty() && !first.contains('*') && + SteamUtils.isValidSdkCloudSubdir(first)) { + saveSubdir = first + } + } + } + currentPath = null + currentPathHasSaveTag = false + currentPathAppliesWindows = true + subSection = null + } + + fun finalizeGame() { + finalizePath() + val name = gameName + val id = steamId + val sub = saveSubdir + if (name != null && id != null && id > 0 && sub != null) { + out[id] = SteamUtils.SdkCloudSaveRecommendation( + appId = id, + subdir = sub, + name = name, + notes = "From Ludusavi manifest", + ) + } + gameName = null + steamId = null + saveSubdir = null + section = null + } + + yamlText.lineSequence().forEach { rawLine -> + if (rawLine.isBlank()) return@forEach + val indent = rawLine.takeWhile { it == ' ' }.length + val content = rawLine.drop(indent) + if (content.startsWith("#")) return@forEach + + when (indent) { + 0 -> { + if (content.endsWith(":") && !content.startsWith("_")) { + finalizeGame() + gameName = content.removeSuffix(":").trim('"', '\'') + } + } + 2 -> { + finalizePath() + section = content.substringBefore(':').trim('"', '\'') + } + 4 -> { + if (section == "files" && content.endsWith(":")) { + finalizePath() + currentPath = content.removeSuffix(":").trim('"', '\'') + } else if (section == "steam") { + val match = STEAM_ID_REGEX.find(content) + if (match != null) steamId = match.groupValues[1].toIntOrNull() + } + } + 6 -> { + if (currentPath != null) { + subSection = content.substringBefore(':').trim('"', '\'') + if (subSection == "when") { + // Explicit constraints present — start with no-OS-matched and + // flip to true only when we see windows. + currentPathAppliesWindows = false + } + } + } + 8 -> { + if (currentPath != null) { + when (subSection) { + "tags" -> { + val tag = content.removePrefix("-").trim().trim('"', '\'') + if (tag.equals("save", ignoreCase = true)) { + currentPathHasSaveTag = true + } + } + "when" -> { + val match = OS_REGEX.find(content) + if (match != null && match.groupValues[1].equals("windows", ignoreCase = true)) { + currentPathAppliesWindows = true + } + } + } + } + } + // Deeper indents (e.g. continuation of a `when` entry) are ignored — we + // already captured what we need at indent 8. + } + } + finalizeGame() + return out + } + + private val STEAM_ID_REGEX = Regex("""id\s*:\s*(\d+)""") + private val OS_REGEX = Regex("""os\s*:\s*(\w+)""") + + private fun parseCacheJson(text: String): Map { + val root = JSONObject(text) + val games = root.optJSONArray("games") ?: return emptyMap() + val out = mutableMapOf() + for (i in 0 until games.length()) { + val entry = games.optJSONObject(i) ?: continue + val appId = entry.optInt("appId", 0) + val subdir = entry.optString("subdir", "") + if (appId <= 0 || subdir.isEmpty()) continue + out[appId] = SteamUtils.SdkCloudSaveRecommendation( + appId = appId, + subdir = subdir, + name = entry.optString("name", ""), + notes = entry.optString("notes", ""), + ) + } + return out + } + + private fun serializeCacheJson(entries: Map): String { + val games = JSONArray() + for ((_, rec) in entries.entries.sortedBy { it.key }) { + games.put(JSONObject().apply { + put("appId", rec.appId) + put("subdir", rec.subdir) + put("name", rec.name) + put("notes", rec.notes) + }) + } + return JSONObject().apply { + put("source", MANIFEST_URL) + put("fetchedAtMs", System.currentTimeMillis()) + put("games", games) + }.toString() + } +} diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 01e4d2b02b..4d6143add0 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -887,7 +887,7 @@ object SteamUtils { ) } - // cloud_enabled="0" in both modes: Pluvia's SteamAutoCloud owns cloud sync + // cloud_enabled="0" in both modes: GameNative's SteamAutoCloud owns cloud sync // (runs on app close + manual "Cloud Sync" button; skipped only on launch in // real-Steam mode to avoid a launch-time conflict dialog). Letting Wine-Steam // also sync in real-Steam mode re-introduced the Steam Input (241100) AutoCloud @@ -2131,7 +2131,7 @@ object SteamUtils { app.children.add(KeyValue("LaunchOptions", exeCommandLine)) } - // Suppress Wine-Steam's per-app cloud sync. Pluvia's SteamAutoCloud handles + // Suppress Wine-Steam's per-app cloud sync. GameNative's SteamAutoCloud handles // cloud (runs on closeApp + manual sync); letting Wine-Steam also sync // blocked graceful shutdown on cloud upload and resurrected the Steam Input // (241100) AutoCloud watcher that purgePhantomAppUserdata works around. @@ -2150,7 +2150,7 @@ object SteamUtils { // lastlaunch/lastexit to decide whether to show "Synchronizing cloud // saves" on startup and block shutdown on upload; flipping cloud_enabled // to "0" alone doesn't clear those, so the client keeps reconciling - // ghosts on every launch. Pluvia's SteamAutoCloud owns cloud now, so + // ghosts on every launch. GameNative's SteamAutoCloud owns cloud now, so // Wine-Steam shouldn't retain any sync bookkeeping for this app. app.children.removeAll { it.name == "last_sync_state" || it.name == "autocloud" } @@ -2389,14 +2389,34 @@ object SteamUtils { } /** - * SDK-cloud games whose on-disk save location differs from the SDK's - * //remote/ directory. Value = install-relative subdir - * the game reads/writes. Desktop Steam reconciles the two internally; - * with our cloudenabled=0 setup that path is dead, so we bridge it. + * Recommended SDK-cloud save subdir for a known game. Sourced from the Ludusavi + * community manifest (fetched on demand, cached on disk by [LudusaviRegistry]). */ - private val sdkCloudSaveMirrors: Map = mapOf( - 588650 to "save", // Dead Cells - ) + data class SdkCloudSaveRecommendation(val appId: Int, val subdir: String, val name: String, val notes: String) + + /** + * Returns the recommended save subdir from the Ludusavi manifest (network-fetched + * and disk-cached by [LudusaviRegistry]). Returns null if Ludusavi has no entry + * or the manifest is unavailable (offline with no cache). + */ + suspend fun getRecommendedSdkCloudSaveSubdirAsync(context: Context, appId: Int): SdkCloudSaveRecommendation? { + return LudusaviRegistry.lookup(context, appId) + } + + /** + * Pattern B detection: returns a recommendation only when the game is + * (a) known to Ludusavi with an install-relative save path, AND + * (b) has NO saveFilePatterns declared in Steam's PICS UFS (which would mean + * Auto-Cloud already covers it and the bridge isn't needed). + * + * Intersection-filtered lookup used by the launch-time prompt to avoid crying + * wolf on the thousands of Auto-Cloud games in Ludusavi that don't need our mirror. + */ + suspend fun shouldSuggestSdkCloudBridge(context: Context, appId: Int): SdkCloudSaveRecommendation? { + val rec = getRecommendedSdkCloudSaveSubdirAsync(context, appId) ?: return null + val hasAutoCloud = SteamService.getAppInfoOf(appId)?.ufs?.saveFilePatterns?.isNotEmpty() == true + return rec.takeIf { !hasAutoCloud } + } private fun sdkCloudRemoteDir(context: Context, appId: Int): File? { val accountId = SteamService.userSteamId?.accountID?.toLong() ?: return null @@ -2404,10 +2424,101 @@ object SteamUtils { } private fun sdkCloudGameSaveDir(context: Context, appId: Int): File? { - val sub = sdkCloudSaveMirrors[appId] ?: return null + // Mirror runs only when the user has explicitly enabled the bridge for this + // container (via the launch-time prompt or the Cloud Save Bridge UI). No + // implicit fallback — prevents silent REPLACE_EXISTING copies into a guessed + // subdir that might not be the game's actual save location. + val container = runCatching { + ContainerUtils.getContainer(context, "STEAM_$appId") + }.getOrNull() ?: return null + val sub = container.sdkCloudSaveSubdir.trim().takeIf { + it.isNotEmpty() && isValidSdkCloudSubdir(it) + } ?: return null return File(SteamService.getAppDirPath(appId), sub) } + /** + * Validation for a user-supplied install-relative save subdir. Rejects anything + * that could escape the install dir or address files outside a single component: + * path separators (/ or \), .. traversal, absolute paths, Windows drive letters, + * and empty values after trimming. + */ + fun isValidSdkCloudSubdir(raw: String): Boolean { + val v = raw.trim() + if (v.isEmpty()) return false + if (v.contains('/') || v.contains('\\')) return false + if (v == "." || v == "..") return false + if (v.contains(':')) return false + return true + } + + /** + * Heuristic: find a single-component subdir under the game's install dir whose + * file names overlap with what's at `//remote/`. Scans install + * top-level only (no recursion — saves typically live one level deep). Returns + * the subdir name with the highest filename overlap, or null if none match. + * + * Used by the container-config UI "Detect" button. Resolves paths via the + * container's own rootDir rather than the global xuser symlink so it works even + * when a different container is currently active. + * + * Best-effort — never returns a guess on ambiguous/empty state. Users still + * confirm before enabling the mirror. + */ + fun detectSdkCloudSaveSubdir(context: Context, appId: Int): String? { + val accountId = SteamService.userSteamId?.accountID?.toLong() + if (accountId == null) { + Timber.w("detectSdkCloudSaveSubdir: no Steam account — cannot resolve userdata path for $appId") + return null + } + + val container = runCatching { + ContainerUtils.getContainer(context, "STEAM_$appId") + }.getOrNull() ?: run { + Timber.w("detectSdkCloudSaveSubdir: no container found for STEAM_$appId") + return null + } + + val remoteDir = File( + container.rootDir, + ".wine/drive_c/Program Files (x86)/Steam/userdata/$accountId/$appId/remote", + ) + if (!remoteDir.isDirectory) { + Timber.i("detectSdkCloudSaveSubdir: no remote/ at ${remoteDir.absolutePath}") + return null + } + val remoteNames = remoteDir.listFiles() + ?.filter { it.isFile } + ?.map { it.name } + ?.toSet() + ?.takeIf { it.isNotEmpty() } + ?: run { + Timber.i("detectSdkCloudSaveSubdir: remote/ empty at ${remoteDir.absolutePath}") + return null + } + + val installDir = File(SteamService.getAppDirPath(appId)) + if (!installDir.isDirectory) { + Timber.i("detectSdkCloudSaveSubdir: install dir missing at ${installDir.absolutePath}") + return null + } + + var bestName: String? = null + var bestOverlap = 0 + installDir.listFiles()?.forEach { sub -> + if (!sub.isDirectory) return@forEach + if (!isValidSdkCloudSubdir(sub.name)) return@forEach + val names = sub.listFiles()?.filter { it.isFile }?.map { it.name }?.toSet() ?: return@forEach + val overlap = remoteNames.intersect(names).size + if (overlap > bestOverlap) { + bestOverlap = overlap + bestName = sub.name + } + } + Timber.i("detectSdkCloudSaveSubdir appId=$appId: remote=${remoteNames.size} file(s), best=$bestName overlap=$bestOverlap") + return bestName + } + fun mirrorSdkCloudRemoteToSave(context: Context, appId: Int) { val gameSaveDir = sdkCloudGameSaveDir(context, appId) ?: return val remoteDir = sdkCloudRemoteDir(context, appId) ?: return diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java index 79c1903e4b..92d6ea4160 100644 --- a/app/src/main/java/com/winlator/container/Container.java +++ b/app/src/main/java/com/winlator/container/Container.java @@ -86,6 +86,7 @@ public enum XrControllerMapping { private boolean showFPS; private boolean launchRealSteam; private boolean disableSteamOverlay = true; + private String sdkCloudSaveSubdir = ""; private boolean allowSteamUpdates; private boolean wow64Mode = true; private boolean needsUnpacking = true; @@ -334,6 +335,14 @@ public void setDisableSteamOverlay(boolean disableSteamOverlay) { this.disableSteamOverlay = disableSteamOverlay; } + public String getSdkCloudSaveSubdir() { + return sdkCloudSaveSubdir == null ? "" : sdkCloudSaveSubdir; + } + + public void setSdkCloudSaveSubdir(String sdkCloudSaveSubdir) { + this.sdkCloudSaveSubdir = sdkCloudSaveSubdir == null ? "" : sdkCloudSaveSubdir; + } + public boolean isAllowSteamUpdates() { return allowSteamUpdates; } @@ -662,6 +671,7 @@ public void saveData() { data.put("showFPS", showFPS); data.put("launchRealSteam", launchRealSteam); data.put("disableSteamOverlay", disableSteamOverlay); + data.put("sdkCloudSaveSubdir", sdkCloudSaveSubdir); data.put("allowSteamUpdates", allowSteamUpdates); data.put("inputType", inputType); data.put("dinputMapperType", dinputMapperType); @@ -784,6 +794,9 @@ public void loadData(JSONObject data) throws JSONException { case "disableSteamOverlay" : setDisableSteamOverlay(data.getBoolean(key)); break; + case "sdkCloudSaveSubdir" : + setSdkCloudSaveSubdir(data.optString(key, "")); + break; case "allowSteamUpdates" : setAllowSteamUpdates(data.getBoolean(key)); break; diff --git a/app/src/main/java/com/winlator/container/ContainerData.kt b/app/src/main/java/com/winlator/container/ContainerData.kt index 81a4db3246..3b22cf4887 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -26,6 +26,9 @@ data class ContainerData( val showFPS: Boolean = false, val launchRealSteam: Boolean = false, val disableSteamOverlay: Boolean = true, + /** Install-relative subdir for SDK-cloud games that keep saves in their install dir + * (e.g. Dead Cells -> "save"). Empty disables mirroring. Single-component only. */ + val sdkCloudSaveSubdir: String = "", val allowSteamUpdates: Boolean = false, val steamType: String = "normal", val cpuList: String = Container.getFallbackCPUList(), @@ -120,6 +123,7 @@ data class ContainerData( "showFPS" to state.showFPS, "launchRealSteam" to state.launchRealSteam, "disableSteamOverlay" to state.disableSteamOverlay, + "sdkCloudSaveSubdir" to state.sdkCloudSaveSubdir, "allowSteamUpdates" to state.allowSteamUpdates, "steamType" to state.steamType, "cpuList" to state.cpuList, @@ -184,6 +188,7 @@ data class ContainerData( showFPS = savedMap["showFPS"] as Boolean, launchRealSteam = savedMap["launchRealSteam"] as Boolean, disableSteamOverlay = (savedMap["disableSteamOverlay"] as? Boolean) ?: true, + sdkCloudSaveSubdir = (savedMap["sdkCloudSaveSubdir"] as? String) ?: "", allowSteamUpdates = savedMap["allowSteamUpdates"] as Boolean, steamType = (savedMap["steamType"] as? String) ?: "normal", cpuList = savedMap["cpuList"] as String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 28894f959f..483d95216c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -681,6 +681,29 @@ Reduces performance and slows down launch\nAllows online play and fixes DRM and controller issues\nNot all games work Disable Steam Overlay Skip injecting gameoverlayrenderer64.dll\nAchievements still unlock but popups won\'t appear\nMay fix crashes on Unity/Vulkan games + Cloud Save Bridge + For games that store cloud saves inside their install directory (e.g. Dead Cells). Leave blank unless saves aren\'t syncing. \"Use Recommended\" pulls from the Ludusavi manifest. + Local save subdirectory + e.g. save + Single folder name under the game\'s install directory. Leave blank to disable the bridge. + Single folder name only — no slashes, \"..\", or drive letters. + Use Recommended + Fetching from Ludusavi… + Detect + Clear + Detected: \"%1$s\" — accept the suggestion in the confirmation dialog to use it. + No matching subfolder found in install dir. Type one manually if you know it. + Ludusavi suggests \"%2$s\" for %1$s — accept in the confirmation dialog to apply. + This game is not in the Ludusavi manifest. It may not need this setting. Use \"Detect\" or enter a value manually if you know one is needed. + Enable local save mirror? + GameNative will copy cloud save files into <install>/%1$s/ before launch and copy changes back on exit.\n\nIf this is not the correct folder, game files may be overwritten. Only enable this for games that read cloud saves from their install directory. + Enable + Cancel + Enable Cloud Save Bridge? + Ludusavi reports %1$s stores saves inside its install directory at \"%2$s/\". Enable Cloud Save Bridge so GameNative mirrors them with Steam Cloud on each launch and exit?\n\nIf cloud saves sync correctly without this, leave it off. + Enable + Skip + Don\'t ask again Steam Offline Mode Launch Steam games in offline mode Achievement Unlocked From c0da85fa07e3f3fa634256d3c1e08948b9002d6c Mon Sep 17 00:00:00 2001 From: TideGear Date: Sun, 26 Apr 2026 23:20:16 -0700 Subject: [PATCH 21/34] fix: complete imageFs.wineprefix migration in SteamUtils.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cascade-fix commit (15716e2b) said "every per-container write was switched to container.getRootDir() + /.wine" but 13 sites in SteamUtils.kt still resolved through imageFs.wineprefix or imageFs.rootDir+ImageFs.WINEPREFIX, which both go through the global xuser symlink. ensureSteamCfg in particular was called immediately after extractSteamFiles in the same launch flow that just took pains to use the per-container path — the cfg write then pivoted back through the symlink and was still cascade-vulnerable. Migration strategy: * appId-based functions (backupSteamclientFiles, restoreSteamclientFiles, restoreUnpackedExecutable, createAppManifest, restoreOriginalExecutable, migrateGSESavesToSteamUserdata, migrateSteamUserdataToGSESaves) now load the Container internally via ContainerUtils.getContainer(context, "STEAM_$steamAppId") and write through container.rootDir. No signature change, no caller updates needed. * imageFs-only functions (setupLightweightSteamConfig, ensureSteamCfg, logSteamBinaryFingerprint, purgePhantomAppUserdata, cleanupExtractedSteamFiles) gained an optional Container? parameter. When passed (launch-flow callers do), writes go to the per-container path. imageFs.wineprefix is retained as a fallback for callers without a container in scope (notably the SteamTokenLogin flow), preserving pre-fix behavior for that path. Callers in XServerScreen.kt, MainViewModel.kt, replaceSteamApi, restoreSteamApi all pass container. * updateOrModifyLocalConfig had container in scope already; just switched to use it. After this, every active per-container write in SteamUtils.kt goes through container.rootDir. The remaining imageFs.wineprefix references are either comments or symlink fallbacks gated behind container == null. --- .../app/gamenative/ui/model/MainViewModel.kt | 2 +- .../ui/screen/xserver/XServerScreen.kt | 6 +- .../java/app/gamenative/utils/SteamUtils.kt | 129 +++++++++++------- 3 files changed, 80 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index dd62f4eac3..b66697f815 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -464,7 +464,7 @@ class MainViewModel @Inject constructor( container.putExtra("lastSteamMode", currentMode) container.saveData() if (previousMode == "real" && currentMode == "emu") { - SteamUtils.cleanupExtractedSteamFiles(context) + SteamUtils.cleanupExtractedSteamFiles(context, container) } } if (container.isLaunchRealSteam()) { 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 30679a695e..d5192e8996 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 @@ -4429,9 +4429,9 @@ private fun setupWineSystemFiles( SteamUtils.deleteTreeNoFollowSymlinks(steamDir) } extractSteamFiles(context, container, onExtractFileListener) - SteamUtils.ensureSteamCfg(ImageFs.find(context)) - SteamUtils.purgePhantomAppUserdata(ImageFs.find(context), "241100") - SteamUtils.logSteamBinaryFingerprint(ImageFs.find(context), "prepareContainer:realSteam") + SteamUtils.ensureSteamCfg(ImageFs.find(context), container) + SteamUtils.purgePhantomAppUserdata(ImageFs.find(context), "241100", container) + SteamUtils.logSteamBinaryFingerprint(ImageFs.find(context), "prepareContainer:realSteam", container) container.putExtra("steamExtractedForWine", steamExtractedKey) containerDataChanged = true } diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 4d6143add0..a7bd0808e0 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -298,8 +298,12 @@ object SteamUtils { var replaced64Count = 0 val backupPaths = mutableSetOf() val imageFs = ImageFs.find(context) - autoLoginUserChanges(imageFs) - setupLightweightSteamConfig(imageFs, SteamService.userSteamId?.toString()) + // Pass the container so writes go to the per-container .wine prefix + // rather than through the global xuser symlink (which can race with + // another concurrent launch and corrupt that game's prefix). + val container = ContainerUtils.getContainer(context, appId) + autoLoginUserChanges(imageFs, container) + setupLightweightSteamConfig(imageFs, SteamService.userSteamId?.toString(), container) val rootPath = Paths.get(appDirPath) // Get ticket once for all DLLs @@ -429,15 +433,18 @@ object SteamUtils { } fun backupSteamclientFiles(context: Context, steamAppId: Int) { - val imageFs = ImageFs.find(context) + // Per-container path; imageFs.wineprefix resolves through the global xuser + // symlink which is unsafe for writes (see WineUtils.applySystemTweaks). + val container = ContainerUtils.getContainer(context, "STEAM_$steamAppId") + val steamDir = File(container.rootDir, ".wine/drive_c/Program Files (x86)/Steam") var backupCount = 0 - val backupDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/steamclient_backup") + val backupDir = File(steamDir, "steamclient_backup") backupDir.mkdirs() steamClientFiles().forEach { file -> - val dll = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/$file") + val dll = File(steamDir, file) if (dll.exists()) { Files.copy(dll.toPath(), File(backupDir, "$file.orig").toPath(), StandardCopyOption.REPLACE_EXISTING) backupCount++ @@ -473,13 +480,14 @@ object SteamUtils { } fun restoreSteamclientFiles(context: Context, steamAppId: Int) { - val imageFs = ImageFs.find(context) + // Per-container path; imageFs.wineprefix resolves through the global xuser + // symlink which is unsafe for writes (see WineUtils.applySystemTweaks). + val container = ContainerUtils.getContainer(context, "STEAM_$steamAppId") + val origDir = File(container.rootDir, ".wine/drive_c/Program Files (x86)/Steam") var restoredCount = 0 - val origDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") - - val backupDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/steamclient_backup") + val backupDir = File(origDir, "steamclient_backup") if (backupDir.exists()) { steamClientFiles().forEach { file -> val dll = File(backupDir, "$file.orig") @@ -490,7 +498,7 @@ object SteamUtils { } } - val extraDllDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/extra_dlls") + val extraDllDir = File(origDir, "extra_dlls") if (extraDllDir.exists()) { extraDllDir.deleteRecursively() } @@ -587,10 +595,13 @@ object SteamUtils { * Creates configuration files that make Steam run in lightweight mode * with reduced resource usage and disabled community features */ - private fun setupLightweightSteamConfig(imageFs: ImageFs, steamId64: String?) { + private fun setupLightweightSteamConfig(imageFs: ImageFs, steamId64: String?, container: Container? = null) { Timber.i("Setting up lightweight steam configs") try { - val steamPath = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + // Prefer per-container path. Fallback to imageFs.wineprefix (xuser + // symlink) is kept for non-launch callers that have no container. + val steamPath = container?.let { File(it.rootDir, ".wine/drive_c/Program Files (x86)/Steam") } + ?: File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") // Create necessary directories val userDataPath = File(steamPath, "userdata/$steamId64") @@ -669,7 +680,6 @@ object SteamUtils { */ private fun restoreUnpackedExecutable(context: Context, steamAppId: Int) { try { - val imageFs = ImageFs.find(context) val appDirPath = SteamService.getAppDirPath(steamAppId) // Convert to Wine path format @@ -685,8 +695,11 @@ object SteamUtils { } val executableFile = "$drive:\\${executablePath}" - val exe = File(imageFs.wineprefix + "/dosdevices/" + executableFile.replace("A:", "a:").replace('\\', '/')) - val unpackedExe = File(imageFs.wineprefix + "/dosdevices/" + executableFile.replace("A:", "a:").replace('\\', '/') + ".unpacked.exe") + // Per-container .wine path; imageFs.wineprefix resolves through the + // global xuser symlink which is unsafe for writes. + val winePrefix = File(container.rootDir, ".wine").absolutePath + val exe = File(winePrefix + "/dosdevices/" + executableFile.replace("A:", "a:").replace('\\', '/')) + val unpackedExe = File(winePrefix + "/dosdevices/" + executableFile.replace("A:", "a:").replace('\\', '/') + ".unpacked.exe") if (unpackedExe.exists()) { // Check if files are different (compare size and last modified time for efficiency) @@ -720,10 +733,10 @@ object SteamUtils { return } - val imageFs = ImageFs.find(context) - - // Create the steamapps folder structure - val steamappsDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/steamapps") + // Per-container path; imageFs.wineprefix resolves through the global + // xuser symlink which is unsafe for writes. + val container = ContainerUtils.getContainer(context, "STEAM_$steamAppId") + val steamappsDir = File(container.rootDir, ".wine/drive_c/Program Files (x86)/Steam/steamapps") if (!steamappsDir.exists()) { steamappsDir.mkdirs() } @@ -1332,8 +1345,13 @@ object SteamUtils { } /** Writes steam.cfg with update-inhibit keys if missing. Both real-Steam and emu paths need it present. */ - fun ensureSteamCfg(imageFs: ImageFs) { - val cfgFile = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/steam.cfg") + fun ensureSteamCfg(imageFs: ImageFs, container: Container? = null) { + // Per-container path; imageFs.wineprefix resolves through the global + // xuser symlink which is unsafe for writes. Fallback retained for + // legacy callers without a container in scope. + val cfgFile = container?.let { + File(it.rootDir, ".wine/drive_c/Program Files (x86)/Steam/steam.cfg") + } ?: File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/steam.cfg") if (cfgFile.exists()) return try { cfgFile.parentFile?.mkdirs() @@ -1345,9 +1363,11 @@ object SteamUtils { } /** Logs size + mtime of the Steam binaries whose unexpected change breaks launches. Diagnostic-only. */ - fun logSteamBinaryFingerprint(imageFs: ImageFs, tag: String) { + fun logSteamBinaryFingerprint(imageFs: ImageFs, tag: String, container: Container? = null) { try { - val steamDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + // Per-container path with symlink fallback for legacy callers. + val steamDir = container?.let { File(it.rootDir, ".wine/drive_c/Program Files (x86)/Steam") } + ?: File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply { timeZone = TimeZone.getDefault() } val parts = listOf("steam.exe", "steamclient.dll", "steamclient64.dll").map { name -> val f = File(steamDir, name) @@ -1367,8 +1387,8 @@ object SteamUtils { val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) val imageFs = ImageFs.find(context) val container = ContainerUtils.getOrCreateContainer(context, appId) - ensureSteamCfg(imageFs) - logSteamBinaryFingerprint(imageFs, "restoreSteamApi:emu:$steamAppId") + ensureSteamCfg(imageFs, container) + logSteamBinaryFingerprint(imageFs, "restoreSteamApi:emu:$steamAppId", container) // Update or modify localconfig.vdf updateOrModifyLocalConfig(imageFs, container, steamAppId.toString(), SteamService.userSteamId!!.accountID.toString()) @@ -1383,8 +1403,8 @@ object SteamUtils { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) MarkerUtils.removeMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) - autoLoginUserChanges(imageFs) - setupLightweightSteamConfig(imageFs, SteamService.userSteamId!!.accountID.toString()) + autoLoginUserChanges(imageFs, container) + setupLightweightSteamConfig(imageFs, SteamService.userSteamId!!.accountID.toString(), container) putBackSteamDlls(appDirPath) restoreOriginalExecutable(context, steamAppId) @@ -1476,8 +1496,10 @@ object SteamUtils { Timber.i("Checking directory: $appDirPath") var restoredCount = 0 - val imageFs = ImageFs.find(context) - val dosDevicesPath = File(imageFs.wineprefix, "dosdevices/a:") + // Per-container path; imageFs.wineprefix resolves through the global + // xuser symlink which is unsafe. + val container = ContainerUtils.getContainer(context, "STEAM_$steamAppId") + val dosDevicesPath = File(container.rootDir, ".wine/dosdevices/a:") dosDevicesPath.walkTopDown().maxDepth(10) .filter { it.isFile && it.name.endsWith(".original.exe", ignoreCase = true) } @@ -1511,7 +1533,6 @@ object SteamUtils { */ fun migrateGSESavesToSteamUserdata(context: Context, appId: Int, isLaunchRealSteam: Boolean = true) { if (!isLaunchRealSteam) return - val imageFs = ImageFs.find(context) val accountId = SteamService.userSteamId?.accountID?.toInt() ?: PrefManager.steamUserAccountId.takeIf { it != 0 } @@ -1520,15 +1541,13 @@ object SteamUtils { return } - val gseDir = File( - imageFs.rootDir, - "${ImageFs.WINEPREFIX}/drive_c/users/xuser/AppData/Roaming/GSE Saves/$appId" - ) + // Per-container path; imageFs.rootDir + ImageFs.WINEPREFIX resolves through + // the global xuser symlink which is unsafe for writes. + val container = ContainerUtils.getContainer(context, "STEAM_$appId") + val winePrefix = File(container.rootDir, ".wine") - val steamUserdataDir = File( - imageFs.rootDir, - "${ImageFs.WINEPREFIX}/drive_c/Program Files (x86)/Steam/userdata/$accountId/$appId" - ) + val gseDir = File(winePrefix, "drive_c/users/xuser/AppData/Roaming/GSE Saves/$appId") + val steamUserdataDir = File(winePrefix, "drive_c/Program Files (x86)/Steam/userdata/$accountId/$appId") fun isDirectoryEmpty(file: File): Boolean { return file.isDirectory && file.list()?.isEmpty() ?: true @@ -1601,7 +1620,6 @@ object SteamUtils { */ fun migrateSteamUserdataToGSESaves(context: Context, appId: Int, isLaunchRealSteam: Boolean = false) { if (isLaunchRealSteam) return - val imageFs = ImageFs.find(context) val accountId = SteamService.userSteamId?.accountID?.toInt() ?: PrefManager.steamUserAccountId.takeIf { it != 0 } @@ -1610,14 +1628,13 @@ object SteamUtils { return } - val steamUserdataDir = File( - imageFs.rootDir, - "${ImageFs.WINEPREFIX}/drive_c/Program Files (x86)/Steam/userdata/$accountId/$appId" - ) - val gseDir = File( - imageFs.rootDir, - "${ImageFs.WINEPREFIX}/drive_c/users/xuser/AppData/Roaming/GSE Saves/$appId" - ) + // Per-container path; imageFs.rootDir + ImageFs.WINEPREFIX resolves through + // the global xuser symlink which is unsafe. + val container = ContainerUtils.getContainer(context, "STEAM_$appId") + val winePrefix = File(container.rootDir, ".wine") + + val steamUserdataDir = File(winePrefix, "drive_c/Program Files (x86)/Steam/userdata/$accountId/$appId") + val gseDir = File(winePrefix, "drive_c/users/xuser/AppData/Roaming/GSE Saves/$appId") fun isDirectoryEmpty(file: File): Boolean { return file.isDirectory && file.list()?.isEmpty() ?: true @@ -1704,10 +1721,12 @@ object SteamUtils { }) } - fun cleanupExtractedSteamFiles(context: Context) { + fun cleanupExtractedSteamFiles(context: Context, container: Container? = null) { try { - val imageFs = ImageFs.find(context) - val steamDir = File(imageFs.rootDir, ImageFs.WINEPREFIX + "/drive_c/Program Files (x86)/Steam") + // Per-container path; imageFs.WINEPREFIX resolves through the + // global xuser symlink which is unsafe for writes. + val steamDir = container?.let { File(it.rootDir, ".wine/drive_c/Program Files (x86)/Steam") } + ?: File(ImageFs.find(context).rootDir, ImageFs.WINEPREFIX + "/drive_c/Program Files (x86)/Steam") if (!steamDir.exists()) return val steamExe = File(steamDir, "steam.exe") if (!steamExe.exists()) return @@ -1725,9 +1744,11 @@ object SteamUtils { * Deleting userdata/// breaks the association so * shutdown completes promptly for subsequent real-Steam launches. */ - fun purgePhantomAppUserdata(imageFs: ImageFs, phantomAppId: String) { + fun purgePhantomAppUserdata(imageFs: ImageFs, phantomAppId: String, container: Container? = null) { try { - val userdataRoot = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/userdata") + // Per-container path with symlink fallback for legacy callers. + val userdataRoot = container?.let { File(it.rootDir, ".wine/drive_c/Program Files (x86)/Steam/userdata") } + ?: File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/userdata") if (!userdataRoot.isDirectory) return val victims = userdataRoot.listFiles { f -> f.isDirectory }?.mapNotNull { userDir -> File(userDir, phantomAppId).takeIf { it.exists() } @@ -2111,7 +2132,9 @@ object SteamUtils { try { val exeCommandLine = container.execArgs - val steamPath = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") + // Per-container path; imageFs.wineprefix resolves through the global + // xuser symlink which is unsafe for writes. + val steamPath = File(container.rootDir, ".wine/drive_c/Program Files (x86)/Steam") // Create necessary directories val userDataPath = File(steamPath, "userdata/$steamUserId64") From cf7b57c22741fc8a952c1d6d1cd5dae66c28e764 Mon Sep 17 00:00:00 2001 From: TideGear Date: Mon, 27 Apr 2026 20:41:18 -0700 Subject: [PATCH 22/34] fix: Steam-only per-container path migrations not covered by the general cascade fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cascade fix on the general-fixes branch covers per-container path migration for Wine system files (system.reg/user.reg, DXVK extraction, Wine registry edit gamefixes, etc.) but does NOT touch SteamUtils.kt — that file doesn't exist on general-fixes since the rest of the Steam Client work landed afterward. This commit extends the same per-container discipline to Steam-specific paths: * applySteamInstallScriptShim: load container via steamAppId and write to container.rootDir/.wine/system.reg. * autoLoginUserChanges: gain optional Container? parameter; when supplied (launch-time callers do), use container.rootDir/.wine for the steam config + user.reg writes. imageFs.wineprefix retained as a fallback for the login flow (SteamTokenLogin) which has no specific container in scope. * skipFirstTimeSteamSetup: gain optional Container? parameter for the system.reg writer. * restoreSteamApi: pass container through to skipFirstTimeSteamSetup. * XServerScreen real-Steam steam.exe probe + stale Steam dir delete: switch the steamExtractedForWine invalidation block from imageFs.WINEPREFIX resolution to container.rootDir/.wine. Same code that was on the Fix-Steam-Client side of the original cascade fix; extracted here so general-fixes can ship without Steam-mode-specific paths. --- .../ui/screen/xserver/XServerScreen.kt | 4 +-- .../java/app/gamenative/utils/SteamUtils.kt | 30 +++++++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) 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 d5192e8996..ed7b70200a 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 @@ -4420,12 +4420,12 @@ private fun setupWineSystemFiles( // on launch, so invalidate the extraction when Wine version or variant changes. val steamExtractedKey = "${container.wineVersion}|${container.containerVariant}" val steamExtractedPrev = container.getExtra("steamExtractedForWine") - val steamExeFile = File(ImageFs.find(context).rootDir.absolutePath, ImageFs.WINEPREFIX + "/drive_c/Program Files (x86)/Steam/steam.exe") + val steamExeFile = File(container.rootDir, ".wine/drive_c/Program Files (x86)/Steam/steam.exe") if (steamExtractedPrev != steamExtractedKey && steamExeFile.exists()) { // Symlink-safe delete: steamapps/common/ symlinks point at // GameNative's own Steam dir, and a following recursive delete would wipe // every installed game's files. - val steamDir = File(ImageFs.find(context).rootDir.absolutePath, ImageFs.WINEPREFIX + "/drive_c/Program Files (x86)/Steam") + val steamDir = File(container.rootDir, ".wine/drive_c/Program Files (x86)/Steam") SteamUtils.deleteTreeNoFollowSymlinks(steamDir) } extractSteamFiles(context, container, onExtractFileListener) diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index a7bd0808e0..d1ba749104 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -159,8 +159,11 @@ object SteamUtils { */ fun applySteamInstallScriptShim(context: Context, steamAppId: Int) { try { - val imageFs = ImageFs.find(context) - val systemRegFile = File(imageFs.wineprefix, "system.reg") + // Per-container path; imageFs.wineprefix resolves through the + // global xuser symlink which is unsafe for writes (see + // WineUtils.applySystemTweaks rationale). + val container = ContainerUtils.getContainer(context, "STEAM_$steamAppId") + val systemRegFile = File(container.rootDir, ".wine/system.reg") if (!systemRegFile.isFile) return val appKeys = listOf( @@ -564,7 +567,7 @@ object SteamUtils { ) } - fun autoLoginUserChanges(imageFs: ImageFs) { + fun autoLoginUserChanges(imageFs: ImageFs, container: Container? = null) { val vdfFileText = SteamService.getLoginUsersVdfOauth( steamId64 = SteamService.userSteamId?.convertToUInt64().toString(), account = PrefManager.username, @@ -572,11 +575,17 @@ object SteamUtils { accessToken = PrefManager.accessToken, // may be blank personaName = SteamService.instance?.localPersona?.value?.name ?: PrefManager.username ) - val steamConfigDir = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam/config") + // When called from a launch flow, prefer the per-container path. + // imageFs.wineprefix resolves through the global xuser symlink which is + // unsafe for concurrent launches (writes leak into the wrong + // container's prefix). The symlink-based fallback is kept for the + // login flow, which has no specific container context. + val winePrefixBase: File = container?.let { File(it.rootDir, ".wine") } + ?: File(imageFs.wineprefix) + val steamConfigDir = File(winePrefixBase, "drive_c/Program Files (x86)/Steam/config") try { File(steamConfigDir, "loginusers.vdf").writeText(vdfFileText) - val rootDir = imageFs.rootDir - val userRegFile = File(rootDir, ImageFs.WINEPREFIX + "/user.reg") + val userRegFile = File(winePrefixBase, "user.reg") val steamRoot = "C:\\Program Files (x86)\\Steam" val steamExe = "$steamRoot\\steam.exe" val hkcu = "Software\\Valve\\Steam" @@ -1393,7 +1402,7 @@ object SteamUtils { // Update or modify localconfig.vdf updateOrModifyLocalConfig(imageFs, container, steamAppId.toString(), SteamService.userSteamId!!.accountID.toString()) - skipFirstTimeSteamSetup(imageFs.rootDir) + skipFirstTimeSteamSetup(imageFs.rootDir, container) val appDirPath = SteamService.getAppDirPath(steamAppId) val restoredMarkerPresent = MarkerUtils.hasMarker(appDirPath, Marker.STEAM_DLL_RESTORED) @@ -2037,8 +2046,11 @@ object SteamUtils { return androidId.hashCode() } - private fun skipFirstTimeSteamSetup(rootDir: File?) { - val systemRegFile = File(rootDir, ImageFs.WINEPREFIX + "/system.reg") + private fun skipFirstTimeSteamSetup(rootDir: File?, container: Container? = null) { + // Per-container path when available — see autoLoginUserChanges for the + // cascade-corruption rationale. + val systemRegFile = container?.let { File(it.rootDir, ".wine/system.reg") } + ?: File(rootDir, ImageFs.WINEPREFIX + "/system.reg") val redistributables = listOf( "DirectX\\Jun2010" to "DXSetup", // DirectX Jun 2010 ".NET\\3.5" to "3.5 SP1", // .NET 3.5 From 0c03f10fd93d36b2a6e75305e658f19bbfa9783f Mon Sep 17 00:00:00 2001 From: TideGear Date: Tue, 28 Apr 2026 21:07:55 -0700 Subject: [PATCH 23/34] fix: post-9.1-merge container API migration in SteamUtils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compile fixes after merging upstream 9.1: - sdkCloudRemoteDir now resolves a Container and passes it to PathType.SteamUserData.toAbsPath. Upstream's PR #1310 changed toAbsPath(context, ...) to toAbsPath(container, ...) to drop the global xuser symlink dependency. - replaceSteamApi had two 'val container' declarations after merging upstream — one from upstream's per-container migration at function top, one from TideGear's later getOrCreateContainer call before ensureSaveLocationsForGames. Drop the second; the first is in scope. --- app/src/main/java/app/gamenative/utils/SteamUtils.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index d1ba749104..a49d7b6065 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -367,7 +367,6 @@ object SteamUtils { createAppManifest(context, steamAppId) // Game-specific Handling - val container = ContainerUtils.getOrCreateContainer(context, appId) ensureSaveLocationsForGames(context, steamAppId, container) // Generate achievements.json @@ -2455,7 +2454,10 @@ object SteamUtils { private fun sdkCloudRemoteDir(context: Context, appId: Int): File? { val accountId = SteamService.userSteamId?.accountID?.toLong() ?: return null - return File(PathType.SteamUserData.toAbsPath(context, appId, accountId)) + val container = runCatching { + ContainerUtils.getContainer(context, "STEAM_$appId") + }.getOrNull() ?: return null + return File(PathType.SteamUserData.toAbsPath(container, appId, accountId)) } private fun sdkCloudGameSaveDir(context: Context, appId: Int): File? { From 4cfe6a6d00c26337924c6660c1c8f1c3923eb05a Mon Sep 17 00:00:00 2001 From: TideGear Date: Tue, 28 Apr 2026 22:15:09 -0700 Subject: [PATCH 24/34] fix: overlay disable also blocks gameoverlayrenderer at Wine's loader The toggle previously only set DISABLE_VK_LAYER_VALVE_steam_overlay_1=1 (Vulkan layer skip) and SteamNoOverlayUIDrawing=1 (in-process draw skip). Steam still injected gameoverlayrenderer*.dll into every game; the DLL just opted out of drawing. Add WINEDLLOVERRIDES with empty load order on gameoverlayrenderer and gameoverlayrenderer64 so Wine's loader refuses to map the PE DLLs in the first place. Three differences from the prior catch-all attempt (commit 5d032357, reverted) that hung all real-Steam launches: - Use individual ';'-separated entries instead of a comma-grouped list with one trailing '='. The comma-grouped shape was the form that hung; individual entries parse unambiguously. - Drop SteamOverlayVulkanLayer / SteamOverlayVulkanLayer64 from the WINEDLLOVERRIDES list. Those are Vulkan-layer DLLs consumed by the Vulkan loader, not Wine's PE loader; the Khronos disable env var already covers that path. - Gate the WINEDLLOVERRIDES path on isLaunchRealSteam. In emu mode Goldberg replaces SteamAPI and the overlay DLL is never loaded, so the override is dead code there; gating it prevents any chance of regressing emu-mode boots. The two existing env vars stay as defense in depth. --- .../ui/screen/xserver/XServerScreen.kt | 34 ++++++++++++++++--- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 30 insertions(+), 6 deletions(-) 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 ed7b70200a..abfefc2e7c 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 @@ -3247,14 +3247,38 @@ private fun setupXEnvironment( envVars.putAll(container.envVars) if (!envVars.has("WINEESYNC")) envVars.put("WINEESYNC", "1") - // Disable the Steam Vulkan overlay layer via its own disable_environment - // hook when the user has opted out. This is the Khronos-canonical way to - // skip an implicit layer — the loader sees the env var and never - // initializes the layer, which avoids the race where Steam re-extracts - // the stashed layer files during client startup. + // Disable the Steam overlay end-to-end when the user opts out: + // + // 1. DISABLE_VK_LAYER_VALVE_steam_overlay_1 — Khronos-canonical + // disable hook for the Steam Vulkan implicit layer. The Vulkan + // loader sees the env var and never initializes the layer. + // (No Wine WINEDLLOVERRIDES needed for the Vulkan layer — it's + // consumed by the Vulkan loader, not Wine's PE loader.) + // 2. SteamNoOverlayUIDrawing — read by gameoverlayrenderer*.dll + // itself; tells the in-process DLL to skip drawing once it's + // already been loaded. + // 3. WINEDLLOVERRIDES with empty load order on the two + // gameoverlayrenderer PE DLLs — makes Wine's loader refuse + // to map the DLLs into the game process at all. Real-Steam + // only: in emu mode Goldberg replaces SteamAPI and the overlay + // DLL is never loaded, so the override is dead code there; + // gating it on isLaunchRealSteam also avoids any chance of + // regressing emu-mode boots. + // + // Each entry is its own `;`-separated key — comma-grouping the + // names with a single trailing `=` (the prior shape) was the form + // that hung Steam launches; individual entries parse unambiguously. if (container.isDisableSteamOverlay) { envVars.put("DISABLE_VK_LAYER_VALVE_steam_overlay_1", "1") envVars.put("SteamNoOverlayUIDrawing", "1") + if (container.isLaunchRealSteam) { + val overlayOverride = "gameoverlayrenderer=;gameoverlayrenderer64=" + val existing = envVars.get("WINEDLLOVERRIDES") + envVars.put( + "WINEDLLOVERRIDES", + if (existing.isEmpty()) overlayOverride else "$existing;$overlayOverride", + ) + } } val graphicsDriverConfig = KeyValueSet(container.getGraphicsDriverConfig()) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 483d95216c..735efe04a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -680,7 +680,7 @@ Launch Steam Client (Beta) Reduces performance and slows down launch\nAllows online play and fixes DRM and controller issues\nNot all games work Disable Steam Overlay - Skip injecting gameoverlayrenderer64.dll\nAchievements still unlock but popups won\'t appear\nMay fix crashes on Unity/Vulkan games + Blocks the GameOverlayRenderer DLLs at Wine\'s loader and skips the Steam Vulkan overlay layer\nAchievements still unlock but popups won\'t appear\nMay fix crashes on Unity/Vulkan games Cloud Save Bridge For games that store cloud saves inside their install directory (e.g. Dead Cells). Leave blank unless saves aren\'t syncing. \"Use Recommended\" pulls from the Ludusavi manifest. Local save subdirectory From 9d2ac05fae33f23f28ffb9951d2bf47ee7074d92 Mon Sep 17 00:00:00 2001 From: TideGear Date: Sat, 2 May 2026 00:32:07 -0700 Subject: [PATCH 25/34] fix: address PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified each finding from the upstream PR review against current code and applied the ones that represent real bugs or genuine privacy/safety improvements; flagged but skipped the rest in review. SteamService.kt - beginLaunchApp / forceSyncUserFiles: replace !! on instance?.applicationContext with a null-check that releases the sync flag and returns; a NPE here previously skipped the try/finally that calls releaseSync(appId), leaving the lock stuck. - pendingRemoteOperations: the localhost filter was applied under the isLaunchRealSteam=true branch, where rawPending is always emptyList() (the RPC is skipped on real-Steam) — making the filter a no-op. The comment correctly described the emulation-mode path; the code did the opposite. Inverted the conditional so the filter runs in the emulation branch where rawPending actually contains entries. GeneralTab.kt - Pattern-B SDK-cloud subdir: re-read state.config.value.sdkCloudSaveSubdir after the suspending getRecommendedSdkCloudSaveSubdirAsync / detectSdkCloudSaveSubdir calls instead of using the click-time `current` snapshot. User can type into the field while we're off-thread. ContainerUtils.kt - createNewContainer's default-config branch now seeds disableSteamOverlay from PrefManager (matching getDefaultContainerData and apply paths). Previously fresh-container creation fell through to ContainerData's Kotlin default and skipped any user pref. SteamTokenLogin.kt - Redact `command` from the timeout error log/exception. The string can contain a refresh JWT (steam-token.exe encrypt ); log the executable basename plus "[args redacted]" instead. SteamUtils.kt - createSteamCommonLink: File.exists() follows symlinks, so it returned false for broken/dangling symlinks and the subsequent Files.createSymbolicLink would throw FileAlreadyExistsException on the leftover entry. Use NOFOLLOW_LINKS to detect the link itself, return early if it already points at the intended target, otherwise remove and recreate. FileUtils.kt - matchesGlob anchoring: the substring-in-order walk matched "fooDXSETUP.exe.bak" against pattern "DXSETUP.exe". Now: pattern with no '*' is exact case-insensitive match; otherwise anchor first/last token at start/end unless prefixed/suffixed with '*'. Used by findFiles / findFilesRecursive — false positives there leak into cloud-sync paths. DRI3Extension.java - pixmapFromHardwareBuffer / pixmapFromFd: when pixmapManager.createPixmap returns null after drawableManager successfully created the drawable, unregister the drawable via removeDrawable(drawable.id) before throwing BadIdChoice. Otherwise the drawable stays in the manager and leaks its GPU/system handles. WindowManager.java - reapLeakedClientWindow: skip currently-mapped windows when collecting reap candidates. The leak chain we're cleaning up is unmapped phantoms; this guards against destroying a real surface that happens to share the (already very narrow) other attributes. Findings reviewed but not applied (with rationale, abridged): the SteamAutoCloud rebaseToAutoCloud / hash-conflict question and the phantom-dismissal blanket question are real but the proposed fixes are invasive design changes; verifyRestoredState's "true on no DLLs" is the intended behavior for non-Steamworks games; createSteamCommonLink mirror loop is by design for Dead Cells's flat saves; the WineUtils leading- slash / GLRenderer phantom-window-return / SysVSharedMemory-deadlock / ImageFsInstaller-getVersion-eager / AdrenotoolsManager-return-checks findings are either pre-existing upstream code, false positives, or defensive nits. --- .../app/gamenative/service/SteamService.kt | 26 +++++++++++++++---- .../ui/component/dialog/GeneralTab.kt | 7 +++-- .../app/gamenative/utils/ContainerUtils.kt | 1 + .../java/app/gamenative/utils/FileUtils.kt | 21 +++++++++++++-- .../app/gamenative/utils/SteamTokenLogin.kt | 7 +++-- .../java/app/gamenative/utils/SteamUtils.kt | 16 +++++++++++- .../com/winlator/xserver/WindowManager.java | 4 +++ .../xserver/extensions/DRI3Extension.java | 12 +++++++-- 8 files changed, 80 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 8ebbc2d791..e7f8344fb5 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2275,7 +2275,14 @@ class SteamService : Service(), IChallengeUrlChanged { } // Only migrate GSE -> userdata when booting real Steam; reverse direction lives in ensureSteamSettings. - SteamUtils.migrateGSESavesToSteamUserdata(instance?.applicationContext!!, appId, isLaunchRealSteam) + // Null-guard before the migrate: a NPE here would skip the try/finally below and leak the sync flag. + val migrateCtx = instance?.applicationContext + if (migrateCtx == null) { + Timber.e("migrateGSESavesToSteamUserdata: applicationContext is null, releasing sync and bailing") + releaseSync(appId) + return@async PostSyncInfo(SyncResult.UnknownFail) + } + SteamUtils.migrateGSESavesToSteamUserdata(migrateCtx, appId, isLaunchRealSteam) var syncResult = PostSyncInfo(SyncResult.UnknownFail) var launchIntentRegistered = false @@ -2417,11 +2424,13 @@ class SteamService : Service(), IChallengeUrlChanged { // mode, a stale localhost entry from a prior real-Steam session // could still surface here. Strip those so we never surface // spurious dialogs or kick our own launch — genuine entries - // from other devices still flow through. + // from other devices still flow through. (In real-Steam mode the + // RPC was skipped, so rawPending is empty and the filter is a no-op + // — the work that matters happens in the emulation branch.) var pendingRemoteOperations = if (isLaunchRealSteam) { - rawPending.filterNot { it.machineName.equals("localhost", ignoreCase = true) } - } else { rawPending + } else { + rawPending.filterNot { it.machineName.equals("localhost", ignoreCase = true) } } // Self-phantom auto-clear: when every pending op is from our own @@ -2523,7 +2532,14 @@ class SteamService : Service(), IChallengeUrlChanged { return@async PostSyncInfo(SyncResult.InProgress) } - SteamUtils.migrateGSESavesToSteamUserdata(instance?.applicationContext!!, appId, isLaunchRealSteam) + // Null-guard: a NPE here would skip the try/finally below and leak the sync flag. + val migrateCtx = instance?.applicationContext + if (migrateCtx == null) { + Timber.e("forceSyncUserFiles: applicationContext is null, releasing sync and bailing") + releaseSync(appId) + return@async PostSyncInfo(SyncResult.UnknownFail) + } + SteamUtils.migrateGSESavesToSteamUserdata(migrateCtx, appId, isLaunchRealSteam) try { val context = instance?.applicationContext ?: return@async PostSyncInfo(SyncResult.UnknownFail) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt index efc3b3d0e4..ac7bc9dd58 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt @@ -510,7 +510,9 @@ private fun SdkCloudSaveSubdirField( rec.name.ifEmpty { appId.toString() }, rec.subdir, ) - if (current.isBlank()) { + // Re-read after suspend: user may have typed in the field while + // we were off-thread asking Ludusavi for a recommendation. + if (state.config.value.sdkCloudSaveSubdir.isBlank()) { pendingValue = rec.subdir showConfirmDialog = true } @@ -543,7 +545,8 @@ private fun SdkCloudSaveSubdirField( } else { context.getString(R.string.sdk_cloud_save_subdir_detect_none) } - if (detected != null && current.isBlank()) { + // Re-read after the IO suspend; the user may have typed in the field meanwhile. + if (detected != null && state.config.value.sdkCloudSaveSubdir.isBlank()) { pendingValue = detected showConfirmDialog = true } diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 3fecb76f85..fb4f412ca8 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -827,6 +827,7 @@ object ContainerUtils { execArgs = PrefManager.execArgs, showFPS = false, launchRealSteam = PrefManager.launchRealSteam, + disableSteamOverlay = PrefManager.disableSteamOverlay, wow64Mode = PrefManager.wow64Mode, startupSelection = PrefManager.startupSelection.toByte(), box86Version = PrefManager.box86Version, diff --git a/app/src/main/java/app/gamenative/utils/FileUtils.kt b/app/src/main/java/app/gamenative/utils/FileUtils.kt index 0eeae9cebb..30502cc32f 100644 --- a/app/src/main/java/app/gamenative/utils/FileUtils.kt +++ b/app/src/main/java/app/gamenative/utils/FileUtils.kt @@ -135,13 +135,30 @@ object FileUtils { fun matchesGlob(fileName: String, pattern: String): Boolean { if (pattern.isEmpty() || pattern == "*") return true + // Pattern with no '*' is an exact (case-insensitive) match, not a substring search. + if (!pattern.contains('*')) return fileName.equals(pattern, ignoreCase = true) + val hasLeadingStar = pattern.startsWith('*') + val hasTrailingStar = pattern.endsWith('*') val patternParts = pattern.split("*").filter { it.isNotEmpty() } - var startIndex = 0 - for (part in patternParts) { + if (patternParts.isEmpty()) return true + // Anchor the first token at fileName start when the pattern has no leading '*'. + if (!hasLeadingStar && !fileName.startsWith(patternParts.first(), ignoreCase = true)) return false + // Anchor the last token at fileName end when the pattern has no trailing '*'. + if (!hasTrailingStar && !fileName.endsWith(patternParts.last(), ignoreCase = true)) return false + // Walk middle tokens in order, starting after any anchored prefix. + var startIndex = if (!hasLeadingStar) patternParts.first().length else 0 + val afterFirst = if (!hasLeadingStar) patternParts.drop(1) else patternParts + val middle = if (!hasTrailingStar && afterFirst.isNotEmpty()) afterFirst.dropLast(1) else afterFirst + for (part in middle) { val index = fileName.indexOf(part, startIndex, ignoreCase = true) if (index < 0) return false startIndex = index + part.length } + // If the trailing anchor consumed a token that overlaps startIndex, ensure no overlap. + if (!hasTrailingStar && afterFirst.isNotEmpty()) { + val lastTokenStart = fileName.length - afterFirst.last().length + if (lastTokenStart < startIndex) return false + } return true } diff --git a/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt b/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt index d64330ddfd..578b889c65 100644 --- a/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt +++ b/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt @@ -64,8 +64,11 @@ class SteamTokenLogin( future.get(WINE_EXEC_TIMEOUT_SECONDS, TimeUnit.SECONDS) } catch (e: TimeoutException) { future.cancel(true) - Timber.tag("SteamTokenLogin").e("wine exec timed out after %ds: %s", WINE_EXEC_TIMEOUT_SECONDS, command) - throw IllegalStateException("wine exec timed out: $command", e) + // Don't log/throw the full command — `command` may contain a refresh JWT + // (steam-token.exe encrypt ). Log just the executable name. + val redacted = command.substringBefore(' ').substringAfterLast('/').ifEmpty { "" } + Timber.tag("SteamTokenLogin").e("wine exec timed out after %ds: %s [args redacted]", WINE_EXEC_TIMEOUT_SECONDS, redacted) + throw IllegalStateException("wine exec timed out: $redacted [args redacted]", e) } } finally { executor.shutdownNow() diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index a49d7b6065..6c384be2d9 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -25,6 +25,7 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import java.nio.file.Files +import java.nio.file.LinkOption import java.nio.file.Path import java.nio.file.Paths import java.nio.file.StandardCopyOption @@ -1328,8 +1329,21 @@ object SteamUtils { } private fun createSteamCommonLink(link: File, target: File) { - if (link.exists()) return + // File.exists() follows symlinks, so it returns false for broken symlinks (target gone) + // and would let createSymbolicLink below blow up with FileAlreadyExistsException on + // the dangling link entry. Use NOFOLLOW_LINKS to detect the link itself, and replace + // it if it points somewhere other than the intended target. link.parentFile?.mkdirs() + if (Files.exists(link.toPath(), LinkOption.NOFOLLOW_LINKS)) { + val existingTarget = runCatching { Files.readSymbolicLink(link.toPath()) }.getOrNull() + if (existingTarget == target.toPath()) return + try { + deleteTreeNoFollowSymlinks(link) + } catch (e: Exception) { + Timber.w(e, "createSteamCommonLink: failed to remove existing entry at ${link.absolutePath}") + return + } + } Files.createSymbolicLink(link.toPath(), target.toPath()) } diff --git a/app/src/main/java/com/winlator/xserver/WindowManager.java b/app/src/main/java/com/winlator/xserver/WindowManager.java index ab9ab1e542..b01d2d5c93 100644 --- a/app/src/main/java/com/winlator/xserver/WindowManager.java +++ b/app/src/main/java/com/winlator/xserver/WindowManager.java @@ -206,6 +206,10 @@ private void reapLeakedClientWindows(Window created) { if (!cand.isInputOutput()) continue; if (!cand.getClassName().isEmpty()) continue; if (cand.getWidth() != w || cand.getHeight() != h) continue; + // Don't reap currently-mapped windows; the leak chain we're cleaning up is + // composed of unmapped phantoms. A real surface that happens to share the + // other attributes (very rare) would still be safe. + if (cand.attributes.isMapped()) continue; matches.add(cand); } if (matches.size() < LEAK_CLIENT_CAP) return; diff --git a/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java b/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java index 843b2b864d..a47df5cff4 100644 --- a/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java +++ b/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java @@ -161,7 +161,11 @@ private void pixmapFromHardwareBuffer(XClient client, int pixmapId, short width, Drawable drawable = client.xServer.drawableManager.createDrawable(pixmapId, gpuImage.getStride(), height, depth); if (drawable == null) throw new BadIdChoice(pixmapId); drawable.setTexture(gpuImage); - if (client.xServer.pixmapManager.createPixmap(drawable) == null) throw new BadIdChoice(pixmapId); + if (client.xServer.pixmapManager.createPixmap(drawable) == null) { + // Drawable is already registered; unregister it before throwing or it leaks. + client.xServer.drawableManager.removeDrawable(drawable.id); + throw new BadIdChoice(pixmapId); + } } finally { XConnectorEpoll.closeFd(fd); @@ -179,7 +183,11 @@ private void pixmapFromFd(XClient client, int pixmapId, short width, short heigh drawable.setData(buffer); drawable.setTexture(null); drawable.setOnDestroyListener(onDestroyDrawableListener); - if (client.xServer.pixmapManager.createPixmap(drawable) == null) throw new BadIdChoice(pixmapId); + if (client.xServer.pixmapManager.createPixmap(drawable) == null) { + // Drawable is already registered; unregister it before throwing or it leaks. + client.xServer.drawableManager.removeDrawable(drawable.id); + throw new BadIdChoice(pixmapId); + } } finally { XConnectorEpoll.closeFd(fd); From 82dddd9908791b87733afb9d6def256280394143 Mon Sep 17 00:00:00 2001 From: TideGear Date: Sat, 2 May 2026 00:47:06 -0700 Subject: [PATCH 26/34] fix: address second round of PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified each finding against current code; applied real bugs/leaks, flagged the rest with rationale. WindowManager.reapLeakedClientWindows - Build the ancestor chain of `created` and exclude its parents from reap candidates. destroyWindow() recurses into descendants, so reaping any ancestor would destroy `created` itself and createWindow() would return a window that is no longer in the tree. The mapped-state guard from the previous fix already covered most real surfaces; this guard closes the case where a parent in the leak chain matched all other predicates. DRI3Extension.pixmapFromFd - Track whether the SHM ByteBuffer was handed off to a Drawable. If drawable creation fails (BadIdChoice) or createPixmap returns null, unmap the buffer in the finally block so the SHM segment doesn't leak. closeFd was already covered; the buffer wasn't. SteamService.beginLaunchApp / forceSyncUserFiles - Move SteamUtils.migrateGSESavesToSteamUserdata inside the existing try block so any exception flows through finally { releaseSync(appId) } instead of leaking the sync flag. The null-guard for applicationContext stays before the try (early-returns with releaseSync). - forceSyncUserFiles also had a duplicate two-arg migrateGSESavesToSteamUserdata(context, appId) call after the new three-arg one. The two-arg form ignores isLaunchRealSteam and ran unconditionally. Drop it; keep only the mode-aware call. GeneralTab SdkCloudSaveSubdirField - Tighten the first-activation-confirm trigger from `wasBlank && trimmed.isNotEmpty()` to `wasBlank && trimmed.length > 1` so the confirm dialog doesn't fire on the very first character typed while the user is still entering a multi-character subdir. Findings flagged but not changed (with rationale): - VcRedistStep container marker: real but rare edge case (most games bundle the same VC++ runtimes; per-game gameDirPath check still catches "this game's installer hasn't run"). - AdrenotoolsManager extraction rollback: would need atomic temp+swap; Android private storage rarely fails delete/mkdirs. - WineUtils `/opt/...` leading slash: pre-existing upstream code; the copies have always silently failed (no `/opt` on Android), so changing the paths would change behavior, not restore it. - SteamTokenLogin future.cancel(true): structural refactor of execShellCommand to be interrupt-aware; out of scope for this PR. - SteamAutoCloud `if (autoCloudPatterns.isEmpty())` SteamUserData skip: the comment is explicit — the skip prevents misrooted-upload ghosts from being re-uploaded under SteamUserData. Mixed-root edge case is rare and the conservative behavior is intentional. - WineThemeManager wallpaper using shared root: registry value is a Wine-side path that resolves through the active xuser symlink at read time, and apply() runs during launch when the symlink is in sync with the launching container. - SteamUtils SDK cloud mirror non-recursive: by design for Dead Cells flat saves; expanding to recursive is scope creep for this PR. - GLRenderer phantom-window early return: deliberate part of the GLX leak-chain skip (52f098f3); descendants of a phantom-pair are themselves part of the leak. --- .../app/gamenative/service/SteamService.kt | 18 +++++++++++------- .../ui/component/dialog/GeneralTab.kt | 7 +++++-- .../com/winlator/xserver/WindowManager.java | 12 ++++++++++++ .../xserver/extensions/DRI3Extension.java | 12 +++++++++++- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index e7f8344fb5..59c6c16a1d 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2275,14 +2275,14 @@ class SteamService : Service(), IChallengeUrlChanged { } // Only migrate GSE -> userdata when booting real Steam; reverse direction lives in ensureSteamSettings. - // Null-guard before the migrate: a NPE here would skip the try/finally below and leak the sync flag. + // Null-guard for context; the migrate itself is invoked inside the main try below so any + // exception goes through finally { releaseSync(appId) } and never leaks the sync flag. val migrateCtx = instance?.applicationContext if (migrateCtx == null) { Timber.e("migrateGSESavesToSteamUserdata: applicationContext is null, releasing sync and bailing") releaseSync(appId) return@async PostSyncInfo(SyncResult.UnknownFail) } - SteamUtils.migrateGSESavesToSteamUserdata(migrateCtx, appId, isLaunchRealSteam) var syncResult = PostSyncInfo(SyncResult.UnknownFail) var launchIntentRegistered = false @@ -2351,6 +2351,10 @@ class SteamService : Service(), IChallengeUrlChanged { } try { + // Migrate GSE saves -> Steam userdata layout. Inside the try so any + // exception still flows through finally { releaseSync(appId) }. + SteamUtils.migrateGSESavesToSteamUserdata(migrateCtx, appId, isLaunchRealSteam) + // GameNative is the sole cloud client in both modes: Wine-Steam has // cloudenabled=0 written to localconfig.vdf AND sharedconfig.vdf and // is launched with -no-browser so it performs no cloud I/O. Running @@ -2532,19 +2536,19 @@ class SteamService : Service(), IChallengeUrlChanged { return@async PostSyncInfo(SyncResult.InProgress) } - // Null-guard: a NPE here would skip the try/finally below and leak the sync flag. + // Null-guard for context; the migrate itself runs inside the try below so any + // exception flows through finally { releaseSync(appId) } and never leaks the lock. val migrateCtx = instance?.applicationContext if (migrateCtx == null) { Timber.e("forceSyncUserFiles: applicationContext is null, releasing sync and bailing") releaseSync(appId) return@async PostSyncInfo(SyncResult.UnknownFail) } - SteamUtils.migrateGSESavesToSteamUserdata(migrateCtx, appId, isLaunchRealSteam) try { - val context = instance?.applicationContext ?: return@async PostSyncInfo(SyncResult.UnknownFail) - // Migrate GSE Saves to Steam userdata - SteamUtils.migrateGSESavesToSteamUserdata(context, appId) + // Single mode-aware migrate: drop the duplicate two-arg call that ignored + // the isLaunchRealSteam flag and ran every time regardless of mode. + SteamUtils.migrateGSESavesToSteamUserdata(migrateCtx, appId, isLaunchRealSteam) var syncResult = PostSyncInfo(SyncResult.UnknownFail) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt index ac7bc9dd58..c1ed328025 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt @@ -466,8 +466,11 @@ private fun SdkCloudSaveSubdirField( onValueChange = { raw -> val trimmed = raw.trim() val wasBlank = current.isBlank() - val nowNonBlank = trimmed.isNotEmpty() - if (wasBlank && nowNonBlank) { + // Don't trigger the confirm dialog on the first character — that disrupts normal + // typing of a multi-character subdir name. Wait until the user has typed something + // meaningful before asking them to confirm activation. + val meaningful = trimmed.length > 1 + if (wasBlank && meaningful) { // First activation for this container — confirm before committing. pendingValue = trimmed showConfirmDialog = true diff --git a/app/src/main/java/com/winlator/xserver/WindowManager.java b/app/src/main/java/com/winlator/xserver/WindowManager.java index b01d2d5c93..c786d64328 100644 --- a/app/src/main/java/com/winlator/xserver/WindowManager.java +++ b/app/src/main/java/com/winlator/xserver/WindowManager.java @@ -198,6 +198,15 @@ private void reapLeakedClientWindows(Window created) { int w = created.getWidth(); int h = created.getHeight(); + // Build the ancestor chain of `created` so we never reap one of its parents. + // destroyWindow recursively destroys descendants, so destroying any ancestor of + // the just-created window would destroy `created` itself, and createWindow() + // would return a stale handle that is no longer in the window tree. + java.util.HashSet ancestors = new java.util.HashSet<>(); + for (Window p = created.getParent(); p != null && p != rootWindow; p = p.getParent()) { + ancestors.add(p.id); + } + ArrayList matches = new ArrayList<>(); for (int i = 0; i < windows.size(); i++) { Window cand = windows.valueAt(i); @@ -210,6 +219,9 @@ private void reapLeakedClientWindows(Window created) { // composed of unmapped phantoms. A real surface that happens to share the // other attributes (very rare) would still be safe. if (cand.attributes.isMapped()) continue; + // Don't reap an ancestor of `created`; destroyWindow recurses into descendants + // so this would destroy `created` too. + if (ancestors.contains(cand.id)) continue; matches.add(cand); } if (matches.size() < LEAK_CLIENT_CAP) return; diff --git a/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java b/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java index a47df5cff4..583ecb7126 100644 --- a/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java +++ b/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java @@ -173,8 +173,10 @@ private void pixmapFromHardwareBuffer(XClient client, int pixmapId, short width, } private void pixmapFromFd(XClient client, int pixmapId, short width, short height, int stride, int offset, byte depth, int fd, long size) throws IOException, XRequestError { + ByteBuffer buffer = null; + boolean handedOffToDrawable = false; try { - ByteBuffer buffer = SysVSharedMemory.mapSHMSegment(fd, size, offset, true); + buffer = SysVSharedMemory.mapSHMSegment(fd, size, offset, true); if (buffer == null) throw new BadAlloc(); short totalWidth = (short)(stride / 4); @@ -188,8 +190,16 @@ private void pixmapFromFd(XClient client, int pixmapId, short width, short heigh client.xServer.drawableManager.removeDrawable(drawable.id); throw new BadIdChoice(pixmapId); } + // Drawable now owns the buffer; onDestroyDrawableListener will unmap it. + handedOffToDrawable = true; } finally { + // If we mapped the buffer but never handed it to a Drawable (e.g. drawable creation + // failed, or createPixmap returned null and we removed the drawable above), the SHM + // segment is otherwise leaked. + if (buffer != null && !handedOffToDrawable) { + SysVSharedMemory.unmapSHMSegment(buffer, size); + } XConnectorEpoll.closeFd(fd); } } From 6be9db130712ccf1395a9ed31614902e1519fbf9 Mon Sep 17 00:00:00 2001 From: TideGear Date: Sat, 2 May 2026 01:19:56 -0700 Subject: [PATCH 27/34] fix: address third round of PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRI3Extension.pixmapFromFd - Move drawable.setOnDestroyListener(onDestroyDrawableListener) so it only fires after createPixmap succeeds. Registering it earlier caused a double-unmap on the failure path: removeDrawable() invoked the listener (unmap #1), then the finally block unmapped again because handedOffToDrawable was still false. (Bug introduced by my earlier SHM-leak fix.) GeneralTab SdkCloudSaveSubdirField - Drop the wasBlank/meaningful/confirm-dialog gate from the typing path entirely. The original wasBlank-on-first-keystroke check fired the confirm dialog on the first character and disrupted typing; my length>1 patch made it never fire because by char 2 wasBlank was already false. Manual typing is intentional — commit each keystroke directly. The first-activation confirmation is reserved for auto-filled values from the Recommend / Detect buttons below, which the user didn't type. SteamService phantom dismiss - Filter probed pending ops by machineName before auto-dismissing. Only entries from our machineName (or "localhost") are treated as phantoms; any cross-device entry blocks the auto-dismiss path so legitimate cloud conflicts surface to the SYNC_CONFLICT dialog instead of getting silently wiped by ignorePendingOperations=true. SteamService beginLaunchApp offline gate - Move the isOffline/!isConnected early-return below the GSE→userdata migrate, inside the existing try block. Local prep should run even offline (one-shot file moves, not cloud-dependent). The cloud RPCs below the gate continue to short-circuit when offline. PluviaMain SDK-cloud-bridge prompt - Don't fire the prompt for non-game launches: bootToContainer (Open Container), useTemporaryOverride, or skipCloudSync flows. The post- prompt `relaunch` lambda doesn't carry those flags forward, so an Open-Container that triggered the prompt would silently turn into a game launch on dismiss. Easier to gate the prompt out than thread every flag through the relaunch. SteamUtils verifyReplacedState / verifyRestoredState - Treat a missing pipe-asset hash as a verification FAILURE rather than a silent skip. The previous `?: return@forEach` would skip the file and leave the function returning true — meaning a future build that drops a pipe DLL asset would silently let the marker check pass and suppress DLL repair. Fail closed instead. LudusaviRegistry.ensureLoaded - Wrap the fetch + disk-write + memoryCache-populate path in a kotlinx.coroutines Mutex with a double-check on entry. Without it, concurrent primeCache() + lookup() callers could both pass the null check and both fetch the 5 MB manifest. Now the second waiter sees the populated cache after the first finishes. Findings flagged but not changed: - SteamUtils SDK cloud mirror nested directories (third time): mirror is by design for Dead Cells flat saves; recursive expansion remains scope creep. --- .../app/gamenative/service/SteamService.kt | 60 ++++++++++++----- .../main/java/app/gamenative/ui/PluviaMain.kt | 7 ++ .../ui/component/dialog/GeneralTab.kt | 20 ++---- .../app/gamenative/utils/LudusaviRegistry.kt | 64 +++++++++++-------- .../java/app/gamenative/utils/SteamUtils.kt | 16 ++++- .../xserver/extensions/DRI3Extension.java | 11 +++- 6 files changed, 118 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 59c6c16a1d..26bc0243ee 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2266,9 +2266,9 @@ class SteamService : Service(), IChallengeUrlChanged { isLaunchRealSteam: Boolean = false, onProgress: ((message: String, progress: Float) -> Unit)? = null, ): Deferred = parentScope.async { - if (isOffline || !isConnected) { - return@async PostSyncInfo(SyncResult.UpToDate) - } + // NB: don't early-return on isOffline/!isConnected here — local prep below + // (GSE→userdata migrate, future SDK-cloud mirror) is independent of cloud + // connectivity and needs to run before the game launches even when offline. if (!tryAcquireSync(appId)) { Timber.w("Cannot launch app when sync already in progress for appId=$appId") return@async PostSyncInfo(SyncResult.InProgress) @@ -2319,24 +2319,43 @@ class SteamService : Service(), IChallengeUrlChanged { ).await() if (probed.isNotEmpty()) { + // Only auto-dismiss our OWN phantom entries. A pending op from a + // different machineName is a legitimate cross-device cloud conflict — + // those need to surface to the SYNC_CONFLICT dialog so the user can + // resolve, not get silently wiped by ignorePendingOperations=true. + val selfPhantoms = probed.filter { + it.machineName.equals(ourMachineName, ignoreCase = true) || + it.machineName.equals("localhost", ignoreCase = true) + } + val crossDevice = probed - selfPhantoms.toSet() Timber.i( - "Proactive real-Steam probe found %d pending op(s) (%s); dismissing phantom", + "Proactive real-Steam probe found %d pending op(s) (%s); selfPhantoms=%d crossDevice=%d", probed.size, probed.joinToString { "${it.machineName}:${it.operation.name}" }, + selfPhantoms.size, + crossDevice.size, ) - steamCloud.signalAppLaunchIntent( - appId = appId, - clientId = proactiveClientId, - machineName = ourMachineName, - ignorePendingOperations = true, - osType = EOSType.AndroidUnknown, - ).await() - steamCloud.signalAppExitSyncDone( - appId = appId, - clientId = proactiveClientId, - uploadsCompleted = false, - uploadsRequired = false, - ) + if (crossDevice.isNotEmpty()) { + Timber.w( + "Skipping phantom auto-dismiss: %d cross-device pending op(s) present (%s) — leaving for the conflict dialog.", + crossDevice.size, + crossDevice.joinToString { "${it.machineName}:${it.operation.name}" }, + ) + } else if (selfPhantoms.isNotEmpty()) { + steamCloud.signalAppLaunchIntent( + appId = appId, + clientId = proactiveClientId, + machineName = ourMachineName, + ignorePendingOperations = true, + osType = EOSType.AndroidUnknown, + ).await() + steamCloud.signalAppExitSyncDone( + appId = appId, + clientId = proactiveClientId, + uploadsCompleted = false, + uploadsRequired = false, + ) + } } }.onFailure { Timber.w(it, "Proactive phantom-clearing probe before real-Steam launch failed") @@ -2355,6 +2374,13 @@ class SteamService : Service(), IChallengeUrlChanged { // exception still flows through finally { releaseSync(appId) }. SteamUtils.migrateGSESavesToSteamUserdata(migrateCtx, appId, isLaunchRealSteam) + // Local prep done. If we're offline or disconnected, skip the cloud RPCs + // below but still report UpToDate — the migrate above ran and the game + // can launch from whatever's in userdata//remote/ already. + if (isOffline || !isConnected) { + return@async PostSyncInfo(SyncResult.UpToDate) + } + // GameNative is the sole cloud client in both modes: Wine-Steam has // cloudenabled=0 written to localconfig.vdf AND sharedconfig.vdf and // is launched with -no-browser so it performs no cloud I/O. Running diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index dee33dfbca..6124164e13 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1637,7 +1637,14 @@ fun preLaunchApp( // Fires only when real-Steam is on, the subdir is blank, the user hasn't dismissed, // Ludusavi knows this game, AND it has no Auto-Cloud saveFilePatterns in PICS UFS. // That intersection is a reliable Pattern B signal and keeps false positives low. + // + // Skip the prompt for non-game launches (Open Container / temporary override / explicit + // skipCloudSync). The post-prompt `relaunch` lambda doesn't carry those flags forward, + // so firing the prompt for them would silently turn an Open-Container into a game launch. if (!skipBridgePrompt && + !bootToContainer && + !useTemporaryOverride && + !skipCloudSync && gameSource == GameSource.STEAM && container.isLaunchRealSteam && container.sdkCloudSaveSubdir.isBlank() && diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt index c1ed328025..c19976b674 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt @@ -463,20 +463,14 @@ private fun SdkCloudSaveSubdirField( NoExtractOutlinedTextField( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), value = current, + // Manual typing is an intentional edit — commit each keystroke directly. The first-activation + // confirmation flow (pendingValue/showConfirmDialog) is reserved for auto-filled values from + // the Recommend / Detect buttons below, which the user didn't type and may want to reject. + // Earlier shapes of this handler tried to gate typing through the confirmation too, but any + // such gate either broke after the first keystroke (state was no longer "blank") or + // interrupted normal multi-character typing. onValueChange = { raw -> - val trimmed = raw.trim() - val wasBlank = current.isBlank() - // Don't trigger the confirm dialog on the first character — that disrupts normal - // typing of a multi-character subdir name. Wait until the user has typed something - // meaningful before asking them to confirm activation. - val meaningful = trimmed.length > 1 - if (wasBlank && meaningful) { - // First activation for this container — confirm before committing. - pendingValue = trimmed - showConfirmDialog = true - } else { - state.config.value = config.copy(sdkCloudSaveSubdir = trimmed) - } + state.config.value = config.copy(sdkCloudSaveSubdir = raw.trim()) }, label = { Text(text = stringResource(R.string.sdk_cloud_save_subdir_label)) }, placeholder = { Text(text = stringResource(R.string.sdk_cloud_save_subdir_placeholder)) }, diff --git a/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt b/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt index 745a6c83d6..1f8ccb8336 100644 --- a/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt +++ b/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt @@ -2,6 +2,8 @@ package app.gamenative.utils import android.content.Context import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okhttp3.Request import org.json.JSONArray @@ -36,6 +38,11 @@ object LudusaviRegistry { @Volatile private var memoryCache: Map? = null + // Serializes the fetch + disk-write + memoryCache populate path. Without this, a + // concurrent primeCache() + lookup() could both pass the null check, both fetch the + // 5 MB manifest, and both write the disk cache (last writer wins; the wasted work and + // duplicate network traffic are the real cost). + private val loadMutex = Mutex() /** * Returns the recommended save subdir for [appId] from the Ludusavi manifest, or null @@ -57,38 +64,45 @@ object LudusaviRegistry { } private suspend fun ensureLoaded(context: Context): Map? { + // Fast path: in-memory cache populated. memoryCache?.let { return it } - val cacheFile = File(context.filesDir, CACHE_FILE) - if (cacheFile.isFile && (System.currentTimeMillis() - cacheFile.lastModified()) < CACHE_TTL_MS) { - runCatching { parseCacheJson(cacheFile.readText()) } - .onSuccess { parsed -> - memoryCache = parsed - Timber.i("LudusaviRegistry: loaded ${parsed.size} Pattern B entries from disk cache") - return parsed - } - .onFailure { Timber.w(it, "LudusaviRegistry: disk cache parse failed; will refetch") } - } + // Slow path: serialize so concurrent callers don't all fetch the 5 MB manifest. + return loadMutex.withLock { + // Re-check after acquiring the lock; another caller may have populated while we waited. + memoryCache?.let { return@withLock it } - val fetched = fetchAndFilter() ?: run { - // Fall back to a stale disk cache if fetch failed and one exists - if (cacheFile.isFile) { + val cacheFile = File(context.filesDir, CACHE_FILE) + if (cacheFile.isFile && (System.currentTimeMillis() - cacheFile.lastModified()) < CACHE_TTL_MS) { runCatching { parseCacheJson(cacheFile.readText()) } - .onSuccess { - memoryCache = it - Timber.w("LudusaviRegistry: using stale disk cache (${it.size} entries) after fetch failure") - return it + .onSuccess { parsed -> + memoryCache = parsed + Timber.i("LudusaviRegistry: loaded ${parsed.size} Pattern B entries from disk cache") + return@withLock parsed } + .onFailure { Timber.w(it, "LudusaviRegistry: disk cache parse failed; will refetch") } } - return null - } - runCatching { - cacheFile.writeText(serializeCacheJson(fetched)) - }.onFailure { Timber.w(it, "LudusaviRegistry: failed to write disk cache") } - memoryCache = fetched - Timber.i("LudusaviRegistry: fetched ${fetched.size} Pattern B entries from upstream manifest") - return fetched + val fetched = fetchAndFilter() ?: run { + // Fall back to a stale disk cache if fetch failed and one exists + if (cacheFile.isFile) { + runCatching { parseCacheJson(cacheFile.readText()) } + .onSuccess { + memoryCache = it + Timber.w("LudusaviRegistry: using stale disk cache (${it.size} entries) after fetch failure") + return@withLock it + } + } + return@withLock null + } + + runCatching { + cacheFile.writeText(serializeCacheJson(fetched)) + }.onFailure { Timber.w(it, "LudusaviRegistry: failed to write disk cache") } + memoryCache = fetched + Timber.i("LudusaviRegistry: fetched ${fetched.size} Pattern B entries from upstream manifest") + fetched + } } private fun fetchAndFilter(): Map? { diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 6c384be2d9..69d5dd4742 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -229,7 +229,13 @@ object SteamUtils { val n = file.name.lowercase() if (n == "steam_api.dll" || n == "steam_api64.dll") { found = true - val expected = assetHashes[n] ?: return@forEach + val expected = assetHashes[n] + if (expected == null) { + // Missing pipe-asset hash means we can't verify — fail closed and force + // a re-replace rather than silently trust the marker. + Timber.w("DLL marker desync: pipe asset hash missing for %s, can't verify replaced state", n) + return false + } if (sha256OfFile(file) != expected) { Timber.w("DLL marker desync: %s hash mismatch (marker says REPLACED)", file.absolutePath) return false @@ -254,7 +260,13 @@ object SteamUtils { if (!file.isFile) return@forEach val n = file.name.lowercase() if (n == "steam_api.dll" || n == "steam_api64.dll") { - val pipeHash = assetHashes[n] ?: return@forEach + val pipeHash = assetHashes[n] + if (pipeHash == null) { + // Missing pipe-asset hash means we can't tell whether this is the pipe + // DLL or the original Valve DLL — fail closed so the caller re-restores. + Timber.w("DLL marker desync: pipe asset hash missing for %s, can't verify restored state", n) + return false + } if (sha256OfFile(file) == pipeHash) { Timber.w("DLL marker desync: %s is still the pipe DLL (marker says RESTORED)", file.absolutePath) return false diff --git a/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java b/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java index 583ecb7126..e0b1e294fa 100644 --- a/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java +++ b/app/src/main/java/com/winlator/xserver/extensions/DRI3Extension.java @@ -184,13 +184,18 @@ private void pixmapFromFd(XClient client, int pixmapId, short width, short heigh if (drawable == null) throw new BadIdChoice(pixmapId); drawable.setData(buffer); drawable.setTexture(null); - drawable.setOnDestroyListener(onDestroyDrawableListener); + // NB: don't register onDestroyDrawableListener until after pixmap creation succeeds. + // If we register early and createPixmap returns null, removeDrawable() would invoke + // the listener → unmapSHMSegment, and the finally block below would unmap again. if (client.xServer.pixmapManager.createPixmap(drawable) == null) { - // Drawable is already registered; unregister it before throwing or it leaks. + // Drawable is registered without a destroy listener; unregister and bail. The + // finally block will unmap the buffer (handedOffToDrawable is still false). client.xServer.drawableManager.removeDrawable(drawable.id); throw new BadIdChoice(pixmapId); } - // Drawable now owns the buffer; onDestroyDrawableListener will unmap it. + // Drawable + Pixmap created. Hand the buffer over: register the listener and flag + // handedOff so the finally block doesn't unmap (the listener will, on destruction). + drawable.setOnDestroyListener(onDestroyDrawableListener); handedOffToDrawable = true; } finally { From ae9653d574c6800d70ded5a7434d3c1fdaeb2c85 Mon Sep 17 00:00:00 2001 From: TideGear Date: Sat, 2 May 2026 22:47:23 -0700 Subject: [PATCH 28/34] fix: ignore working-tree dirtiness in lsfg-vk-android submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set submodule.app/src/main/cpp/lsfg-vk-android.ignore=dirty in .gitmodules so git/GitHub Desktop stops flagging the parent repo as having uncommitted changes whenever the submodule's working tree is dirty. The dirtiness comes from a chain three levels deep: lsfg-vk-android pulls in thirdparty/pe-parse, which pulls in tests/assets/corkami-poc-dataset. The corkami dataset is a collection of intentionally-weird PE files used to test pe-parse — Windows Defender flags some of them as PUA and quarantines them, leaving the submodule with deleted files. Those files aren't consumed by our build (they're pe-parse's own test fixtures), so hiding the working-tree dirtiness is the right call. The submodule's commit pin is still tracked normally; only the 'this submodule has local modifications' notice is suppressed. --- .gitmodules | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitmodules b/.gitmodules index 51eee4eb19..5db4d62963 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,4 @@ [submodule "app/src/main/cpp/lsfg-vk-android"] path = app/src/main/cpp/lsfg-vk-android url = https://github.com/GameNative/lsfg-vk-android.git + ignore = dirty From ffcac7564e16d83d6d7131e90444ba1877921bbc Mon Sep 17 00:00:00 2001 From: TideGear Date: Sat, 2 May 2026 22:53:56 -0700 Subject: [PATCH 29/34] fix: address fourth round of PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PluviaMain SDK-cloud-bridge dialog - relaunch() now takes a suppressPrompt flag; only set skipBridgePrompt=true when persistence actually succeeded. Previous behavior unconditionally suppressed the prompt for the next launch even when container.saveData() threw and the subdir wasn't actually saved — bridge ended up "off but prompt hidden" instead of either fully on or normal. - Tightened the prompt-fire gate to also skip when preferredSave != None or ignorePendingOperations is true. Those flags come from conflict- resolution dialogs; firing the bridge prompt during a conflict re-entry would drop the user's chosen save / dismiss-pending choice via the relaunch lambda. SteamUtils.writeSharedAppManifest - Added LastOwner to the early-return reuse criteria. Previously, depots/buildId/scripts could all match while the existing acf was attributed to a different signed-in account; reusing it would leave the manifest pointing at a stale LastOwner and break cloud/ownership checks for the current user. Force-rewrite when the owner doesn't match. New helper parseAcfLastOwner() and acfLastOwnerRegex. SteamUtils.autoLoginUserChanges (loginusers.vdf) - Ensure the Steam config dir exists before writeText(). On a fresh container the Wine prefix skeleton is laid down but Steam's own drive_c/Program Files (x86)/Steam/config/ subdir hasn't been populated yet — without mkdirs() the write throws FileNotFoundException and the auto-login fails silently. ContainerUtils.createNewContainer - Replace the open-coded ContainerData(...) construction in the default branch with getDefaultContainerData().copy(drives = drives, dxwrapper = initialDxWrapper). The manual builder was missing several fields that getDefaultContainerData() carries — localSavesOnly, useSteamInput, sharpnessEffect/Level/Denoise, vibrationMode/Intensity, lsfgEnabled — so new containers inherited Kotlin defaults instead of the user's PrefManager defaults. Inheriting through copy() also means future fields don't need to be added in two places. Findings flagged but not changed: - verifyRestoredState true on no DLLs: the function is meant to detect "DLLs are NOT the pipe-DLL" (i.e., they're the original Valve DLLs or absent). Returning false on absent would force re-extraction of pipe DLLs into game folders that don't ship them, which is wrong for non-Steamworks games. - SDK cloud mirror non-recursive (4th time): by design for Dead Cells flat saves; recursive expansion is scope creep. - matchesGlob unit-test nitpick: skipping; no test infra changes. --- .../main/java/app/gamenative/ui/PluviaMain.kt | 43 +++++++------ .../app/gamenative/utils/ContainerUtils.kt | 61 +++---------------- .../java/app/gamenative/utils/SteamUtils.kt | 22 ++++++- 3 files changed, 55 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 6124164e13..466d63cb66 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -792,11 +792,15 @@ fun PluviaMain( } DialogType.SDK_CLOUD_BRIDGE_SUGGESTION -> { - val relaunch = { + val relaunch = { suppressPrompt: Boolean -> preLaunchApp( context = context, appId = state.launchedAppId, - skipBridgePrompt = true, + // Only suppress the prompt next time when we actually persisted a subdir + // (or the user picked "Don't ask again"). If persistence failed, leave + // the prompt enabled so it fires again on the next attempt rather than + // silently disabling itself in memory. + skipBridgePrompt = suppressPrompt, setLoadingDialogVisible = viewModel::setLoadingDialogVisible, setLoadingProgress = viewModel::setLoadingDialogProgress, setLoadingMessage = viewModel::setLoadingDialogMessage, @@ -813,36 +817,37 @@ fun PluviaMain( val rec = runCatching { SteamUtils.getRecommendedSdkCloudSaveSubdirAsync(context, gameId) }.getOrNull() - if (rec != null) { - runCatching { - val container = ContainerUtils.getContainer(context, state.launchedAppId) - container.sdkCloudSaveSubdir = rec.subdir - container.saveData() - }.onFailure { Timber.w(it, "Failed to persist sdkCloudSaveSubdir=${rec.subdir}") } - } - withContext(Dispatchers.Main) { relaunch() } + val persisted = rec != null && runCatching { + val container = ContainerUtils.getContainer(context, state.launchedAppId) + container.sdkCloudSaveSubdir = rec.subdir + container.saveData() + }.onFailure { Timber.w(it, "Failed to persist sdkCloudSaveSubdir=${rec.subdir}") } + .isSuccess + withContext(Dispatchers.Main) { relaunch(persisted) } } } onDismissClick = { - // Skip this time — continue launch without setting the field. + // Skip this time — continue launch without setting the field. Don't suppress + // the prompt on the next launch since we didn't persist anything. msgDialogState = MessageDialogState(false) - relaunch() + relaunch(false) } onActionClick = { // Don't ask again for this game. msgDialogState = MessageDialogState(false) CoroutineScope(Dispatchers.IO).launch { - runCatching { + val persisted = runCatching { val container = ContainerUtils.getContainer(context, state.launchedAppId) container.putExtra("sdkCloudBridgePromptDismissed", "1") container.saveData() }.onFailure { Timber.w(it, "Failed to persist sdkCloudBridgePromptDismissed") } - withContext(Dispatchers.Main) { relaunch() } + .isSuccess + withContext(Dispatchers.Main) { relaunch(persisted) } } } onDismissRequest = { msgDialogState = MessageDialogState(false) - relaunch() + relaunch(false) } } @@ -1639,12 +1644,16 @@ fun preLaunchApp( // That intersection is a reliable Pattern B signal and keeps false positives low. // // Skip the prompt for non-game launches (Open Container / temporary override / explicit - // skipCloudSync). The post-prompt `relaunch` lambda doesn't carry those flags forward, - // so firing the prompt for them would silently turn an Open-Container into a game launch. + // skipCloudSync) and for re-entries from conflict resolution (preferredSave set, or + // ignorePendingOperations true). The post-prompt `relaunch` lambda doesn't carry those + // flags forward, so firing the prompt for them would silently drop the user's + // conflict-resolution choice. if (!skipBridgePrompt && !bootToContainer && !useTemporaryOverride && !skipCloudSync && + !ignorePendingOperations && + preferredSave == SaveLocation.None && gameSource == GameSource.STEAM && container.isLaunchRealSteam && container.sdkCloudSaveSubdir.isBlank() && diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index fb4f412ca8..7e635d809f 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -810,60 +810,15 @@ object ContainerUtils { customConfig } } else { - // Use default config with drives - ContainerData( - screenSize = PrefManager.screenSize, - envVars = PrefManager.envVars, - cpuList = PrefManager.cpuList, - cpuListWoW64 = PrefManager.cpuListWoW64, - graphicsDriver = PrefManager.graphicsDriver, - graphicsDriverVersion = PrefManager.graphicsDriverVersion, - graphicsDriverConfig = PrefManager.graphicsDriverConfig, - dxwrapper = initialDxWrapper, - dxwrapperConfig = PrefManager.dxWrapperConfig, - audioDriver = PrefManager.audioDriver, - wincomponents = PrefManager.winComponents, + // Build the default config from PrefManager and override only what this + // create-site needs to vary (drives + the per-game initial dxwrapper). Inheriting + // from getDefaultContainerData() means new containers automatically pick up + // any new fields added there (localSavesOnly, useSteamInput, sharpnessEffect/Level/ + // Denoise, vibrationMode/Intensity, lsfgEnabled, ...) without this branch needing + // to be edited every time. + getDefaultContainerData().copy( drives = drives, - execArgs = PrefManager.execArgs, - showFPS = false, - launchRealSteam = PrefManager.launchRealSteam, - disableSteamOverlay = PrefManager.disableSteamOverlay, - wow64Mode = PrefManager.wow64Mode, - startupSelection = PrefManager.startupSelection.toByte(), - box86Version = PrefManager.box86Version, - box64Version = PrefManager.box64Version, - box86Preset = PrefManager.box86Preset, - box64Preset = PrefManager.box64Preset, - desktopTheme = WineThemeManager.DEFAULT_DESKTOP_THEME, - language = PrefManager.containerLanguage, - containerVariant = PrefManager.containerVariant, - wineVersion = PrefManager.wineVersion, - emulator = PrefManager.emulator, - fexcoreVersion = PrefManager.fexcoreVersion, - fexcoreTSOMode = PrefManager.fexcoreTSOMode, - fexcoreX87Mode = PrefManager.fexcoreX87Mode, - fexcoreMultiBlock = PrefManager.fexcoreMultiBlock, - fexcorePreset = PrefManager.fexcorePreset, - renderer = PrefManager.renderer, - csmt = PrefManager.csmt, - videoPciDeviceID = PrefManager.videoPciDeviceID, - offScreenRenderingMode = PrefManager.offScreenRenderingMode, - strictShaderMath = PrefManager.strictShaderMath, - useDRI3 = PrefManager.useDRI3, - videoMemorySize = PrefManager.videoMemorySize, - mouseWarpOverride = PrefManager.mouseWarpOverride, - enableXInput = PrefManager.xinputEnabled, - enableDInput = PrefManager.dinputEnabled, - dinputMapperType = PrefManager.dinputMapperType.toByte(), - disableMouseInput = PrefManager.disableMouseInput, - forceDlc = PrefManager.forceDlc, - steamOfflineMode = PrefManager.steamOfflineMode, - useLegacyDRM = PrefManager.useLegacyDRM, - unpackFiles = PrefManager.unpackFiles, - suspendPolicy = PrefManager.suspendPolicy, - portraitMode = PrefManager.portraitMode, - externalDisplayMode = PrefManager.externalDisplayInputMode, - externalDisplaySwap = PrefManager.externalDisplaySwap, + dxwrapper = initialDxWrapper, ) } diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 69d5dd4742..b8c0bbd6f9 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -596,6 +596,12 @@ object SteamUtils { ?: File(imageFs.wineprefix) val steamConfigDir = File(winePrefixBase, "drive_c/Program Files (x86)/Steam/config") try { + // Ensure the Steam config dir exists. On a fresh container the Wine prefix + // skeleton is in place but Steam's own subdir hasn't been laid down yet — + // writeText() would fail with FileNotFoundException without this. + if (!steamConfigDir.isDirectory && !steamConfigDir.mkdirs()) { + Timber.w("autoLoginUserChanges: failed to create $steamConfigDir; loginusers.vdf write may fail") + } File(steamConfigDir, "loginusers.vdf").writeText(vdfFileText) val userRegFile = File(winePrefixBase, "user.reg") val steamRoot = "C:\\Program Files (x86)\\Steam" @@ -1108,10 +1114,15 @@ object SteamUtils { val existingBuildId = parseAcfBuildId(staleAcf) val existingDepots = parseAcfInstalledDepotIds(staleAcf) val existingScripts = parseAcfInstallScriptDepotIds(staleAcf) + val existingOwner = parseAcfLastOwner(staleAcf) val depotsMatch = existingDepots == presentDepotIds val buildIdMatch = existingBuildId == sharedBuildId val scriptsMatch = existingScripts == presentScriptDepotIds - if (depotsMatch && buildIdMatch && scriptsMatch) { + // LastOwner mismatch means the manifest was last written by a different + // signed-in account; reusing it would attribute the install to that account + // and break cloud/ownership checks for the current user. Force a rewrite. + val lastOwnerMatch = existingOwner == lastOwner + if (depotsMatch && buildIdMatch && scriptsMatch && lastOwnerMatch) { val updateResult = parseAcfUpdateResult(staleAcf) if (updateResult == 0L) return staleAcf.delete() @@ -1217,6 +1228,7 @@ object SteamUtils { private val acfBuildIdRegex = Regex("\"buildid\"\\s*\"(\\d+)\"") private val acfUpdateResultRegex = Regex("\"UpdateResult\"\\s*\"(\\d+)\"") + private val acfLastOwnerRegex = Regex("\"LastOwner\"\\s*\"(\\d+)\"") // Matches `"" { "manifest" "" ... }` entries inside the // InstalledDepots block of our own acf output. Tolerant of whitespace / @@ -1244,6 +1256,14 @@ object SteamUtils { } } + private fun parseAcfLastOwner(acf: File): String { + return try { + acfLastOwnerRegex.find(acf.readText())?.groupValues?.get(1).orEmpty() + } catch (_: Exception) { + "" + } + } + private fun parseAcfInstalledDepotIds(acf: File): Set { return try { val text = acf.readText() From 234738417167edc31221e0c68cad8445a39e7b4c Mon Sep 17 00:00:00 2001 From: TideGear Date: Sun, 3 May 2026 21:36:32 -0700 Subject: [PATCH 30/34] fix: address fifth round of PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LudusaviRegistry: refuse to cache an empty parse result; fall through to the stale-disk fallback instead. Guards against silent schema drift in the upstream manifest poisoning both caches with a no-data result for CACHE_TTL_MS. WindowManager.reapLeakedClientWindows: tighten the orphan-chain predicate to match the compositor's signature from dd3987be (blank WM_CLASS, _NET_WM_PID==0, no WM_NAME, no children, parent is non-root with blank className+pid==0). The prior heuristic could destroy a legitimate unmapped popup from the same client that happened to share size and lack WM_CLASS at create time. SteamUtils.verifyRestoredState: fail closed when no steam_api*.dll is present under appDir. Mirrors the existing verifyReplacedState `found` guard so a stale RESTORED marker on a relocated/wiped game dir lets putBackSteamDlls re-attempt the restore on next launch. SteamService.beginLaunchApp: propagate detected real-Steam cross-device pending ops into PostSyncInfo(SyncResult.PendingOperations, …) so the conflict dialog in PluviaMain actually fires. Same-device phantom auto-dismiss is unchanged; the kickPlayingSession call still runs before the new bail. SteamAutoCloud.writeRemoteCache: rewrite remotecache.vdf even when the SteamUserData entry list is empty so a wipe of local saves truncates the manifest. Previously skipped, leaving stale entries on disk. VcRedistStep: replace the container-wide .vcredist_installed marker with per-year sidecars (_2005…_2022 plus _legacy). appliesTo only short-circuits when every required year is already covered; buildCommand skips already- installed years; one-shot legacy-marker migration converts the old marker on first encounter. Tests rewritten. PluviaMain SDK bridge prompt: every dialog handler now calls relaunch(true) so the current relaunch always suppresses; persistence (sdkCloudSaveSubdir, sdkCloudBridgePromptDismissed) still gates future launches via the preLaunchApp gate. The previous relaunch(persisted) / relaunch(false) calls re-fired the dialog when persistence failed or on Skip/Dismiss. XServerScreen graceful Steam exit: fall through to exit() on non-cancellation throwables from winHandler.exec()/awaitSteamShutdown() instead of leaving the game process stuck. XServerScreen env merge: re-pin envVars["WINEPREFIX"] = imageFs.wineprefix after envVars.putAll(container.envVars) so a stale WINEPREFIX baked into container.envVars doesn't redirect Wine to the wrong prefix. WinHandler + XServerScreen process-info listener: add multi-listener support (addOnGetProcessInfoListener / removeOnGetProcessInfoListener, backed by a CopyOnWriteArrayList) and migrate requestWineProcessSnapshot and startExitWatchForUnmappedGameWindow off the single-slot setter so concurrent shutdown polling and unmapped-window watching no longer trample each other's listener registration. Co-Authored-By: Claude Opus 4.7 --- .../app/gamenative/service/SteamAutoCloud.kt | 39 ++--- .../app/gamenative/service/SteamService.kt | 28 ++++ .../main/java/app/gamenative/ui/PluviaMain.kt | 20 +-- .../ui/screen/xserver/XServerScreen.kt | 32 +++-- .../app/gamenative/utils/LudusaviRegistry.kt | 9 +- .../app/gamenative/utils/PreInstallSteps.kt | 30 +++- .../java/app/gamenative/utils/SteamUtils.kt | 9 ++ .../utils/preInstallSteps/VcRedistStep.kt | 136 +++++++++++++++++- .../com/winlator/winhandler/WinHandler.java | 45 +++++- .../com/winlator/xserver/WindowManager.java | 13 ++ .../utils/preInstallSteps/VcRedistStepTest.kt | 87 ++++++++++- 11 files changed, 390 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index b5ad234794..d19a1f2fe0 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -219,24 +219,29 @@ object SteamAutoCloud { // ISteamRemoteStorage indexes the files for SDK-cloud games. With cloudenabled=0 // Wine-Steam doesn't rescan remote/ on startup — it trusts this index. val writeRemoteCache: (Map>, Long) -> Unit = { localMap, changeNumber -> - val entries = localMap.values.flatten() - .filter { it.root == PathType.SteamUserData } - .mapNotNull { file -> - val absPath = file.getAbsPath(prefixToPath) - try { - if (!Files.exists(absPath)) return@mapNotNull null - SteamUtils.RemoteCacheFile( - relativePath = file.filename, - size = Files.size(absPath), - timeSeconds = file.timestamp / 1000, - shaHex = file.sha.joinToString("") { "%02x".format(it) }, - ) - } catch (e: Exception) { - Timber.w(e, "Skipping remotecache entry for ${file.filename}") - null + // Auto-Cloud apps don't use SteamUserData/remote/ at all, so there's no + // remotecache.vdf to maintain for them — skip the write entirely. + if (autoCloudPatterns.isEmpty()) { + val entries = localMap.values.flatten() + .filter { it.root == PathType.SteamUserData } + .mapNotNull { file -> + val absPath = file.getAbsPath(prefixToPath) + try { + if (!Files.exists(absPath)) return@mapNotNull null + SteamUtils.RemoteCacheFile( + relativePath = file.filename, + size = Files.size(absPath), + timeSeconds = file.timestamp / 1000, + shaHex = file.sha.joinToString("") { "%02x".format(it) }, + ) + } catch (e: Exception) { + Timber.w(e, "Skipping remotecache entry for ${file.filename}") + null + } } - } - if (entries.isNotEmpty()) { + // Always write — including with an empty entry list — so a wipe of local + // saves truncates the manifest instead of leaving stale entries on disk + // for the next sync to (incorrectly) trust. SteamUtils.writeRemoteCacheVdf( remoteDir = File(steamUserDataBase), appId = appInfo.id, diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index bf5b4025dd..60fb83c93f 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -89,6 +89,7 @@ import `in`.dragonbra.javasteam.steam.handlers.steamapps.License import `in`.dragonbra.javasteam.steam.handlers.steamapps.PICSRequest import `in`.dragonbra.javasteam.steam.handlers.steamapps.SteamApps import `in`.dragonbra.javasteam.steam.handlers.steamapps.callback.LicenseListCallback +import `in`.dragonbra.javasteam.steam.handlers.steamcloud.PendingRemoteOperation import `in`.dragonbra.javasteam.steam.handlers.steamcloud.SteamCloud import `in`.dragonbra.javasteam.steam.handlers.steamfriends.SteamFriends import `in`.dragonbra.javasteam.steam.handlers.steamfriends.callback.PersonaStateCallback @@ -2305,6 +2306,15 @@ class SteamService : Service(), IChallengeUrlChanged { // the server that we completed uploads and reset cloud ChangeNumber to 0 — // Wine-Steam then "forgot" steam_autocloud.vdf and games with Steamworks // cloud integration (e.g. 868-HACK) exited with code 1. + // + // Cross-device pending ops surfaced by the real-Steam probe are also + // captured here. The main sync flow below skips signalAppLaunchIntent in + // real-Steam mode (so Wine-Steam's own localhost intent isn't fenced by + // ours), which makes the probe the ONLY chance to observe cross-device + // conflicts on this path; we propagate them into PostSyncInfo so the + // SYNC_CONFLICT dialog can fire. + var realSteamCrossDevicePending: List = emptyList() + if (isLaunchRealSteam) { runCatching { instance?._steamUser?.kickPlayingSession() } .onFailure { Timber.w(it, "Proactive kickPlayingSession before real-Steam launch failed") } @@ -2346,6 +2356,12 @@ class SteamService : Service(), IChallengeUrlChanged { crossDevice.size, crossDevice.joinToString { "${it.machineName}:${it.operation.name}" }, ) + // Capture for propagation; if the caller passed + // ignorePendingOperations=true (user already chose Play + // Anyway in a prior pass), don't re-surface the dialog. + if (!ignorePendingOperations) { + realSteamCrossDevicePending = crossDevice + } } else if (selfPhantoms.isNotEmpty()) { steamCloud.signalAppLaunchIntent( appId = appId, @@ -2374,6 +2390,18 @@ class SteamService : Service(), IChallengeUrlChanged { } } + // If the real-Steam probe surfaced cross-device pending ops, short-circuit + // before any further cloud work and let the conflict UI run. The main sync + // flow below skips signalAppLaunchIntent in real-Steam mode, so without this + // bail the detected ops would never reach PostSyncInfo.pendingRemoteOperations. + if (realSteamCrossDevicePending.isNotEmpty()) { + releaseSync(appId) + return@async PostSyncInfo( + syncResult = SyncResult.PendingOperations, + pendingRemoteOperations = realSteamCrossDevicePending, + ) + } + try { // Migrate GSE saves -> Steam userdata layout. Inside the try so any // exception still flows through finally { releaseSync(appId) }. diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 43dabb5465..c966c4faae 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -801,10 +801,9 @@ fun PluviaMain( preLaunchApp( context = context, appId = state.launchedAppId, - // Only suppress the prompt next time when we actually persisted a subdir - // (or the user picked "Don't ask again"). If persistence failed, leave - // the prompt enabled so it fires again on the next attempt rather than - // silently disabling itself in memory. + // Always suppress for the current relaunch so the dialog doesn't immediately + // resurface and loop; persistence (sdkCloudSaveSubdir / sdkCloudBridgePromptDismissed) + // is what gates future launches. skipBridgePrompt = suppressPrompt, setLoadingDialogVisible = viewModel::setLoadingDialogVisible, setLoadingProgress = viewModel::setLoadingDialogProgress, @@ -828,14 +827,15 @@ fun PluviaMain( container.saveData() }.onFailure { Timber.w(it, "Failed to persist sdkCloudSaveSubdir=${rec.subdir}") } .isSuccess - withContext(Dispatchers.Main) { relaunch(persisted) } + withContext(Dispatchers.Main) { relaunch(true) } } } onDismissClick = { - // Skip this time — continue launch without setting the field. Don't suppress - // the prompt on the next launch since we didn't persist anything. + // Skip this time — continue launch without setting the field. Always suppress + // for the current relaunch so the dialog doesn't reappear; since nothing was + // persisted, future launches will still re-prompt. msgDialogState = MessageDialogState(false) - relaunch(false) + relaunch(true) } onActionClick = { // Don't ask again for this game. @@ -847,12 +847,12 @@ fun PluviaMain( container.saveData() }.onFailure { Timber.w(it, "Failed to persist sdkCloudBridgePromptDismissed") } .isSuccess - withContext(Dispatchers.Main) { relaunch(persisted) } + withContext(Dispatchers.Main) { relaunch(true) } } } onDismissRequest = { msgDialogState = MessageDialogState(false) - relaunch(false) + relaunch(true) } } 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 c33d34dc48..db5be4ccd0 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 @@ -311,14 +311,16 @@ private fun buildEssentialProcessAllowlist(): Set { } private suspend fun requestWineProcessSnapshot(winHandler: WinHandler): List? { - val previousListener = winHandler.getOnGetProcessInfoListener() val lock = Any() var currentList = mutableListOf() var expectedCount = 0 val deferred = CompletableDeferred?>() + // Register via add/removeOnGetProcessInfoListener so we don't clobber + // other concurrent watchers (e.g. startExitWatchForUnmappedGameWindow, + // or another awaitSteamShutdown poll on the same WinHandler) that also + // need process-info events. val listener = OnGetProcessInfoListener { index, count, processInfo -> - previousListener?.onGetProcessInfo(index, count, processInfo) synchronized(lock) { if (count == 0 && processInfo == null) { if (!deferred.isCompleted) deferred.complete(emptyList()) @@ -342,13 +344,13 @@ private suspend fun requestWineProcessSnapshot(winHandler: WinHandler): List?>? = null var currentList = mutableListOf() var expectedCount = 0 + // Register via add/removeOnGetProcessInfoListener so that a concurrent + // awaitSteamShutdown (or other process-info consumer) can coexist + // without each install/remove cycle clobbering the other. val listener = OnGetProcessInfoListener { index, count, processInfo -> - previousListener?.onGetProcessInfo(index, count, processInfo) synchronized(lock) { val deferred = pendingSnapshot ?: return@synchronized if (count == 0 && processInfo == null) { @@ -852,7 +855,7 @@ fun XServerScreen( } } - winHandler.setOnGetProcessInfoListener(listener) + winHandler.addOnGetProcessInfoListener(listener) try { val startTime = System.currentTimeMillis() while (System.currentTimeMillis() - startTime < EXIT_PROCESS_TIMEOUT_MS) { @@ -886,7 +889,7 @@ fun XServerScreen( delay(EXIT_PROCESS_POLL_INTERVAL_MS) } } finally { - winHandler.setOnGetProcessInfoListener(previousListener) + winHandler.removeOnGetProcessInfoListener(listener) synchronized(lock) { pendingSnapshot = null } @@ -1312,8 +1315,12 @@ fun XServerScreen( delay(GRACEFUL_EXIT_GRACE_MS) } exit(winHandler, frameRating, currentAppInfo, container, appId, onExit, navigateBack) - } catch (_: kotlinx.coroutines.CancellationException) { + } catch (ce: kotlinx.coroutines.CancellationException) { // Force-quit path already called exit(). + throw ce + } catch (t: Throwable) { + Timber.w(t, "graceful Steam exit failed, falling through to exit()") + exit(winHandler, frameRating, currentAppInfo, container, appId, onExit, navigateBack) } finally { steamShutdownDialogResolver?.let { if (!it.isCompleted) it.cancel() } steamShutdownDialogResolver = null @@ -3243,6 +3250,13 @@ private fun setupXEnvironment( guestProgramLauncherComponent.setSteamType(container.getSteamType()) envVars.putAll(container.envVars) + // putAll above can overwrite the per-container WINEPREFIX we set earlier + // (line ~3135) if container.envVars carries a stale value. Re-pin it to + // the resolved imageFs.wineprefix so the launch always uses the prefix + // we actually prepared for this container. + if (!imageFs.wineprefix.isNullOrEmpty()) { + envVars.put("WINEPREFIX", imageFs.wineprefix) + } if (!envVars.has("WINEESYNC")) envVars.put("WINEESYNC", "1") // Disable the Steam overlay end-to-end when the user opts out: diff --git a/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt b/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt index 1f8ccb8336..6dc499306f 100644 --- a/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt +++ b/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt @@ -83,8 +83,13 @@ object LudusaviRegistry { .onFailure { Timber.w(it, "LudusaviRegistry: disk cache parse failed; will refetch") } } - val fetched = fetchAndFilter() ?: run { - // Fall back to a stale disk cache if fetch failed and one exists + val fetched = fetchAndFilter() + // Treat an empty parse as failure: guards against silent schema drift in the + // upstream manifest poisoning both caches with a no-data result for CACHE_TTL_MS. + if (fetched.isNullOrEmpty()) { + if (fetched != null) { + Timber.w("LudusaviRegistry: parser returned 0 entries; refusing to cache (likely schema drift)") + } if (cacheFile.isFile) { runCatching { parseCacheJson(cacheFile.readText()) } .onSuccess { diff --git a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt index 830cbbaff3..c16a8c46b0 100644 --- a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt +++ b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt @@ -52,7 +52,10 @@ object PreInstallSteps { if (containerVariantChanged) { resetMarkers(gameDirPath) - container.rootDir?.absolutePath?.let { resetMarkers(it) } + container.rootDir?.absolutePath?.let { containerRoot -> + resetMarkers(containerRoot) + resetVcRedistVersionMarkers(containerRoot) + } } val commands = mutableListOf() @@ -98,11 +101,13 @@ object PreInstallSteps { MarkerUtils.addMarker(gameDirPath, marker) // Also persist container-scoped prereqs at the Wine prefix root so a // game reinstall doesn't force a redundant re-run of an installer that - // already landed system-wide. Matches the appliesTo check in VcRedistStep. + // already landed system-wide. For vcredist this is keyed per-year via + // VcRedistStep.recordInstalledVersions so a later game bundling a + // different MSVC year still triggers an install (just for the missing + // years), instead of being short-circuited by a coarse container-wide + // marker. if (marker == Marker.VCREDIST_INSTALLED) { - container.rootDir?.absolutePath?.let { containerRoot -> - MarkerUtils.addMarker(containerRoot, marker) - } + VcRedistStep.recordInstalledVersions(container, gameDir) } } @@ -112,6 +117,21 @@ object PreInstallSteps { } } + /** + * Clears per-year vcredist sidecar markers (".vcredist_installed_") + * at the container root. Called when the container variant changes so the + * Wine prefix gets re-seeded with the right redistributables. + */ + private fun resetVcRedistVersionMarkers(containerRoot: String) { + val dir = java.io.File(containerRoot) + val files = dir.listFiles() ?: return + for (f in files) { + if (f.isFile && f.name.startsWith(".vcredist_installed_")) { + runCatching { f.delete() } + } + } + } + private fun wrapAsGuestExecutable(cmdChain: String, screenInfo: String): String { val wrapped = "winhandler.exe cmd /c \"$cmdChain & taskkill /F /IM explorer.exe & wineserver -k\"" return "wine explorer /desktop=shell,$screenInfo $wrapped" diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 4a94b66d1d..4af6e8860d 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -300,10 +300,12 @@ object SteamUtils { private fun verifyRestoredState(context: Context, appDirPath: String): Boolean { return try { val assetHashes = pipeDllHashes(context) + var found = false Paths.get(appDirPath).toFile().walkTopDown().maxDepth(10).forEach { file -> if (!file.isFile) return@forEach val n = file.name.lowercase() if (n == "steam_api.dll" || n == "steam_api64.dll") { + found = true val pipeHash = assetHashes[n] if (pipeHash == null) { // Missing pipe-asset hash means we can't tell whether this is the pipe @@ -317,6 +319,13 @@ object SteamUtils { } } } + if (!found) { + // No steam_api*.dll on disk — marker is stale (game dir moved/deleted/relocated). + // Fail closed so putBackSteamDlls() runs on next launch; it's a safe no-op when + // there are no .orig backups either (some games genuinely don't ship the DLL). + Timber.w("DLL marker desync: no steam_api DLL found under %s but RESTORED marker present", appDirPath) + return false + } true } catch (e: Exception) { Timber.w(e, "verifyRestoredState failed, treating as desync") diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt index f3273d45e4..bfc0f6cdbe 100644 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt @@ -54,21 +54,69 @@ private val vcRedistMap: Map = mapOf( object VcRedistStep : PreInstallStep { override val marker: Marker = Marker.VCREDIST_INSTALLED + /** + * Per-version marker prefix written at the container root so we can tell + * which redistributable years are already installed system-wide. Pairs + * with [Marker.VCREDIST_INSTALLED] (which marks the step "complete" for + * the current game) but is keyed per-year so a later game bundling a + * different year is not silently skipped. + */ + private const val PER_VERSION_MARKER_PREFIX = ".vcredist_installed_" + override fun appliesTo( container: Container, gameSource: GameSource, gameDirPath: String, ): Boolean { - // vcredist installs system-wide into the Wine prefix, not per-game. Check a - // container-root marker first so reinstalling the game (which wipes the game - // dir) doesn't force a redundant re-run + wineserver kill on every launch. + // vcredist installs system-wide into the Wine prefix, not per-game. We + // track installed years at the container root (one marker per year) + // so reinstalling the game (which wipes the game dir) doesn't force a + // redundant re-run, while still detecting when a *different* game + // bundles a year we have not yet installed. + val gameDir = File(gameDirPath) + val required = requiredVersions(gameDir) + if (required.isEmpty()) { + // Nothing to install: treat the step as satisfied. + return false + } val containerRoot = container.rootDir?.absolutePath - if (containerRoot != null && MarkerUtils.hasMarker(containerRoot, Marker.VCREDIST_INSTALLED)) { + migrateLegacyContainerMarker(containerRoot, required) + val installed = installedVersions(containerRoot) + val missing = required - installed + if (missing.isEmpty()) { + // All required years already covered by container-level markers. return false } + // Fall back to the legacy game-dir marker so an in-flight install + // that already ran for this exact game directory still short-circuits. return !MarkerUtils.hasMarker(gameDirPath, Marker.VCREDIST_INSTALLED) } + /** + * One-shot migration for containers that were tagged by the previous + * coarse `.vcredist_installed` marker at the prefix root: convert it + * into per-year sidecars covering [requiredForCurrentGame], then drop + * the legacy file. Years not bundled by the current game are *not* + * marked installed, since we have no way to know if the previous + * launch's game actually installed them. Worst case the next launch + * re-runs one installer once. + */ + private fun migrateLegacyContainerMarker( + containerRoot: String?, + requiredForCurrentGame: Set, + ) { + if (containerRoot == null) return + val legacy = File(containerRoot, Marker.VCREDIST_INSTALLED.fileName) + if (!legacy.isFile) return + for (version in requiredForCurrentGame) { + val marker = File(containerRoot, "$PER_VERSION_MARKER_PREFIX$version") + if (!marker.exists()) { + runCatching { marker.createNewFile() } + } + } + runCatching { legacy.delete() } + } + override fun buildCommand( container: Container, appId: String, @@ -76,6 +124,8 @@ object VcRedistStep : PreInstallStep { gameDir: File, gameDirPath: String, ): String? { + val containerRoot = container.rootDir?.absolutePath + val installed = installedVersions(containerRoot) val parts = mutableListOf() for ((winPath, args) in vcRedistMap) { if (winPath.length < 4 || winPath[1] != ':' || winPath[2] != '\\') continue @@ -84,9 +134,85 @@ object VcRedistStep : PreInstallStep { if (lastSep < 0) continue val hostFile = File(gameDir, rest.replace('\\', '/')) if (!hostFile.isFile) continue + // Skip installer entries for years already installed system-wide + // so we don't pop a fresh installer window per launch (the + // 963d7999 fix). Years we haven't seen still run. + val version = versionKey(winPath) + if (version != null && installed.contains(version)) continue parts.add(if (args.isEmpty()) winPath else "$winPath $args") } return if (parts.isEmpty()) null else parts.joinToString(" & ") } -} + /** + * Persist per-version markers for the years detected in [gameDir] at the + * container root. Called after the install step completes so future + * launches (and other games sharing the container) know which years are + * already covered without having to rerun any installer. + */ + fun recordInstalledVersions(container: Container, gameDir: File) { + val containerRoot = container.rootDir?.absolutePath ?: return + val versions = requiredVersions(gameDir) + for (version in versions) { + val marker = File(containerRoot, "$PER_VERSION_MARKER_PREFIX$version") + if (!marker.exists()) { + runCatching { marker.createNewFile() } + } + } + } + + /** The set of redistributable years bundled with the game at [gameDir]. */ + private fun requiredVersions(gameDir: File): Set { + val out = linkedSetOf() + for (winPath in vcRedistMap.keys) { + if (winPath.length < 4 || winPath[1] != ':' || winPath[2] != '\\') continue + val rest = winPath.substring(3) + if (rest.lastIndexOf('\\') < 0) continue + val hostFile = File(gameDir, rest.replace('\\', '/')) + if (!hostFile.isFile) continue + versionKey(winPath)?.let { out.add(it) } + } + return out + } + + /** Read the set of years already installed system-wide in the container. */ + private fun installedVersions(containerRoot: String?): Set { + if (containerRoot == null) return emptySet() + val dir = File(containerRoot) + if (!dir.isDirectory) return emptySet() + val files = dir.listFiles() ?: return emptySet() + val out = linkedSetOf() + for (f in files) { + val name = f.name + if (f.isFile && name.startsWith(PER_VERSION_MARKER_PREFIX)) { + out.add(name.substring(PER_VERSION_MARKER_PREFIX.length)) + } + } + return out + } + + /** + * Map an installer's Windows path to a stable version key. Recognises + * year-suffixed folders ("2005".."2022"). Paths with no clear year + * (legacy `A:\redist\…`, root-level `A:\_CommonRedist\VC_redist.*`) map + * to "legacy" so they are still tracked, just with a single shared key. + */ + private fun versionKey(winPath: String): String? { + val lower = winPath.lowercase() + for (year in YEAR_KEYS) { + if (lower.contains("\\$year\\") || lower.contains("\\msvc$year\\") || + lower.contains("\\msvc${year}_x64\\") + ) { + return year + } + } + // Generic / yearless installers — track under a shared key so they + // don't all collapse to "no key" and bypass the marker check. + return "legacy" + } + + private val YEAR_KEYS = listOf( + "2005", "2008", "2010", "2012", "2013", + "2015", "2017", "2019", "2022", + ) +} diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index e4b6f904b9..def0b8cbb1 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -64,6 +64,13 @@ public class WinHandler { private boolean initReceived; private InetAddress localhost; private OnGetProcessInfoListener onGetProcessInfoListener; + // Additional listeners that subscribe via add/removeOnGetProcessInfoListener. + // The single-slot setter above is kept for backwards compatibility; the + // dispatch path forwards to the slot AND every listener in this list, so + // multiple concurrent watchers (e.g. awaitSteamShutdown polling at the + // same time as startExitWatchForUnmappedGameWindow) no longer clobber + // each other's listener installation. + private final CopyOnWriteArrayList extraProcessInfoListeners = new CopyOnWriteArrayList<>(); private PreferredInputApi preferredInputApi; private final ByteBuffer receiveData; private final DatagramPacket receivePacket; @@ -240,12 +247,17 @@ public void killProcess(final String processName, final int pid) { public void listProcesses() { addAction(() -> { - OnGetProcessInfoListener onGetProcessInfoListener; this.sendData.rewind(); this.sendData.put(RequestCodes.LIST_PROCESSES); this.sendData.putInt(0); - if (!sendPacket(CLIENT_PORT) && (onGetProcessInfoListener = this.onGetProcessInfoListener) != null) { - onGetProcessInfoListener.onGetProcessInfo(0, 0, null); + if (!sendPacket(CLIENT_PORT)) { + OnGetProcessInfoListener slotListener = this.onGetProcessInfoListener; + if (slotListener != null) { + slotListener.onGetProcessInfo(0, 0, null); + } + for (OnGetProcessInfoListener l : extraProcessInfoListeners) { + l.onGetProcessInfo(0, 0, null); + } } }); } @@ -349,6 +361,23 @@ public void setOnGetProcessInfoListener(OnGetProcessInfoListener onGetProcessInf } } + /** + * Register an additional listener that will be notified for every process-info + * event. Unlike {@link #setOnGetProcessInfoListener}, multiple listeners can + * coexist; each caller manages its own registration via + * {@link #removeOnGetProcessInfoListener}. This is the preferred API when more + * than one component polls process info concurrently. + */ + public void addOnGetProcessInfoListener(OnGetProcessInfoListener listener) { + if (listener == null) return; + extraProcessInfoListeners.addIfAbsent(listener); + } + + public void removeOnGetProcessInfoListener(OnGetProcessInfoListener listener) { + if (listener == null) return; + extraProcessInfoListeners.remove(listener); + } + private void startSendThread() { Executors.newSingleThreadExecutor().execute(() -> { while (this.running) { @@ -388,7 +417,7 @@ private void handleRequest(byte requestCode, final int port) throws IOException } return; case RequestCodes.GET_PROCESS: - if (this.onGetProcessInfoListener == null) { + if (this.onGetProcessInfoListener == null && extraProcessInfoListeners.isEmpty()) { return; } ByteBuffer byteBuffer = this.receiveData; @@ -402,7 +431,13 @@ private void handleRequest(byte requestCode, final int port) throws IOException byte[] bytes = new byte[32]; this.receiveData.get(bytes); String name = StringUtils.fromANSIString(bytes); - this.onGetProcessInfoListener.onGetProcessInfo(index, numProcesses, new ProcessInfo(pid, name, memoryUsage, affinityMask, wow64Process)); + ProcessInfo info = new ProcessInfo(pid, name, memoryUsage, affinityMask, wow64Process); + if (this.onGetProcessInfoListener != null) { + this.onGetProcessInfoListener.onGetProcessInfo(index, numProcesses, info); + } + for (OnGetProcessInfoListener l : extraProcessInfoListeners) { + l.onGetProcessInfo(index, numProcesses, info); + } return; case RequestCodes.GET_GAMEPAD: boolean isXInput = this.receiveData.get() == 1; diff --git a/app/src/main/java/com/winlator/xserver/WindowManager.java b/app/src/main/java/com/winlator/xserver/WindowManager.java index c786d64328..cd33ce93ec 100644 --- a/app/src/main/java/com/winlator/xserver/WindowManager.java +++ b/app/src/main/java/com/winlator/xserver/WindowManager.java @@ -222,6 +222,19 @@ private void reapLeakedClientWindows(Window created) { // Don't reap an ancestor of `created`; destroyWindow recurses into descendants // so this would destroy `created` too. if (ancestors.contains(cand.id)) continue; + // Tightened orphan-chain signature: mirror the compositor filter from + // dd3987be ("skip orphaned Wine GLX leak chain in compositor"). The proven + // leak marker is blank WM_CLASS + _NET_WM_PID==0 reparented under a 1x1 + // blank-className/pid=0 orphanage. Without these extra predicates the + // reaper could destroy a legitimate unmapped popup from the same client + // that happens to share size and lack WM_CLASS at create time. + if (cand.getProcessId() != 0) continue; + if (!cand.getName().isEmpty()) continue; + if (!cand.getChildren().isEmpty()) continue; + Window candParent = cand.getParent(); + if (candParent == null || candParent == rootWindow) continue; + if (!candParent.getClassName().isEmpty()) continue; + if (candParent.getProcessId() != 0) continue; matches.add(cand); } if (matches.size() < LEAK_CLIENT_CAP) return; diff --git a/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt b/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt index 5d6144e0bf..6229e9bf44 100644 --- a/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt +++ b/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt @@ -19,31 +19,79 @@ import kotlin.io.path.createTempDirectory class VcRedistStepTest { private lateinit var container: Container private lateinit var gameDir: File + private lateinit var containerRoot: File @Before fun setUp() { container = mockk(relaxed = true) gameDir = createTempDirectory(prefix = "vcredist-step-test").toFile() + containerRoot = createTempDirectory(prefix = "vcredist-container-test").toFile() + every { container.rootDir } returns containerRoot + } + + private fun seedInstaller(year: String = "MSVC2017", filename: String = "VC_redist.x86.exe"): File { + val installer = File(gameDir, "_CommonRedist/$year/$filename") + installer.parentFile?.mkdirs() + installer.writeText("dummy") + return installer } @Test - fun appliesTo_returnsTrue_whenMarkerMissing() { + fun appliesTo_returnsTrue_whenInstallerPresentAndNoMarker() { + seedInstaller() val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) assertTrue(applies) } @Test - fun appliesTo_returnsFalse_whenMarkerExists() { + fun appliesTo_returnsFalse_whenNoInstallersBundled() { + // Game ships no vcredist installers — nothing to do, step does not apply. + val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) + assertFalse(applies) + } + + @Test + fun appliesTo_returnsFalse_whenGameDirMarkerExists() { + seedInstaller() MarkerUtils.addMarker(gameDir.absolutePath, Marker.VCREDIST_INSTALLED) val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) assertFalse(applies) } + @Test + fun appliesTo_returnsFalse_whenAllRequiredYearsAlreadyInstalledInContainer() { + seedInstaller(year = "MSVC2017") + File(containerRoot, ".vcredist_installed_2017").createNewFile() + val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) + assertFalse(applies) + } + + @Test + fun appliesTo_returnsTrue_whenContainerHasDifferentYearMarker() { + // Previous game installed 2015; current game bundles 2019. The + // container-level 2015 marker must not short-circuit the 2019 install. + seedInstaller(year = "MSVC2019") + File(containerRoot, ".vcredist_installed_2015").createNewFile() + val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) + assertTrue(applies) + } + + @Test + fun appliesTo_migratesLegacyContainerMarker_andSkipsForCurrentGame() { + // Pre-fix containers may have a coarse `.vcredist_installed` file at + // the prefix root. We migrate it to per-year sidecars covering what + // the current game bundles, then skip the install. + seedInstaller(year = "MSVC2017") + File(containerRoot, Marker.VCREDIST_INSTALLED.fileName).createNewFile() + val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) + assertFalse(applies) + assertTrue(File(containerRoot, ".vcredist_installed_2017").isFile) + assertFalse(File(containerRoot, Marker.VCREDIST_INSTALLED.fileName).exists()) + } + @Test fun buildCommand_returnsCommand_forDetectedInstaller() { - val installer = File(gameDir, "_CommonRedist/MSVC2017/VC_redist.x86.exe") - installer.parentFile?.mkdirs() - installer.writeText("dummy") + seedInstaller() val cmd = VcRedistStep.buildCommand( container = container, @@ -56,4 +104,33 @@ class VcRedistStepTest { val expected = "A:\\_CommonRedist\\MSVC2017\\VC_redist.x86.exe /install /passive /norestart" assertEquals(expected, checkNotNull(cmd)) } + + @Test + fun buildCommand_skipsAlreadyInstalledYears() { + seedInstaller(year = "MSVC2017", filename = "VC_redist.x86.exe") + seedInstaller(year = "MSVC2019", filename = "VC_redist.x86.exe") + File(containerRoot, ".vcredist_installed_2017").createNewFile() + + val cmd = VcRedistStep.buildCommand( + container = container, + appId = "STEAM_1", + gameSource = GameSource.STEAM, + gameDir = gameDir, + gameDirPath = gameDir.absolutePath, + ) + + val expected = "A:\\_CommonRedist\\MSVC2019\\VC_redist.x86.exe /install /passive /norestart" + assertEquals(expected, checkNotNull(cmd)) + } + + @Test + fun recordInstalledVersions_writesPerYearMarkers() { + seedInstaller(year = "MSVC2017") + seedInstaller(year = "MSVC2022") + + VcRedistStep.recordInstalledVersions(container, gameDir) + + assertTrue(File(containerRoot, ".vcredist_installed_2017").isFile) + assertTrue(File(containerRoot, ".vcredist_installed_2022").isFile) + } } From b233279599151c1d1bb1df0d9e6b0eb0c04d5b08 Mon Sep 17 00:00:00 2001 From: TideGear Date: Sun, 3 May 2026 22:29:21 -0700 Subject: [PATCH 31/34] fix: address sixth round of PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VcRedistStep: per-year markers now also encode architecture (`$year-x86` / `$year-x64`, plus `legacy-x86` / `legacy-x64` for yearless paths). Without this, an x86-only install (e.g. vc_redist.x86.exe for 2015) wrote a year-only marker that incorrectly suppressed the matching x64 install on a later game. Yearless redists (A:\redist\vcredist_*, A:\_CommonRedist\VC_redist.*) are similarly distinguished. archKey() inspects the win path for x64/amd64/wow64/ x86_64 hints (and explicit .x64./.x86. filenames) and defaults to x86 for ambiguous paths. Round-5 archless markers are migrated as `_$year-x86` only — conservative: any actual x64 install will re-run once. appliesTo no longer short-circuits on the coarse game-dir VCREDIST_INSTALLED marker when per-year/ arch markers indicate a missing (year, arch) pair; the game-dir marker is now only a fallback when no per-year markers exist or containerRoot is null. WinHandler.GET_PROCESS dispatch: each listener invocation (slot listener and each multi-listener entry) is now wrapped in try/catch so one bad listener can't kill the worker thread. Same fix applied to the listProcesses() send- failure broadcast. WinHandler.exec(String): handle quoted paths. A leading `"` triggers quoted- path parsing (filename = quoted substring; parameters = trimmed remainder); unquoted commands still split on the first whitespace. Without this, "C:\Program Files (x86)\Steam\steam.exe" -shutdown shredded into a bogus filename of "C:\Program". XServerScreen.requestWineProcessSnapshot: distinguish request/send failure from a legitimate empty process list. WinHandler.listProcesses() emits (0, 0, null) only when sendPacket() fails — Wine itself never broadcasts empty. Added ProcessSnapshotException, listener completes deferred exceptionally on (count==0 && info==null), await maps it to null so isSteamExeAlive(null) == true keeps the polling loop alive instead of falsely concluding Steam exited. WinHandler + XServerScreen: add a snapshot-collection mutex (processSnapshotMutex). Both Kotlin pollers (requestWineProcessSnapshot and startExitWatchForUnmappedGameWindow) now serialize their add-listener → listProcesses → await → remove-listener sequence so concurrent listProcesses calls don't cross-pollute each other's deferreds. Multi-listener support is preserved for long-running watchers; only the per-iteration snapshot is serialized. LudusaviRegistry.ensureLoaded: extend the empty-set guard to the disk-cache fallback. A poisoned `{}` cache file (e.g., one written before the round-5 empty-fetch guard shipped) used to populate memoryCache with emptyMap(), after which the fast path short-circuited forever. Now: if the parsed disk cache is empty, log and delete the poisoned file (best-effort), fall through to null so the next ensureLoaded retries the fetch. Parser exceptions still leave the file alone. WindowManager.reapLeakedClientWindows: add the 1x1 parent-size predicate to match the compositor's full orphan-chain marker (dd3987be). Without it, a legitimate window whose parent happens to be blank-class+pid==0 but is NOT a 1x1 orphanage stub could still be reaped. PluviaMain SDK bridge prompt: drop the dead `persisted` boolean computed in onConfirmClick / onActionClick (round-5 changed every relaunch(...) to relaunch(true), so the Boolean is no longer read). Side effects and the existing onFailure Timber.w log are preserved. Co-Authored-By: Claude Opus 4.7 --- .../main/java/app/gamenative/ui/PluviaMain.kt | 16 ++-- .../ui/screen/xserver/XServerScreen.kt | 56 ++++++++---- .../app/gamenative/utils/LudusaviRegistry.kt | 16 +++- .../utils/preInstallSteps/VcRedistStep.kt | 83 +++++++++++++----- .../com/winlator/winhandler/WinHandler.java | 67 +++++++++++++-- .../com/winlator/xserver/WindowManager.java | 4 + .../utils/preInstallSteps/VcRedistStepTest.kt | 86 +++++++++++++++++-- 7 files changed, 262 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index c966c4faae..35c6fa6653 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -821,12 +821,13 @@ fun PluviaMain( val rec = runCatching { SteamUtils.getRecommendedSdkCloudSaveSubdirAsync(context, gameId) }.getOrNull() - val persisted = rec != null && runCatching { - val container = ContainerUtils.getContainer(context, state.launchedAppId) - container.sdkCloudSaveSubdir = rec.subdir - container.saveData() - }.onFailure { Timber.w(it, "Failed to persist sdkCloudSaveSubdir=${rec.subdir}") } - .isSuccess + if (rec != null) { + runCatching { + val container = ContainerUtils.getContainer(context, state.launchedAppId) + container.sdkCloudSaveSubdir = rec.subdir + container.saveData() + }.onFailure { Timber.w(it, "Failed to persist sdkCloudSaveSubdir=${rec.subdir}") } + } withContext(Dispatchers.Main) { relaunch(true) } } } @@ -841,12 +842,11 @@ fun PluviaMain( // Don't ask again for this game. msgDialogState = MessageDialogState(false) CoroutineScope(Dispatchers.IO).launch { - val persisted = runCatching { + runCatching { val container = ContainerUtils.getContainer(context, state.launchedAppId) container.putExtra("sdkCloudBridgePromptDismissed", "1") container.saveData() }.onFailure { Timber.w(it, "Failed to persist sdkCloudBridgePromptDismissed") } - .isSuccess withContext(Dispatchers.Main) { relaunch(true) } } } 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 db5be4ccd0..4d7a68ca21 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 @@ -177,6 +177,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONException @@ -310,20 +311,27 @@ private fun buildEssentialProcessAllowlist(): Set { return (essentialServices + CORE_WINE_PROCESSES).toSet() } +private class ProcessSnapshotException(msg: String) : Exception(msg) + private suspend fun requestWineProcessSnapshot(winHandler: WinHandler): List? { val lock = Any() var currentList = mutableListOf() var expectedCount = 0 val deferred = CompletableDeferred?>() - // Register via add/removeOnGetProcessInfoListener so we don't clobber - // other concurrent watchers (e.g. startExitWatchForUnmappedGameWindow, - // or another awaitSteamShutdown poll on the same WinHandler) that also - // need process-info events. val listener = OnGetProcessInfoListener { index, count, processInfo -> synchronized(lock) { + // (0, 0, null) is only emitted by WinHandler.listProcesses() when + // the underlying UDP send fails; wine itself never broadcasts an + // empty list. Treating it as a successful empty snapshot would let + // awaitSteamShutdown conclude Steam exited on a transient send + // failure, so surface it as an exception instead. if (count == 0 && processInfo == null) { - if (!deferred.isCompleted) deferred.complete(emptyList()) + if (!deferred.isCompleted) { + deferred.completeExceptionally( + ProcessSnapshotException("request/send failure"), + ) + } return@synchronized } if (index == 0) { @@ -343,14 +351,23 @@ private suspend fun requestWineProcessSnapshot(winHandler: WinHandler): List?>() - synchronized(lock) { - pendingSnapshot = deferred - } - winHandler.listProcesses() - val snapshot = withTimeoutOrNull(EXIT_PROCESS_RESPONSE_TIMEOUT_MS) { - deferred.await() + // Serialize against any other listProcesses-driven caller so a + // concurrent awaitSteamShutdown poll doesn't deliver its + // GET_PROCESS responses into this watcher's pending snapshot. + val snapshot = winHandler.processSnapshotMutex.withLock { + synchronized(lock) { + pendingSnapshot = deferred + } + winHandler.listProcesses() + withTimeoutOrNull(EXIT_PROCESS_RESPONSE_TIMEOUT_MS) { + deferred.await() + } } if (snapshot != null) { val hasNonEssential = snapshot.any { diff --git a/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt b/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt index 6dc499306f..2b26ef1342 100644 --- a/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt +++ b/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt @@ -93,9 +93,19 @@ object LudusaviRegistry { if (cacheFile.isFile) { runCatching { parseCacheJson(cacheFile.readText()) } .onSuccess { - memoryCache = it - Timber.w("LudusaviRegistry: using stale disk cache (${it.size} entries) after fetch failure") - return@withLock it + // Mirror the empty-fetch guard above: a 0-entry disk cache is a + // poisoned/stale artifact (e.g. a `{}` file from before this guard + // shipped). Promoting it to memoryCache would let the fast path + // short-circuit forever. Delete the file so a future fetch can + // replace it, and fall through to return null. + if (it.isEmpty()) { + Timber.w("LudusaviRegistry: disk cache parsed to 0 entries; deleting poisoned file") + runCatching { cacheFile.delete() } + } else { + memoryCache = it + Timber.w("LudusaviRegistry: using stale disk cache (${it.size} entries) after fetch failure") + return@withLock it + } } } return@withLock null diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt index bfc0f6cdbe..679726b794 100644 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt @@ -69,26 +69,32 @@ object VcRedistStep : PreInstallStep { gameDirPath: String, ): Boolean { // vcredist installs system-wide into the Wine prefix, not per-game. We - // track installed years at the container root (one marker per year) - // so reinstalling the game (which wipes the game dir) doesn't force a - // redundant re-run, while still detecting when a *different* game - // bundles a year we have not yet installed. + // track installed years/arches at the container root (one marker per + // year+arch) so reinstalling the game (which wipes the game dir) + // doesn't force a redundant re-run, while still detecting when a + // *different* game bundles a year/arch we have not yet installed. val gameDir = File(gameDirPath) val required = requiredVersions(gameDir) if (required.isEmpty()) { - // Nothing to install: treat the step as satisfied. return false } val containerRoot = container.rootDir?.absolutePath migrateLegacyContainerMarker(containerRoot, required) + migrateLegacyArchlessMarkers(containerRoot) val installed = installedVersions(containerRoot) + if (containerRoot != null && installed.isNotEmpty()) { + // Per-year/arch markers are the source of truth here. The coarse + // game-dir marker is ignored — if anything required is missing we + // must run buildCommand for the missing set. + return required.any { it !in installed } + } val missing = required - installed if (missing.isEmpty()) { - // All required years already covered by container-level markers. return false } - // Fall back to the legacy game-dir marker so an in-flight install - // that already ran for this exact game directory still short-circuits. + // No per-year markers to consult: fall back to the legacy game-dir + // marker so an in-flight install that already ran for this exact game + // directory still short-circuits. return !MarkerUtils.hasMarker(gameDirPath, Marker.VCREDIST_INSTALLED) } @@ -125,6 +131,7 @@ object VcRedistStep : PreInstallStep { gameDirPath: String, ): String? { val containerRoot = container.rootDir?.absolutePath + migrateLegacyArchlessMarkers(containerRoot) val installed = installedVersions(containerRoot) val parts = mutableListOf() for ((winPath, args) in vcRedistMap) { @@ -134,11 +141,11 @@ object VcRedistStep : PreInstallStep { if (lastSep < 0) continue val hostFile = File(gameDir, rest.replace('\\', '/')) if (!hostFile.isFile) continue - // Skip installer entries for years already installed system-wide - // so we don't pop a fresh installer window per launch (the - // 963d7999 fix). Years we haven't seen still run. + // Skip installer entries for year+arch combos already installed + // system-wide so we don't pop a fresh installer window per launch + // (the 963d7999 fix). Year+arch combos we haven't seen still run. val version = versionKey(winPath) - if (version != null && installed.contains(version)) continue + if (installed.contains(version)) continue parts.add(if (args.isEmpty()) winPath else "$winPath $args") } return if (parts.isEmpty()) null else parts.joinToString(" & ") @@ -170,7 +177,7 @@ object VcRedistStep : PreInstallStep { if (rest.lastIndexOf('\\') < 0) continue val hostFile = File(gameDir, rest.replace('\\', '/')) if (!hostFile.isFile) continue - versionKey(winPath)?.let { out.add(it) } + out.add(versionKey(winPath)) } return out } @@ -192,23 +199,55 @@ object VcRedistStep : PreInstallStep { } /** - * Map an installer's Windows path to a stable version key. Recognises - * year-suffixed folders ("2005".."2022"). Paths with no clear year - * (legacy `A:\redist\…`, root-level `A:\_CommonRedist\VC_redist.*`) map - * to "legacy" so they are still tracked, just with a single shared key. + * Map an installer's Windows path to a stable "$year-$arch" key (or + * "legacy-$arch" when no year is present). x86 and x64 installers from + * the same year get distinct keys so installing one does not mask the + * other. When no architecture hint is detectable we default to x86, + * matching the most common older redistributable layout. */ - private fun versionKey(winPath: String): String? { + private fun versionKey(winPath: String): String { val lower = winPath.lowercase() + val arch = archKey(lower) for (year in YEAR_KEYS) { if (lower.contains("\\$year\\") || lower.contains("\\msvc$year\\") || lower.contains("\\msvc${year}_x64\\") ) { - return year + return "$year-$arch" + } + } + return "legacy-$arch" + } + + private fun archKey(lowerWinPath: String): String { + val x64Hints = listOf("x64", "amd64", "wow64", "x86_64", "_x64\\", ".x64.") + if (x64Hints.any { lowerWinPath.contains(it) }) return "x64" + if (lowerWinPath.contains("x86") || lowerWinPath.contains(".x86.")) return "x86" + return "x86" + } + + /** + * One-time upgrade for markers written by the prior round-5 fix that were + * keyed by year only (e.g. `.vcredist_installed_2005`, `.vcredist_installed_legacy`). + * We don't know which arch was actually installed back then, so we + * conservatively claim only x86 coverage; if x64 was also installed it + * will be re-run on the next launch (one redundant installer at most). + */ + private fun migrateLegacyArchlessMarkers(containerRoot: String?) { + if (containerRoot == null) return + val dir = File(containerRoot) + val files = dir.listFiles() ?: return + for (f in files) { + if (!f.isFile) continue + val name = f.name + if (!name.startsWith(PER_VERSION_MARKER_PREFIX)) continue + val suffix = name.substring(PER_VERSION_MARKER_PREFIX.length) + if (suffix.contains('-')) continue + val migrated = File(containerRoot, "$PER_VERSION_MARKER_PREFIX$suffix-x86") + if (!migrated.exists()) { + runCatching { migrated.createNewFile() } } + runCatching { f.delete() } } - // Generic / yearless installers — track under a shared key so they - // don't all collapse to "no key" and bypass the marker check. - return "legacy" } private val YEAR_KEYS = listOf( diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index def0b8cbb1..70f81aec31 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -201,9 +201,24 @@ public void exec(String command) { if (command2.isEmpty()) { return; } - String[] cmdList = command2.split(" ", 2); - final String filename = cmdList[0]; - final String parameters = cmdList.length > 1 ? cmdList[1] : ""; + final String filename; + final String parameters; + // A naive split(" ", 2) would shred Windows paths like + // "C:\Program Files (x86)\Steam\steam.exe" -shutdown. + if (command2.charAt(0) == '"') { + int closing = command2.indexOf('"', 1); + if (closing > 0) { + filename = command2.substring(1, closing); + parameters = command2.substring(closing + 1).trim(); + } else { + filename = command2.substring(1); + parameters = ""; + } + } else { + String[] cmdList = command2.split(" ", 2); + filename = cmdList[0]; + parameters = cmdList.length > 1 ? cmdList[1] : ""; + } exec(filename, parameters); } @@ -253,10 +268,18 @@ public void listProcesses() { if (!sendPacket(CLIENT_PORT)) { OnGetProcessInfoListener slotListener = this.onGetProcessInfoListener; if (slotListener != null) { - slotListener.onGetProcessInfo(0, 0, null); + try { + slotListener.onGetProcessInfo(0, 0, null); + } catch (Throwable t) { + Log.w(TAG, "process info listener threw", t); + } } for (OnGetProcessInfoListener l : extraProcessInfoListeners) { - l.onGetProcessInfo(0, 0, null); + try { + l.onGetProcessInfo(0, 0, null); + } catch (Throwable t) { + Log.w(TAG, "process info listener threw", t); + } } } }); @@ -378,6 +401,26 @@ public void removeOnGetProcessInfoListener(OnGetProcessInfoListener listener) { extraProcessInfoListeners.remove(listener); } + /** + * Coordinates listProcesses-driven snapshot collection so events from one + * snapshot don't bleed into another concurrent snapshot's listener. The + * wire protocol has no request id to disambiguate broadcast responses, so + * a serializing primitive is the cheapest fix that preserves the existing + * GET_PROCESS packet format. + * + *

Callers that wrap an {@code addOnGetProcessInfoListener} / + * {@code listProcesses} / await / {@code removeOnGetProcessInfoListener} + * sequence should hold this lock for the duration of the sequence. + * Long-running listeners that don't drive listProcesses themselves don't + * need to acquire it. + * + *

Exposed as a {@link kotlinx.coroutines.sync.Mutex} so Kotlin callers + * can suspend on it without binding to a thread, which a JVM monitor would + * forbid across coroutine suspension points. + */ + public final kotlinx.coroutines.sync.Mutex processSnapshotMutex = + kotlinx.coroutines.sync.MutexKt.Mutex(false); + private void startSendThread() { Executors.newSingleThreadExecutor().execute(() -> { while (this.running) { @@ -432,11 +475,21 @@ private void handleRequest(byte requestCode, final int port) throws IOException this.receiveData.get(bytes); String name = StringUtils.fromANSIString(bytes); ProcessInfo info = new ProcessInfo(pid, name, memoryUsage, affinityMask, wow64Process); + // Isolate each listener so a misbehaving consumer can't kill the + // receive thread and break future polling for everyone else. if (this.onGetProcessInfoListener != null) { - this.onGetProcessInfoListener.onGetProcessInfo(index, numProcesses, info); + try { + this.onGetProcessInfoListener.onGetProcessInfo(index, numProcesses, info); + } catch (Throwable t) { + Log.w(TAG, "process info listener threw", t); + } } for (OnGetProcessInfoListener l : extraProcessInfoListeners) { - l.onGetProcessInfo(index, numProcesses, info); + try { + l.onGetProcessInfo(index, numProcesses, info); + } catch (Throwable t) { + Log.w(TAG, "process info listener threw", t); + } } return; case RequestCodes.GET_GAMEPAD: diff --git a/app/src/main/java/com/winlator/xserver/WindowManager.java b/app/src/main/java/com/winlator/xserver/WindowManager.java index cd33ce93ec..998cbce430 100644 --- a/app/src/main/java/com/winlator/xserver/WindowManager.java +++ b/app/src/main/java/com/winlator/xserver/WindowManager.java @@ -233,6 +233,10 @@ private void reapLeakedClientWindows(Window created) { if (!cand.getChildren().isEmpty()) continue; Window candParent = cand.getParent(); if (candParent == null || candParent == rootWindow) continue; + // The compositor's orphan-chain marker pins the parent to a 1x1 orphanage + // (see dd3987be). Mirror that here so we never reap children of a blank + // pid=0 parent that isn't the actual orphanage stub. + if (candParent.getWidth() != 1 || candParent.getHeight() != 1) continue; if (!candParent.getClassName().isEmpty()) continue; if (candParent.getProcessId() != 0) continue; matches.add(cand); diff --git a/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt b/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt index 6229e9bf44..f3bb20fa59 100644 --- a/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt +++ b/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt @@ -61,7 +61,7 @@ class VcRedistStepTest { @Test fun appliesTo_returnsFalse_whenAllRequiredYearsAlreadyInstalledInContainer() { seedInstaller(year = "MSVC2017") - File(containerRoot, ".vcredist_installed_2017").createNewFile() + File(containerRoot, ".vcredist_installed_2017-x86").createNewFile() val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) assertFalse(applies) } @@ -71,7 +71,38 @@ class VcRedistStepTest { // Previous game installed 2015; current game bundles 2019. The // container-level 2015 marker must not short-circuit the 2019 install. seedInstaller(year = "MSVC2019") - File(containerRoot, ".vcredist_installed_2015").createNewFile() + File(containerRoot, ".vcredist_installed_2015-x86").createNewFile() + val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) + assertTrue(applies) + } + + @Test + fun appliesTo_x64MarkerDoesNotSkipX86Install() { + // Previous game installed 2017 x64; current game bundles 2017 x86. + // The two arches are independent — x64 marker must not short-circuit + // the x86 install. + seedInstaller(year = "MSVC2017", filename = "VC_redist.x86.exe") + File(containerRoot, ".vcredist_installed_2017-x64").createNewFile() + val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) + assertTrue(applies) + } + + @Test + fun appliesTo_x86MarkerDoesNotSkipX64Install() { + seedInstaller(year = "MSVC2017_x64", filename = "VC_redist.x64.exe") + File(containerRoot, ".vcredist_installed_2017-x86").createNewFile() + val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) + assertTrue(applies) + } + + @Test + fun appliesTo_ignoresGameDirMarker_whenContainerMarkersIndicateMissingYears() { + // Game-dir VCREDIST_INSTALLED marker exists (e.g. from a prior run) + // but the container is missing the year+arch this game requires. + // The coarse game-dir marker must not hide the missing runtime. + seedInstaller(year = "MSVC2019", filename = "VC_redist.x86.exe") + MarkerUtils.addMarker(gameDir.absolutePath, Marker.VCREDIST_INSTALLED) + File(containerRoot, ".vcredist_installed_2015-x86").createNewFile() val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) assertTrue(applies) } @@ -85,10 +116,26 @@ class VcRedistStepTest { File(containerRoot, Marker.VCREDIST_INSTALLED.fileName).createNewFile() val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) assertFalse(applies) - assertTrue(File(containerRoot, ".vcredist_installed_2017").isFile) + assertTrue(File(containerRoot, ".vcredist_installed_2017-x86").isFile) assertFalse(File(containerRoot, Marker.VCREDIST_INSTALLED.fileName).exists()) } + @Test + fun appliesTo_migratesArchlessYearMarker_toX86() { + // The prior round-5 fix wrote year-only markers like `.vcredist_installed_2017`. + // We migrate those to `.vcredist_installed_2017-x86` (conservative + // assumption: the most common older redist is x86). + seedInstaller(year = "MSVC2017", filename = "VC_redist.x86.exe") + File(containerRoot, ".vcredist_installed_2017").createNewFile() + val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) + assertFalse(applies) + assertTrue(File(containerRoot, ".vcredist_installed_2017-x86").isFile) + assertFalse(File(containerRoot, ".vcredist_installed_2017").exists()) + // Migration must NOT also create the x64 marker — we don't know if it + // was actually installed. + assertFalse(File(containerRoot, ".vcredist_installed_2017-x64").exists()) + } + @Test fun buildCommand_returnsCommand_forDetectedInstaller() { seedInstaller() @@ -109,7 +156,7 @@ class VcRedistStepTest { fun buildCommand_skipsAlreadyInstalledYears() { seedInstaller(year = "MSVC2017", filename = "VC_redist.x86.exe") seedInstaller(year = "MSVC2019", filename = "VC_redist.x86.exe") - File(containerRoot, ".vcredist_installed_2017").createNewFile() + File(containerRoot, ".vcredist_installed_2017-x86").createNewFile() val cmd = VcRedistStep.buildCommand( container = container, @@ -124,13 +171,34 @@ class VcRedistStepTest { } @Test - fun recordInstalledVersions_writesPerYearMarkers() { - seedInstaller(year = "MSVC2017") - seedInstaller(year = "MSVC2022") + fun buildCommand_x64MarkerDoesNotSkipX86Installer() { + seedInstaller(year = "MSVC2017", filename = "VC_redist.x86.exe") + seedInstaller(year = "MSVC2017_x64", filename = "VC_redist.x64.exe") + // Only x64 has been installed; x86 must still run. + File(containerRoot, ".vcredist_installed_2017-x64").createNewFile() + + val cmd = VcRedistStep.buildCommand( + container = container, + appId = "STEAM_1", + gameSource = GameSource.STEAM, + gameDir = gameDir, + gameDirPath = gameDir.absolutePath, + ) + + val expected = "A:\\_CommonRedist\\MSVC2017\\VC_redist.x86.exe /install /passive /norestart" + assertEquals(expected, checkNotNull(cmd)) + } + + @Test + fun recordInstalledVersions_writesPerYearAndArchMarkers() { + seedInstaller(year = "MSVC2017", filename = "VC_redist.x86.exe") + seedInstaller(year = "MSVC2017_x64", filename = "VC_redist.x64.exe") + seedInstaller(year = "MSVC2022", filename = "VC_redist.x86.exe") VcRedistStep.recordInstalledVersions(container, gameDir) - assertTrue(File(containerRoot, ".vcredist_installed_2017").isFile) - assertTrue(File(containerRoot, ".vcredist_installed_2022").isFile) + assertTrue(File(containerRoot, ".vcredist_installed_2017-x86").isFile) + assertTrue(File(containerRoot, ".vcredist_installed_2017-x64").isFile) + assertTrue(File(containerRoot, ".vcredist_installed_2022-x86").isFile) } } From b19efda276cd00663f39d4cc2be2179caf96be72 Mon Sep 17 00:00:00 2001 From: TideGear Date: Thu, 14 May 2026 12:24:38 -0700 Subject: [PATCH 32/34] fix: surface Cloud Save Bridge UI + launch-time prompt in Bionic Steam mode too The SdkCloudSaveSubdirField was nested under `if (config.launchRealSteam)`, and the launch-time bridge prompt in PluviaMain only fired for isLaunchRealSteam. Pattern B games (Dead Cells etc.) hit the same // vs //remote/ path mismatch in bionic mode as they do in Wine-Steam mode, so the bridge should be reachable from both. SteamAutoCloud's sync paths are already mode-agnostic; this just exposes the configuration UI/prompt. --- app/src/main/java/app/gamenative/ui/PluviaMain.kt | 2 +- .../java/app/gamenative/ui/component/dialog/GeneralTab.kt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index d423ba2756..55405df977 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1659,7 +1659,7 @@ fun preLaunchApp( !ignorePendingOperations && preferredSave == SaveLocation.None && gameSource == GameSource.STEAM && - container.isLaunchRealSteam && + (container.isLaunchRealSteam || container.isLaunchBionicSteam) && container.sdkCloudSaveSubdir.isBlank() && container.getExtra("sdkCloudBridgePromptDismissed", "") != "1" ) { diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt index ec45c2adc2..914d8b48a1 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt @@ -389,7 +389,6 @@ fun GeneralTabContent( state = config.disableSteamOverlay, onCheckedChange = { state.config.value = config.copy(disableSteamOverlay = it) }, ) - SdkCloudSaveSubdirField(state = state, config = config) } if (config.containerVariant.equals(Container.BIONIC, ignoreCase = true)) { SettingsSwitch( @@ -406,6 +405,9 @@ fun GeneralTabContent( }, ) } + if (config.launchRealSteam || config.launchBionicSteam) { + SdkCloudSaveSubdirField(state = state, config = config) + } val steamTypeItems = listOf("Normal", "Light", "Ultra Light") val currentSteamTypeIndex = when (config.steamType.lowercase()) { Container.STEAM_TYPE_LIGHT -> 1 From 910605464a4c21b6bbda321055f57a4c3cc1f74b Mon Sep 17 00:00:00 2001 From: TideGear Date: Thu, 14 May 2026 14:16:25 -0700 Subject: [PATCH 33/34] fix: address PR review feedback on exit ordering + recursive SDK cloud mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues raised by AI review on the merged Real-Steam branch: 1. XServerScreen.kt graceful exit (non-Steam path): killProcess ran immediately before the GRACEFUL_EXIT_GRACE_MS delay, defeating the purpose of the grace window. Swap the order so the delay precedes the kill, giving any in-flight game-side exit (autosave, in-game quit dialog) time to complete before hard-kill. Note: a proper WM_CLOSE escalation requires a new winhandler.exe RequestCode (native code change outside this commit's scope). 2. SteamUtils.kt mirrorSdkCloud{RemoteToSave,SaveToRemote} were non-recursive — top-level listFiles + 'is regular file' filter silently skipped any subdirectories. Pattern B games with nested save structures (slots/profiles/etc.) would have partial or no restore. Replaced with Files.walk + relativize + createDirectories for full-tree recursion. backup-*.zip filter retained on filename (so a backup-* zip nested in a saves hierarchy is still excluded, matching the pre-recursive semantics for that one exclusion). --- .../ui/screen/xserver/XServerScreen.kt | 11 ++- .../java/app/gamenative/utils/SteamUtils.kt | 80 ++++++++++++------- 2 files changed, 59 insertions(+), 32 deletions(-) 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 be3599106a..a09ca3e608 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 @@ -1341,8 +1341,17 @@ fun XServerScreen( onEscalationResolved = { steamShutdownDialogResolver = null }, ) } else { - if (gameExe.isNotEmpty()) winHandler.killProcess(gameExe) + // Non-Steam exit path: no Steam IPC to ask the game to + // quit politely. WinHandler currently has no WM_CLOSE + // primitive (would require a new RequestCode + a + // winhandler.exe change), so we can't actively signal + // the game. We still wait the grace window first to + // let any in-flight game-side exit (autosave, an + // in-game quit dialog the user also clicked) complete + // before we hard-kill. killProcess runs as the + // escalation after the timeout. delay(GRACEFUL_EXIT_GRACE_MS) + if (gameExe.isNotEmpty()) winHandler.killProcess(gameExe) } exit(winHandler, frameRating, currentAppInfo, container, appId, onExit, navigateBack) } catch (ce: kotlinx.coroutines.CancellationException) { diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 5ccdd28100..3ce63c8175 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -2676,20 +2676,27 @@ object SteamUtils { } if (!gameSaveDir.exists()) gameSaveDir.mkdirs() - remoteDir.listFiles()?.forEach { src -> - if (!src.isFile) return@forEach - val dst = File(gameSaveDir, src.name) - try { - Files.copy( - src.toPath(), - dst.toPath(), - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.COPY_ATTRIBUTES, - ) - Timber.i("SDK cloud mirror remote->save appId=$appId: ${src.name} (${src.length()} bytes)") - } catch (e: Exception) { - Timber.w(e, "Failed to mirror ${src.name} remote->save appId=$appId") - } + val remoteBase = remoteDir.toPath() + val saveBase = gameSaveDir.toPath() + Files.walk(remoteBase).use { stream -> + stream + .filter { Files.isRegularFile(it) } + .forEach { src -> + val rel = remoteBase.relativize(src) + val dst = saveBase.resolve(rel) + try { + dst.parent?.let { Files.createDirectories(it) } + Files.copy( + src, + dst, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.COPY_ATTRIBUTES, + ) + Timber.i("SDK cloud mirror remote->save appId=$appId: $rel (${Files.size(src)} bytes)") + } catch (e: Exception) { + Timber.w(e, "Failed to mirror $rel remote->save appId=$appId") + } + } } } @@ -2700,23 +2707,34 @@ object SteamUtils { if (!gameSaveDir.exists()) return if (!remoteDir.exists()) remoteDir.mkdirs() - gameSaveDir.listFiles()?.forEach { src -> - if (!src.isFile) return@forEach - // Skip local-only artifacts (e.g. Dead Cells writes backup-YYYY-MM-DD-N.zip snapshots - // alongside saves; those aren't cloud-synced). - if (src.name.startsWith("backup-") && src.name.endsWith(".zip")) return@forEach - val dst = File(remoteDir, src.name) - try { - Files.copy( - src.toPath(), - dst.toPath(), - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.COPY_ATTRIBUTES, - ) - Timber.i("SDK cloud mirror save->remote appId=$appId: ${src.name} (${src.length()} bytes)") - } catch (e: Exception) { - Timber.w(e, "Failed to mirror ${src.name} save->remote appId=$appId") - } + val saveBase = gameSaveDir.toPath() + val remoteBase = remoteDir.toPath() + Files.walk(saveBase).use { stream -> + stream + .filter { Files.isRegularFile(it) } + .forEach { src -> + // Skip local-only artifacts (e.g. Dead Cells writes + // backup-YYYY-MM-DD-N.zip snapshots alongside saves; those aren't + // cloud-synced). Filename-only check — a nested "backup-*.zip" deep + // in a saves hierarchy is still treated the same; preserves the + // pre-recursive behavior for that specific exclusion. + val fname = src.fileName.toString() + if (fname.startsWith("backup-") && fname.endsWith(".zip")) return@forEach + val rel = saveBase.relativize(src) + val dst = remoteBase.resolve(rel) + try { + dst.parent?.let { Files.createDirectories(it) } + Files.copy( + src, + dst, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.COPY_ATTRIBUTES, + ) + Timber.i("SDK cloud mirror save->remote appId=$appId: $rel (${Files.size(src)} bytes)") + } catch (e: Exception) { + Timber.w(e, "Failed to mirror $rel save->remote appId=$appId") + } + } } } From f105fd5a5a7299bb31f41fc5bb9f1c63304542f5 Mon Sep 17 00:00:00 2001 From: TideGear Date: Thu, 14 May 2026 15:41:15 -0700 Subject: [PATCH 34/34] fix: route Bionic Steam exit through Steam's graceful shutdown path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `shutdownSteam` flag was gated only on `isLaunchRealSteam`, so bionic-mode exits fell through to the else branch's hard-kill (with a synthetic delay-then-kill that doesn't actually signal the game). Bionic Steam runs the same steam.exe in Wine — just with the native libsteamclient.so loaded in-process. `steam.exe -shutdown` works there identically: Steam delivers SteamShutdown_t to running games via the SteamAPI callback, games autosave + exit cleanly, Steam then flushes its own state. Broaden the flag to cover bionic mode so the existing graceful path (winHandler.exec("steam.exe -shutdown") + awaitSteamShutdown loop) runs for both Steam modes. Emu mode keeps the delay-then-kill fallback — no Steam to talk to there. --- .../app/gamenative/ui/screen/xserver/XServerScreen.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 a09ca3e608..59344f245b 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 @@ -1314,7 +1314,13 @@ fun XServerScreen( // Resume processes before exiting so they can receive SIGTERM cleanly. forceResumeIfSuspended() val gameExe = extractExecutableBasename(container.executablePath) - val shutdownSteam = container.isLaunchRealSteam + // Bionic Steam runs the same steam.exe in Wine (just with + // libsteamclient.so loaded in-process), so `steam.exe -shutdown` + // works there too — it delivers the Steam quit callback to the + // game via the standard SteamAPI path, the game saves and exits, + // and Steam itself flushes. Without this, bionic mode fell + // through to the else branch's hard-kill-after-delay. + val shutdownSteam = container.isLaunchRealSteam || container.isLaunchBionicSteam SnackbarManager.show( context.getString( if (shutdownSteam) R.string.exit_steam_shutdown_toast