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 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/PluviaApp.kt b/app/src/main/java/app/gamenative/PluviaApp.kt index ad51e1596b..95834cb9c9 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 @@ -89,6 +90,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/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 18ec8ef451..23e2204250 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, true) + set(value) { + setPref(DISABLE_STEAM_OVERLAY, value) + } + private val LAUNCH_BIONIC_STEAM = booleanPreferencesKey("launch_bionic_steam") var launchBionicSteam: Boolean get() = getPref(LAUNCH_BIONIC_STEAM, 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..d19a1f2fe0 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 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 + // 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,53 @@ 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 -> + // 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 + } + } + // 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, + 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 +279,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 +392,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 +860,8 @@ object SteamAutoCloud { } } + writeRemoteCache(updatedLocalFiles, cloudAppChangeNumber) + return@async null } } @@ -819,6 +896,10 @@ object SteamAutoCloud { changeNumbersDao.insert(appInfo.id, uploadResult.appChangeNumber) } } + writeRemoteCache( + allLocalUserFiles.groupBy { "%${it.root.name}%" }, + uploadResult.appChangeNumber, + ) } else { syncResult = SyncResult.UpdateFail } @@ -842,46 +923,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 () -> Unit = { + with(steamInstance) { + db.withTransaction { + fileChangeListsDao.insert(appInfo.id, allLocalUserFiles) + changeNumbersDao.insert(appInfo.id, cloudAppChangeNumber) } - syncResult = SyncResult.UpToDate - filesManaged = allLocalUserFiles.size - rehydratedSilently = true + } + writeRemoteCache( + allLocalUserFiles.groupBy { "%${it.root.name}%" }, + cloudAppChangeNumber, + ) + 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() } 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()) { + // 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) { @@ -941,6 +1034,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 cd4cb08770..09b09138da 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 @@ -2332,23 +2333,161 @@ 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 { - 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) } + // Only migrate GSE -> userdata when booting real Steam; reverse direction lives in ensureSteamSettings. + // 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) + } + + var syncResult = PostSyncInfo(SyncResult.UnknownFail) + var launchIntentRegistered = false + + // 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. + // + // 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. + // + // 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") } + + val steamInstance = instance + val steamCloud = steamInstance?._steamCloud + val proactiveClientId = PrefManager.clientId + if (steamInstance != null && steamCloud != null && proactiveClientId != null) { + val ourMachineName = SteamUtils.getMachineName(steamInstance) + runCatching { + val probed = steamCloud.signalAppLaunchIntent( + appId = appId, + clientId = proactiveClientId, + machineName = ourMachineName, + ignorePendingOperations = false, + osType = EOSType.AndroidUnknown, + ).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); selfPhantoms=%d crossDevice=%d", + probed.size, + probed.joinToString { "${it.machineName}:${it.operation.name}" }, + selfPhantoms.size, + crossDevice.size, + ) + 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}" }, + ) + // 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, + 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") + } + + // 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") } + } + } + + // 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 { - val context = instance?.applicationContext ?: return@async PostSyncInfo(SyncResult.UnknownFail) - // Migrate GSE Saves to Steam userdata - SteamUtils.migrateGSESavesToSteamUserdata(context, appId) + // Migrate GSE saves -> Steam userdata layout. Inside the try so any + // exception still flows through finally { releaseSync(appId) }. + SteamUtils.migrateGSESavesToSteamUserdata(migrateCtx, appId, isLaunchRealSteam) - var syncResult = PostSyncInfo(SyncResult.UnknownFail) + // 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 + // 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 GameNative + // 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 { @@ -2371,20 +2510,93 @@ 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", + "Signaling app launch:\n\tappId: %d\n\tclientId: %s\n\tosType: %s\n\tisLaunchRealSteam: %s", appId, PrefManager.clientId, EOSType.AndroidUnknown, + isLaunchRealSteam, ) - val pendingRemoteOperations = steamCloud.signalAppLaunchIntent( - appId = appId, - clientId = clientId, - machineName = SteamUtils.getMachineName(steamInstance), - ignorePendingOperations = ignorePendingOperations, - osType = EOSType.AndroidUnknown, - ).await() + // 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().also { launchIntentRegistered = true } + } + + // 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. (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 + } else { + rawPending.filterNot { it.machineName.equals("localhost", ignoreCase = true) } + } + + // 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( @@ -2418,6 +2630,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) } } @@ -2428,16 +2652,26 @@ 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) } + // 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) + } + 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) @@ -2484,7 +2718,7 @@ 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 { if (isOffline || !isConnected) { return@async @@ -2496,10 +2730,24 @@ class SteamService : Service(), IChallengeUrlChanged { } try { + // 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") + } + } + + // 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 { - syncAchievementsFromGoldberg(context, appId) + SteamUtils.mirrorSdkCloudSaveToRemote(context, appId) } catch (e: Exception) { - Timber.e(e, "Achievement sync failed for appId=$appId, continuing with cloud save sync") + Timber.w(e, "SDK cloud save->remote mirror failed for appId=$appId") } val maxAttempts = 3 @@ -2522,8 +2770,21 @@ class SteamService : Service(), IChallengeUrlChanged { appId = appId, clientId = clientId, uploadsCompleted = postSyncInfo?.uploadsCompleted == true, - uploadsRequired = postSyncInfo?.uploadsRequired == false, + 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") } + } } } } diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index b68ea5415c..55405df977 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -91,12 +91,12 @@ 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 import app.gamenative.utils.GameFeedbackUtils import app.gamenative.utils.IntentLaunchManager -import app.gamenative.utils.SteamUtils import app.gamenative.utils.UpdateChecker import app.gamenative.utils.UpdateInfo import app.gamenative.utils.UpdateInstaller @@ -796,6 +796,66 @@ fun PluviaMain( } } + DialogType.SDK_CLOUD_BRIDGE_SUGGESTION -> { + val relaunch = { suppressPrompt: Boolean -> + preLaunchApp( + context = context, + appId = state.launchedAppId, + // 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, + 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(true) } + } + } + onDismissClick = { + // 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(true) + } + 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(true) } + } + } + onDismissRequest = { + msgDialogState = MessageDialogState(false) + relaunch(true) + } + } + DialogType.SYNC_CONFLICT -> { onConfirmClick = { preLaunchApp( @@ -1525,6 +1585,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, @@ -1581,6 +1642,52 @@ 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. + // + // Skip the prompt for non-game launches (Open Container / temporary override / explicit + // 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.isLaunchBionicSteam) && + 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) @@ -2032,6 +2139,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/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index bcf06bcc8a..72f20428c3 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 0ede7ebef2..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 @@ -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 @@ -372,6 +381,15 @@ fun GeneralTabContent( } }, ) + 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) }, + ) + } if (config.containerVariant.equals(Container.BIONIC, ignoreCase = true)) { SettingsSwitch( colors = settingsTileColorsAlt(), @@ -387,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 @@ -424,3 +445,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, + // 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 -> + 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)) }, + 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, + ) + // 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 + } + } 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) + } + // 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 + } + } + }) { + 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/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index 90b1f74387..a2c72a1563 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -455,6 +455,22 @@ class MainViewModel @Inject constructor( val container = ContainerUtils.getOrCreateContainer(context, appId) val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) if (gameSource == GameSource.STEAM) { + // 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 = when { + container.isLaunchRealSteam() -> "real" + container.isLaunchBionicSteam() -> "bionic" + else -> "emu" + } + if (previousMode != currentMode) { + container.putExtra("lastSteamMode", currentMode) + container.saveData() + if (previousMode == "real" && currentMode == "emu") { + SteamUtils.cleanupExtractedSteamFiles(context, container) + } + } if (container.isLaunchRealSteam() || container.isLaunchBionicSteam()) { SteamUtils.restoreSteamApi(context, appId) } else { @@ -590,9 +606,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) { 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 5510869c31..90b6820192 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 @@ -1221,6 +1221,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/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index de85f036d8..36162c2b97 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 @@ -813,6 +813,7 @@ class SteamAppScreen : BaseAppScreen() { val syncResult = SteamService.forceSyncUserFiles( appId = gameId, prefixToPath = prefixToPath, + isLaunchRealSteam = container.isLaunchRealSteam, ).await() when (syncResult.syncResult) { @@ -1111,6 +1112,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 9c914293f1..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 @@ -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 @@ -177,6 +178,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 @@ -240,6 +242,30 @@ 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 + +// 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 = 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 -no-browser -nobigpicture -nofriendsui -nochatui -nointro" + private data class XServerViewReleaseBinding( val xServerView: XServerView, val windowModificationListener: WindowManager.OnWindowModificationListener, @@ -286,6 +312,96 @@ 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?>() + + 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.completeExceptionally( + ProcessSnapshotException("request/send failure"), + ) + } + return@synchronized + } + if (index == 0) { + currentList = mutableListOf() + expectedCount = count + if (count == 0 && !deferred.isCompleted) { + deferred.complete(emptyList()) + return@synchronized + } + } + if (processInfo != null) { + currentList.add(processInfo) + } + if (currentList.size >= expectedCount && !deferred.isCompleted) { + deferred.complete(currentList.toList()) + } + } + } + + // Serialize the add/list/await/remove sequence against any other concurrent + // snapshot caller; the wire protocol broadcasts GET_PROCESS responses to + // every registered listener with no request id to disambiguate. + return winHandler.processSnapshotMutex.withLock { + winHandler.addOnGetProcessInfoListener(listener) + try { + winHandler.listProcesses() + withTimeoutOrNull(EXIT_PROCESS_RESPONSE_TIMEOUT_MS) { + try { + deferred.await() + } catch (e: ProcessSnapshotException) { + null + } + } + } finally { + winHandler.removeOnGetProcessInfoListener(listener) + } + } +} + +private fun isSteamExeAlive(snapshot: 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 @@ -397,6 +513,8 @@ 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) } + var steamShutdownDialogResolver by remember { mutableStateOf?>(null) } DisposableEffect(Unit) { onDispose { @@ -404,6 +522,10 @@ fun XServerScreen( physicalControllerHandler = null exitWatchJob?.cancel() exitWatchJob = null + gracefulExitJob?.cancel() + gracefulExitJob = null + steamShutdownDialogResolver?.let { if (!it.isCompleted) it.cancel() } + steamShutdownDialogResolver = null } } var isKeyboardVisible = false @@ -730,14 +852,15 @@ fun XServerScreen( exitWatchJob = CoroutineScope(Dispatchers.IO).launch { val allowlist = buildEssentialProcessAllowlist() - val previousListener = winHandler.getOnGetProcessInfoListener() val lock = Any() var pendingSnapshot: CompletableDeferred?>? = 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) { @@ -757,17 +880,22 @@ fun XServerScreen( } } - winHandler.setOnGetProcessInfoListener(listener) + winHandler.addOnGetProcessInfoListener(listener) try { val startTime = System.currentTimeMillis() while (System.currentTimeMillis() - startTime < EXIT_PROCESS_TIMEOUT_MS) { val deferred = CompletableDeferred?>() - 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 { @@ -791,7 +919,7 @@ fun XServerScreen( delay(EXIT_PROCESS_POLL_INTERVAL_MS) } } finally { - winHandler.setOnGetProcessInfoListener(previousListener) + winHandler.removeOnGetProcessInfoListener(listener) synchronized(lock) { pendingSnapshot = null } @@ -1167,17 +1295,83 @@ 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) + // 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 + else R.string.exit_graceful_toast, + ), + ) + gracefulExitJob = CoroutineScope(Dispatchers.Main).launch { + try { + 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 { + // 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) { + // 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 + } + } + } true } @@ -2437,6 +2631,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( @@ -3087,9 +3303,51 @@ 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) + } envVars.remove("DXVK_FRAME_RATE") envVars.remove("VKD3D_FRAME_RATE") if (!envVars.has("WINEESYNC")) envVars.put("WINEESYNC", "1") + + // 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()) if (graphicsDriverConfig.get("version").lowercase(Locale.getDefault()).contains("gen8")) { var tuDebug = envVars.get("TU_DEBUG") @@ -3276,20 +3534,16 @@ private fun setupXEnvironment( Timber.i("---------------------------") } - // Request encrypted app ticket for Steam games at launch time + // 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 && !container.isLaunchBionicSteam) { CoroutineScope(Dispatchers.IO).launch { try { - val ticket = SteamService.instance?.getEncryptedAppTicket(gameIdForTicket) - if (ticket != null) { - Timber.i("Successfully retrieved encrypted app ticket for app $gameIdForTicket") - } else { - Timber.w("Failed to retrieve encrypted app ticket for app $gameIdForTicket") - } + SteamService.instance?.getEncryptedAppTicket(gameIdForTicket) } catch (e: Exception) { - Timber.e(e, "Error requesting encrypted app ticket for app $gameIdForTicket") + Timber.e(e, "Encrypted app ticket request failed for app $gameIdForTicket") } } } @@ -3732,9 +3986,7 @@ private fun getWineStartCommand( Timber.i("Bionic-Steam working directory is $executableDir") "\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" \"C:\\\\Program Files (x86)\\\\Steam\\\\steamapps\\\\common\\\\$gameFolderName\\\\$normalizedExe\"" } 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" + "\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" $STEAM_LAUNCH_FLAGS -applaunch $gameId" } else { var executablePath = "" if (container.executablePath.isNotEmpty()) { @@ -4279,7 +4531,26 @@ private fun setupWineSystemFiles( } if (container.isLaunchRealSteam || container.isLaunchBionicSteam) { + // 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. + // Applies to both real-Steam and bionic-Steam modes since both write steam.exe + // via extractSteamFiles below. + val steamExtractedKey = "${container.wineVersion}|${container.containerVariant}" + val steamExtractedPrev = container.getExtra("steamExtractedForWine") + 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(container.rootDir, ".wine/drive_c/Program Files (x86)/Steam") + SteamUtils.deleteTreeNoFollowSymlinks(steamDir) + } extractSteamFiles(context, container, onExtractFileListener) + 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 } // If bionic mode is off, scrub any bionic-installed files from a previous diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 3e874958d2..ab57e9a747 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -103,6 +103,7 @@ object ContainerUtils { execArgs = PrefManager.execArgs, showFPS = false, launchRealSteam = PrefManager.launchRealSteam, + disableSteamOverlay = PrefManager.disableSteamOverlay, launchBionicSteam = PrefManager.launchBionicSteam, cpuList = PrefManager.cpuList, cpuListWoW64 = PrefManager.cpuListWoW64, @@ -163,6 +164,7 @@ object ContainerUtils { PrefManager.drives = containerData.drives PrefManager.execArgs = containerData.execArgs PrefManager.launchRealSteam = containerData.launchRealSteam + PrefManager.disableSteamOverlay = containerData.disableSteamOverlay PrefManager.launchBionicSteam = containerData.launchBionicSteam PrefManager.cpuList = containerData.cpuList PrefManager.cpuListWoW64 = containerData.cpuListWoW64 @@ -277,6 +279,8 @@ object ContainerUtils { executablePath = container.executablePath, showFPS = false, launchRealSteam = container.isLaunchRealSteam, + disableSteamOverlay = container.isDisableSteamOverlay, + sdkCloudSaveSubdir = container.sdkCloudSaveSubdir, launchBionicSteam = container.isLaunchBionicSteam, allowSteamUpdates = container.isAllowSteamUpdates, steamType = container.getSteamType(), @@ -453,6 +457,8 @@ object ContainerUtils { container.executablePath = containerData.executablePath container.isShowFPS = false container.isLaunchRealSteam = containerData.launchRealSteam + container.isDisableSteamOverlay = containerData.disableSteamOverlay + container.sdkCloudSaveSubdir = containerData.sdkCloudSaveSubdir container.isLaunchBionicSteam = containerData.launchBionicSteam container.isAllowSteamUpdates = containerData.allowSteamUpdates container.setSteamType(containerData.steamType) @@ -810,60 +816,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, - launchBionicSteam = PrefManager.launchBionicSteam, - 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/FileUtils.kt b/app/src/main/java/app/gamenative/utils/FileUtils.kt index 7ad8339fa8..30502cc32f 100644 --- a/app/src/main/java/app/gamenative/utils/FileUtils.kt +++ b/app/src/main/java/app/gamenative/utils/FileUtils.kt @@ -133,24 +133,43 @@ 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 + // 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() } - Timber.i("$pattern -> $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 + } + + 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 +180,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/LudusaviRegistry.kt b/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt new file mode 100644 index 0000000000..2b26ef1342 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/LudusaviRegistry.kt @@ -0,0 +1,313 @@ +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 +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 + // 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 + * 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? { + // Fast path: in-memory cache populated. + memoryCache?.let { return it } + + // 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 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@withLock parsed + } + .onFailure { Timber.w(it, "LudusaviRegistry: disk cache parse failed; will refetch") } + } + + 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 { + // 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 + } + + 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? { + // 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/PreInstallSteps.kt b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt index c1a76d766e..c16a8c46b0 100644 --- a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt +++ b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt @@ -50,7 +50,13 @@ object PreInstallSteps { val gameDir = getGameDir(container) ?: return emptyList() val gameDirPath = gameDir.absolutePath - if (containerVariantChanged) resetMarkers(gameDirPath) + if (containerVariantChanged) { + resetMarkers(gameDirPath) + container.rootDir?.absolutePath?.let { containerRoot -> + resetMarkers(containerRoot) + resetVcRedistVersionMarkers(containerRoot) + } + } val commands = mutableListOf() @@ -93,6 +99,16 @@ 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. 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) { + VcRedistStep.recordInstalledVersions(container, gameDir) + } } private fun resetMarkers(gameDirPath: String) { @@ -101,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/SteamTokenLogin.kt b/app/src/main/java/app/gamenative/utils/SteamTokenLogin.kt index 9fe796907d..578b889c65 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,10 @@ import kotlin.io.path.exists const val NULL_CHAR = '\u0000' const val TOKEN_EXPIRE_TIME = 86400L // 1 day +// 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( private val steamId: String, private val login: String, @@ -43,8 +51,28 @@ class SteamTokenLogin( } private fun execCommand(command: String) : String { - return guestProgramLauncherComponent?.execShellCommand(command, false) + val launcher = guestProgramLauncherComponent ?: throw IllegalStateException("GuestProgramLauncherComponent is required for command execution") + 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) + // 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() + } } private fun killWineServer() { @@ -178,16 +206,10 @@ 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 - shouldWriteConfig = true - } else { - Timber.tag("SteamTokenLogin").d("Saved JWT is not expired, do not override config.vdf") - 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("SteamTokenLogin").d("Cannot parse saved JWT, overriding config.vdf") 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 aacc319960..3ce63c8175 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -11,6 +11,7 @@ import app.gamenative.data.ManifestInfo import app.gamenative.data.SteamApp import app.gamenative.enums.LoginResult import app.gamenative.enums.Marker +import app.gamenative.enums.PathType import app.gamenative.enums.SpecialGameSaveMapping import app.gamenative.events.SteamEvent import app.gamenative.service.SteamService @@ -30,10 +31,12 @@ 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 import java.nio.file.StandardOpenOption +import java.security.MessageDigest import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone @@ -192,6 +195,157 @@ object SteamUtils { } } + /** + * 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. + */ + fun applySteamInstallScriptShim(context: Context, steamAppId: Int) { + try { + // 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( + "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: 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) + } + } + + // 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 + val n = file.name.lowercase() + if (n == "steam_api.dll" || n == "steam_api64.dll") { + found = true + 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 + } + } + } + if (!found) { + Timber.w("DLL marker desync: no steam_api DLL found under %s but REPLACED marker present", appDirPath) + return false + } + true + } catch (e: Exception) { + Timber.w(e, "verifyReplacedState failed, treating as desync") + false + } + } + + 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 + // 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 + } + } + } + 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") + false + } + } + + 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") + 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 @@ -200,7 +354,10 @@ object SteamUtils { val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) val appDirPath = SteamService.getAppDirPath(steamAppId) if (MarkerUtils.hasMarker(appDirPath, Marker.STEAM_DLL_REPLACED)) { - return + if (verifyReplacedState(context, appDirPath)) { + return + } + MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_REPLACED) } MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DLL_RESTORED) MarkerUtils.removeMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) @@ -210,9 +367,13 @@ object SteamUtils { var replaced64Count = 0 val backupPaths = mutableSetOf() val imageFs = ImageFs.find(context) - autoLoginUserChanges(imageFs) + // 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) // userdata is keyed by Steam3 accountID (matches restoreSteamApi). userSteamId is null on offline. - setupLightweightSteamConfig(imageFs, getSteam3AccountId()?.toString()) + setupLightweightSteamConfig(imageFs, getSteam3AccountId()?.toString(), container) val rootPath = Paths.get(appDirPath) // Get ticket once for all DLLs @@ -273,7 +434,6 @@ object SteamUtils { createAppManifest(context, steamAppId) // Game-specific Handling - val container = ContainerUtils.getOrCreateContainer(context, appId) ensureSaveLocationsForGames(context, steamAppId, container) // Generate achievements.json @@ -325,6 +485,8 @@ object SteamUtils { // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId, container) + restoreLegacyStashedOverlayFiles(container) + MarkerUtils.addMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) } @@ -340,15 +502,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++ @@ -358,14 +523,40 @@ object SteamUtils { Timber.i("Finished backupSteamclientFiles for appId: $steamAppId. Backed up $backupCount file(s)") } + // 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", + "SteamOverlayVulkanLayer.json", + "SteamOverlayVulkanLayer64.json", + ) + + private fun restoreLegacyStashedOverlayFiles(container: com.winlator.container.Container) { + val steamDir = File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam") + legacyStashedOverlayFiles.forEach { name -> + val live = File(steamDir, name) + val stashed = File(steamDir, "$name.disabled") + if (stashed.exists() && !live.exists()) stashed.renameTo(live) + } + } + 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") @@ -376,7 +567,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() } @@ -442,7 +633,7 @@ object SteamUtils { ) } - fun autoLoginUserChanges(imageFs: ImageFs) { + fun autoLoginUserChanges(imageFs: ImageFs, container: Container? = null) { // userSteamId is null on offline launch — fall back to persisted ID, else writer puts "null" in vdf val steamId64 = SteamService.userSteamId?.convertToUInt64()?.toString() ?: PrefManager.steamUserSteamId64.takeIf { it != 0L }?.toString() @@ -454,11 +645,23 @@ 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 { + // 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 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" @@ -477,10 +680,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") @@ -559,7 +765,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 @@ -575,8 +780,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) @@ -604,17 +812,16 @@ object SteamUtils { */ private fun createAppManifest(context: Context, steamAppId: Int) { try { - Timber.i("Attempting to createAppManifest for appId: $steamAppId") val appInfo = SteamService.getAppInfoOf(steamAppId) if (appInfo == null) { - Timber.w("No app info found for appId: $steamAppId") + Timber.w("createAppManifest: no SteamApp info for appId=$steamAppId — Steam will gray Play") 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() } @@ -630,15 +837,24 @@ 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 } + 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) } val installedBranch = SteamService.getInstalledApp(steamAppId)?.branch ?: "public" val buildId = (appInfo.branches[installedBranch] ?: appInfo.branches["public"])?.buildId ?: 0L + if (buildId == 0L) { + Timber.w("createAppManifest: unresolvable buildid for appId=$steamAppId branch=$installedBranch") + return + } val downloadableDepots = SteamService.getDownloadableDepots(steamAppId) val regularDepots = mutableMapOf() @@ -658,6 +874,44 @@ 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.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. + 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.w("createAppManifest: appId=$steamAppId depot(s) $brokenDepotIds have no resolvable manifest GID for branch=$installedBranch — skipping (would trigger Update Required)") + return + } + + // 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) { + 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, + ) + } + // Still refresh shared helper manifests (228980, 241100) — they're independent of the child's state. + writeAllSharedAppManifests(steamappsDir, commonDir, lastOwner, sharedDepots.keys) + return + } + // Create ACF content val acfContent = buildString { appendLine("\"AppState\"") @@ -665,40 +919,83 @@ 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). 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\"") + // 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\"") + 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. + // 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{") + 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" 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 + // watcher and blocked graceful steam.exe -shutdown on cloud upload — hence 0. + 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("}") @@ -708,30 +1005,28 @@ 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("}") + // 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") + sharedHelperApps.forEach { helper -> + val helperAcf = File(steamappsDir, "appmanifest_${helper.appId}.acf") + if (helperAcf.isFile) { + validateAcfShape( + helperAcf, + expectDepotCount = -1, + label = "shared ${helper.appId}", + ) } - - // Write Steamworks ACF file - val steamworksAcfFile = File(steamappsDir, "appmanifest_228980.acf") - steamworksAcfFile.writeText(steamworksAcfContent) - - Timber.i("Created Steamworks Common Redistributables ACF manifest at ${steamworksAcfFile.absolutePath}") } } catch (e: Exception) { @@ -739,11 +1034,409 @@ object SteamUtils { } } + private data class ResolvedSharedDepot( + val id: Int, + val manifestGid: Long, + val size: Long, + val installScript: String, + ) + + // 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", + ) + + // 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, + depotFilter: Set?, + ): List { + val sharedAppInfo = SteamService.getAppInfoOf(sharedAppId) ?: return emptyList() + 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() + ?: return@mapNotNull null + if (manifest.gid == 0L) return@mapNotNull null + ResolvedSharedDepot( + id = depotId, + manifestGid = manifest.gid, + size = manifest.size, + installScript = sharedDepotInstallScripts[depotId] ?: "", + ) + } + } + + // 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_.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 writeSharedAppManifest( + steamappsDir: File, + commonDir: File, + lastOwner: String, + helper: SharedHelperApp, + childSharedDepotIds: Set, + ) { + 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("writeSharedAppManifest: PICS has no AppInfo for $sharedAppId — Steam may gray Play") + return + } + + val sharedBuildId = sharedAppInfo.branches["public"]?.buildId + ?: sharedAppInfo.branches.values.firstOrNull()?.buildId + ?: 0L + if (sharedBuildId == 0L) { + Timber.w("writeSharedAppManifest: $sharedAppId PICS has no buildid — skipping") + return + } + + val depotFilter = if (helper.useChildDeclaredDepots) childSharedDepotIds else null + val presentDepots = resolveSharedAppDepots(sharedAppId, depotFilter) + if (presentDepots.isEmpty()) { + if (staleAcf.exists()) staleAcf.delete() + Timber.w( + "writeSharedAppManifest: no $sharedAppId depots resolvable from PICS (filter=%s) — deleting stale acf", + depotFilter, + ) + 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 existingOwner = parseAcfLastOwner(staleAcf) + val depotsMatch = existingDepots == presentDepotIds + val buildIdMatch = existingBuildId == sharedBuildId + val scriptsMatch = existingScripts == presentScriptDepotIds + // 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() + } + } + + val sharedInstallDir = helper.installDir + val sharedCommonDir = File(commonDir, sharedInstallDir) + if (!sharedCommonDir.exists()) { + sharedCommonDir.mkdirs() + } + + 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 { "App $sharedAppId" })}\"") + appendLine("\t\"StateFlags\"\t\t\"4\"") + 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 } + 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}") + + 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\"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) + } + + // 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") } + 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 / + // 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 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() + 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() + } + } + + 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 text = runCatching { acf.readText() }.getOrNull() + if (text == null) { + Timber.e("ACF self-check FAIL [%s]: %s unreadable", label, acf.absolutePath) + return + } + 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) { + // 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()) + } + private fun calculateDirectorySize(directory: File): Long { if (!directory.exists() || !directory.isDirectory()) { return 0L @@ -763,57 +1456,92 @@ 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, 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() + 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, container: Container? = null) { + try { + // 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) + 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. */ fun restoreSteamApi(context: Context, appId: String) { - - Timber.i("Starting restoreSteamApi for appId: ${appId}") 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, container) + logSteamBinaryFingerprint(imageFs, "restoreSteamApi:emu:$steamAppId", container) // 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) - 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()) - - putBackSteamDlls(appDirPath) - Timber.i("Finished restoreSteamApi for appId: ${appId}") + 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) + + autoLoginUserChanges(imageFs, container) + setupLightweightSteamConfig(imageFs, SteamService.userSteamId!!.accountID.toString(), container) + + putBackSteamDlls(appDirPath) + // In bionic-Steam mode, the original executable must remain in place + // (the launch path runs steam.exe with the game's exe as its arg); + // don't restore the .orig swap in that mode. + if (!container.isLaunchBionicSteam) { + restoreOriginalExecutable(context, steamAppId) + } + restoreSteamclientFiles(context, steamAppId) - // Restore original executable if it exists (for real Steam mode) - if (!container.isLaunchBionicSteam) { - restoreOriginalExecutable(context, steamAppId) + MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_RESTORED) } - // Restore original steamclient.dll files if they exist - restoreSteamclientFiles(context, steamAppId) + restoreLegacyStashedOverlayFiles(container) - // Create Steam ACF manifest for real Steam compatibility + // Always refresh the manifest + symlinks: multi-account switches and branch + // changes otherwise leave stale LastOwner/buildid/installdir behind. createAppManifest(context, steamAppId) + // 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) - - MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_RESTORED) } fun findSteamApiDllRootFile(file: File, depth: Int): File? { @@ -885,8 +1613,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) } @@ -915,12 +1645,11 @@ 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. + * 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) { - val imageFs = ImageFs.find(context) + fun migrateGSESavesToSteamUserdata(context: Context, appId: Int, isLaunchRealSteam: Boolean = true) { + if (!isLaunchRealSteam) return val accountId = SteamService.userSteamId?.accountID?.toInt() ?: PrefManager.steamUserAccountId.takeIf { it != 0 } @@ -929,15 +1658,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 @@ -1003,6 +1730,156 @@ object SteamUtils { Timber.tag("migrateGSESavesToSteamUserdata").i("Migration completed for appId=$appId. Migrated $migratedCount file(s)") } + /** + * 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) return + val accountId = SteamService.userSteamId?.accountID?.toInt() + ?: PrefManager.steamUserAccountId.takeIf { it != 0 } + + if (accountId == null) { + Timber.w("migrateSteamUserdataToGSESaves: no Steam account ID available") + return + } + + // 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 + } + + if ( + !steamUserdataDir.exists() || + !steamUserdataDir.isDirectory || + isDirectoryEmpty(steamUserdataDir) + ) { + return + } + + if (!gseDir.exists()) { + try { + Files.createDirectories(gseDir.toPath()) + } catch (e: IOException) { + Timber.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.w(e, "migrateSteamUserdataToGSESaves: failed to migrate %s", file.name) + } + } + + if (!migrationFailed) { + steamUserdataDir.deleteRecursively() + } + } + + /** + * 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. + */ + 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, container: Container? = null) { + try { + // 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 + deleteTreeNoFollowSymlinks(steamDir) + } catch (e: Exception) { + Timber.w(e, "cleanupExtractedSteamFiles failed") + } + } + + /** + * 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, container: Container? = null) { + try { + // 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() } + }.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. */ @@ -1061,8 +1938,9 @@ object SteamUtils { appendLine("ticket=$ticketBase64") } - // Migrate GSE Saves to Steam userdata - migrateGSESavesToSteamUserdata(context, steamAppId) + // 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 val steamUserDataPath = "C:\\Program Files (x86)\\Steam\\userdata\\$accountId" @@ -1276,8 +2154,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 @@ -1371,7 +2252,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") @@ -1391,6 +2274,29 @@ object SteamUtils { app.children.add(KeyValue("LaunchOptions", exeCommandLine)) } + // 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. + // + // "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. 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" } + vdfData.saveToFile(localConfigFile, false) } else { val vdfData = KeyValue(name = "UserLocalConfigStore") @@ -1402,6 +2308,9 @@ 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) steam.children.add(apps) valve.children.add(steam) @@ -1411,6 +2320,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") @@ -1455,6 +2373,100 @@ 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) { + existing.value = value + } else { + parent.children.add(KeyValue(name, value)) + } + } + fun getSteamId64(): Long? { return SteamService.userSteamId?.convertToUInt64()?.toLong() ?: PrefManager.steamUserSteamId64.takeIf { it != 0L } @@ -1520,6 +2532,212 @@ object SteamUtils { } } + /** + * Recommended SDK-cloud save subdir for a known game. Sourced from the Ludusavi + * community manifest (fetched on demand, cached on disk by [LudusaviRegistry]). + */ + 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 + 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? { + // 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 + + if (!remoteDir.exists()) { + Timber.i("mirrorSdkCloudRemoteToSave: remote/ missing for appId=$appId") + return + } + if (!gameSaveDir.exists()) gameSaveDir.mkdirs() + + 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") + } + } + } + } + + 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() + + 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") + } + } + } + } + fun generateAchievementsFile(dllPath: Path, appId: String) { if (!SteamService.isLoggedIn) { Timber.w("Skipping achievements generation for $appId — Steam not logged in") 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 38f7c03638..679726b794 100644 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt @@ -54,14 +54,75 @@ 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. We + // 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()) { + 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()) { + return false + } + // 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) } + /** + * 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, @@ -69,6 +130,9 @@ object VcRedistStep : PreInstallStep { gameDir: File, gameDirPath: String, ): String? { + val containerRoot = container.rootDir?.absolutePath + migrateLegacyArchlessMarkers(containerRoot) + val installed = installedVersions(containerRoot) val parts = mutableListOf() for ((winPath, args) in vcRedistMap) { if (winPath.length < 4 || winPath[1] != ':' || winPath[2] != '\\') continue @@ -77,9 +141,117 @@ object VcRedistStep : PreInstallStep { if (lastSep < 0) continue val hostFile = File(gameDir, rest.replace('\\', '/')) if (!hostFile.isFile) continue + // 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 (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 + out.add(versionKey(winPath)) + } + 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 "$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 { + 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-$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() } + } + } + + private val YEAR_KEYS = listOf( + "2005", "2008", "2010", "2012", "2013", + "2015", "2017", "2019", "2022", + ) +} diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java index 3df4893d60..c166f46970 100644 --- a/app/src/main/java/com/winlator/container/Container.java +++ b/app/src/main/java/com/winlator/container/Container.java @@ -85,6 +85,8 @@ public enum XrControllerMapping { private String wineVersion = WineInfo.MAIN_WINE_VERSION.identifier(); private boolean showFPS; private boolean launchRealSteam; + private boolean disableSteamOverlay = true; + private String sdkCloudSaveSubdir = ""; private boolean launchBionicSteam; private boolean allowSteamUpdates; private boolean wow64Mode = true; @@ -326,6 +328,22 @@ public void setLaunchRealSteam(boolean launchRealSteam) { this.launchRealSteam = launchRealSteam; } + public boolean isDisableSteamOverlay() { + return disableSteamOverlay; + } + + 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 isLaunchBionicSteam() { return launchBionicSteam; } @@ -661,6 +679,8 @@ public void saveData() { data.put("drives", drives); data.put("showFPS", showFPS); data.put("launchRealSteam", launchRealSteam); + data.put("disableSteamOverlay", disableSteamOverlay); + data.put("sdkCloudSaveSubdir", sdkCloudSaveSubdir); data.put("launchBionicSteam", launchBionicSteam); data.put("allowSteamUpdates", allowSteamUpdates); data.put("inputType", inputType); @@ -781,6 +801,12 @@ public void loadData(JSONObject data) throws JSONException { case "launchRealSteam" : setLaunchRealSteam(data.getBoolean(key)); break; + case "disableSteamOverlay" : + setDisableSteamOverlay(data.getBoolean(key)); + break; + case "sdkCloudSaveSubdir" : + setSdkCloudSaveSubdir(data.optString(key, "")); + break; case "launchBionicSteam" : setLaunchBionicSteam(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 7fb3a389fe..fce903bd55 100644 --- a/app/src/main/java/com/winlator/container/ContainerData.kt +++ b/app/src/main/java/com/winlator/container/ContainerData.kt @@ -25,6 +25,10 @@ data class ContainerData( val installPath: String = "", 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 launchBionicSteam: Boolean = false, val allowSteamUpdates: Boolean = false, val steamType: String = "normal", @@ -119,6 +123,8 @@ data class ContainerData( "installPath" to state.installPath, "showFPS" to state.showFPS, "launchRealSteam" to state.launchRealSteam, + "disableSteamOverlay" to state.disableSteamOverlay, + "sdkCloudSaveSubdir" to state.sdkCloudSaveSubdir, "launchBionicSteam" to state.launchBionicSteam, "allowSteamUpdates" to state.allowSteamUpdates, "steamType" to state.steamType, @@ -183,6 +189,8 @@ data class ContainerData( installPath = savedMap["installPath"] as String, showFPS = savedMap["showFPS"] as Boolean, launchRealSteam = savedMap["launchRealSteam"] as Boolean, + disableSteamOverlay = (savedMap["disableSteamOverlay"] as? Boolean) ?: true, + sdkCloudSaveSubdir = (savedMap["sdkCloudSaveSubdir"] as? String) ?: "", launchBionicSteam = (savedMap["launchBionicSteam"] as? Boolean) ?: false, allowSteamUpdates = savedMap["allowSteamUpdates"] as Boolean, steamType = (savedMap["steamType"] as? String) ?: "normal", 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; } 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)); + } } } 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); } } diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index df97ade100..f823fab919 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; @@ -176,12 +183,33 @@ 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); + } + + 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); @@ -216,12 +244,25 @@ 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) { + try { + slotListener.onGetProcessInfo(0, 0, null); + } catch (Throwable t) { + Log.w(TAG, "process info listener threw", t); + } + } + for (OnGetProcessInfoListener l : extraProcessInfoListeners) { + try { + l.onGetProcessInfo(0, 0, null); + } catch (Throwable t) { + Log.w(TAG, "process info listener threw", t); + } + } } }); } @@ -325,6 +366,43 @@ 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); + } + + /** + * 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) { @@ -364,7 +442,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; @@ -378,7 +456,23 @@ 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); + // Isolate each listener so a misbehaving consumer can't kill the + // receive thread and break future polling for everyone else. + if (this.onGetProcessInfoListener != null) { + try { + this.onGetProcessInfoListener.onGetProcessInfo(index, numProcesses, info); + } catch (Throwable t) { + Log.w(TAG, "process info listener threw", t); + } + } + for (OnGetProcessInfoListener l : extraProcessInfoListeners) { + try { + l.onGetProcessInfo(index, numProcesses, info); + } catch (Throwable t) { + Log.w(TAG, "process info listener threw", t); + } + } return; case RequestCodes.GET_GAMEPAD: boolean isXInput = this.receiveData.get() == 1; diff --git a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java index 84b05aa633..8c05723b56 100644 --- a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java +++ b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java @@ -215,7 +215,11 @@ 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)) { Log.d("ImageFsInstaller", "Installing image from assets"); return installFromAssetsFuture( context, diff --git a/app/src/main/java/com/winlator/xserver/WindowManager.java b/app/src/main/java/com/winlator/xserver/WindowManager.java index 83145a1432..998cbce430 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,76 @@ 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(); + + // 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); + 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; + // 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; + // 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; + // 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); + } + 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)) { 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..e0b1e294fa 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,13 @@ 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) { + // 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); @@ -168,18 +173,38 @@ 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); Drawable drawable = client.xServer.drawableManager.createDrawable(pixmapId, totalWidth, height, depth); + if (drawable == null) throw new BadIdChoice(pixmapId); drawable.setData(buffer); drawable.setTexture(null); + // 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 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 + 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); - client.xServer.pixmapManager.createPixmap(drawable); + 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); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 69074d0826..d5f9ad2a58 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -216,6 +216,12 @@ Logs 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 @@ -674,6 +680,31 @@ 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 + 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 + 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 Enable Experimental Bionic Steam Enables online play, improves compatibility\nDoes not always work\nBack up your saves before trying this Steam Offline Mode 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..f3bb20fa59 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,126 @@ 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_whenInstallerPresentAndNoMarker() { + seedInstaller() + val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) + assertTrue(applies) + } + + @Test + 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_returnsTrue_whenMarkerMissing() { + fun appliesTo_returnsFalse_whenAllRequiredYearsAlreadyInstalledInContainer() { + seedInstaller(year = "MSVC2017") + File(containerRoot, ".vcredist_installed_2017-x86").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-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_returnsFalse_whenMarkerExists() { + 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) + } + + @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-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() { - 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 +151,54 @@ 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-x86").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 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-x86").isFile) + assertTrue(File(containerRoot, ".vcredist_installed_2017-x64").isFile) + assertTrue(File(containerRoot, ".vcredist_installed_2022-x86").isFile) + } }