From 11f44b74f1e2210c29f61f4045030467f17353df Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 17 Sep 2026 00:15:06 +0530 Subject: [PATCH 1/3] Install VC++ 2022 redist with winetricks-style DLL overrides The 2022 vcredist was skipped entirely because its native ucrtbase broke games like Dawn of War Definitive Edition, but games like Meccha Chameleon need the newer msvcp140 it ships. Queue it like every other year and, for any v140-family installer, write the winetricks vcrun2022 overrides into user.reg first: ucrtbase stays builtin, the v140 DLLs load native,builtin. --- .../utils/preInstallSteps/VcRedistStep.kt | 41 ++++++++++++++++- .../utils/preInstallSteps/VcRedistStepTest.kt | 46 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt index 25c72ff1d0..c68863060e 100644 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt @@ -3,6 +3,8 @@ package app.gamenative.utils import app.gamenative.enums.Marker import app.gamenative.data.GameSource import com.winlator.container.Container +import com.winlator.core.WineRegistryEditor +import timber.log.Timber import java.io.File /** Windows path -> installer args, checked against host filesystem to see which exist. */ @@ -45,6 +47,20 @@ private val vcRedistMap: Map = mapOf( "A:\\_CommonRedist\\VC_redist.x64.exe" to "/install /passive /norestart", ) +private const val DLL_OVERRIDES_KEY = "Software\\Wine\\DllOverrides" +private val v140NativeDlls = listOf( + "concrt140", + "msvcp140", + "msvcp140_1", + "msvcp140_2", + "msvcp140_atomic_wait", + "msvcp140_codecvt_ids", + "vccorlib140", + "vcomp140", + "vcruntime140", + "vcruntime140_1", +) + object VcRedistStep : PreInstallStep { override val marker: Marker = Marker.VCREDIST_INSTALLED @@ -75,7 +91,7 @@ object VcRedistStep : PreInstallStep { } val covered = vcRedistMap.keys.map { it.lowercase() }.toSet() File(gameDir, "_CommonRedist/vcredist").listFiles()?.sortedBy { it.name }?.forEach { yearDir -> - if (!yearDir.isDirectory || (yearDir.name.toIntOrNull() ?: 0) >= 2022) return@forEach + if (!yearDir.isDirectory) return@forEach yearDir.listFiles()?.sortedBy { it.name }?.forEach { exe -> val name = exe.name.lowercase() if (!exe.isFile || !name.endsWith(".exe") || !(name.startsWith("vc_redist") || name.startsWith("vcredist"))) return@forEach @@ -84,7 +100,28 @@ object VcRedistStep : PreInstallStep { parts.add("$winPath /install /passive /norestart") } } - return if (parts.isEmpty()) null else parts.joinToString(" & ") + if (parts.isEmpty()) return null + if (parts.any { isV140Installer(it) }) writeV140Overrides(container) + return parts.joinToString(" & ") + } + + private fun isV140Installer(part: String): Boolean = + part.substringBefore(' ').substringAfterLast('\\').startsWith("vc_redist", ignoreCase = true) + + private fun writeV140Overrides(container: Container) { + val prefixDir = File(container.rootDir, ".wine") + val userReg = File(prefixDir, "user.reg") + runCatching { + if (!userReg.isFile) { + prefixDir.mkdirs() + userReg.writeText("WINE REGISTRY Version 2\n\n") + } + WineRegistryEditor(userReg).use { editor -> + editor.setCreateKeyIfNotExist(true) + editor.setStringValue(DLL_OVERRIDES_KEY, "ucrtbase", "builtin") + for (dll in v140NativeDlls) editor.setStringValue(DLL_OVERRIDES_KEY, dll, "native,builtin") + } + }.onFailure { Timber.w(it, "Failed to write v140 DLL overrides to ${userReg.absolutePath}") } } } diff --git a/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt b/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt index 5d6144e0bf..bf22752ab1 100644 --- a/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt +++ b/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt @@ -19,11 +19,28 @@ import kotlin.io.path.createTempDirectory class VcRedistStepTest { private lateinit var container: Container private lateinit var gameDir: File + private lateinit var rootDir: File @Before fun setUp() { container = mockk(relaxed = true) gameDir = createTempDirectory(prefix = "vcredist-step-test").toFile() + rootDir = createTempDirectory(prefix = "vcredist-step-root").toFile() + every { container.rootDir } returns rootDir + } + + private fun build(): String? = VcRedistStep.buildCommand( + container = container, + appId = "STEAM_1", + gameSource = GameSource.STEAM, + gameDir = gameDir, + gameDirPath = gameDir.absolutePath, + ) + + private fun addInstaller(relativePath: String) { + val installer = File(gameDir, relativePath) + installer.parentFile?.mkdirs() + installer.writeText("dummy") } @Test @@ -56,4 +73,33 @@ class VcRedistStepTest { val expected = "A:\\_CommonRedist\\MSVC2017\\VC_redist.x86.exe /install /passive /norestart" assertEquals(expected, checkNotNull(cmd)) } + + @Test + fun buildCommand_queues2022Installer() { + addInstaller("_CommonRedist/vcredist/2022/VC_redist.x64.exe") + + val expected = "A:\\_CommonRedist\\vcredist\\2022\\VC_redist.x64.exe /install /passive /norestart" + assertEquals(expected, checkNotNull(build())) + } + + @Test + fun buildCommand_writesV140Overrides_forVcRedistInstaller() { + addInstaller("_CommonRedist/vcredist/2022/VC_redist.x64.exe") + + build() + + val userReg = File(rootDir, ".wine/user.reg").readText() + assertTrue(userReg.contains("\"ucrtbase\"=\"builtin\"")) + assertTrue(userReg.contains("\"msvcp140\"=\"native,builtin\"")) + assertTrue(userReg.contains("\"vcruntime140\"=\"native,builtin\"")) + } + + @Test + fun buildCommand_skipsOverrides_forPreV140Installer() { + addInstaller("_CommonRedist/vcredist/2013/vcredist_x86.exe") + + build() + + assertFalse(File(rootDir, ".wine/user.reg").exists()) + } } From 2bd226f46d1ad87ed49c6b73ffbffbc9ebf2169c Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 17 Sep 2026 00:35:53 +0530 Subject: [PATCH 2/3] Read vcredist installers and their args from Steam install scripts Parse the Run Process section of the game's installscript.vdf and of every installscript.vdf shipped under _CommonRedist, and let the vcredist step take the exe path and command line from there instead of guessing names and flags. The hardcoded table and year scan remain as a fallback for games that ship no script. --- .../utils/SteamInstallScriptRegistry.kt | 4 +- .../utils/SteamInstallScriptRunProcess.kt | 71 +++++++++++ .../utils/preInstallSteps/VcRedistStep.kt | 50 +++++--- .../utils/SteamInstallScriptRunProcessTest.kt | 117 ++++++++++++++++++ .../utils/preInstallSteps/VcRedistStepTest.kt | 75 +++++++++++ 5 files changed, 295 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/app/gamenative/utils/SteamInstallScriptRunProcess.kt create mode 100644 app/src/test/java/app/gamenative/utils/SteamInstallScriptRunProcessTest.kt diff --git a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt index 14a43bc305..f7c88e99aa 100644 --- a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt +++ b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt @@ -35,7 +35,7 @@ object SteamInstallScriptRegistry { private const val USER_PROFILE = "C:\\users\\${ImageFs.USER}" private val TOKEN_PATTERN = Regex("(?i)%([A-Z_]+)%([\\\\/]?)") - private fun tokens(installDir: String): Map = mapOf( + internal fun tokens(installDir: String): Map = mapOf( "INSTALLDIR" to installDir, "ROOTDRIVE" to installDir.substringBefore(':'), "WINDIR" to "C:\\windows", @@ -168,7 +168,7 @@ object SteamInstallScriptRegistry { return (listOf(segments[0], "Wow6432Node") + segments.drop(1)).joinToString("\\") } - private fun expandTokens(value: String, tokens: Map): String = + internal 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] diff --git a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRunProcess.kt b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRunProcess.kt new file mode 100644 index 0000000000..36bb63b95b --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRunProcess.kt @@ -0,0 +1,71 @@ +package app.gamenative.utils + +import `in`.dragonbra.javasteam.types.KeyValue +import timber.log.Timber +import java.io.File + +object SteamInstallScriptRunProcess { + data class Entry( + val winPath: String, + val args: String, + val hostFile: File, + ) { + val exeName: String get() = winPath.substringAfterLast('\\') + val commandLine: String + get() { + val exe = if (winPath.contains(' ')) "\"$winPath\"" else winPath + return if (args.isBlank()) exe else "$exe ${args.trim()}" + } + } + + private const val GAME_DRIVE_ROOT = "A:\\" + private const val SCRIPT_NAME = "installscript.vdf" + private val PROCESS_KEY = Regex("(?i)^process\\s*(\\d+)$") + + fun entries(gameDir: File, installDir: String = GAME_DRIVE_ROOT): List = + scripts(gameDir).flatMap { script -> + runCatching { parse(script.readText(), gameDir, installDir) } + .onFailure { Timber.w(it, "Failed to read ${script.absolutePath}") } + .getOrDefault(emptyList()) + }.distinctBy { it.winPath.lowercase() } + + internal fun parse(vdf: String, gameDir: File, installDir: String = GAME_DRIVE_ROOT): List { + val root = runCatching { KeyValue.loadFromString(vdf) }.getOrNull() ?: return emptyList() + val runProcess = root["InstallScript"]["Run Process"].takeUnless { it === KeyValue.INVALID } + ?: root["Run Process"].takeUnless { it === KeyValue.INVALID } + ?: return emptyList() + val tokens = SteamInstallScriptRegistry.tokens(installDir) + val entries = mutableListOf() + for (block in runProcess.children) { + for (child in block.children) { + val index = PROCESS_KEY.find(child.name.orEmpty())?.groupValues?.get(1) ?: continue + val winPath = SteamInstallScriptRegistry.expandTokens(child.value.orEmpty().trim(), tokens) + if (!winPath.startsWith(installDir, ignoreCase = true)) { + Timber.d("Skipping run-process entry outside the install dir: $winPath") + continue + } + val relative = winPath.substring(installDir.length).replace('\\', '/') + val hostFile = resolveCaseInsensitive(gameDir, relative) ?: continue + val args = SteamInstallScriptRegistry.expandTokens(block["command $index"].value.orEmpty(), tokens) + entries += Entry(winPath, args, hostFile) + } + } + return entries + } + + private fun scripts(gameDir: File): List { + val root = gameDir.listFiles()?.filter { it.isFile && it.name.equals(SCRIPT_NAME, ignoreCase = true) }.orEmpty() + val redist = gameDir.listFiles()?.firstOrNull { it.isDirectory && it.name.equals("_CommonRedist", ignoreCase = true) } + ?.walkTopDown()?.filter { it.isFile && it.name.equals(SCRIPT_NAME, ignoreCase = true) }?.toList().orEmpty() + return root + redist.sortedBy { it.path } + } + + private fun resolveCaseInsensitive(root: File, relativePath: String): File? { + var current = root + for (segment in relativePath.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/VcRedistStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt index c68863060e..f744a22248 100644 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt @@ -80,24 +80,28 @@ object VcRedistStep : PreInstallStep { gameDirPath: String, ): String? { val parts = mutableListOf() - for ((winPath, args) in vcRedistMap) { - if (winPath.length < 4 || winPath[1] != ':' || winPath[2] != '\\') continue - val rest = winPath.substring(3) - val lastSep = rest.lastIndexOf('\\') - if (lastSep < 0) continue - val hostFile = File(gameDir, rest.replace('\\', '/')) - if (!hostFile.isFile) continue - parts.add(if (args.isEmpty()) winPath else "$winPath $args") - } - val covered = vcRedistMap.keys.map { it.lowercase() }.toSet() - File(gameDir, "_CommonRedist/vcredist").listFiles()?.sortedBy { it.name }?.forEach { yearDir -> - if (!yearDir.isDirectory) return@forEach - yearDir.listFiles()?.sortedBy { it.name }?.forEach { exe -> - val name = exe.name.lowercase() - if (!exe.isFile || !name.endsWith(".exe") || !(name.startsWith("vc_redist") || name.startsWith("vcredist"))) return@forEach - val winPath = "A:\\_CommonRedist\\vcredist\\${yearDir.name}\\${exe.name}" - if (winPath.lowercase() in covered) return@forEach - parts.add("$winPath /install /passive /norestart") + val scripted = SteamInstallScriptRunProcess.entries(gameDir).filter { isVcRedistExe(it.exeName) } + if (scripted.isNotEmpty()) { + parts += scripted.map { it.commandLine } + } else { + for ((winPath, args) in vcRedistMap) { + if (winPath.length < 4 || winPath[1] != ':' || winPath[2] != '\\') continue + val rest = winPath.substring(3) + val lastSep = rest.lastIndexOf('\\') + if (lastSep < 0) continue + val hostFile = File(gameDir, rest.replace('\\', '/')) + if (!hostFile.isFile) continue + parts.add(if (args.isEmpty()) winPath else "$winPath $args") + } + val covered = vcRedistMap.keys.map { it.lowercase() }.toSet() + File(gameDir, "_CommonRedist/vcredist").listFiles()?.sortedBy { it.name }?.forEach { yearDir -> + if (!yearDir.isDirectory) return@forEach + yearDir.listFiles()?.sortedBy { it.name }?.forEach { exe -> + if (!exe.isFile || !isVcRedistExe(exe.name)) return@forEach + val winPath = "A:\\_CommonRedist\\vcredist\\${yearDir.name}\\${exe.name}" + if (winPath.lowercase() in covered) return@forEach + parts.add("$winPath /install /passive /norestart") + } } } if (parts.isEmpty()) return null @@ -105,8 +109,14 @@ object VcRedistStep : PreInstallStep { return parts.joinToString(" & ") } - private fun isV140Installer(part: String): Boolean = - part.substringBefore(' ').substringAfterLast('\\').startsWith("vc_redist", ignoreCase = true) + private fun isVcRedistExe(name: String): Boolean { + val lower = name.lowercase() + return lower.endsWith(".exe") && (lower.startsWith("vc_redist") || lower.startsWith("vcredist")) + } + + private val v140InstallerPattern = Regex("(?i)\\\\vc_redist[^\\\\]*\\.exe") + + private fun isV140Installer(part: String): Boolean = v140InstallerPattern.containsMatchIn(part) private fun writeV140Overrides(container: Container) { val prefixDir = File(container.rootDir, ".wine") diff --git a/app/src/test/java/app/gamenative/utils/SteamInstallScriptRunProcessTest.kt b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRunProcessTest.kt new file mode 100644 index 0000000000..1784bd4cdf --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRunProcessTest.kt @@ -0,0 +1,117 @@ +package app.gamenative.utils + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import kotlin.io.path.createTempDirectory + +class SteamInstallScriptRunProcessTest { + private lateinit var gameDir: File + + @Before + fun setUp() { + gameDir = createTempDirectory(prefix = "run-process-test").toFile() + } + + @After + fun tearDown() { + gameDir.deleteRecursively() + } + + private fun touch(relativePath: String) { + val f = File(gameDir, relativePath) + f.parentFile?.mkdirs() + f.writeText("x") + } + + @Test + fun parse_expandsInstallDirAndPairsProcessWithCommand() { + touch("VCRedist/vcredist_x86.exe") + touch("DirectX/DXSetup.exe") + + val entries = SteamInstallScriptRunProcess.parse( + """ + "installscript" + { + "run process" + { + "vc" + { + "process 1" "%INSTALLDIR%\\VCRedist\\vcredist_x86.exe" + "command 1" "/q:a" + "description" "Visual C++ 2008 SP1 Redistributable Package (x86)" + "nocleanup" "1" + "ignoreexitcode" "1" + } + "dx" + { + "process 1" "%INSTALLDIR%\\DirectX\\DXSetup.exe" + "command 1" "/silent" + } + } + } + """.trimIndent(), + gameDir, + ) + + assertEquals(listOf("A:\\VCRedist\\vcredist_x86.exe", "A:\\DirectX\\DXSetup.exe"), entries.map { it.winPath }) + assertEquals(listOf("/q:a", "/silent"), entries.map { it.args }) + assertEquals("A:\\VCRedist\\vcredist_x86.exe /q:a", entries[0].commandLine) + assertEquals("vcredist_x86.exe", entries[0].exeName) + } + + @Test + fun parse_skipsMissingFilesAndPathsOutsideInstallDir() { + val entries = SteamInstallScriptRunProcess.parse( + """ + "InstallScript" + { + "Run Process" + { + "missing" { "process 1" "%INSTALLDIR%\\nope.exe" "command 1" "/q" } + "outside" { "process 1" "%WINDIR%\\system32\\cmd.exe" "command 1" "/c dir" } + } + } + """.trimIndent(), + gameDir, + ) + + assertTrue(entries.isEmpty()) + } + + @Test + fun entries_collectsRootScriptAndEveryCommonRedistScript() { + touch("_CommonRedist/vcredist/2019/VC_redist.x64.exe") + touch("_CommonRedist/DirectX/Jun2010/DXSETUP.exe") + File(gameDir, "_CommonRedist/vcredist/2019/installscript.vdf").writeText( + """ + "InstallScript" { "Run Process" { "vc" { + "HasRunKey" "HKEY_LOCAL_MACHINE\\Software\\Valve\\Steam\\Apps\\CommonRedist\\vcredist\\2019\\x64" + "process 1" "%INSTALLDIR%\\_CommonRedist\\vcredist\\2019\\VC_redist.x64.exe" + "command 1" "/install /quiet /norestart" + } } } + """.trimIndent(), + ) + File(gameDir, "_CommonRedist/DirectX/Jun2010/installscript.vdf").writeText( + """ + "installscript" { "Run Process" { "dxsetup" { + "process 1" "%INSTALLDIR%\\_CommonRedist\\DirectX\\Jun2010\\DXSETUP.exe" + "command 1" "/silent" + } } } + """.trimIndent(), + ) + + val entries = SteamInstallScriptRunProcess.entries(gameDir) + + assertEquals( + listOf( + "A:\\_CommonRedist\\DirectX\\Jun2010\\DXSETUP.exe /silent", + "A:\\_CommonRedist\\vcredist\\2019\\VC_redist.x64.exe /install /quiet /norestart", + ), + entries.map { it.commandLine }, + ) + } +} diff --git a/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt b/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt index bf22752ab1..4a140a51db 100644 --- a/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt +++ b/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt @@ -102,4 +102,79 @@ class VcRedistStepTest { assertFalse(File(rootDir, ".wine/user.reg").exists()) } + + @Test + fun buildCommand_usesScriptPathAndArgs_whenInstallScriptListsRedist() { + addInstaller("VCRedist/vcredist_x86.exe") + addInstaller("_CommonRedist/vcredist/2019/VC_redist.x64.exe") + File(gameDir, "installscript.vdf").writeText( + """ + "installscript" + { + "run process" + { + "visual c++ 2008 sp1 redistributable package (x86)" + { + "process 1" "%INSTALLDIR%\\VCRedist\\vcredist_x86.exe" + "command 1" "/q:a" + "nocleanup" "1" + } + "directx 9" + { + "process 1" "%INSTALLDIR%\\DirectX\\DXSetup.exe" + "command 1" "/silent" + } + } + } + """.trimIndent(), + ) + + assertEquals("A:\\VCRedist\\vcredist_x86.exe /q:a", checkNotNull(build())) + } + + @Test + fun buildCommand_readsScriptsShippedUnderCommonRedist() { + addInstaller("_CommonRedist/vcredist/2022/VC_redist.x64.exe") + File(gameDir, "_CommonRedist/vcredist/2022/installscript.vdf").writeText( + """ + "InstallScript" + { + "Run Process" + { + "VCRedist2022x64" + { + "HasRunKey" "HKEY_LOCAL_MACHINE\\Software\\Valve\\Steam\\Apps\\CommonRedist\\vcredist\\2022\\x64" + "process 1" "%INSTALLDIR%\\_CommonRedist\\vcredist\\2022\\VC_redist.x64.exe" + "command 1" "/install /quiet /norestart" + "NoCleanUp" "1" + } + } + } + """.trimIndent(), + ) + + assertEquals("A:\\_CommonRedist\\vcredist\\2022\\VC_redist.x64.exe /install /quiet /norestart", checkNotNull(build())) + assertTrue(File(rootDir, ".wine/user.reg").readText().contains("\"ucrtbase\"=\"builtin\"")) + } + + @Test + fun buildCommand_ignoresScriptEntriesWhoseFileIsMissing() { + File(gameDir, "installscript.vdf").writeText( + """ + "installscript" + { + "run process" + { + "vc" + { + "process 1" "%INSTALLDIR%\\VCRedist\\vcredist_x86.exe" + "command 1" "/q" + } + } + } + """.trimIndent(), + ) + + assertEquals(null, build()) + } } From d722bd8a771752ce2d263245a93c010a43427928 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 17 Sep 2026 12:08:55 +0530 Subject: [PATCH 3/3] Track vcredist installs with Steam's HasRunKey instead of a game-dir marker Each Run Process entry names a HasRunKey; Steam writes it after the installer exits and skips the entry when it exists. Do the same: before launch skip entries whose key is already in system.reg, and after the step's wine session ends write the key for every entry that ran. Entries without a HasRunKey (old root scripts, the fallback table) get a synthetic key under the same Valve CommonRedist path. This makes the check per redist rather than per step, retries a failed installer, and lets a game shipping 2022 upgrade a prefix that only has 2019. It lives in the prefix, so a Wine version change or container reset reruns everything, while verify does not. --- .../app/gamenative/utils/PreInstallSteps.kt | 5 ++ .../utils/SteamInstallScriptRegistry.kt | 4 +- .../utils/SteamInstallScriptRunProcess.kt | 34 ++++++++- .../utils/preInstallSteps/PreInstallStep.kt | 2 + .../utils/preInstallSteps/VcRedistStep.kt | 75 +++++++++++-------- .../com/winlator/core/WineRegistryEditor.java | 5 ++ .../gamenative/utils/PreInstallStepsTest.kt | 1 + .../utils/SteamInstallScriptRunProcessTest.kt | 19 +++++ .../utils/preInstallSteps/VcRedistStepTest.kt | 52 +++++++++++-- 9 files changed, 155 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt index 15e68e5555..eec078afba 100644 --- a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt +++ b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt @@ -4,6 +4,7 @@ import app.gamenative.data.GameSource import app.gamenative.enums.Marker import com.winlator.container.Container import java.io.File +import timber.log.Timber /** * Determines whether pre-install steps (VC Redist, GOG script interpreter) need to run @@ -97,6 +98,10 @@ object PreInstallSteps { val gameDir = getGameDir(container) ?: return val gameDirPath = gameDir.absolutePath MarkerUtils.addMarker(gameDirPath, marker) + currentSteps().filter { it.marker == marker }.forEach { step -> + runCatching { step.onCompleted(container, gameDir) } + .onFailure { Timber.w(it, "onCompleted failed for ${marker.name}") } + } touchPrefixStamp(container) } diff --git a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt index f7c88e99aa..4709f3c0d3 100644 --- a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt +++ b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt @@ -148,7 +148,7 @@ object SteamInstallScriptRegistry { } } - private fun splitHive(path: String): Pair? { + internal 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('\\') @@ -161,7 +161,7 @@ object SteamInstallScriptRegistry { return hive to rest } - private fun redirectTo32BitView(path: String): String { + internal 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 diff --git a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRunProcess.kt b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRunProcess.kt index 36bb63b95b..af80de2285 100644 --- a/app/src/main/java/app/gamenative/utils/SteamInstallScriptRunProcess.kt +++ b/app/src/main/java/app/gamenative/utils/SteamInstallScriptRunProcess.kt @@ -1,5 +1,6 @@ package app.gamenative.utils +import com.winlator.core.WineRegistryEditor import `in`.dragonbra.javasteam.types.KeyValue import timber.log.Timber import java.io.File @@ -9,6 +10,7 @@ object SteamInstallScriptRunProcess { val winPath: String, val args: String, val hostFile: File, + val hasRunKey: String = syntheticHasRunKey(winPath), ) { val exeName: String get() = winPath.substringAfterLast('\\') val commandLine: String @@ -20,8 +22,31 @@ object SteamInstallScriptRunProcess { private const val GAME_DRIVE_ROOT = "A:\\" private const val SCRIPT_NAME = "installscript.vdf" + private const val SYNTHETIC_KEY_ROOT = "Software\\Wow6432Node\\Valve\\Steam\\Apps\\CommonRedist\\GameNative" private val PROCESS_KEY = Regex("(?i)^process\\s*(\\d+)$") + fun syntheticHasRunKey(winPath: String): String = + SYNTHETIC_KEY_ROOT + "\\" + winPath.substringAfter(':').trim('\\') + + fun hasRun(prefixDir: File, entry: Entry): Boolean { + val systemReg = File(prefixDir, "system.reg") + if (!systemReg.isFile) return false + return WineRegistryEditor(systemReg).use { it.hasKey(entry.hasRunKey) } + } + + fun markRun(prefixDir: File, entries: List) { + if (entries.isEmpty()) return + val systemReg = File(prefixDir, "system.reg") + if (!systemReg.isFile) { + prefixDir.mkdirs() + systemReg.writeText("WINE REGISTRY Version 2\n\n") + } + WineRegistryEditor(systemReg).use { editor -> + editor.setCreateKeyIfNotExist(true) + for (entry in entries) editor.setDwordValue(entry.hasRunKey, "Installed", 1) + } + } + fun entries(gameDir: File, installDir: String = GAME_DRIVE_ROOT): List = scripts(gameDir).flatMap { script -> runCatching { parse(script.readText(), gameDir, installDir) } @@ -47,12 +72,19 @@ object SteamInstallScriptRunProcess { val relative = winPath.substring(installDir.length).replace('\\', '/') val hostFile = resolveCaseInsensitive(gameDir, relative) ?: continue val args = SteamInstallScriptRegistry.expandTokens(block["command $index"].value.orEmpty(), tokens) - entries += Entry(winPath, args, hostFile) + val hasRunKey = block["HasRunKey"].value?.let { scriptHasRunKey(it) } ?: syntheticHasRunKey(winPath) + entries += Entry(winPath, args, hostFile, hasRunKey) } } return entries } + private fun scriptHasRunKey(raw: String): String? { + val (hive, path) = SteamInstallScriptRegistry.splitHive(raw.trim()) ?: return null + if (hive != SteamInstallScriptRegistry.Hive.HKLM) return null + return SteamInstallScriptRegistry.redirectTo32BitView(path) + } + private fun scripts(gameDir: File): List { val root = gameDir.listFiles()?.filter { it.isFile && it.name.equals(SCRIPT_NAME, ignoreCase = true) }.orEmpty() val redist = gameDir.listFiles()?.firstOrNull { it.isDirectory && it.name.equals("_CommonRedist", ignoreCase = true) } diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/PreInstallStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/PreInstallStep.kt index 4e9832b927..a223573d1d 100644 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/PreInstallStep.kt +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/PreInstallStep.kt @@ -21,4 +21,6 @@ interface PreInstallStep { gameDir: File, gameDirPath: String, ): String? + + fun onCompleted(container: Container, gameDir: File) {} } diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt index f744a22248..68e5f6ce2e 100644 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt @@ -68,9 +68,7 @@ object VcRedistStep : PreInstallStep { container: Container, gameSource: GameSource, gameDirPath: String, - ): Boolean { - return !MarkerUtils.hasMarker(gameDirPath, Marker.VCREDIST_INSTALLED) - } + ): Boolean = true override fun buildCommand( container: Container, @@ -79,34 +77,49 @@ object VcRedistStep : PreInstallStep { gameDir: File, gameDirPath: String, ): String? { - val parts = mutableListOf() + val pending = pendingEntries(container, gameDir) + if (pending.isEmpty()) return null + if (pending.any { isV140Installer(it.exeName) }) writeV140Overrides(container) + return pending.joinToString(" & ") { it.commandLine } + } + + override fun onCompleted(container: Container, gameDir: File) { + val prefixDir = prefixDir(container) ?: return + SteamInstallScriptRunProcess.markRun(prefixDir, pendingEntries(container, gameDir)) + } + + private fun prefixDir(container: Container): File? = + container.rootDir?.path?.takeIf { it.isNotEmpty() }?.let { File(it, ".wine") } + + private fun pendingEntries(container: Container, gameDir: File): List { + val prefixDir = prefixDir(container) ?: return candidates(gameDir) + return candidates(gameDir).filter { !SteamInstallScriptRunProcess.hasRun(prefixDir, it) } + } + + private fun candidates(gameDir: File): List { val scripted = SteamInstallScriptRunProcess.entries(gameDir).filter { isVcRedistExe(it.exeName) } - if (scripted.isNotEmpty()) { - parts += scripted.map { it.commandLine } - } else { - for ((winPath, args) in vcRedistMap) { - if (winPath.length < 4 || winPath[1] != ':' || winPath[2] != '\\') continue - val rest = winPath.substring(3) - val lastSep = rest.lastIndexOf('\\') - if (lastSep < 0) continue - val hostFile = File(gameDir, rest.replace('\\', '/')) - if (!hostFile.isFile) continue - parts.add(if (args.isEmpty()) winPath else "$winPath $args") - } - val covered = vcRedistMap.keys.map { it.lowercase() }.toSet() - File(gameDir, "_CommonRedist/vcredist").listFiles()?.sortedBy { it.name }?.forEach { yearDir -> - if (!yearDir.isDirectory) return@forEach - yearDir.listFiles()?.sortedBy { it.name }?.forEach { exe -> - if (!exe.isFile || !isVcRedistExe(exe.name)) return@forEach - val winPath = "A:\\_CommonRedist\\vcredist\\${yearDir.name}\\${exe.name}" - if (winPath.lowercase() in covered) return@forEach - parts.add("$winPath /install /passive /norestart") - } + if (scripted.isNotEmpty()) return scripted + + val fallback = mutableListOf() + for ((winPath, args) in vcRedistMap) { + if (winPath.length < 4 || winPath[1] != ':' || winPath[2] != '\\') continue + val rest = winPath.substring(3) + if (rest.lastIndexOf('\\') < 0) continue + val hostFile = File(gameDir, rest.replace('\\', '/')) + if (!hostFile.isFile) continue + fallback += SteamInstallScriptRunProcess.Entry(winPath, args, hostFile) + } + val covered = vcRedistMap.keys.map { it.lowercase() }.toSet() + File(gameDir, "_CommonRedist/vcredist").listFiles()?.sortedBy { it.name }?.forEach { yearDir -> + if (!yearDir.isDirectory) return@forEach + yearDir.listFiles()?.sortedBy { it.name }?.forEach { exe -> + if (!exe.isFile || !isVcRedistExe(exe.name)) return@forEach + val winPath = "A:\\_CommonRedist\\vcredist\\${yearDir.name}\\${exe.name}" + if (winPath.lowercase() in covered) return@forEach + fallback += SteamInstallScriptRunProcess.Entry(winPath, "/install /passive /norestart", exe) } } - if (parts.isEmpty()) return null - if (parts.any { isV140Installer(it) }) writeV140Overrides(container) - return parts.joinToString(" & ") + return fallback } private fun isVcRedistExe(name: String): Boolean { @@ -114,12 +127,10 @@ object VcRedistStep : PreInstallStep { return lower.endsWith(".exe") && (lower.startsWith("vc_redist") || lower.startsWith("vcredist")) } - private val v140InstallerPattern = Regex("(?i)\\\\vc_redist[^\\\\]*\\.exe") - - private fun isV140Installer(part: String): Boolean = v140InstallerPattern.containsMatchIn(part) + private fun isV140Installer(exeName: String): Boolean = exeName.startsWith("vc_redist", ignoreCase = true) private fun writeV140Overrides(container: Container) { - val prefixDir = File(container.rootDir, ".wine") + val prefixDir = prefixDir(container) ?: return val userReg = File(prefixDir, "user.reg") runCatching { if (!userReg.isFile) { diff --git a/app/src/main/java/com/winlator/core/WineRegistryEditor.java b/app/src/main/java/com/winlator/core/WineRegistryEditor.java index dd4c2b5a9f..81767adb83 100644 --- a/app/src/main/java/com/winlator/core/WineRegistryEditor.java +++ b/app/src/main/java/com/winlator/core/WineRegistryEditor.java @@ -161,6 +161,11 @@ private Location createKey(String key) { } } + public boolean hasKey(String key) { + resetLastParentKeyPositionIfNeed(key); + return getKeyLocation(key) != null; + } + public String getStringValue(String key, String name) { return getStringValue(key, name, null); } diff --git a/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt b/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt index aad754359c..0067cc0173 100644 --- a/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt +++ b/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt @@ -42,6 +42,7 @@ class PreInstallStepsTest { gameDir = createTempDirectory(prefix = "preinstall-steps-test").toFile() every { container.drives } returns "A:${gameDir.absolutePath}" every { container.containerVariant } returns Container.BIONIC + every { container.rootDir } returns File(gameDir, "container") } @After diff --git a/app/src/test/java/app/gamenative/utils/SteamInstallScriptRunProcessTest.kt b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRunProcessTest.kt index 1784bd4cdf..2ce3687b80 100644 --- a/app/src/test/java/app/gamenative/utils/SteamInstallScriptRunProcessTest.kt +++ b/app/src/test/java/app/gamenative/utils/SteamInstallScriptRunProcessTest.kt @@ -61,6 +61,10 @@ class SteamInstallScriptRunProcessTest { assertEquals(listOf("/q:a", "/silent"), entries.map { it.args }) assertEquals("A:\\VCRedist\\vcredist_x86.exe /q:a", entries[0].commandLine) assertEquals("vcredist_x86.exe", entries[0].exeName) + assertEquals( + "Software\\Wow6432Node\\Valve\\Steam\\Apps\\CommonRedist\\GameNative\\VCRedist\\vcredist_x86.exe", + entries[0].hasRunKey, + ) } @Test @@ -113,5 +117,20 @@ class SteamInstallScriptRunProcessTest { ), entries.map { it.commandLine }, ) + assertEquals( + "Software\\Wow6432Node\\Valve\\Steam\\Apps\\CommonRedist\\vcredist\\2019\\x64", + entries[1].hasRunKey, + ) + } + + @Test + fun hasRunAndMarkRun_roundTripThroughSystemReg() { + touch("VCRedist/vcredist_x86.exe") + val prefixDir = File(gameDir, "prefix") + val entry = SteamInstallScriptRunProcess.Entry("A:\\VCRedist\\vcredist_x86.exe", "/q", File(gameDir, "VCRedist/vcredist_x86.exe")) + + assertTrue(!SteamInstallScriptRunProcess.hasRun(prefixDir, entry)) + SteamInstallScriptRunProcess.markRun(prefixDir, listOf(entry)) + assertTrue(SteamInstallScriptRunProcess.hasRun(prefixDir, entry)) } } diff --git a/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt b/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt index 4a140a51db..9f9a28da36 100644 --- a/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt +++ b/app/src/test/java/app/gamenative/utils/preInstallSteps/VcRedistStepTest.kt @@ -43,17 +43,55 @@ class VcRedistStepTest { installer.writeText("dummy") } + private fun systemReg(): String = File(rootDir, ".wine/system.reg").readText() + @Test - fun appliesTo_returnsTrue_whenMarkerMissing() { - val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) - assertTrue(applies) + fun appliesTo_ignoresGameDirMarker() { + MarkerUtils.addMarker(gameDir.absolutePath, Marker.VCREDIST_INSTALLED) + assertTrue(VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath)) } @Test - fun appliesTo_returnsFalse_whenMarkerExists() { - MarkerUtils.addMarker(gameDir.absolutePath, Marker.VCREDIST_INSTALLED) - val applies = VcRedistStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath) - assertFalse(applies) + fun buildCommand_returnsNull_whenHasRunKeyAlreadyInPrefix() { + addInstaller("_CommonRedist/MSVC2017/VC_redist.x86.exe") + assertEquals("A:\\_CommonRedist\\MSVC2017\\VC_redist.x86.exe /install /passive /norestart", build()) + + VcRedistStep.onCompleted(container, gameDir) + + assertTrue(systemReg().contains("[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\GameNative\\\\_CommonRedist\\\\MSVC2017\\\\VC_redist.x86.exe]")) + assertEquals(null, build()) + } + + @Test + fun onCompleted_writesScriptHasRunKeyUnderWow6432Node() { + addInstaller("_CommonRedist/vcredist/2022/VC_redist.x64.exe") + File(gameDir, "_CommonRedist/vcredist/2022/installscript.vdf").writeText( + """ + "InstallScript" { "Run Process" { "vc" { + "HasRunKey" "HKEY_LOCAL_MACHINE\\Software\\Valve\\Steam\\Apps\\CommonRedist\\vcredist\\2022\\x64" + "process 1" "%INSTALLDIR%\\_CommonRedist\\vcredist\\2022\\VC_redist.x64.exe" + "command 1" "/install /quiet /norestart" + } } } + """.trimIndent(), + ) + checkNotNull(build()) + + VcRedistStep.onCompleted(container, gameDir) + + assertTrue(systemReg().contains("[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2022\\\\x64]")) + assertEquals(null, build()) + } + + @Test + fun buildCommand_runsOnlyEntriesWithoutHasRunKey() { + addInstaller("_CommonRedist/vcredist/2019/VC_redist.x64.exe") + addInstaller("_CommonRedist/vcredist/2022/VC_redist.x64.exe") + checkNotNull(build()) + VcRedistStep.onCompleted(container, gameDir) + File(gameDir, "_CommonRedist/vcredist/2022/VC_redist.x64.exe").delete() + addInstaller("_CommonRedist/vcredist/2013/vcredist_x64.exe") + + assertEquals("A:\\_CommonRedist\\vcredist\\2013\\vcredist_x64.exe /install /passive /norestart", build()) } @Test