Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import kotlin.io.path.Path
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name

/**
* Manages Epic Cloud Saves - downloading and uploading save files
Expand Down Expand Up @@ -1193,20 +1199,8 @@ object EpicCloudSavesManager {
"{appname}" to game.appName,
)

// Map to Wine prefix paths (like GOG does)
// Check for both proper casing (AppData) and legacy lowercase (appdata)
val usersPath = File(winePrefix, "drive_c/users/$user")
val appDataDir = when {
File(usersPath, "AppData").exists() -> "AppData"
File(usersPath, "appdata").exists() -> "appdata"
File(usersPath, "appData").exists() -> "appData"
else -> "AppData" // Default to proper Windows casing
}

Timber.tag("Epic").d("[Cloud Saves] Using AppData directory name: $appDataDir")

val appDataPath = File(winePrefix, "drive_c/users/$user/$appDataDir/Local").absolutePath
val appDataRoamingPath = File(winePrefix, "drive_c/users/$user/$appDataDir/Roaming").absolutePath
val appDataPath = File(winePrefix, "drive_c/users/$user/AppData/Local").absolutePath
val appDataRoamingPath = File(winePrefix, "drive_c/users/$user/AppData/Roaming").absolutePath
val documentsPath = File(winePrefix, "drive_c/users/$user/Documents").absolutePath
val savedGamesPath = File(winePrefix, "drive_c/users/$user/Saved Games").absolutePath

Expand Down Expand Up @@ -1252,7 +1246,8 @@ object EpicCloudSavesManager {
}
}

val finalPath = File(normalizedParts.joinToString("/"))
val resolvedWinePath: String = resolveCaseInsensitivity(normalizedParts.joinToString("/"))

@cubic-dev-ai cubic-dev-ai Bot Mar 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: Windows-rooted save paths (C:/...) are no longer translated to Wine drive_c, causing incorrect absolute resolution outside the Wine prefix.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt, line 1249:

<comment>Windows-rooted save paths (`C:/...`) are no longer translated to Wine `drive_c`, causing incorrect absolute resolution outside the Wine prefix.</comment>

<file context>
@@ -1246,7 +1246,7 @@ object EpicCloudSavesManager {
         }
 
-        val resolvedWinePath: String = windowsToWinePath(winePrefix, normalizedParts.joinToString("/")) ?: return null
+        val resolvedWinePath: String = resolveCaseInsensitivity(normalizedParts.joinToString("/"))
         val finalPath = File(resolvedWinePath)
 
</file context>
Fix with Cubic

val finalPath = File(resolvedWinePath)
Comment on lines +1249 to +1250

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Windows drive-letter paths currently resolve incorrectly (/C:) and can break save resolution.

At Line 1299, resolution always starts from Path("/"). For input like C:/Users/..., segment C: becomes /C: instead of <winePrefix>/drive_c/....
At Line 1249, that bad result is used as the final save path, so templates using {installdir} or literal C:/... can fail.

💡 Proposed fix
-        val resolvedWinePath: String = resolveCaseInsensitivity(normalizedParts.joinToString("/"))
+        val resolvedWinePath: String = resolveCaseInsensitivity(winePrefix, normalizedParts.joinToString("/"))
         val finalPath = File(resolvedWinePath)
-    private fun resolveCaseInsensitivity(path: String): String {
-        val pathParts = path.replace("\\", "/").split("/")
-        var currentPath = Path("/")
+    private fun resolveCaseInsensitivity(winePrefix: String, path: String): String {
+        val normalized = path.replace("\\", "/")
+        val driveLetter = Regex("^[A-Za-z]:").find(normalized)?.value?.firstOrNull()?.lowercaseChar()
+        val strippedPath = if (driveLetter != null) normalized.drop(2).trimStart('/') else normalized
+        val pathParts = strippedPath.split("/").filter { it.isNotEmpty() }
+
+        var currentPath = when {
+            driveLetter != null -> Path(winePrefix, "drive_$driveLetter")
+            normalized.startsWith("/") -> Path("/")
+            else -> Path(winePrefix)
+        }

         for (segment in pathParts) {
             // If we're at a place where the current path doesn't exist,
             // just resolve as-is and continue
             if (!currentPath.exists() || !currentPath.isDirectory()) {
                 currentPath = currentPath.resolve(segment)
                 continue
             }

             // Avoid directory traversal if possible by trying to directly resolve
             val exactMatchPath = currentPath.resolve(segment)
             if (exactMatchPath.exists()) {
                 currentPath = exactMatchPath
                 continue
             }

             val match = try {
                 currentPath.listDirectoryEntries().firstOrNull {
                     it.name.equals(segment, ignoreCase = true)
                 }
             } catch (_: Exception) {
                 null
             }

             // Use matched casing if not, otherwise fallback to requested casing
             currentPath = match ?: currentPath.resolve(segment)
         }

         return currentPath.absolutePathString()
     }

Also applies to: 1298-1331

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/service/epic/EpicCloudSavesManager.kt`
around lines 1249 - 1250, The drive-letter segments (e.g., "C:" or "C:\") are
being treated as top-level segments because resolution always begins from
Path("/"), causing inputs like "C:/Users/..." to become "/C:..." and break save
resolution; update the logic around resolveCaseInsensitivity and the
normalizedParts handling so that when the first normalizedParts element matches
a Windows drive-letter pattern (e.g., /^[A-Za-z]:$/ or startsWith drive token)
you map it to the appropriate Wine prefix drive path (e.g., winePrefix/drive_c)
and start path resolution from that winePrefix base instead of Path("/"); ensure
the value used to construct resolvedWinePath and finalPath is built from that
adjusted base so templates using {installdir} or literal "C:/" resolve
correctly.


// Check subdirectories for save files
// Some games store saves in user-specific subdirectories (e.g., "0/", "1/", etc.)
Expand All @@ -1278,7 +1273,7 @@ object EpicCloudSavesManager {
}

// Always check for subdirectories with files
val subDirs = finalPath.listFiles { it -> it.isDirectory } ?: emptyArray()
val subDirs = finalPath.listFiles { it.isDirectory } ?: emptyArray()
val dirWithFiles = subDirs.firstOrNull { subDir ->
subDir.listFiles()?.any { it.isFile } == true
}
Expand All @@ -1300,6 +1295,41 @@ object EpicCloudSavesManager {
return actualPath
}

// Resolve a path case insensitively if there's no direct casing match
private fun resolveCaseInsensitivity(path: String): String {
val pathParts = path.replace("\\", "/").split("/")
var currentPath = Path("/")

for (segment in pathParts) {
// If we're at a place where the current path doesn't exist,
// just resolve as-is and continue
if (!currentPath.exists() || !currentPath.isDirectory()) {
currentPath = currentPath.resolve(segment)
continue
}

// Avoid directory traversal if possible by trying to directly resolve
val exactMatchPath = currentPath.resolve(segment)
if (exactMatchPath.exists()) {
currentPath = exactMatchPath
continue
}

val match = try {
currentPath.listDirectoryEntries().firstOrNull {
it.name.equals(segment, ignoreCase = true)
}
} catch (_: Exception) {
null
}

// Use matched casing if not, otherwise fallback to requested casing
currentPath = match ?: currentPath.resolve(segment)
}

return currentPath.absolutePathString()
}
Comment on lines +1299 to +1331

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.

can you explain this method please?

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.

Yeah the idea here is that we have a full unix-style path (ie "/data/app/0/app.gamenative/winePrefix/drive_c/users/xuser/AppData/locallow/Dodge Roll/Enter The Gungeon/LocalA.save"). After splitting up into the different pieces (ie ["data", "app", "0", "app.gamenative"...], we start from the root and see if "data" matches exactly case sensitively. This is to avoid directory traversals for every level if the path already exists. This will keep going until we get to a path segment we can't resolve case sensitively, locallow in this example. That point, it will list the directories of AppData and then check to see if there's any case insensitive matches, which should find "LocalLow" and resolve that instead. After that, the "Dodge Roll" and subsequent directories cannot be found at all so we'll fall into 1327 where "Dodge Roll" is added with its original casing. For "Enter the Gungeon", currentPath doesn't exist since "Dodge Roll" so anything further will fall into the 1307 branch and add the remainder with the original casing.

So in this scenario, we'd expect
"/data/app/0/app.gamenative/winePrefix/drive_c/users/xuser/AppData/locallow/Dodge Roll/Enter The Gungeon/LocalA.save"
to resolve to
"/data/app/0/app.gamenative/winePrefix/drive_c/users/xuser/AppData/LocalLow/Dodge Roll/Enter The Gungeon/LocalA.save"

so we can avoid creating that second directory.


private fun getSyncTimestamp(context: Context, appId: Int): String? {
val prefs = context.getSharedPreferences("epic_cloud_saves", Context.MODE_PRIVATE)
return prefs.getString("sync_timestamp_$appId", null)
Expand Down
Loading