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
Binary file modified app/src/main/assets/redirect.tzst
Binary file not shown.
22 changes: 20 additions & 2 deletions app/src/main/java/app/gamenative/service/DownloadService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app.gamenative.service

import android.content.Context
import android.os.Environment
import app.gamenative.PrefManager
import app.gamenative.utils.StorageUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -37,10 +38,27 @@ object DownloadService {
baseExternalAppDirPath = extFiles?.parentFile?.path ?: ""

val sm = context.getSystemService(android.os.storage.StorageManager::class.java)
externalVolumePaths = StorageUtils.getAllExternalFilesDirs(context)
val appFilesDirs = StorageUtils.getAllExternalFilesDirs(context)
.filter { Environment.getExternalStorageState(it) == Environment.MEDIA_MOUNTED }
.filter { sm?.getStorageVolume(it)?.isPrimary != true }
.map { it.absolutePath }
// both layouts per volume: legacy Android/data (existing installs) + public root (new installs)
externalVolumePaths = appFilesDirs
.flatMap { dir -> listOfNotNull(dir.absolutePath, StorageUtils.publicInstallRoot(dir)?.absolutePath) }
.distinct()

migrateExternalStoragePath()
}

// Android/data paths pay a ~1000x FUSE metadata penalty (MediaProvider disables kernel
// caching there); repoint the install pref at the public root so new installs avoid it
private fun migrateExternalStoragePath() {
val pref = PrefManager.externalStoragePath
if (pref.isBlank() || !pref.contains("/Android/data/")) return
val public = StorageUtils.publicInstallRoot(File(pref)) ?: return
if (StorageUtils.ensureInstallRoot(public)) {
Timber.i("Migrating external install root from $pref to ${public.absolutePath}")
PrefManager.externalStoragePath = public.absolutePath

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: Interrupted GOG, Epic, and Amazon downloads on existing external installs stop being resumable after startup. This repoints the sole external root without migrating legacy partial directories or retaining the old root in each service’s scan set; migrate those directories before updating the preference, or preserve the legacy root for discovery.

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/DownloadService.kt, line 60:

<comment>Interrupted GOG, Epic, and Amazon downloads on existing external installs stop being resumable after startup. This repoints the sole external root without migrating legacy partial directories or retaining the old root in each service’s scan set; migrate those directories before updating the preference, or preserve the legacy root for discovery.</comment>

<file context>
@@ -37,10 +38,27 @@ object DownloadService {
+        val public = StorageUtils.publicInstallRoot(File(pref)) ?: return
+        if (StorageUtils.ensureInstallRoot(public)) {
+            Timber.i("Migrating external install root from $pref to ${public.absolutePath}")
+            PrefManager.externalStoragePath = public.absolutePath
+        }
     }
</file context>

}
}

@Synchronized
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -561,16 +561,18 @@ fun SettingsGroupInterface(
useExternalStorage = it
PrefManager.useExternalStorage = it
if (it && dirs.isNotEmpty()) {
PrefManager.externalStoragePath = dirs[0].absolutePath
PrefManager.externalStoragePath = StorageUtils.preferredInstallRoot(dirs[0])
}
},
)
if (useExternalStorage) {
// Currently selected item
var selectedIndex by rememberSaveable {
mutableStateOf(
dirs.indexOfFirst { it.absolutePath == PrefManager.externalStoragePath }
.takeIf { it >= 0 } ?: 0,
dirs.indexOfFirst { dir ->
dir.absolutePath == PrefManager.externalStoragePath ||
StorageUtils.publicInstallRoot(dir)?.absolutePath == PrefManager.externalStoragePath
}.takeIf { it >= 0 } ?: 0,
)
}
SettingsListDropdown(
Expand All @@ -579,7 +581,7 @@ fun SettingsGroupInterface(
value = selectedIndex,
onItemSelected = { idx ->
selectedIndex = idx
PrefManager.externalStoragePath = dirs[idx].absolutePath
PrefManager.externalStoragePath = StorageUtils.preferredInstallRoot(dirs[idx])
},
colors = settingsTileColorsAlt(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3658,6 +3658,12 @@ private fun setupXEnvironment(
envVars.remove("DXVK_FRAME_RATE")
envVars.remove("VKD3D_FRAME_RATE")
if (!envVars.has("WINEESYNC")) envVars.put("WINEESYNC", "1")

val ffpGameDir = runCatching {

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.

P2: This FFP_ENABLE logic only resolves the game directory through SteamService.getAppDirPath(), which means it will silently fail (caught by runCatching) for non-Steam games (GOG, Epic, Amazon, custom). As a result, FFP_ENABLE will never be set for those game types even when they're installed on external storage. Consider resolving the A: drive mapping from container.drives instead, which would work regardless of game source.

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/ui/screen/xserver/XServerScreen.kt, line 3662:

<comment>This FFP_ENABLE logic only resolves the game directory through `SteamService.getAppDirPath()`, which means it will silently fail (caught by `runCatching`) for non-Steam games (GOG, Epic, Amazon, custom). As a result, FFP_ENABLE will never be set for those game types even when they're installed on external storage. Consider resolving the `A:` drive mapping from `container.drives` instead, which would work regardless of game source.</comment>

<file context>
@@ -3658,6 +3658,12 @@ private fun setupXEnvironment(
         envVars.remove("VKD3D_FRAME_RATE")
         if (!envVars.has("WINEESYNC")) envVars.put("WINEESYNC", "1")
+
+        val ffpGameDir = runCatching {
+            File(SteamService.getAppDirPath(ContainerUtils.extractGameIdFromContainerId(appId))).canonicalFile.path
+        }.getOrDefault("")
</file context>

File(SteamService.getAppDirPath(ContainerUtils.extractGameIdFromContainerId(appId))).canonicalFile.path

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: The canonicalFile call on line 3663 resolves symbolic links in the game directory path. On Android, /storage/emulated/0 is commonly a symlink to a different mount point (e.g., /mnt/user/0/primary on pre-FUSE devices, or /mnt/shell/emulated/0). After canonicalFile resolves these symlinks, the resulting path will typically not start with /storage/, so the startsWith("/storage/") check on the next line silently evaluates to false and FFP_ENABLE is never set — even for games on external storage. This defeats the purpose of the optimization for the primary external storage volume.

Since getAppDirPath already returns a clean constructed path (without .. or . components), canonicalFile is unnecessary here. Remove it so the check runs against the logical /storage/... path that Android presents to apps.

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/ui/screen/xserver/XServerScreen.kt, line 3663:

<comment>The `canonicalFile` call on line 3663 resolves symbolic links in the game directory path. On Android, `/storage/emulated/0` is commonly a symlink to a different mount point (e.g., `/mnt/user/0/primary` on pre-FUSE devices, or `/mnt/shell/emulated/0`). After `canonicalFile` resolves these symlinks, the resulting path will typically **not** start with `/storage/`, so the `startsWith("/storage/")` check on the next line silently evaluates to `false` and `FFP_ENABLE` is never set — even for games on external storage. This defeats the purpose of the optimization for the primary external storage volume.

Since `getAppDirPath` already returns a clean constructed path (without `..` or `.` components), `canonicalFile` is unnecessary here. Remove it so the check runs against the logical `/storage/...` path that Android presents to apps.</comment>

<file context>
@@ -3658,6 +3658,12 @@ private fun setupXEnvironment(
         if (!envVars.has("WINEESYNC")) envVars.put("WINEESYNC", "1")
+
+        val ffpGameDir = runCatching {
+            File(SteamService.getAppDirPath(ContainerUtils.extractGameIdFromContainerId(appId))).canonicalFile.path
+        }.getOrDefault("")
+        if (ffpGameDir.startsWith("/storage/")) envVars.put("FFP_ENABLE", "1")
</file context>

}.getOrDefault("")
if (ffpGameDir.startsWith("/storage/")) envVars.put("FFP_ENABLE", "1")
Comment on lines +3662 to +3665

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.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Use the container’s resolved game drive, not a Steam-only lookup.

This executes for GOG, Epic, Amazon, and custom games too, but always queries SteamService. Resolve the A: mapping established by ContainerUtils so FFP_ENABLE reflects the launched game’s actual directory.

Proposed fix
-val ffpGameDir = runCatching {
-    File(SteamService.getAppDirPath(ContainerUtils.extractGameIdFromContainerId(appId))).canonicalFile.path
-}.getOrDefault("")
+val ffpGameDir = ContainerUtils.getADrivePath(container.drives)
+    ?.let { runCatching { File(it).canonicalFile.path }.getOrNull() }
+    .orEmpty()
 if (ffpGameDir.startsWith("/storage/")) envVars.put("FFP_ENABLE", "1")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val ffpGameDir = runCatching {
File(SteamService.getAppDirPath(ContainerUtils.extractGameIdFromContainerId(appId))).canonicalFile.path
}.getOrDefault("")
if (ffpGameDir.startsWith("/storage/")) envVars.put("FFP_ENABLE", "1")
val ffpGameDir = ContainerUtils.getADrivePath(container.drives)
?.let { runCatching { File(it).canonicalFile.path }.getOrNull() }
.orEmpty()
if (ffpGameDir.startsWith("/storage/")) envVars.put("FFP_ENABLE", "1")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt` around
lines 3662 - 3665, Update the FFP_ENABLE path logic near ffpGameDir to resolve
the container’s established A: drive mapping through ContainerUtils instead of
calling SteamService.getAppDirPath. Use the resolved game directory for all
supported game sources, including GOG, Epic, Amazon, and custom games, while
preserving the existing /storage/ prefix check and environment-variable
behavior.


val graphicsDriverConfig = KeyValueSet(container.getGraphicsDriverConfig())
if (graphicsDriverConfig.get("version").lowercase(Locale.getDefault()).contains("gen8")) {
var tuDebug = envVars.get("TU_DEBUG")
Expand Down
8 changes: 5 additions & 3 deletions app/src/main/java/app/gamenative/utils/ContainerUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1041,11 +1041,13 @@ object ContainerUtils {
}
}

if (gameFolderPath != null) {
val resolvedGameFolderPath = StorageUtils.migrateLegacyGameDir(gameFolderPath)

if (resolvedGameFolderPath != null) {
// Check if A: drive is already mapped to the correct path
var hasCorrectADrive = false
for (drive in Container.drivesIterator(container.drives)) {
if (drive[0] == "A" && drive[1] == gameFolderPath) {
if (drive[0] == "A" && drive[1] == resolvedGameFolderPath) {
hasCorrectADrive = true
break
}
Expand All @@ -1056,7 +1058,7 @@ object ContainerUtils {
val currentDrives = container.drives
// Rebuild drives string, excluding existing A: drive and adding new one
val drivesBuilder = StringBuilder()
drivesBuilder.append("A:$gameFolderPath")
drivesBuilder.append("A:$resolvedGameFolderPath")

// Add all other drives (excluding A:)
for (drive in Container.drivesIterator(currentDrives)) {
Expand Down
49 changes: 49 additions & 0 deletions app/src/main/java/app/gamenative/utils/StorageUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,55 @@ object StorageUtils {
return result
}

private const val PUBLIC_INSTALL_DIR_NAME = "GameNative"

/**
* Maps an app-specific dir (<volume>/Android/data/<pkg>/files) to a public install root
* (<volume>/GameNative). MediaProvider disables FUSE kernel caching under Android/data,
* making per-open metadata ops ~1000x slower there; public dirs get normal dcache treatment.
*/
fun publicInstallRoot(appFilesDir: File): File? {
val path = appFilesDir.absolutePath
val idx = path.indexOf("/Android/data/")
if (idx <= 0) return null
return File(path.substring(0, idx), PUBLIC_INSTALL_DIR_NAME)

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: Modern builds select a root-level shared-storage directory they cannot create or write on Android 11+, so external installs silently fall back here or later download operations fail. Keep modern installs app-scoped, or gate this public-root path behind the existing legacy all-files-access flow.

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/utils/StorageUtils.kt, line 112:

<comment>Modern builds select a root-level shared-storage directory they cannot create or write on Android 11+, so external installs silently fall back here or later download operations fail. Keep modern installs app-scoped, or gate this public-root path behind the existing legacy all-files-access flow.</comment>

<file context>
@@ -98,6 +98,55 @@ object StorageUtils {
+        val path = appFilesDir.absolutePath
+        val idx = path.indexOf("/Android/data/")
+        if (idx <= 0) return null
+        return File(path.substring(0, idx), PUBLIC_INSTALL_DIR_NAME)
+    }
+
</file context>

}

fun ensureInstallRoot(dir: File): Boolean {
if (!dir.isDirectory && !dir.mkdirs()) return false
runCatching { File(dir, ".nomedia").createNewFile() }
return true
}

fun preferredInstallRoot(appFilesDir: File): String {
val public = publicInstallRoot(appFilesDir)
if (public != null && ensureInstallRoot(public)) return public.absolutePath
return appFilesDir.absolutePath
}

fun migrateLegacyGameDir(path: String?): String? {
if (path.isNullOrBlank()) return path
val idx = path.indexOf("/Android/data/")
if (idx <= 0) return path
val filesIdx = path.indexOf("/files/", idx)
if (filesIdx < 0) return path
val legacyRoot = File(path.substring(0, filesIdx + "/files".length))
val rel = path.substring(filesIdx + "/files/".length)
val src = File(path)
if (!src.isDirectory) return path
val publicRoot = publicInstallRoot(legacyRoot) ?: return path
val dst = File(publicRoot, rel)
if (dst.exists() || !ensureInstallRoot(publicRoot)) return path
Comment on lines +135 to +139

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make legacy-path resolution idempotent.

After a successful first migration, src no longer exists, so a later call with the still-stored legacy path returns it at Line 136 instead of the already-migrated directory. The next container update can therefore remap A: back to a nonexistent legacy path. Return dst.absolutePath when the destination is already a directory.

Proposed fix
 val src = File(path)
-if (!src.isDirectory) return path
 val publicRoot = publicInstallRoot(legacyRoot) ?: return path
 val dst = File(publicRoot, rel)
-if (dst.exists() || !ensureInstallRoot(publicRoot)) return path
+if (dst.isDirectory) return dst.absolutePath
+if (!src.isDirectory || dst.exists() || !ensureInstallRoot(publicRoot)) return path
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val src = File(path)
if (!src.isDirectory) return path
val publicRoot = publicInstallRoot(legacyRoot) ?: return path
val dst = File(publicRoot, rel)
if (dst.exists() || !ensureInstallRoot(publicRoot)) return path
val src = File(path)
val publicRoot = publicInstallRoot(legacyRoot) ?: return path
val dst = File(publicRoot, rel)
if (dst.isDirectory) return dst.absolutePath
if (!src.isDirectory || dst.exists() || !ensureInstallRoot(publicRoot)) return path
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/app/gamenative/utils/StorageUtils.kt` around lines 135 -
139, Update the legacy-path resolution logic around src and dst so that when dst
already exists as a directory, it returns dst.absolutePath even if src no longer
exists. Preserve the existing fallback to path for non-directory destinations
and migration failures, making repeated calls idempotently resolve to the
migrated directory.

Comment on lines +136 to +139

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: migrateLegacyGameDir is not idempotent: after a successful migration, src no longer exists, so !src.isDirectory is true and the function returns the original (now non-existent) legacy path. On a subsequent container update, A: can be remapped to this stale path. The fix is to check whether dst already exists as a directory before the src.isDirectory guard, and return dst.absolutePath in that case.

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/utils/StorageUtils.kt, line 136:

<comment>`migrateLegacyGameDir` is not idempotent: after a successful migration, `src` no longer exists, so `!src.isDirectory` is true and the function returns the original (now non-existent) legacy `path`. On a subsequent container update, `A:` can be remapped to this stale path. The fix is to check whether `dst` already exists as a directory *before* the `src.isDirectory` guard, and return `dst.absolutePath` in that case.</comment>

<file context>
@@ -98,6 +98,55 @@ object StorageUtils {
+        val legacyRoot = File(path.substring(0, filesIdx + "/files".length))
+        val rel = path.substring(filesIdx + "/files/".length)
+        val src = File(path)
+        if (!src.isDirectory) return path
+        val publicRoot = publicInstallRoot(legacyRoot) ?: return path
+        val dst = File(publicRoot, rel)
</file context>
Suggested change
if (!src.isDirectory) return path
val publicRoot = publicInstallRoot(legacyRoot) ?: return path
val dst = File(publicRoot, rel)
if (dst.exists() || !ensureInstallRoot(publicRoot)) return path
val publicRoot = publicInstallRoot(legacyRoot) ?: return path
val dst = File(publicRoot, rel)
if (dst.isDirectory) return dst.absolutePath
if (!src.isDirectory || dst.exists() || !ensureInstallRoot(publicRoot)) return path

dst.parentFile?.mkdirs()
return if (src.renameTo(dst)) {

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: Migrated Epic, GOG, and Amazon games work for the migration launch but revert to their deleted legacy path on the next launch because the source-specific persisted install path is never updated. Persist the destination path with the game metadata as part of a successful migration, or resolve legacy paths to the destination when the source no longer exists.

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/utils/StorageUtils.kt, line 141:

<comment>Migrated Epic, GOG, and Amazon games work for the migration launch but revert to their deleted legacy path on the next launch because the source-specific persisted install path is never updated. Persist the destination path with the game metadata as part of a successful migration, or resolve legacy paths to the destination when the source no longer exists.</comment>

<file context>
@@ -98,6 +98,55 @@ object StorageUtils {
+        val dst = File(publicRoot, rel)
+        if (dst.exists() || !ensureInstallRoot(publicRoot)) return path
+        dst.parentFile?.mkdirs()
+        return if (src.renameTo(dst)) {
+            Timber.i("Migrated game dir $path to ${dst.absolutePath}")
+            dst.absolutePath
</file context>

Timber.i("Migrated game dir $path to ${dst.absolutePath}")
dst.absolutePath
} else {
Timber.w("Could not migrate $path; leaving in place")
path
}
}

/**
* Gets all app-specific external files directories, using StorageManager as a fallback
* for cases where context.getExternalFilesDirs(null) might return null or incomplete results
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
import java.util.concurrent.atomic.AtomicLong;

public abstract class ImageFsInstaller {
public static final byte LATEST_VERSION = 28;
public static final byte LATEST_VERSION = 29;

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not mark the imagefs latest when guest-library deployment fails.

With LATEST_VERSION set to 29, installFromAssetsFuture() can call installGuestLibs() at Line [170], receive an early failure return, and still persist version 29 at Line [171]. Since installIfNeededFuture() skips installation when the stored version is at least 29, subsequent launches will not retry the missing or stale redirect libraries.

Make installGuestLibs() return success/failure—including extraction results—and write the imagefs version only after successful deployment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java` at line
46, The installFromAssetsFuture flow must not persist LATEST_VERSION when
guest-library deployment fails. Update installGuestLibs() to return deployment
success, including extraction outcomes, and make installFromAssetsFuture write
the imagefs version only when that result indicates success so
installIfNeededFuture can retry failed deployments.

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: Bumping LATEST_VERSION to 29 means that if installGuestLibs() fails during installFromAssetsFuture(), version 29 is still persisted. Since installIfNeededFuture() skips installation when the stored version meets LATEST_VERSION, subsequent launches will never retry the failed redirect library deployment. Consider making the version write conditional on successful guest library installation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java, line 46:

<comment>Bumping `LATEST_VERSION` to 29 means that if `installGuestLibs()` fails during `installFromAssetsFuture()`, version 29 is still persisted. Since `installIfNeededFuture()` skips installation when the stored version meets `LATEST_VERSION`, subsequent launches will never retry the failed redirect library deployment. Consider making the version write conditional on successful guest library installation.</comment>

<file context>
@@ -43,7 +43,7 @@
 
 public abstract class ImageFsInstaller {
-    public static final byte LATEST_VERSION = 28;
+    public static final byte LATEST_VERSION = 29;
 
     private static void resetContainerImgVersions(Context context) {
</file context>


private static void resetContainerImgVersions(Context context) {
ContainerManager manager = new ContainerManager(context);
Expand Down
Binary file modified app/src/modern/assets/libredirect-bionic-wx.so
Binary file not shown.
Loading