From 91c1cacdf22199aca83537276cab05d0b524306d Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Wed, 20 May 2026 20:48:09 +0200 Subject: [PATCH 1/2] Fix Steam Cloud save placement for remapped UFS roots Steam can report Auto-Cloud files under the original cloud prefix even when a Windows rootoverride adds a local subdirectory. Two Point Museum exposes this as cloud prefixes like %WinAppDataLocalLow%75264032/Saves/Slot1/ while the game reads from AppData/LocalLow/Two Point Studios/Two Point Museum/Cloud/75264032/. Map both compact and slash-separated cloud prefixes to the remapped local path, then preserve any nested suffix from the Steam prefix so downloaded slot saves land where the game expects them. Adds a SteamAutoCloud regression test using the observed Two Point Museum prefix/filename split. --- .../app/gamenative/service/SteamAutoCloud.kt | 30 ++- .../gamenative/service/SteamAutoCloudTest.kt | 212 ++++++++++++++++++ 2 files changed, 237 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt index a8e2ae59d9..4b5cc0c507 100644 --- a/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt +++ b/app/src/main/java/app/gamenative/service/SteamAutoCloud.kt @@ -167,13 +167,27 @@ object SteamAutoCloud { // not "/saves" — root-only replacement can't express this. val cloudPrefixToLocalPath: Map = appInfo.ufs.saveFilePatterns .filter { it.uploadPath != it.path } - .associate { p -> - val cloudKey = "%${p.uploadRoot.name}%${p.uploadPath}" + .flatMap { p -> + val localPath = Paths.get(prefixToPath(p.root.name), p.substitutedPath).pathString + val cloudPath = p.uploadPath + .replace("\\", "/") .replace("{64BitSteamID}", SteamUtils.getSteamId64().toString()) .replace("{Steam3AccountID}", SteamUtils.getSteam3AccountId().toString()) - .trimEnd('/') // keep consistent with the trimEnd done at lookup time - cloudKey to Paths.get(prefixToPath(p.root.name), p.substitutedPath).pathString + .trim('/') + val cloudRoot = "%${p.uploadRoot.name}%" + val cloudPrefixes = if (cloudPath.isBlank()) { + listOf(cloudRoot) + } else { + listOf( + "$cloudRoot$cloudPath", + "$cloudRoot/$cloudPath", + ) + } + cloudPrefixes.map { cloudKey -> + cloudKey to localPath + } } + .toMap() val getPathTypePairs: (AppFileChangeList) -> List> = { fileList -> fileList.pathPrefixes @@ -205,7 +219,13 @@ object SteamAutoCloud { // subfolder that the local path includes. Root-only replacement can't express this. // Cloud prefixes sometimes include a trailing slash (e.g. "%WinAppDataLocalLow%76561198035529760/save1/") // but the map keys are built without one — trim before lookup so they match. - cloudPrefixToLocalPath[prefix.trimEnd('/')] + val cloudPrefix = prefix.trimEnd('/') + cloudPrefixToLocalPath.entries + .filter { (cloudKey, _) -> cloudPrefix == cloudKey || cloudPrefix.startsWith("$cloudKey/") } + .maxByOrNull { (cloudKey, _) -> cloudKey.length } + ?.let { (cloudKey, localPath) -> + Paths.get(localPath, cloudPrefix.removePrefix(cloudKey).trimStart('/')).pathString + } ?: run { var modified = prefix diff --git a/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt index 117f6a5325..22dfb3aa7b 100644 --- a/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt +++ b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt @@ -1529,6 +1529,218 @@ class SteamAutoCloudTest { ) } + @Test + fun twoPointMuseumSaveFolderUsesAccountIdAsChildOfCloudDirectory() = runBlocking { + val matchingChangeNumber = 5 + db.appChangeNumbersDao().deleteByAppId(steamAppId) + db.appFileChangeListsDao().deleteByAppId(steamAppId) + db.appChangeNumbersDao().insert(app.gamenative.data.ChangeNumbers(steamAppId, matchingChangeNumber.toLong())) + db.appFileChangeListsDao().insert(steamAppId, listOf( + app.gamenative.data.UserFileInfo( + root = PathType.WinMyDocuments, + path = "__stale__", + filename = "__placeholder__", + timestamp = 0L, + sha = ByteArray(20) { 0 }, + ) + )) + + val localLowRoot = File(tempDir, "local-low") + val userdataRoot = File(tempDir, "userdata") + val saveDir = File(localLowRoot, "Two Point Studios/Two Point Museum/Cloud/75264032/Saves") + saveDir.mkdirs() + File(saveDir, "1003.sav").writeBytes("museum save content".toByteArray()) + + val saveFilePatterns = listOf( + SaveFilePattern( + root = PathType.WinAppDataLocalLow, + path = "Two Point Studios/Two Point Museum/Cloud/75264032", + pattern = "*", + recursive = 1, + uploadRoot = PathType.WinAppDataLocalLow, + uploadPath = "75264032", + ), + ) + val appUnderTest = db.steamAppDao().findApp(steamAppId)!! + .copy(ufs = UFS(saveFilePatterns = saveFilePatterns)) + + val mockAppFileChangeList = mock() + whenever(mockAppFileChangeList.currentChangeNumber).thenReturn(matchingChangeNumber.toLong()) + whenever(mockAppFileChangeList.isOnlyDelta).thenReturn(false) + whenever(mockAppFileChangeList.appBuildIDHwm).thenReturn(0) + whenever(mockAppFileChangeList.pathPrefixes).thenReturn(emptyList()) + whenever(mockAppFileChangeList.machineNames).thenReturn(emptyList()) + whenever(mockAppFileChangeList.files).thenReturn(emptyList()) + + every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns + CompletableFuture.completedFuture(mockAppFileChangeList) + + val mockUploadBatchResponse = mock<`in`.dragonbra.javasteam.steam.handlers.steamcloud.AppUploadBatchResponse>() + whenever(mockUploadBatchResponse.batchID).thenReturn(1) + whenever(mockUploadBatchResponse.appChangeNumber).thenReturn((matchingChangeNumber + 1).toLong()) + + val capturedFilesToUpload = mutableListOf>() + every { + mockSteamCloud.beginAppUploadBatch(any(), any(), any(), any(), any(), any(), any()) + } answers { + for (i in args.indices) { + val a = args[i] + if (a is List<*> && a.all { it is String } && capturedFilesToUpload.isEmpty()) { + capturedFilesToUpload.add(a as List) + } + } + CompletableFuture.completedFuture(mockUploadBatchResponse) + } + + val mockFileUploadInfo = mock<`in`.dragonbra.javasteam.steam.handlers.steamcloud.FileUploadInfo>() + whenever(mockFileUploadInfo.blockRequests).thenReturn(emptyList()) + + every { mockSteamCloud.beginFileUpload(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } returns + CompletableFuture.completedFuture(mockFileUploadInfo) + + every { mockSteamCloud.commitFileUpload(any(), any(), any(), any(), any()) } returns + CompletableFuture.completedFuture(true) + + every { mockSteamCloud.completeAppUploadBatch(any(), any(), any(), any()) } returns + CompletableFuture.completedFuture(Unit) + + val prefixToPath: (String) -> String = { prefix -> + when (prefix) { + "WinAppDataLocalLow" -> localLowRoot.absolutePath + "SteamUserData" -> userdataRoot.absolutePath + else -> tempDir.absolutePath + } + } + + val result = SteamAutoCloud.syncUserFiles( + appInfo = appUnderTest, + clientId = clientId, + steamInstance = mockSteamService, + steamCloud = mockSteamCloud, + preferredSave = SaveLocation.None, + prefixToPath = prefixToPath, + ).await() + + assertNotNull("Result should not be null", result) + assertEquals("Should upload 1 file from the account ID child folder", 1, result!!.filesUploaded) + assertTrue("Uploads should be completed", result.uploadsCompleted) + + val filesToUpload = capturedFilesToUpload.singleOrNull() ?: emptyList() + assertTrue( + "Upload should use the account ID cloud prefix and nested Saves file. Got: $filesToUpload", + filesToUpload.contains("%WinAppDataLocalLow%75264032/Saves/1003.sav") + ) + } + + @Test + fun twoPointMuseumDownloadWithSlashAfterRootLandsInCloudDirectory() = runBlocking { + db.appChangeNumbersDao().deleteByAppId(steamAppId) + db.appFileChangeListsDao().deleteByAppId(steamAppId) + db.appChangeNumbersDao().insert(app.gamenative.data.ChangeNumbers(steamAppId, 0)) + db.appFileChangeListsDao().insert(steamAppId, emptyList()) + + val localLowRoot = File(tempDir, "local-low-download") + val userdataRoot = File(tempDir, "userdata-download") + val cloudContent = "museum cloud save content".toByteArray() + val cloudSha = CryptoHelper.shaHash(cloudContent) + val expectedSave = File( + localLowRoot, + "Two Point Studios/Two Point Museum/Cloud/75264032/Saves/Slot1/1003.sav", + ) + val wrongSave = File(localLowRoot, "75264032/Saves/Slot1/1003.sav") + + val appUnderTest = db.steamAppDao().findApp(steamAppId)!! + .copy( + ufs = UFS( + saveFilePatterns = listOf( + SaveFilePattern( + root = PathType.WinAppDataLocalLow, + path = "Two Point Studios/Two Point Museum/Cloud/75264032", + pattern = "*", + recursive = 1, + uploadRoot = PathType.WinAppDataLocalLow, + uploadPath = "75264032", + ), + ), + ), + ) + + val mockFile = mock() + whenever(mockFile.filename).thenReturn("1003.sav") + whenever(mockFile.shaFile).thenReturn(cloudSha) + whenever(mockFile.pathPrefixIndex).thenReturn(0) + whenever(mockFile.timestamp).thenReturn(Date()) + whenever(mockFile.rawFileSize).thenReturn(cloudContent.size) + + val cloudChangeNumber = 5L + val mockAppFileChangeList = mock() + whenever(mockAppFileChangeList.currentChangeNumber).thenReturn(cloudChangeNumber) + whenever(mockAppFileChangeList.isOnlyDelta).thenReturn(false) + whenever(mockAppFileChangeList.appBuildIDHwm).thenReturn(0) + whenever(mockAppFileChangeList.pathPrefixes).thenReturn(listOf("%WinAppDataLocalLow%75264032/Saves/Slot1/")) + whenever(mockAppFileChangeList.machineNames).thenReturn(emptyList()) + whenever(mockAppFileChangeList.files).thenReturn(listOf(mockFile)) + + every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns + CompletableFuture.completedFuture(mockAppFileChangeList) + + val mockDownloadInfo = mock() + whenever(mockDownloadInfo.urlHost).thenReturn("test.example.com") + whenever(mockDownloadInfo.urlPath).thenReturn("/download/two-point-museum") + whenever(mockDownloadInfo.useHttps).thenReturn(true) + whenever(mockDownloadInfo.requestHeaders).thenReturn(emptyList()) + whenever(mockDownloadInfo.fileSize).thenReturn(cloudContent.size) + whenever(mockDownloadInfo.rawFileSize).thenReturn(cloudContent.size) + whenever(mockDownloadInfo.timestamp).thenReturn(Date()) + + every { mockSteamCloud.clientFileDownload(any(), any()) } answers { + assertEquals("%WinAppDataLocalLow%75264032/Saves/Slot1/1003.sav", secondArg()) + CompletableFuture.completedFuture(mockDownloadInfo) + } + every { mockSteamCloud.clientFileDownload(any(), any(), any(), any(), any()) } answers { + assertEquals("%WinAppDataLocalLow%75264032/Saves/Slot1/1003.sav", secondArg()) + CompletableFuture.completedFuture(mockDownloadInfo) + } + + val mockHttpClient = mock() + val mockCall = mock() + every { Net.httpForParallelDownloads(any()) } returns mockHttpClient + whenever(mockHttpClient.newCall(any())).thenReturn(mockCall) + whenever(mockCall.execute()).thenReturn( + Response.Builder() + .request(okhttp3.Request.Builder().url("https://test.example.com/download/two-point-museum").build()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(cloudContent.toResponseBody(null)) + .build(), + ) + + val prefixToPath: (String) -> String = { prefix -> + when (prefix) { + "WinAppDataLocalLow" -> localLowRoot.absolutePath + "SteamUserData" -> userdataRoot.absolutePath + else -> tempDir.absolutePath + } + } + + val result = SteamAutoCloud.syncUserFiles( + appInfo = appUnderTest, + clientId = clientId, + steamInstance = mockSteamService, + steamCloud = mockSteamCloud, + preferredSave = SaveLocation.None, + prefixToPath = prefixToPath, + ).await() + + assertNotNull("Result should not be null", result) + assertEquals(SyncResult.Success, result!!.syncResult) + assertEquals(1, result.filesDownloaded) + assertTrue("Downloaded save should land in the game's Cloud directory", expectedSave.exists()) + assertFalse("Downloaded save must not land directly under LocalLow/accountId", wrongSave.exists()) + assertEquals(cloudContent.contentToString(), expectedSave.readBytes().contentToString()) + } + @Test fun uploadUsesOriginalRootPrefixWhenRootoverrideApplied() = runBlocking { val matchingChangeNumber = 5 From 998da633f47128b51db07b12c657d4ddd9cdeafe Mon Sep 17 00:00:00 2001 From: Dan Brooke Date: Wed, 20 May 2026 20:48:09 +0200 Subject: [PATCH 2/2] Refresh Steam Cloud state after UFS remap upgrades Bump the UFS parse version so cached SteamApp rows using older path mapping are regenerated. For apps with UFS rootoverrides, clear the stored Steam app change number while preserving the local file snapshot. The next sync performs a full cloud query and can download files using the corrected path mapping, without turning existing local files into an unnecessary conflict. --- .../app/gamenative/service/SteamService.kt | 7 +++--- .../app/gamenative/utils/KeyValueUtils.kt | 2 +- .../gamenative/service/SteamAutoCloudTest.kt | 25 +++++++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 5c503bca98..90555cf8b1 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -4188,10 +4188,9 @@ class SteamService : Service(), IChallengeUrlChanged { licenseFlags = packageFromDb?.licenseFlags ?: EnumSet.noneOf(ELicenseFlags::class.java), ) if (ufsParseVersionOutdated && newApp.ufs.saveFilePatterns.any { it.uploadRoot != it.root || it.uploadPath != it.path }) { - // UFS path logic changed and this app has rootoverrides — clear - // the file cache so the next sync detects the mismatch and - // prompts the user to choose between local and cloud saves. - fileChangeListsDao.deleteByAppId(app.id) + // UFS path logic changed and this app has rootoverrides: store 0 to force one + // full cloud query while preserving the local sync snapshot. + changeNumbersDao.insert(app.id, 0L) } newApp } else { diff --git a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt index b8b24c929f..81c6b7f77f 100644 --- a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt +++ b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt @@ -26,7 +26,7 @@ import `in`.dragonbra.javasteam.types.KeyValue import java.util.Date import timber.log.Timber -const val CURRENT_UFS_PARSE_VERSION = 3 +const val CURRENT_UFS_PARSE_VERSION = 4 /** * Extension functions relating to [KeyValue] as the receiver type. diff --git a/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt index 22dfb3aa7b..e2fa87ba94 100644 --- a/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt +++ b/app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt @@ -2463,6 +2463,31 @@ class SteamAutoCloudTest { ) } + @Test + fun ufsRefreshPreservesSnapshot_emptyCloudDoesNotDeleteLocalFiles() = runBlocking { + cacheCurrentLocalFiles(0) + val localSave = File(saveFilesDir, "SaveData_0.sav") + assertTrue("Precondition: local save file exists", localSave.exists()) + + every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns + CompletableFuture.completedFuture(makeCloudFileChangeList(cloudChangeNumber = 0)) + + val testApp = db.steamAppDao().findApp(steamAppId)!! + val result = SteamAutoCloud.syncUserFiles( + appInfo = testApp, + clientId = clientId, + steamInstance = mockSteamService, + steamCloud = mockSteamCloud, + preferredSave = SaveLocation.None, + prefixToPath = makePrefixToPath(), + ).await() + + assertNotNull(result) + assertEquals(SyncResult.UpToDate, result!!.syncResult) + assertEquals("No local files should be deleted", 0, result.filesDeleted) + assertTrue("Local save should be preserved when cloud is empty", localSave.exists()) + } + // ── Scenario 11: Brand new game, never played anywhere — nothing to sync ── @Test fun neverSynced_noLocalFiles_noCloud_succeeds() = runBlocking {