From 83ffefcc3d47b88d1b8a4bdacec34d78bc83aa02 Mon Sep 17 00:00:00 2001 From: GameNative Support Bot Date: Sat, 12 Sep 2026 21:18:15 +0000 Subject: [PATCH 1/7] Add Spore registry pre-install step --- .../main/java/app/gamenative/enums/Marker.kt | 1 + .../app/gamenative/utils/PreInstallSteps.kt | 1 + .../preInstallSteps/SporeRegistryStep.kt | 55 +++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 app/src/main/java/app/gamenative/utils/preInstallSteps/SporeRegistryStep.kt diff --git a/app/src/main/java/app/gamenative/enums/Marker.kt b/app/src/main/java/app/gamenative/enums/Marker.kt index d4955455de..18450abcdd 100644 --- a/app/src/main/java/app/gamenative/enums/Marker.kt +++ b/app/src/main/java/app/gamenative/enums/Marker.kt @@ -9,6 +9,7 @@ enum class Marker(val fileName: String ) { VCREDIST_INSTALLED(".vcredist_installed"), GOG_SCRIPT_INSTALLED(".gog_script_installed"), GOG_SUPPORT_INSTALLED(".gog_support_installed"), + SPORE_REGISTRY_INSTALLED(".spore_registry_installed"), PHYSX_INSTALLED(".physx_installed"), OPENAL_INSTALLED(".openal_installed"), XNA_INSTALLED(".xna_installed"), diff --git a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt index 80f6dd3a1b..1f1a211695 100644 --- a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt +++ b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt @@ -30,6 +30,7 @@ object PreInstallSteps { XnaFrameworkStep, GogScriptInterpreterStep, GogSupportCommandsStep, + SporeRegistryStep, UbisoftConnectStep, ) diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/SporeRegistryStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/SporeRegistryStep.kt new file mode 100644 index 0000000000..b153694c6b --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/SporeRegistryStep.kt @@ -0,0 +1,55 @@ +package app.gamenative.utils + +import app.gamenative.data.GameSource +import app.gamenative.enums.Marker +import com.winlator.container.Container +import java.io.File + +/** + * Creates the registry entries normally supplied by Spore's Steam install script. + * Without the SPORE key, Spore stops at startup with "Configuration script failed". + */ +object SporeRegistryStep : PreInstallStep { + private const val SPORE_APP_ID = "17390" + private const val REGISTRY_KEY = "HKLM\\Software\\Wow6432Node\\Electronic Arts\\SPORE" + + override val marker: Marker = Marker.SPORE_REGISTRY_INSTALLED + + private fun prefixStamp(container: Container): File = + File(container.rootDir, ".wine/${Marker.SPORE_REGISTRY_INSTALLED.fileName}") + + override fun appliesTo( + container: Container, + gameSource: GameSource, + gameDirPath: String, + ): Boolean = gameSource == GameSource.STEAM + + override fun buildCommand( + container: Container, + appId: String, + gameSource: GameSource, + gameDir: File, + gameDirPath: String, + ): String? { + if (appId != SPORE_APP_ID || + (MarkerUtils.hasMarker(gameDirPath, marker) && prefixStamp(container).exists()) + ) { + return null + } + + val values = linkedMapOf( + "InstallLoc" to "A:\\", + "DataDir" to "A:\\Data", + "AppDir" to "A:\\SporeBin", + ) + val commands = values.map { (name, value) -> + "reg add \"$REGISTRY_KEY\" /v $name /t REG_SZ /d \"$value\" /f" + } + + try { + prefixStamp(container).createNewFile() + } catch (_: Exception) { + } + return commands.joinToString(" & ") + } +} From e6d9598934f641763ebda38932e0671097e00817 Mon Sep 17 00:00:00 2001 From: GameNative Support Bot Date: Sat, 12 Sep 2026 21:30:15 +0000 Subject: [PATCH 2/7] Revert "Add Spore registry pre-install step" This reverts commit 83ffefcc3d47b88d1b8a4bdacec34d78bc83aa02. --- .../main/java/app/gamenative/enums/Marker.kt | 1 - .../app/gamenative/utils/PreInstallSteps.kt | 1 - .../preInstallSteps/SporeRegistryStep.kt | 55 ------------------- 3 files changed, 57 deletions(-) delete mode 100644 app/src/main/java/app/gamenative/utils/preInstallSteps/SporeRegistryStep.kt diff --git a/app/src/main/java/app/gamenative/enums/Marker.kt b/app/src/main/java/app/gamenative/enums/Marker.kt index 18450abcdd..d4955455de 100644 --- a/app/src/main/java/app/gamenative/enums/Marker.kt +++ b/app/src/main/java/app/gamenative/enums/Marker.kt @@ -9,7 +9,6 @@ enum class Marker(val fileName: String ) { VCREDIST_INSTALLED(".vcredist_installed"), GOG_SCRIPT_INSTALLED(".gog_script_installed"), GOG_SUPPORT_INSTALLED(".gog_support_installed"), - SPORE_REGISTRY_INSTALLED(".spore_registry_installed"), PHYSX_INSTALLED(".physx_installed"), OPENAL_INSTALLED(".openal_installed"), XNA_INSTALLED(".xna_installed"), diff --git a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt index 1f1a211695..80f6dd3a1b 100644 --- a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt +++ b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt @@ -30,7 +30,6 @@ object PreInstallSteps { XnaFrameworkStep, GogScriptInterpreterStep, GogSupportCommandsStep, - SporeRegistryStep, UbisoftConnectStep, ) diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/SporeRegistryStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/SporeRegistryStep.kt deleted file mode 100644 index b153694c6b..0000000000 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/SporeRegistryStep.kt +++ /dev/null @@ -1,55 +0,0 @@ -package app.gamenative.utils - -import app.gamenative.data.GameSource -import app.gamenative.enums.Marker -import com.winlator.container.Container -import java.io.File - -/** - * Creates the registry entries normally supplied by Spore's Steam install script. - * Without the SPORE key, Spore stops at startup with "Configuration script failed". - */ -object SporeRegistryStep : PreInstallStep { - private const val SPORE_APP_ID = "17390" - private const val REGISTRY_KEY = "HKLM\\Software\\Wow6432Node\\Electronic Arts\\SPORE" - - override val marker: Marker = Marker.SPORE_REGISTRY_INSTALLED - - private fun prefixStamp(container: Container): File = - File(container.rootDir, ".wine/${Marker.SPORE_REGISTRY_INSTALLED.fileName}") - - override fun appliesTo( - container: Container, - gameSource: GameSource, - gameDirPath: String, - ): Boolean = gameSource == GameSource.STEAM - - override fun buildCommand( - container: Container, - appId: String, - gameSource: GameSource, - gameDir: File, - gameDirPath: String, - ): String? { - if (appId != SPORE_APP_ID || - (MarkerUtils.hasMarker(gameDirPath, marker) && prefixStamp(container).exists()) - ) { - return null - } - - val values = linkedMapOf( - "InstallLoc" to "A:\\", - "DataDir" to "A:\\Data", - "AppDir" to "A:\\SporeBin", - ) - val commands = values.map { (name, value) -> - "reg add \"$REGISTRY_KEY\" /v $name /t REG_SZ /d \"$value\" /f" - } - - try { - prefixStamp(container).createNewFile() - } catch (_: Exception) { - } - return commands.joinToString(" & ") - } -} From 37f04dabcb2d8600a828964703f6df4374a2687e Mon Sep 17 00:00:00 2001 From: GameNative Support Bot Date: Sat, 12 Sep 2026 21:29:12 +0000 Subject: [PATCH 3/7] Apply Steam install-script registry entries --- .../main/java/app/gamenative/enums/Marker.kt | 1 + .../app/gamenative/utils/PreInstallSteps.kt | 1 + .../preInstallSteps/SteamInstallScriptStep.kt | 131 ++++++++++++++++++ .../gamenative/utils/PreInstallStepsTest.kt | 1 + .../utils/SteamInstallScriptStepTest.kt | 100 +++++++++++++ 5 files changed, 234 insertions(+) create mode 100644 app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt create mode 100644 app/src/test/java/app/gamenative/utils/SteamInstallScriptStepTest.kt diff --git a/app/src/main/java/app/gamenative/enums/Marker.kt b/app/src/main/java/app/gamenative/enums/Marker.kt index d4955455de..f4ad562c88 100644 --- a/app/src/main/java/app/gamenative/enums/Marker.kt +++ b/app/src/main/java/app/gamenative/enums/Marker.kt @@ -9,6 +9,7 @@ enum class Marker(val fileName: String ) { VCREDIST_INSTALLED(".vcredist_installed"), GOG_SCRIPT_INSTALLED(".gog_script_installed"), GOG_SUPPORT_INSTALLED(".gog_support_installed"), + STEAM_INSTALL_SCRIPT_INSTALLED(".steam_install_script_installed"), PHYSX_INSTALLED(".physx_installed"), OPENAL_INSTALLED(".openal_installed"), XNA_INSTALLED(".xna_installed"), diff --git a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt index 80f6dd3a1b..beea56626b 100644 --- a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt +++ b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt @@ -30,6 +30,7 @@ object PreInstallSteps { XnaFrameworkStep, GogScriptInterpreterStep, GogSupportCommandsStep, + SteamInstallScriptStep, UbisoftConnectStep, ) diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt new file mode 100644 index 0000000000..f3cc7e43dc --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt @@ -0,0 +1,131 @@ +package app.gamenative.utils + +import app.gamenative.data.GameSource +import app.gamenative.data.SteamApp +import app.gamenative.enums.Marker +import app.gamenative.service.SteamService +import com.winlator.container.Container +import `in`.dragonbra.javasteam.types.KeyValue +import java.io.File + +/** Applies the Registry section of the install-script VDF shipped by Steam games. */ +object SteamInstallScriptStep : PreInstallStep { + override val marker: Marker = Marker.STEAM_INSTALL_SCRIPT_INSTALLED + + internal var appInfoProvider: (Int) -> SteamApp? = SteamService::getAppInfoOf + + override fun appliesTo( + container: Container, + gameSource: GameSource, + gameDirPath: String, + ): Boolean = gameSource == GameSource.STEAM + + override fun buildCommand( + container: Container, + appId: String, + gameSource: GameSource, + gameDir: File, + gameDirPath: String, + ): String? { + if (gameSource != GameSource.STEAM) return null + val numericAppId = runCatching { ContainerUtils.extractGameIdFromContainerId(appId) }.getOrNull() + ?: return null + val app = appInfoProvider(numericAppId) ?: return null + val scriptName = app.installScript.trim() + if (scriptName.isEmpty()) return null + + val prefixStamp = File(container.rootDir, ".wine/.steam_install_script_$numericAppId") + if (MarkerUtils.hasMarker(gameDirPath, marker) && prefixStamp.isFile) return null + + val scriptFile = resolveChildCaseInsensitive(gameDir, scriptName) ?: return null + val root = runCatching { KeyValue.loadFromString(scriptFile.readText()) }.getOrNull() ?: return null + val registry = root["InstallScript"]["Registry"].takeUnless { it === KeyValue.INVALID } + ?: root["Registry"].takeUnless { it === KeyValue.INVALID } + ?: return null + + val commands = buildRegistryCommands(registry, "A:\\") + if (commands.isEmpty()) { + markDone(gameDirPath, prefixStamp) + return null + } + + prefixStamp.parentFile?.mkdirs() + runCatching { prefixStamp.createNewFile() } + return commands.joinToString(" & ") + } + + internal fun buildRegistryCommands(registry: KeyValue, installDir: String): List { + val commands = mutableListOf() + for (key in registry.children) { + val keyName = normalizeHive(expandTokens(key.name, installDir)) ?: continue + addValues(commands, keyName, key["string"], "REG_SZ", installDir) + addValues(commands, keyName, key["expandstring"], "REG_EXPAND_SZ", installDir) + addValues(commands, keyName, key["dword"], "REG_DWORD", installDir) + } + return commands + } + + private fun addValues( + commands: MutableList, + keyName: String, + values: KeyValue, + type: String, + installDir: String, + ) { + if (values === KeyValue.INVALID) return + for (value in values.children) { + val safeKey = escapeCmdArgument(keyName) ?: continue + val name = escapeCmdArgument(value.name) ?: continue + val data = escapeCmdArgument(expandTokens(value.value.orEmpty(), installDir)) ?: continue + // Steam's Windows client applies install scripts as a 32-bit process. + commands += "reg add \"$safeKey\" /v \"$name\" /t $type /d \"$data\" /f /reg:32" + } + } + + private fun normalizeHive(path: String): String? { + val separator = path.indexOf('\\') + val hive = if (separator < 0) path else path.substring(0, separator) + val rest = if (separator < 0) "" else path.substring(separator) + val shortHive = when (hive.uppercase()) { + "HKEY_LOCAL_MACHINE", "HKLM" -> "HKLM" + "HKEY_CURRENT_USER", "HKCU" -> "HKCU" + "HKEY_CLASSES_ROOT", "HKCR" -> "HKCR" + "HKEY_USERS", "HKU" -> "HKU" + else -> return null + } + return shortHive + rest + } + + private fun expandTokens(value: String, installDir: String): String { + val normalizedDir = installDir.trimEnd('\\', '/') + return value + .replace(Regex("(?i)%INSTALLDIR%[\\\\/]")) { "$normalizedDir\\" } + .replace("%INSTALLDIR%", installDir, ignoreCase = true) + } + + /** Keep parsed VDF data inside a quoted cmd.exe argument. */ + private fun escapeCmdArgument(value: String): String? { + if (value.any { it == '\"' || it == '\r' || it == '\n' }) return null + return value.replace("%", "%%") + } + + private fun resolveChildCaseInsensitive(root: File, relativePath: String): File? { + var current = root + for (segment in relativePath.replace('\\', '/').split('/').filter { it.isNotEmpty() }) { + if (segment == "." || segment == "..") return null + current = current.listFiles()?.firstOrNull { it.name.equals(segment, ignoreCase = true) } + ?: return null + } + return current.takeIf { it.isFile } + } + + private fun markDone(gameDirPath: String, prefixStamp: File) { + MarkerUtils.addMarker(gameDirPath, marker) + prefixStamp.parentFile?.mkdirs() + runCatching { prefixStamp.createNewFile() } + } + + internal fun resetForTests() { + appInfoProvider = SteamService::getAppInfoOf + } +} diff --git a/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt b/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt index aad754359c..7b7d2057a5 100644 --- a/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt +++ b/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt @@ -129,6 +129,7 @@ class PreInstallStepsTest { Marker.OPENAL_INSTALLED, Marker.XNA_INSTALLED, Marker.GOG_SCRIPT_INSTALLED, + Marker.STEAM_INSTALL_SCRIPT_INSTALLED, Marker.UBISOFT_CONNECT_INSTALLED, ) assertTrue(expectedMarkers.all { marker -> File(gameDir, marker.fileName).exists() }) diff --git a/app/src/test/java/app/gamenative/utils/SteamInstallScriptStepTest.kt b/app/src/test/java/app/gamenative/utils/SteamInstallScriptStepTest.kt new file mode 100644 index 0000000000..985cb1151e --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/SteamInstallScriptStepTest.kt @@ -0,0 +1,100 @@ +package app.gamenative.utils + +import app.gamenative.data.GameSource +import app.gamenative.data.SteamApp +import com.winlator.container.Container +import io.mockk.every +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import kotlin.io.path.createTempDirectory + +@RunWith(RobolectricTestRunner::class) +class SteamInstallScriptStepTest { + private lateinit var container: Container + private lateinit var gameDir: File + private lateinit var prefixDir: File + + @Before + fun setUp() { + gameDir = createTempDirectory(prefix = "steam-install-script-game").toFile() + prefixDir = createTempDirectory(prefix = "steam-install-script-prefix").toFile() + File(prefixDir, ".wine").mkdirs() + container = mockk(relaxed = true) + every { container.rootDir } returns prefixDir + } + + @After + fun tearDown() { + SteamInstallScriptStep.resetForTests() + gameDir.deleteRecursively() + prefixDir.deleteRecursively() + } + + @Test + fun buildCommand_readsSteamMetadataAndExpandsRegistryValues() { + File(gameDir, "InstallScript.vdf").writeText( + """ + "InstallScript" + { + "Registry" + { + "HKEY_LOCAL_MACHINE\\Software\\Electronic Arts\\SPORE" + { + "string" + { + "InstallLoc" "%INSTALLDIR%" + "DataDir" "%INSTALLDIR%\\Data" + } + "dword" + { + "Installed" "1" + } + } + } + } + """.trimIndent(), + ) + SteamInstallScriptStep.appInfoProvider = { id -> + SteamApp(id = id, installScript = "installscript.vdf") + } + + val command = SteamInstallScriptStep.buildCommand( + container, + "STEAM_17390", + GameSource.STEAM, + gameDir, + gameDir.absolutePath, + ) + + assertEquals( + listOf( + "reg add \"HKLM\\Software\\Electronic Arts\\SPORE\" /v \"InstallLoc\" /t REG_SZ /d \"A:\\\" /f /reg:32", + "reg add \"HKLM\\Software\\Electronic Arts\\SPORE\" /v \"DataDir\" /t REG_SZ /d \"A:\\Data\" /f /reg:32", + "reg add \"HKLM\\Software\\Electronic Arts\\SPORE\" /v \"Installed\" /t REG_DWORD /d \"1\" /f /reg:32", + ).joinToString(" & "), + command, + ) + } + + @Test + fun buildCommand_returnsNullForNonSteamContainerId() { + SteamInstallScriptStep.appInfoProvider = { SteamApp(id = it, installScript = "installscript.vdf") } + + assertNull( + SteamInstallScriptStep.buildCommand( + container, + "GOG_17390", + GameSource.GOG, + gameDir, + gameDir.absolutePath, + ), + ) + } +} From eb14b27f009e3d54091e039638a9e56805cc3ebf Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Mon, 14 Sep 2026 15:15:32 +0530 Subject: [PATCH 4/7] Write install-script registry entries directly into the prefix Replace the reg add command chain with a direct write through WineRegistryEditor. The cmd path broke on values ending in a backslash (a bare %INSTALLDIR%) and mangled % in REG_EXPAND_SZ data, and it cost an extra Wine session per game and prefix. --- .../ui/screen/xserver/XServerScreen.kt | 6 + .../app/gamenative/utils/PreInstallSteps.kt | 3 +- .../utils/SteamInstallScriptRegistry.kt | 167 ++++++++++++++++++ .../preInstallSteps/SteamInstallScriptStep.kt | 131 -------------- .../com/winlator/core/WineRegistryEditor.java | 4 + .../gamenative/utils/PreInstallStepsTest.kt | 1 - .../utils/SteamInstallScriptRegistryTest.kt | 138 +++++++++++++++ .../utils/SteamInstallScriptStepTest.kt | 100 ----------- 8 files changed, 316 insertions(+), 234 deletions(-) create mode 100644 app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt delete mode 100644 app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt create mode 100644 app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt delete mode 100644 app/src/test/java/app/gamenative/utils/SteamInstallScriptStepTest.kt diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index e374f68080..0f86ead990 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -137,6 +137,7 @@ import app.gamenative.utils.launchdependencies.BionicSteamAssetsDependency import app.gamenative.utils.downloader.DXWrapperDownloader import app.gamenative.utils.downloader.GraphicsDriverDownloader import app.gamenative.utils.PreInstallSteps +import app.gamenative.utils.SteamInstallScriptRegistry import app.gamenative.utils.BrightnessManager import app.gamenative.utils.SteamTokenLogin import app.gamenative.utils.SteamUtils @@ -3937,6 +3938,11 @@ private fun setupXEnvironment( } catch (e: Exception) { Timber.tag("GameFixes").w(e, "Game fixes failed before launch") } + try { + SteamInstallScriptRegistry.applyForLaunch(container, appId) + } catch (e: Exception) { + Timber.w(e, "Install-script registry apply failed before launch") + } if (container.startupSelection == Container.STARTUP_SELECTION_AGGRESSIVE) { if (container.containerVariant.equals(Container.BIONIC)){ Timber.d("Incorrect startup selection detected. Reverting to essential startup selection") diff --git a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt index beea56626b..31ebfb8825 100644 --- a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt +++ b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt @@ -30,7 +30,6 @@ object PreInstallSteps { XnaFrameworkStep, GogScriptInterpreterStep, GogSupportCommandsStep, - SteamInstallScriptStep, UbisoftConnectStep, ) @@ -108,7 +107,7 @@ object PreInstallSteps { return "wine explorer /desktop=shell,$screenInfo $wrapped" } - private fun getGameDir(container: Container): File? { + internal fun getGameDir(container: Container): File? { for (drive in Container.drivesIterator(container.drives)) { if (drive[0].equals("A", ignoreCase = true)) return File(drive[1]) } diff --git a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt new file mode 100644 index 0000000000..76f0b4628d --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt @@ -0,0 +1,167 @@ +package app.gamenative.utils + +import app.gamenative.data.GameSource +import app.gamenative.enums.Marker +import app.gamenative.service.SteamService +import com.winlator.container.Container +import com.winlator.core.WineRegistryEditor +import `in`.dragonbra.javasteam.types.KeyValue +import timber.log.Timber +import java.io.File + +/** + * Applies the Registry section of a Steam game's install script (installscript.vdf) by + * writing the values straight into the container's Wine prefix, the same way game fixes do. + * Steam's Windows client is a 32-bit process, so HKLM\Software keys land under Wow6432Node. + * + * Completion is tracked by a marker in the game directory (cleared by verify) and a stamp in + * the prefix, so the values are re-applied when either the install or the prefix is recreated. + */ +object SteamInstallScriptRegistry { + enum class Hive { HKLM, HKCU } + enum class ValueType { STRING, EXPAND_STRING, DWORD } + + data class Entry( + val hive: Hive, + val key: String, + val name: String?, + val type: ValueType, + val data: String, + ) + + private const val GAME_DRIVE_ROOT = "A:\\" + + fun applyForLaunch(container: Container, appId: String) { + if (ContainerUtils.extractGameSourceFromContainerId(appId) != GameSource.STEAM) return + val numericAppId = ContainerUtils.extractGameIdFromContainerId(appId) ?: return + val gameDir = PreInstallSteps.getGameDir(container) ?: return + val app = SteamService.getAppInfoOf(numericAppId) ?: return + val scriptName = app.installScript.trim() + if (scriptName.isEmpty()) return + + val prefixDir = File(container.rootDir, ".wine") + val prefixStamp = File(prefixDir, "${Marker.STEAM_INSTALL_SCRIPT_INSTALLED.fileName}_$numericAppId") + if (MarkerUtils.hasMarker(gameDir.absolutePath, Marker.STEAM_INSTALL_SCRIPT_INSTALLED) && prefixStamp.isFile) return + + val scriptFile = resolveChildCaseInsensitive(gameDir, scriptName) + if (scriptFile == null) { + Timber.w("Install script $scriptName not found in ${gameDir.absolutePath}") + return + } + val entries = parse(scriptFile.readText(), GAME_DRIVE_ROOT) + write(prefixDir, entries) + Timber.i("Applied ${entries.size} install-script registry values for app $numericAppId") + + MarkerUtils.addMarker(gameDir.absolutePath, Marker.STEAM_INSTALL_SCRIPT_INSTALLED) + prefixDir.mkdirs() + runCatching { prefixStamp.createNewFile() } + } + + internal fun parse(vdf: String, installDir: String): List { + val root = runCatching { KeyValue.loadFromString(vdf) }.getOrNull() ?: return emptyList() + val registry = root["InstallScript"]["Registry"].takeUnless { it === KeyValue.INVALID } + ?: root["Registry"].takeUnless { it === KeyValue.INVALID } + ?: return emptyList() + + val entries = mutableListOf() + for (key in registry.children) { + val split = splitHive(expandTokens(key.name, installDir)) + if (split == null) { + Timber.d("Skipping unsupported registry key ${key.name}") + continue + } + val (hive, path) = split + val redirected = if (hive == Hive.HKLM) redirectTo32BitView(path) else path + addValues(entries, hive, redirected, key["string"], ValueType.STRING, installDir) + addValues(entries, hive, redirected, key["expandstring"], ValueType.EXPAND_STRING, installDir) + addValues(entries, hive, redirected, key["dword"], ValueType.DWORD, installDir) + } + return entries + } + + internal fun write(prefixDir: File, entries: List) { + for (hive in Hive.entries) { + val hiveEntries = entries.filter { it.hive == hive } + if (hiveEntries.isEmpty()) continue + val regFile = File(prefixDir, if (hive == Hive.HKLM) "system.reg" else "user.reg") + if (!regFile.isFile) { + regFile.parentFile?.mkdirs() + regFile.writeText("WINE REGISTRY Version 2\n\n") + } + WineRegistryEditor(regFile).use { editor -> + editor.setCreateKeyIfNotExist(true) + for (entry in hiveEntries) { + when (entry.type) { + ValueType.STRING -> editor.setStringValue(entry.key, entry.name, entry.data) + ValueType.EXPAND_STRING -> editor.setExpandStringValue(entry.key, entry.name, entry.data) + ValueType.DWORD -> { + val value = runCatching { java.lang.Long.decode(entry.data).toInt() }.getOrNull() + if (value == null) { + Timber.w("Skipping non-numeric dword ${entry.key}\\${entry.name}=${entry.data}") + } else { + editor.setDwordValue(entry.key, entry.name, value) + } + } + } + } + } + } + } + + private fun addValues( + entries: MutableList, + hive: Hive, + key: String, + values: KeyValue, + type: ValueType, + installDir: String, + ) { + if (values === KeyValue.INVALID) return + for (value in values.children) { + entries += Entry( + hive = hive, + key = key, + name = value.name.ifEmpty { null }, + type = type, + data = expandTokens(value.value.orEmpty(), installDir), + ) + } + } + + private fun splitHive(path: String): Pair? { + val separator = path.indexOf('\\') + val hiveName = if (separator < 0) path else path.substring(0, separator) + val rest = if (separator < 0) "" else path.substring(separator + 1).trim('\\') + if (rest.isEmpty()) return null + val hive = when (hiveName.uppercase()) { + "HKEY_LOCAL_MACHINE", "HKLM" -> Hive.HKLM + "HKEY_CURRENT_USER", "HKCU" -> Hive.HKCU + else -> return null + } + return hive to rest + } + + private fun redirectTo32BitView(path: String): String { + val segments = path.split('\\') + if (segments.size < 2 || !segments[0].equals("Software", ignoreCase = true)) return path + if (segments[1].equals("Wow6432Node", ignoreCase = true)) return path + return (listOf(segments[0], "Wow6432Node") + segments.drop(1)).joinToString("\\") + } + + private fun expandTokens(value: String, installDir: String): String { + val normalizedDir = installDir.trimEnd('\\', '/') + return value + .replace(Regex("(?i)%INSTALLDIR%[\\\\/]")) { "$normalizedDir\\" } + .replace("%INSTALLDIR%", installDir, ignoreCase = true) + } + + private fun resolveChildCaseInsensitive(root: File, relativePath: String): File? { + var current = root + for (segment in relativePath.replace('\\', '/').split('/').filter { it.isNotEmpty() }) { + if (segment == "." || segment == "..") return null + current = current.listFiles()?.firstOrNull { it.name.equals(segment, ignoreCase = true) } + ?: return null + } + return current.takeIf { it.isFile } + } +} diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt deleted file mode 100644 index f3cc7e43dc..0000000000 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt +++ /dev/null @@ -1,131 +0,0 @@ -package app.gamenative.utils - -import app.gamenative.data.GameSource -import app.gamenative.data.SteamApp -import app.gamenative.enums.Marker -import app.gamenative.service.SteamService -import com.winlator.container.Container -import `in`.dragonbra.javasteam.types.KeyValue -import java.io.File - -/** Applies the Registry section of the install-script VDF shipped by Steam games. */ -object SteamInstallScriptStep : PreInstallStep { - override val marker: Marker = Marker.STEAM_INSTALL_SCRIPT_INSTALLED - - internal var appInfoProvider: (Int) -> SteamApp? = SteamService::getAppInfoOf - - override fun appliesTo( - container: Container, - gameSource: GameSource, - gameDirPath: String, - ): Boolean = gameSource == GameSource.STEAM - - override fun buildCommand( - container: Container, - appId: String, - gameSource: GameSource, - gameDir: File, - gameDirPath: String, - ): String? { - if (gameSource != GameSource.STEAM) return null - val numericAppId = runCatching { ContainerUtils.extractGameIdFromContainerId(appId) }.getOrNull() - ?: return null - val app = appInfoProvider(numericAppId) ?: return null - val scriptName = app.installScript.trim() - if (scriptName.isEmpty()) return null - - val prefixStamp = File(container.rootDir, ".wine/.steam_install_script_$numericAppId") - if (MarkerUtils.hasMarker(gameDirPath, marker) && prefixStamp.isFile) return null - - val scriptFile = resolveChildCaseInsensitive(gameDir, scriptName) ?: return null - val root = runCatching { KeyValue.loadFromString(scriptFile.readText()) }.getOrNull() ?: return null - val registry = root["InstallScript"]["Registry"].takeUnless { it === KeyValue.INVALID } - ?: root["Registry"].takeUnless { it === KeyValue.INVALID } - ?: return null - - val commands = buildRegistryCommands(registry, "A:\\") - if (commands.isEmpty()) { - markDone(gameDirPath, prefixStamp) - return null - } - - prefixStamp.parentFile?.mkdirs() - runCatching { prefixStamp.createNewFile() } - return commands.joinToString(" & ") - } - - internal fun buildRegistryCommands(registry: KeyValue, installDir: String): List { - val commands = mutableListOf() - for (key in registry.children) { - val keyName = normalizeHive(expandTokens(key.name, installDir)) ?: continue - addValues(commands, keyName, key["string"], "REG_SZ", installDir) - addValues(commands, keyName, key["expandstring"], "REG_EXPAND_SZ", installDir) - addValues(commands, keyName, key["dword"], "REG_DWORD", installDir) - } - return commands - } - - private fun addValues( - commands: MutableList, - keyName: String, - values: KeyValue, - type: String, - installDir: String, - ) { - if (values === KeyValue.INVALID) return - for (value in values.children) { - val safeKey = escapeCmdArgument(keyName) ?: continue - val name = escapeCmdArgument(value.name) ?: continue - val data = escapeCmdArgument(expandTokens(value.value.orEmpty(), installDir)) ?: continue - // Steam's Windows client applies install scripts as a 32-bit process. - commands += "reg add \"$safeKey\" /v \"$name\" /t $type /d \"$data\" /f /reg:32" - } - } - - private fun normalizeHive(path: String): String? { - val separator = path.indexOf('\\') - val hive = if (separator < 0) path else path.substring(0, separator) - val rest = if (separator < 0) "" else path.substring(separator) - val shortHive = when (hive.uppercase()) { - "HKEY_LOCAL_MACHINE", "HKLM" -> "HKLM" - "HKEY_CURRENT_USER", "HKCU" -> "HKCU" - "HKEY_CLASSES_ROOT", "HKCR" -> "HKCR" - "HKEY_USERS", "HKU" -> "HKU" - else -> return null - } - return shortHive + rest - } - - private fun expandTokens(value: String, installDir: String): String { - val normalizedDir = installDir.trimEnd('\\', '/') - return value - .replace(Regex("(?i)%INSTALLDIR%[\\\\/]")) { "$normalizedDir\\" } - .replace("%INSTALLDIR%", installDir, ignoreCase = true) - } - - /** Keep parsed VDF data inside a quoted cmd.exe argument. */ - private fun escapeCmdArgument(value: String): String? { - if (value.any { it == '\"' || it == '\r' || it == '\n' }) return null - return value.replace("%", "%%") - } - - private fun resolveChildCaseInsensitive(root: File, relativePath: String): File? { - var current = root - for (segment in relativePath.replace('\\', '/').split('/').filter { it.isNotEmpty() }) { - if (segment == "." || segment == "..") return null - current = current.listFiles()?.firstOrNull { it.name.equals(segment, ignoreCase = true) } - ?: return null - } - return current.takeIf { it.isFile } - } - - private fun markDone(gameDirPath: String, prefixStamp: File) { - MarkerUtils.addMarker(gameDirPath, marker) - prefixStamp.parentFile?.mkdirs() - runCatching { prefixStamp.createNewFile() } - } - - internal fun resetForTests() { - appInfoProvider = SteamService::getAppInfoOf - } -} diff --git a/app/src/main/java/com/winlator/core/WineRegistryEditor.java b/app/src/main/java/com/winlator/core/WineRegistryEditor.java index 7925651e23..dd4c2b5a9f 100644 --- a/app/src/main/java/com/winlator/core/WineRegistryEditor.java +++ b/app/src/main/java/com/winlator/core/WineRegistryEditor.java @@ -180,6 +180,10 @@ public void setStringValue(String key, String name, String value) { setRawValue(key, name, str); } + public void setExpandStringValue(String key, String name, String value) { + setRawValue(key, name, "str(2):\"" + escape(value != null ? value : "") + "\""); + } + public void setStringValues(String key, String[]... items) { String[][] escapedItems = new String[items.length][]; for (int i = 0; i < items.length; i++) { diff --git a/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt b/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt index 7b7d2057a5..aad754359c 100644 --- a/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt +++ b/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt @@ -129,7 +129,6 @@ class PreInstallStepsTest { Marker.OPENAL_INSTALLED, Marker.XNA_INSTALLED, Marker.GOG_SCRIPT_INSTALLED, - Marker.STEAM_INSTALL_SCRIPT_INSTALLED, Marker.UBISOFT_CONNECT_INSTALLED, ) assertTrue(expectedMarkers.all { marker -> File(gameDir, marker.fileName).exists() }) diff --git a/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt new file mode 100644 index 0000000000..f390edb486 --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt @@ -0,0 +1,138 @@ +package app.gamenative.utils + +import app.gamenative.utils.SteamInstallScriptRegistry.Entry +import app.gamenative.utils.SteamInstallScriptRegistry.Hive +import app.gamenative.utils.SteamInstallScriptRegistry.ValueType +import com.winlator.core.WineRegistryEditor +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import kotlin.io.path.createTempDirectory + +@RunWith(RobolectricTestRunner::class) +class SteamInstallScriptRegistryTest { + private lateinit var prefixDir: File + + @Before + fun setUp() { + prefixDir = createTempDirectory(prefix = "steam-install-script-prefix").toFile() + } + + @After + fun tearDown() { + prefixDir.deleteRecursively() + } + + @Test + fun parse_expandsTokensAndRedirectsHklmSoftwareTo32BitView() { + val entries = SteamInstallScriptRegistry.parse(SPORE_SCRIPT, "A:\\") + + assertEquals( + listOf( + Entry(Hive.HKLM, SPORE_KEY, "InstallLoc", ValueType.STRING, "A:\\"), + Entry(Hive.HKLM, SPORE_KEY, "DataDir", ValueType.STRING, "A:\\Data"), + Entry(Hive.HKLM, SPORE_KEY, "Cache", ValueType.EXPAND_STRING, "%LOCALAPPDATA%\\Spore"), + Entry(Hive.HKLM, SPORE_KEY, "Installed", ValueType.DWORD, "1"), + Entry(Hive.HKCU, "Software\\Electronic Arts\\SPORE", "Language", ValueType.STRING, "en_US"), + ), + entries, + ) + } + + @Test + fun parse_skipsUnsupportedHivesAndKeepsExistingWow6432Node() { + val entries = SteamInstallScriptRegistry.parse( + """ + "InstallScript" + { + "Registry" + { + "HKEY_CLASSES_ROOT\\spore" + { + "string" { "" "URL:spore" } + } + "HKLM\\Software\\Wow6432Node\\Foo" + { + "dword" { "Bar" "0x10" } + } + } + } + """.trimIndent(), + "A:\\", + ) + + assertEquals( + listOf(Entry(Hive.HKLM, "Software\\Wow6432Node\\Foo", "Bar", ValueType.DWORD, "0x10")), + entries, + ) + } + + @Test + fun parse_returnsEmptyForMalformedOrScriptWithoutRegistry() { + assertTrue(SteamInstallScriptRegistry.parse("not a vdf {{{", "A:\\").isEmpty()) + assertTrue(SteamInstallScriptRegistry.parse("\"InstallScript\" { \"Run Process\" { } }", "A:\\").isEmpty()) + } + + @Test + fun write_createsRegFilesAndStoresEachValueType() { + val entries = SteamInstallScriptRegistry.parse(SPORE_SCRIPT, "A:\\") + + SteamInstallScriptRegistry.write(prefixDir, entries) + + val systemReg = File(prefixDir, "system.reg") + val userReg = File(prefixDir, "user.reg") + assertTrue(systemReg.readText().startsWith("WINE REGISTRY Version 2")) + WineRegistryEditor(systemReg).use { editor -> + assertEquals("A:\\", editor.getStringValue(SPORE_KEY, "InstallLoc")) + assertEquals("A:\\Data", editor.getStringValue(SPORE_KEY, "DataDir")) + assertEquals(1, editor.getDwordValue(SPORE_KEY, "Installed")) + } + assertTrue(systemReg.readText().contains("\"Cache\"=str(2):\"%LOCALAPPDATA%\\\\Spore\"")) + WineRegistryEditor(userReg).use { editor -> + assertEquals("en_US", editor.getStringValue("Software\\Electronic Arts\\SPORE", "Language")) + } + assertFalse(prefixDir.listFiles().orEmpty().any { it.name.endsWith(".tmp") }) + } + + private companion object { + const val SPORE_KEY = "Software\\Wow6432Node\\Electronic Arts\\SPORE" + + val SPORE_SCRIPT = """ + "InstallScript" + { + "Registry" + { + "HKEY_LOCAL_MACHINE\\Software\\Electronic Arts\\SPORE" + { + "string" + { + "InstallLoc" "%INSTALLDIR%" + "DataDir" "%INSTALLDIR%\\Data" + } + "expandstring" + { + "Cache" "%LOCALAPPDATA%\\Spore" + } + "dword" + { + "Installed" "1" + } + } + "HKEY_CURRENT_USER\\Software\\Electronic Arts\\SPORE" + { + "string" + { + "Language" "en_US" + } + } + } + } + """.trimIndent() + } +} diff --git a/app/src/test/java/app/gamenative/utils/SteamInstallScriptStepTest.kt b/app/src/test/java/app/gamenative/utils/SteamInstallScriptStepTest.kt deleted file mode 100644 index 985cb1151e..0000000000 --- a/app/src/test/java/app/gamenative/utils/SteamInstallScriptStepTest.kt +++ /dev/null @@ -1,100 +0,0 @@ -package app.gamenative.utils - -import app.gamenative.data.GameSource -import app.gamenative.data.SteamApp -import com.winlator.container.Container -import io.mockk.every -import io.mockk.mockk -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import java.io.File -import kotlin.io.path.createTempDirectory - -@RunWith(RobolectricTestRunner::class) -class SteamInstallScriptStepTest { - private lateinit var container: Container - private lateinit var gameDir: File - private lateinit var prefixDir: File - - @Before - fun setUp() { - gameDir = createTempDirectory(prefix = "steam-install-script-game").toFile() - prefixDir = createTempDirectory(prefix = "steam-install-script-prefix").toFile() - File(prefixDir, ".wine").mkdirs() - container = mockk(relaxed = true) - every { container.rootDir } returns prefixDir - } - - @After - fun tearDown() { - SteamInstallScriptStep.resetForTests() - gameDir.deleteRecursively() - prefixDir.deleteRecursively() - } - - @Test - fun buildCommand_readsSteamMetadataAndExpandsRegistryValues() { - File(gameDir, "InstallScript.vdf").writeText( - """ - "InstallScript" - { - "Registry" - { - "HKEY_LOCAL_MACHINE\\Software\\Electronic Arts\\SPORE" - { - "string" - { - "InstallLoc" "%INSTALLDIR%" - "DataDir" "%INSTALLDIR%\\Data" - } - "dword" - { - "Installed" "1" - } - } - } - } - """.trimIndent(), - ) - SteamInstallScriptStep.appInfoProvider = { id -> - SteamApp(id = id, installScript = "installscript.vdf") - } - - val command = SteamInstallScriptStep.buildCommand( - container, - "STEAM_17390", - GameSource.STEAM, - gameDir, - gameDir.absolutePath, - ) - - assertEquals( - listOf( - "reg add \"HKLM\\Software\\Electronic Arts\\SPORE\" /v \"InstallLoc\" /t REG_SZ /d \"A:\\\" /f /reg:32", - "reg add \"HKLM\\Software\\Electronic Arts\\SPORE\" /v \"DataDir\" /t REG_SZ /d \"A:\\Data\" /f /reg:32", - "reg add \"HKLM\\Software\\Electronic Arts\\SPORE\" /v \"Installed\" /t REG_DWORD /d \"1\" /f /reg:32", - ).joinToString(" & "), - command, - ) - } - - @Test - fun buildCommand_returnsNullForNonSteamContainerId() { - SteamInstallScriptStep.appInfoProvider = { SteamApp(id = it, installScript = "installscript.vdf") } - - assertNull( - SteamInstallScriptStep.buildCommand( - container, - "GOG_17390", - GameSource.GOG, - gameDir, - gameDir.absolutePath, - ), - ) - } -} From 54a6c8d8cfaef174e8e6d5b148b476cc51aa0f33 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Mon, 14 Sep 2026 15:30:31 +0530 Subject: [PATCH 5/7] Handle language blocks, Steam path tokens and (Default) in install scripts --- .../utils/SteamInstallScriptRegistry.kt | 52 +++++++++---- .../utils/SteamInstallScriptRegistryTest.kt | 74 ++++++++++++++++++- 2 files changed, 108 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt index 76f0b4628d..42485bf85b 100644 --- a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt +++ b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt @@ -5,6 +5,7 @@ import app.gamenative.enums.Marker import app.gamenative.service.SteamService import com.winlator.container.Container import com.winlator.core.WineRegistryEditor +import com.winlator.xenvironment.ImageFs import `in`.dragonbra.javasteam.types.KeyValue import timber.log.Timber import java.io.File @@ -30,6 +31,20 @@ object SteamInstallScriptRegistry { ) private const val GAME_DRIVE_ROOT = "A:\\" + private const val DEFAULT_LANGUAGE = "english" + private const val USER_PROFILE = "C:\\users\\${ImageFs.USER}" + private val TOKEN_PATTERN = Regex("(?i)%([A-Z_]+)%([\\\\/]?)") + + private fun tokens(installDir: String): Map = mapOf( + "INSTALLDIR" to installDir, + "ROOTDRIVE" to installDir.substringBefore(':'), + "WINDIR" to "C:\\windows", + "APPDATA" to "$USER_PROFILE\\AppData\\Roaming", + "LOCALAPPDATA" to "$USER_PROFILE\\AppData\\Local", + "USER_MYDOCS" to "$USER_PROFILE\\Documents", + "COMMON_MYDOCS" to "C:\\users\\Public\\Documents", + "STEAMPATH" to "C:\\Program Files (x86)\\Steam", + ) fun applyForLaunch(container: Container, appId: String) { if (ContainerUtils.extractGameSourceFromContainerId(appId) != GameSource.STEAM) return @@ -48,7 +63,7 @@ object SteamInstallScriptRegistry { Timber.w("Install script $scriptName not found in ${gameDir.absolutePath}") return } - val entries = parse(scriptFile.readText(), GAME_DRIVE_ROOT) + val entries = parse(scriptFile.readText(), GAME_DRIVE_ROOT, container.language) write(prefixDir, entries) Timber.i("Applied ${entries.size} install-script registry values for app $numericAppId") @@ -57,24 +72,25 @@ object SteamInstallScriptRegistry { runCatching { prefixStamp.createNewFile() } } - internal fun parse(vdf: String, installDir: String): List { + internal fun parse(vdf: String, installDir: String, language: String = DEFAULT_LANGUAGE): List { val root = runCatching { KeyValue.loadFromString(vdf) }.getOrNull() ?: return emptyList() val registry = root["InstallScript"]["Registry"].takeUnless { it === KeyValue.INVALID } ?: root["Registry"].takeUnless { it === KeyValue.INVALID } ?: return emptyList() + val tokens = tokens(installDir) val entries = mutableListOf() for (key in registry.children) { - val split = splitHive(expandTokens(key.name, installDir)) + val split = splitHive(expandTokens(key.name, tokens)) if (split == null) { Timber.d("Skipping unsupported registry key ${key.name}") continue } val (hive, path) = split val redirected = if (hive == Hive.HKLM) redirectTo32BitView(path) else path - addValues(entries, hive, redirected, key["string"], ValueType.STRING, installDir) - addValues(entries, hive, redirected, key["expandstring"], ValueType.EXPAND_STRING, installDir) - addValues(entries, hive, redirected, key["dword"], ValueType.DWORD, installDir) + addValues(entries, hive, redirected, key["string"], ValueType.STRING, tokens, language) + addValues(entries, hive, redirected, key["expandstring"], ValueType.EXPAND_STRING, tokens, language) + addValues(entries, hive, redirected, key["dword"], ValueType.DWORD, tokens, language) } return entries } @@ -114,16 +130,20 @@ object SteamInstallScriptRegistry { key: String, values: KeyValue, type: ValueType, - installDir: String, + tokens: Map, + language: String, ) { if (values === KeyValue.INVALID) return - for (value in values.children) { + val (languageBlocks, plainValues) = values.children.partition { it.value == null && it.children.isNotEmpty() } + val selectedBlock = languageBlocks.firstOrNull { it.name.equals(language, ignoreCase = true) } + ?: languageBlocks.firstOrNull { it.name.equals(DEFAULT_LANGUAGE, ignoreCase = true) } + for (value in plainValues + selectedBlock?.children.orEmpty()) { entries += Entry( hive = hive, key = key, - name = value.name.ifEmpty { null }, + name = value.name.takeUnless { it.isEmpty() || it.equals("(Default)", ignoreCase = true) }, type = type, - data = expandTokens(value.value.orEmpty(), installDir), + data = expandTokens(value.value.orEmpty(), tokens), ) } } @@ -148,12 +168,12 @@ object SteamInstallScriptRegistry { return (listOf(segments[0], "Wow6432Node") + segments.drop(1)).joinToString("\\") } - private fun expandTokens(value: String, installDir: String): String { - val normalizedDir = installDir.trimEnd('\\', '/') - return value - .replace(Regex("(?i)%INSTALLDIR%[\\\\/]")) { "$normalizedDir\\" } - .replace("%INSTALLDIR%", installDir, ignoreCase = true) - } + private fun expandTokens(value: String, tokens: Map): String = + TOKEN_PATTERN.replace(value) { match -> + val replacement = tokens[match.groupValues[1].uppercase()] ?: return@replace match.value + val separator = match.groupValues[2] + if (separator.isEmpty()) replacement else replacement.trimEnd('\\', '/') + "\\" + } private fun resolveChildCaseInsensitive(root: File, relativePath: String): File? { var current = root diff --git a/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt index f390edb486..7e04217d51 100644 --- a/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt +++ b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt @@ -37,7 +37,7 @@ class SteamInstallScriptRegistryTest { listOf( Entry(Hive.HKLM, SPORE_KEY, "InstallLoc", ValueType.STRING, "A:\\"), Entry(Hive.HKLM, SPORE_KEY, "DataDir", ValueType.STRING, "A:\\Data"), - Entry(Hive.HKLM, SPORE_KEY, "Cache", ValueType.EXPAND_STRING, "%LOCALAPPDATA%\\Spore"), + Entry(Hive.HKLM, SPORE_KEY, "Cache", ValueType.EXPAND_STRING, "C:\\users\\xuser\\AppData\\Local\\Spore"), Entry(Hive.HKLM, SPORE_KEY, "Installed", ValueType.DWORD, "1"), Entry(Hive.HKCU, "Software\\Electronic Arts\\SPORE", "Language", ValueType.STRING, "en_US"), ), @@ -73,6 +73,76 @@ class SteamInstallScriptRegistryTest { ) } + @Test + fun parse_selectsLanguageBlockAndFallsBackToEnglish() { + val script = """ + "InstallScript" + { + "Registry" + { + "HKLM\\Software\\Foo" + { + "dword" + { + "english" { "Language" "1" } + "german" { "Language" "3" } + "Installed" "1" + } + } + } + } + """.trimIndent() + + assertEquals( + listOf( + Entry(Hive.HKLM, "Software\\Wow6432Node\\Foo", "Installed", ValueType.DWORD, "1"), + Entry(Hive.HKLM, "Software\\Wow6432Node\\Foo", "Language", ValueType.DWORD, "3"), + ), + SteamInstallScriptRegistry.parse(script, "A:\\", "german"), + ) + assertEquals( + listOf( + Entry(Hive.HKLM, "Software\\Wow6432Node\\Foo", "Installed", ValueType.DWORD, "1"), + Entry(Hive.HKLM, "Software\\Wow6432Node\\Foo", "Language", ValueType.DWORD, "1"), + ), + SteamInstallScriptRegistry.parse(script, "A:\\", "french"), + ) + } + + @Test + fun parse_expandsSteamTokensAndDefaultValueName() { + val script = """ + "InstallScript" + { + "Registry" + { + "HKCU\\Software\\Foo" + { + "string" + { + "(Default)" "%ROOTDRIVE%:\\Games" + "Saves" "%USER_MYDOCS%\\Foo" + "Cache" "%LOCALAPPDATA%" + "Client" "%StEaMpAtH%/steam.exe" + "Unknown" "%NOPE%\\x" + } + } + } + } + """.trimIndent() + + assertEquals( + listOf( + Entry(Hive.HKCU, "Software\\Foo", null, ValueType.STRING, "A:\\Games"), + Entry(Hive.HKCU, "Software\\Foo", "Saves", ValueType.STRING, "C:\\users\\xuser\\Documents\\Foo"), + Entry(Hive.HKCU, "Software\\Foo", "Cache", ValueType.STRING, "C:\\users\\xuser\\AppData\\Local"), + Entry(Hive.HKCU, "Software\\Foo", "Client", ValueType.STRING, "C:\\Program Files (x86)\\Steam\\steam.exe"), + Entry(Hive.HKCU, "Software\\Foo", "Unknown", ValueType.STRING, "%NOPE%\\x"), + ), + SteamInstallScriptRegistry.parse(script, "A:\\"), + ) + } + @Test fun parse_returnsEmptyForMalformedOrScriptWithoutRegistry() { assertTrue(SteamInstallScriptRegistry.parse("not a vdf {{{", "A:\\").isEmpty()) @@ -93,7 +163,7 @@ class SteamInstallScriptRegistryTest { assertEquals("A:\\Data", editor.getStringValue(SPORE_KEY, "DataDir")) assertEquals(1, editor.getDwordValue(SPORE_KEY, "Installed")) } - assertTrue(systemReg.readText().contains("\"Cache\"=str(2):\"%LOCALAPPDATA%\\\\Spore\"")) + assertTrue(systemReg.readText().contains("\"Cache\"=str(2):\"C:\\\\users\\\\xuser\\\\AppData\\\\Local\\\\Spore\"")) WineRegistryEditor(userReg).use { editor -> assertEquals("en_US", editor.getStringValue("Software\\Electronic Arts\\SPORE", "Language")) } From 0e838fabb1d2ac79bda88bb529a7c6fcd133b4aa Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Mon, 14 Sep 2026 22:40:01 +0530 Subject: [PATCH 6/7] Fix nullable KeyValue names --- .../java/app/gamenative/utils/SteamInstallScriptRegistry.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt index 42485bf85b..14a43bc305 100644 --- a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt +++ b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt @@ -81,7 +81,7 @@ object SteamInstallScriptRegistry { val tokens = tokens(installDir) val entries = mutableListOf() for (key in registry.children) { - val split = splitHive(expandTokens(key.name, tokens)) + val split = splitHive(expandTokens(key.name.orEmpty(), tokens)) if (split == null) { Timber.d("Skipping unsupported registry key ${key.name}") continue @@ -141,7 +141,7 @@ object SteamInstallScriptRegistry { entries += Entry( hive = hive, key = key, - name = value.name.takeUnless { it.isEmpty() || it.equals("(Default)", ignoreCase = true) }, + name = value.name.orEmpty().takeUnless { it.isEmpty() || it.equals("(Default)", ignoreCase = true) }, type = type, data = expandTokens(value.value.orEmpty(), tokens), ) From 383e2c422895a9571d7fc388075fbefa07f3df24 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Mon, 14 Sep 2026 22:48:09 +0530 Subject: [PATCH 7/7] Use (Default) in install-script parser test --- .../java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt index 7e04217d51..12be376600 100644 --- a/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt +++ b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt @@ -55,7 +55,7 @@ class SteamInstallScriptRegistryTest { { "HKEY_CLASSES_ROOT\\spore" { - "string" { "" "URL:spore" } + "string" { "(Default)" "URL:spore" } } "HKLM\\Software\\Wow6432Node\\Foo" {