Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions app/src/main/java/app/gamenative/service/SteamAutoCloud.kt
Original file line number Diff line number Diff line change
Expand Up @@ -881,21 +881,52 @@ object SteamAutoCloud {
}.inWholeMicroseconds

val hasUncachedLocalFiles = cacheIsAbsentOrEmpty && allLocalUserFiles.isNotEmpty()
var rehydratedSilently = false
if (hasUncachedLocalFiles) {
if (localAppChangeNumber < 0) {
// first offline play: never synced, no cache, but local
// files exist — cloud would silently overwrite them
hasLocalChanges = true
conflictUfsVersion = CURRENT_UFS_PARSE_VERSION
// 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.
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.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)
}
}
syncResult = SyncResult.UpToDate
filesManaged = allLocalUserFiles.size
rehydratedSilently = true
Comment on lines +897 to +918
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

we're checking the sha here twice, first here, and then when deciding whether to upload/download games. Can we reduce that duplication? Gonna happen twice on every game boot

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The SHA check is gated by hasUncachedLocalFiles, will only happen after a destructive DB update, not on every boot. If hasUncachedLocalFiles == true, there is either:

  • rehydration and no 2nd SHA check, or
  • conflict dialog, which would do a 2nd SHA check via getFilesDiff or hasHashConflicts depending on the resolution.

So the potential duplication is only if there's been a destructive DB update, and only if there's a genuine conflict. I think the (significant) additional complexity for consolidation of those two checks isn't justified. Or are you referring to something else?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

ah fair you're right

} else {
// app update cleared the cache but a prior sync was
// recorded — can't tell if local files changed
hasLocalChanges = true
conflictUfsVersion = CURRENT_UFS_PARSE_VERSION
remoteTimestamp = appFileListChange.files.map { it.timestamp.time }.maxOrNull() ?: 0L
localTimestamp = allLocalUserFiles.map { it.timestamp }.maxOrNull() ?: 0L
}
}

if (!hasLocalChanges) {
if (rehydratedSilently) {
// nothing to do — cache is now consistent with cloud
} else if (!hasLocalChanges) {
// we can safely download the new changes since no changes have been
// made locally

Expand Down
80 changes: 80 additions & 0 deletions app/src/test/java/app/gamenative/service/SteamAutoCloudTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2168,5 +2168,85 @@ class SteamAutoCloudTest {
assertEquals(SyncResult.Success, result!!.syncResult)
assertTrue("Should have downloaded files", result.filesDownloaded > 0)
}

// ── Scenario 16: DB cache wiped by destructive migration, local == remote ──
// Repro of the "every steam game reports conflict post-update" bug. When
// fallbackToDestructiveMigration wipes file_change_lists but local save files
// are byte-identical to the cloud manifest, we must rehydrate the cache
// silently and NOT show a conflict dialog.
@Test
fun dbCleared_localMatchesRemote_rehydratesSilently_noConflict() = runBlocking {
db.appChangeNumbersDao().deleteByAppId(steamAppId)
db.appFileChangeListsDao().deleteByAppId(steamAppId)

// the 5 files created in setUp() — cloud manifest must match exactly.
// local scan basePath = %WinMyDocuments%/My Games/TestGame/Steam/{id},
// files live under SaveGames/ → filename (relativized) includes that prefix.
val localFiles = mapOf(
"SaveGames/AutoSaveData.sav" to "autosave content".toByteArray(),
"SaveGames/SaveData_0.sav" to "savedata0 content".toByteArray(),
"SaveGames/ContinueSaveData.sav" to "continue content".toByteArray(),
"SaveGames/SaveData_1.sav" to "savedata1 content".toByteArray(),
"SaveGames/SystemData_0.sav" to "systemdata content".toByteArray(),
)

assertTrue("Precondition: local save files exist", saveFilesDir.listFiles()!!.isNotEmpty())

val cloudFiles = localFiles.map { (name, content) ->
val m = mock<AppFileInfo>()
whenever(m.filename).thenReturn(name)
whenever(m.shaFile).thenReturn(sha1(content))
whenever(m.pathPrefixIndex).thenReturn(0)
whenever(m.timestamp).thenReturn(Date())
whenever(m.rawFileSize).thenReturn(content.size)
m
}

val cloudFileChangeList = makeCloudFileChangeList(
cloudChangeNumber = 5,
files = cloudFiles,
pathPrefixes = listOf("%WinMyDocuments%/My Games/TestGame/Steam/76561198025127569"),
)
every { mockSteamCloud.getAppFileListChange(any(), any(), any()) } returns
CompletableFuture.completedFuture(cloudFileChangeList)

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(
"local matches remote exactly — should be UpToDate, not Conflict",
SyncResult.UpToDate,
result!!.syncResult,
)
assertNull(
"no conflict dialog — conflictUfsVersion must be null",
result.conflictUfsVersion,
)
assertEquals("no downloads", 0, result.filesDownloaded)
assertEquals("no uploads", 0, result.filesUploaded)

// cache must be rehydrated so next launch doesn't trip the same path
val rehydrated = db.appFileChangeListsDao().getByAppId(steamAppId)
assertNotNull("cache should be rehydrated", rehydrated)
assertEquals(
"rehydrated cache should contain all 5 local files",
5,
rehydrated!!.userFileInfo.size,
)
val rehydratedCn = db.appChangeNumbersDao().getByAppId(steamAppId)
assertEquals(
"rehydrated change number should match cloud",
5L,
rehydratedCn!!.changeNumber,
)
}
}

Loading