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/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 80f6dd3a1b..31ebfb8825 100644 --- a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt +++ b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt @@ -107,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..14a43bc305 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt @@ -0,0 +1,187 @@ +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 com.winlator.xenvironment.ImageFs +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:\\" + 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 + 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, container.language) + 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, 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.orEmpty(), 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, 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 + } + + 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, + tokens: Map, + language: String, + ) { + if (values === KeyValue.INVALID) return + 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.orEmpty().takeUnless { it.isEmpty() || it.equals("(Default)", ignoreCase = true) }, + type = type, + data = expandTokens(value.value.orEmpty(), tokens), + ) + } + } + + 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, 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 + 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/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/SteamInstallScriptRegistryTest.kt b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt new file mode 100644 index 0000000000..12be376600 --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRegistryTest.kt @@ -0,0 +1,208 @@ +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, "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"), + ), + entries, + ) + } + + @Test + fun parse_skipsUnsupportedHivesAndKeepsExistingWow6432Node() { + val entries = SteamInstallScriptRegistry.parse( + """ + "InstallScript" + { + "Registry" + { + "HKEY_CLASSES_ROOT\\spore" + { + "string" { "(Default)" "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_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()) + 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):\"C:\\\\users\\\\xuser\\\\AppData\\\\Local\\\\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() + } +}