From 6e6f3d080ecbda7eb42de5c858ae692243279b07 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:39:02 +0800 Subject: [PATCH 1/3] Adds game-specific save location symlink support Implements a system to create symbolic links for game save locations, addressing compatibility issues with games that store saves in non-standard locations. Adds a registry to define these workarounds, allowing for automatic creation of symlinks based on the game's app ID. The system supports placeholders for Steam ID and Account ID to accommodate user-specific save paths. --- .../enums/SaveLocationWorkaround.kt | 42 ++++++++++++ .../java/app/gamenative/utils/SteamUtils.kt | 66 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 app/src/main/java/app/gamenative/enums/SaveLocationWorkaround.kt diff --git a/app/src/main/java/app/gamenative/enums/SaveLocationWorkaround.kt b/app/src/main/java/app/gamenative/enums/SaveLocationWorkaround.kt new file mode 100644 index 000000000..29f77e85e --- /dev/null +++ b/app/src/main/java/app/gamenative/enums/SaveLocationWorkaround.kt @@ -0,0 +1,42 @@ +package app.gamenative.enums + +/** + * Configuration for game-specific save location workarounds + * + * This data class defines how to create symlinks for games that store saves + * in non-standard locations or need compatibility workarounds. + * + * @property appId The Steam app ID + * @property pathType Which PathType to use as the base path (e.g., WinAppDataLocal, WinMyDocuments) + * @property sourceRelativePath Relative path from the base path to the actual save location + * @property targetRelativePath Relative path where the symlink should be created + * @property description Human-readable game name for logging + * + * Supports placeholders in paths: + * - {64BitSteamID} - Replaced with the user's 64-bit Steam ID + * - {Steam3AccountID} - Replaced with the user's Steam3 account ID + */ +data class SaveLocationWorkaround( + val appId: Int, + val pathType: PathType, + val sourceRelativePath: String, + val targetRelativePath: String, + val description: String +) { + companion object { + /** + * Registry of game-specific save location workarounds + * Add new entries here for games that need save folder symlinks + */ + val registry = listOf( + SaveLocationWorkaround( + appId = 2680010, + pathType = PathType.WinAppDataLocal, + sourceRelativePath = "The First Berserker Khazan/Saved/SaveGames/{64BitSteamID}", + targetRelativePath = "BBQ/Saved/SaveGames", + description = "The First Berserker Khazan" + ) + // Add more game workarounds here as needed + ) + } +} diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 250291fb3..41867e13d 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -11,6 +11,7 @@ import app.gamenative.data.SaveFilePattern import app.gamenative.data.SteamApp import app.gamenative.enums.Marker import app.gamenative.enums.PathType +import app.gamenative.enums.SaveLocationWorkaround import app.gamenative.service.SteamService import app.gamenative.service.SteamService.Companion.getAppDirName import app.gamenative.service.SteamService.Companion.getAppInfoOf @@ -221,6 +222,10 @@ object SteamUtils { // Create Steam ACF manifest for real Steam compatibility createAppManifest(context, steamAppId) + + // Game-specific workarounds + ensureSaveLocationsForGames(context, steamAppId) + MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_REPLACED) } @@ -262,6 +267,9 @@ object SteamUtils { val ticketBase64 = SteamService.instance?.getEncryptedAppTicketBase64(steamAppId) ensureSteamSettings(context, File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient.dll").toPath(), appId, ticketBase64) + // Game-specific workarounds + ensureSaveLocationsForGames(context, steamAppId) + MarkerUtils.addMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) } @@ -727,6 +735,10 @@ object SteamUtils { // Create Steam ACF manifest for real Steam compatibility createAppManifest(context, steamAppId) + + // Game-specific workarounds + ensureSaveLocationsForGames(context, steamAppId) + MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_RESTORED) } @@ -1209,5 +1221,59 @@ object SteamUtils { fun getSteam3AccountId(): Long? { return SteamService.userSteamId?.accountID?.toLong() } + + /** + * Ensures save locations for games that require special handling (e.g., symlinks) + * This function checks if the current game needs any save location workarounds + * and applies them automatically. + * + * Supports placeholders in paths: + * - {64BitSteamID} - Replaced with the user's 64-bit Steam ID + * - {Steam3AccountID} - Replaced with the user's Steam3 account ID + */ + fun ensureSaveLocationsForGames(context: Context, steamAppId: Int) { + val workaround = SaveLocationWorkaround.registry.find { it.appId == steamAppId } ?: return + + try { + val accountId = SteamService.userSteamId?.accountID?.toLong() ?: 0L + val steamId64 = SteamService.userSteamId?.convertToUInt64()?.toString() ?: "0" + val steam3AccountId = accountId.toString() + + val basePath = workaround.pathType.toAbsPath(context, steamAppId, accountId) + + // Substitute placeholders in paths + val sourceRelativePath = workaround.sourceRelativePath + .replace("{64BitSteamID}", steamId64) + .replace("{Steam3AccountID}", steam3AccountId) + val targetRelativePath = workaround.targetRelativePath + .replace("{64BitSteamID}", steamId64) + .replace("{Steam3AccountID}", steam3AccountId) + + val sourcePath = File(basePath, sourceRelativePath) + val targetPath = File(basePath, targetRelativePath) + + if (!sourcePath.exists()) { + Timber.i("[${workaround.description}] Source save folder does not exist yet: ${sourcePath.absolutePath}") + return + } + + if (targetPath.exists()) { + if (Files.isSymbolicLink(targetPath.toPath())) { + Timber.i("[${workaround.description}] Symlink already exists: ${targetPath.absolutePath}") + return + } else { + Timber.w("[${workaround.description}] Target path exists but is not a symlink: ${targetPath.absolutePath}") + return + } + } + + targetPath.parentFile?.mkdirs() + + Files.createSymbolicLink(targetPath.toPath(), sourcePath.toPath()) + Timber.i("[${workaround.description}] Created symlink: ${targetPath.absolutePath} -> ${sourcePath.absolutePath}") + } catch (e: Exception) { + Timber.e(e, "[${workaround.description}] Failed to create save location symlink") + } + } } From 2e88aedd78c40887d843b9368af68dfcec408863 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:18:03 +0800 Subject: [PATCH 2/3] Renames `SaveLocationWorkaround` to `SpecialGameSaveMapping` --- ...orkaround.kt => SpecialGameSaveMapping.kt} | 4 +-- .../java/app/gamenative/utils/SteamUtils.kt | 34 ++++++++----------- 2 files changed, 16 insertions(+), 22 deletions(-) rename app/src/main/java/app/gamenative/enums/{SaveLocationWorkaround.kt => SpecialGameSaveMapping.kt} (95%) diff --git a/app/src/main/java/app/gamenative/enums/SaveLocationWorkaround.kt b/app/src/main/java/app/gamenative/enums/SpecialGameSaveMapping.kt similarity index 95% rename from app/src/main/java/app/gamenative/enums/SaveLocationWorkaround.kt rename to app/src/main/java/app/gamenative/enums/SpecialGameSaveMapping.kt index 29f77e85e..ae0208917 100644 --- a/app/src/main/java/app/gamenative/enums/SaveLocationWorkaround.kt +++ b/app/src/main/java/app/gamenative/enums/SpecialGameSaveMapping.kt @@ -16,7 +16,7 @@ package app.gamenative.enums * - {64BitSteamID} - Replaced with the user's 64-bit Steam ID * - {Steam3AccountID} - Replaced with the user's Steam3 account ID */ -data class SaveLocationWorkaround( +data class SpecialGameSaveMapping( val appId: Int, val pathType: PathType, val sourceRelativePath: String, @@ -29,7 +29,7 @@ data class SaveLocationWorkaround( * Add new entries here for games that need save folder symlinks */ val registry = listOf( - SaveLocationWorkaround( + SpecialGameSaveMapping( appId = 2680010, pathType = PathType.WinAppDataLocal, sourceRelativePath = "The First Berserker Khazan/Saved/SaveGames/{64BitSteamID}", diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 41867e13d..a3cd79451 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -3,20 +3,15 @@ package app.gamenative.utils import android.annotation.SuppressLint import android.content.Context import android.provider.Settings -import androidx.navigation.ActivityNavigator import app.gamenative.PrefManager import app.gamenative.data.DepotInfo -import app.gamenative.data.LibraryItem -import app.gamenative.data.SaveFilePattern import app.gamenative.data.SteamApp import app.gamenative.enums.Marker -import app.gamenative.enums.PathType -import app.gamenative.enums.SaveLocationWorkaround +import app.gamenative.enums.SpecialGameSaveMapping import app.gamenative.service.SteamService import app.gamenative.service.SteamService.Companion.getAppDirName import app.gamenative.service.SteamService.Companion.getAppInfoOf import com.winlator.container.Container -import com.winlator.container.ContainerManager import com.winlator.core.TarCompressorUtils import com.winlator.core.WineRegistryEditor import com.winlator.xenvironment.ImageFs @@ -34,7 +29,6 @@ import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone import kotlin.io.path.absolutePathString -import kotlin.io.path.exists import kotlin.io.path.name import timber.log.Timber import okhttp3.* @@ -223,7 +217,7 @@ object SteamUtils { // Create Steam ACF manifest for real Steam compatibility createAppManifest(context, steamAppId) - // Game-specific workarounds + // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId) MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_REPLACED) @@ -267,7 +261,7 @@ object SteamUtils { val ticketBase64 = SteamService.instance?.getEncryptedAppTicketBase64(steamAppId) ensureSteamSettings(context, File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/steamclient.dll").toPath(), appId, ticketBase64) - // Game-specific workarounds + // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId) MarkerUtils.addMarker(appDirPath, Marker.STEAM_COLDCLIENT_USED) @@ -736,7 +730,7 @@ object SteamUtils { // Create Steam ACF manifest for real Steam compatibility createAppManifest(context, steamAppId) - // Game-specific workarounds + // Game-specific Handling ensureSaveLocationsForGames(context, steamAppId) MarkerUtils.addMarker(appDirPath, Marker.STEAM_DLL_RESTORED) @@ -1224,7 +1218,7 @@ object SteamUtils { /** * Ensures save locations for games that require special handling (e.g., symlinks) - * This function checks if the current game needs any save location workarounds + * This function checks if the current game needs any save location mappings * and applies them automatically. * * Supports placeholders in paths: @@ -1232,20 +1226,20 @@ object SteamUtils { * - {Steam3AccountID} - Replaced with the user's Steam3 account ID */ fun ensureSaveLocationsForGames(context: Context, steamAppId: Int) { - val workaround = SaveLocationWorkaround.registry.find { it.appId == steamAppId } ?: return + val mapping = SpecialGameSaveMapping.registry.find { it.appId == steamAppId } ?: return try { val accountId = SteamService.userSteamId?.accountID?.toLong() ?: 0L val steamId64 = SteamService.userSteamId?.convertToUInt64()?.toString() ?: "0" val steam3AccountId = accountId.toString() - val basePath = workaround.pathType.toAbsPath(context, steamAppId, accountId) + val basePath = mapping.pathType.toAbsPath(context, steamAppId, accountId) // Substitute placeholders in paths - val sourceRelativePath = workaround.sourceRelativePath + val sourceRelativePath = mapping.sourceRelativePath .replace("{64BitSteamID}", steamId64) .replace("{Steam3AccountID}", steam3AccountId) - val targetRelativePath = workaround.targetRelativePath + val targetRelativePath = mapping.targetRelativePath .replace("{64BitSteamID}", steamId64) .replace("{Steam3AccountID}", steam3AccountId) @@ -1253,16 +1247,16 @@ object SteamUtils { val targetPath = File(basePath, targetRelativePath) if (!sourcePath.exists()) { - Timber.i("[${workaround.description}] Source save folder does not exist yet: ${sourcePath.absolutePath}") + Timber.i("[${mapping.description}] Source save folder does not exist yet: ${sourcePath.absolutePath}") return } if (targetPath.exists()) { if (Files.isSymbolicLink(targetPath.toPath())) { - Timber.i("[${workaround.description}] Symlink already exists: ${targetPath.absolutePath}") + Timber.i("[${mapping.description}] Symlink already exists: ${targetPath.absolutePath}") return } else { - Timber.w("[${workaround.description}] Target path exists but is not a symlink: ${targetPath.absolutePath}") + Timber.w("[${mapping.description}] Target path exists but is not a symlink: ${targetPath.absolutePath}") return } } @@ -1270,9 +1264,9 @@ object SteamUtils { targetPath.parentFile?.mkdirs() Files.createSymbolicLink(targetPath.toPath(), sourcePath.toPath()) - Timber.i("[${workaround.description}] Created symlink: ${targetPath.absolutePath} -> ${sourcePath.absolutePath}") + Timber.i("[${mapping.description}] Created symlink: ${targetPath.absolutePath} -> ${sourcePath.absolutePath}") } catch (e: Exception) { - Timber.e(e, "[${workaround.description}] Failed to create save location symlink") + Timber.e(e, "[${mapping.description}] Failed to create save location symlink") } } } From 5ff6feea85343b358ef48bd5943a9c91da853382 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:33:21 +0800 Subject: [PATCH 3/3] wording --- .../app/gamenative/enums/SpecialGameSaveMapping.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/gamenative/enums/SpecialGameSaveMapping.kt b/app/src/main/java/app/gamenative/enums/SpecialGameSaveMapping.kt index ae0208917..063023e2c 100644 --- a/app/src/main/java/app/gamenative/enums/SpecialGameSaveMapping.kt +++ b/app/src/main/java/app/gamenative/enums/SpecialGameSaveMapping.kt @@ -1,17 +1,17 @@ package app.gamenative.enums /** - * Configuration for game-specific save location workarounds - * + * Configuration for game-specific save location mappings + * * This data class defines how to create symlinks for games that store saves - * in non-standard locations or need compatibility workarounds. - * + * in non-standard locations or need compatibility mappings. + * * @property appId The Steam app ID * @property pathType Which PathType to use as the base path (e.g., WinAppDataLocal, WinMyDocuments) * @property sourceRelativePath Relative path from the base path to the actual save location * @property targetRelativePath Relative path where the symlink should be created * @property description Human-readable game name for logging - * + * * Supports placeholders in paths: * - {64BitSteamID} - Replaced with the user's 64-bit Steam ID * - {Steam3AccountID} - Replaced with the user's Steam3 account ID @@ -25,7 +25,7 @@ data class SpecialGameSaveMapping( ) { companion object { /** - * Registry of game-specific save location workarounds + * Registry of game-specific save location mappings * Add new entries here for games that need save folder symlinks */ val registry = listOf( @@ -36,7 +36,7 @@ data class SpecialGameSaveMapping( targetRelativePath = "BBQ/Saved/SaveGames", description = "The First Berserker Khazan" ) - // Add more game workarounds here as needed + // Add more game mappings here as needed ) } }