Skip to content

Speed up external - #1773

Merged
utkarshdalal merged 3 commits into
masterfrom
speed-up-external
Jul 29, 2026
Merged

Speed up external#1773
utkarshdalal merged 3 commits into
masterfrom
speed-up-external

Conversation

@utkarshdalal

@utkarshdalal utkarshdalal commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Description

migrate external games to public external storage to speed up, and update redirect shims to load external storage games faster

Recording

Type of Change

  • Bug fix
  • Performance / stability improvement
  • Compatibility improvements
  • Other (requires prior approval)

Checklist

  • If I have access to #code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.
  • This change aligns with the current project scope (core functionality, stability, or performance). If not, it has been explicitly approved beforehand.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by cubic

Move external installs from app-specific Android/data to a public GameNative folder on each external volume to remove FUSE overhead and speed up launches and IO. Updated redirect shims and env handling so external games start faster.

  • New Features

    • Use a public install root (/storage/.../GameNative) by default for new external installs.
    • Detect both legacy Android/data and new public roots per volume for backward compatibility.
    • Enable fast-path I/O for external games by setting FFP_ENABLE=1 when running from /storage.
    • Bump image FS version to 29 and update redirect shim assets for faster external loads.
  • Migration

    • Automatically repoint the saved external install path from Android/data to the public root when available.
    • On first launch, try moving existing game folders from Android/data to the public root; fall back if the move fails.
    • Settings dropdown recognizes both layouts and saves the public root by default.

Written for commit 8a78fd7. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added improved external storage support, including public game installation locations across mounted storage volumes.
    • Existing game installations can be migrated automatically to the updated storage location.
    • Game launches from external storage now receive improved graphics compatibility settings.
  • Bug Fixes

    • Fixed game directory and selected storage volume handling when switching between storage locations.
    • Updated system files to the latest image version for improved compatibility.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

External storage path migration

Layer / File(s) Summary
Public install-root and legacy migration helpers
app/src/main/java/app/gamenative/utils/StorageUtils.kt
Adds public-root derivation, directory creation with .nomedia, preferred-root selection, and legacy directory migration.
External volume and game-directory integration
app/src/main/java/app/gamenative/service/DownloadService.kt, app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt, app/src/main/java/app/gamenative/utils/ContainerUtils.kt
Persists public install roots, migrates stored legacy paths, enumerates legacy and public volume paths, and updates container A: mappings.
Storage-aware launch environment
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
Enables FFP_ENABLE when the game directory is under /storage/.

Runtime asset updates

Layer / File(s) Summary
Redirect asset and imagefs version refresh
app/src/main/assets/redirect.tzst, app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java
Replaces the compressed redirect payload and increments the latest imagefs version from 28 to 29.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: phobos665, xxjsonderulox

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is too vague to convey the specific change and reads like a generic performance note. Use a specific title such as "Migrate external games to public storage" or similar.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description follows the required template and covers the change, type, and checklist, but the Recording section is empty.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch speed-up-external

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

Inline comments:
In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 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.

In `@app/src/main/java/app/gamenative/utils/StorageUtils.kt`:
- Around line 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.

In `@app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java`:
- 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 25922ec3-14db-4839-b247-13db11c20db2

📥 Commits

Reviewing files that changed from the base of the PR and between 1369c7e and 8a78fd7.

⛔ Files ignored due to path filters (1)
  • app/src/modern/assets/libredirect-bionic-wx.so is excluded by !**/*.so
📒 Files selected for processing (7)
  • app/src/main/assets/redirect.tzst
  • app/src/main/java/app/gamenative/service/DownloadService.kt
  • app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
  • app/src/main/java/app/gamenative/utils/ContainerUtils.kt
  • app/src/main/java/app/gamenative/utils/StorageUtils.kt
  • app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java

Comment on lines +3662 to +3665
val ffpGameDir = runCatching {
File(SteamService.getAppDirPath(ContainerUtils.extractGameIdFromContainerId(appId))).canonicalFile.path
}.getOrDefault("")
if (ffpGameDir.startsWith("/storage/")) envVars.put("FFP_ENABLE", "1")

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.

Comment on lines +135 to +139
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

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.


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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

7 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:3662">
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.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:3663">
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.</violation>
</file>

<file name="app/src/main/java/app/gamenative/utils/StorageUtils.kt">

<violation number="1" location="app/src/main/java/app/gamenative/utils/StorageUtils.kt:112">
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.</violation>

<violation number="2" location="app/src/main/java/app/gamenative/utils/StorageUtils.kt:136">
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.</violation>

<violation number="3" location="app/src/main/java/app/gamenative/utils/StorageUtils.kt:141">
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.</violation>
</file>

<file name="app/src/main/java/app/gamenative/service/DownloadService.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/DownloadService.kt:60">
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.</violation>
</file>

<file name="app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java">

<violation number="1" location="app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java:46">
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.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

if (!envVars.has("WINEESYNC")) envVars.put("WINEESYNC", "1")

val ffpGameDir = runCatching {
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>

Comment on lines +136 to +139
if (!src.isDirectory) return path
val publicRoot = publicInstallRoot(legacyRoot) ?: return path
val dst = File(publicRoot, rel)
if (dst.exists() || !ensureInstallRoot(publicRoot)) return 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: 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

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>

val dst = File(publicRoot, rel)
if (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>

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>


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.

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>

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>

@utkarshdalal
utkarshdalal merged commit a5d0c28 into master Jul 29, 2026
3 checks passed
pull Bot pushed a commit to le-si/GameNative that referenced this pull request Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant