-
-
Notifications
You must be signed in to change notification settings - Fork 428
Read vcredist installers from Steam install scripts, track them by HasRunKey, install 2022 with winetricks-style overrides #1942
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| package app.gamenative.utils | ||
|
|
||
| import com.winlator.core.WineRegistryEditor | ||
| 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 hasRunKey: String = syntheticHasRunKey(winPath), | ||
| ) { | ||
| 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 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<Entry>) { | ||
| 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<Entry> = | ||
| 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<Entry> { | ||
| 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<Entry>() | ||
| 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) | ||
| 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<File> { | ||
| 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 } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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,16 +47,28 @@ private val vcRedistMap: Map<String, String> = 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", | ||||||||||||
|
Comment on lines
+59
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '45,135p' app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt
rg -n -i 'vcamp140|vcrun2022|v140NativeDlls|DllOverrides' . --glob '!**/build/**' --glob '!**/.gradle/**'Repository: utkarshdalal/GameNative Length of output: 12579 🌐 Web query:
💡 Result: <search_synthesis> <source_evidence> Citations:
🏁 Script executed: #!/bin/bash
sed -n '1,75p' app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt
fd -i 'VcRedistStepTest' .
test -n "$(fd -i 'VcRedistStepTest' . | head -n 1)" && sed -n '1,260p' "$(fd -i 'VcRedistStepTest' . | head -n 1)"Repository: utkarshdalal/GameNative Length of output: 7624 🤖 get_repo_knowledge executed:
Length of output: 31904 Add the When "vccorlib140",
+ "vcamp140",
"vcomp140",Add a test that asserts 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
| "vcruntime140_1", | ||||||||||||
| ) | ||||||||||||
|
|
||||||||||||
| object VcRedistStep : PreInstallStep { | ||||||||||||
| override val marker: Marker = Marker.VCREDIST_INSTALLED | ||||||||||||
|
|
||||||||||||
| override fun appliesTo( | ||||||||||||
| container: Container, | ||||||||||||
| gameSource: GameSource, | ||||||||||||
| gameDirPath: String, | ||||||||||||
| ): Boolean { | ||||||||||||
| return !MarkerUtils.hasMarker(gameDirPath, Marker.VCREDIST_INSTALLED) | ||||||||||||
| } | ||||||||||||
| ): Boolean = true | ||||||||||||
|
|
||||||||||||
| override fun buildCommand( | ||||||||||||
| container: Container, | ||||||||||||
|
|
@@ -63,28 +77,72 @@ object VcRedistStep : PreInstallStep { | |||||||||||
| gameDir: File, | ||||||||||||
| gameDirPath: String, | ||||||||||||
| ): String? { | ||||||||||||
| val parts = mutableListOf<String>() | ||||||||||||
| 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)) | ||||||||||||
|
Comment on lines
+83
to
+88
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: sed -n '1,180p' app/src/main/java/app/gamenative/utils/preInstallSteps/PreInstallStep.kt
sed -n '1,180p' app/src/main/java/app/gamenative/utils/PreInstallSteps.kt
sed -n '40,170p' app/src/main/java/app/gamenative/utils/preInstallSteps/VcRedistStep.kt
rg -n 'markStepDone|buildCommand|VCREDIST_INSTALLED|onCompleted|PreInstallSteps' app/src/main app/src/testRepository: utkarshdalal/GameNative Length of output: 20942 🏁 Script executed: sed -n '3950,4190p' app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
sed -n '1,220p' app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt
rg -n -C 8 'object SteamInstallScriptRunProcess|class SteamInstallScriptRunProcess|markRun|hasRun|HasRun|exit|terminated|on.*Complete|process.*完|Process' app/src/main/java app/src/testRepository: utkarshdalal/GameNative Length of output: 50380 🏁 Script executed: sed -n '3950,4190p' app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
sed -n '1,220p' app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt
rg -n -C 8 'object SteamInstallScriptRunProcess|class SteamInstallScriptRunProcess|markRun|hasRun|HasRun|exit|terminated|on.*Complete|Process' app/src/main/java app/src/testRepository: utkarshdalal/GameNative Length of output: 50380 🏁 Script executed: sed -n '4135,4180p' app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
rg -l --glob '*.kt' --glob '*.java' 'SteamInstallScriptRunProcess' app/src/main app/src/test
rg -n -C 12 'SteamInstallScriptRunProcess|markRun|hasRun|HasRun' app/src/main/java/app/gamenative/utils app/src/test/java/app/gamenative/utilsRepository: utkarshdalal/GameNative Length of output: 44811 🏁 Script executed: rg -l --glob '*.kt' --glob '*.java' 'class .*GuestProgramLauncher|interface .*GuestProgramLauncher|setTerminationCallback' app/src/main app/src/test | head -40
rg -n -C 8 --glob '*.kt' --glob '*.java' 'setTerminationCallback|GuestProgramLauncherComponent' app/src/main/java/com app/src/main/java/app/gamenative | head -180
rg -n -i -C 4 'pre.?install|failed installer|interrupted installer|retry|HasRunKey|has.?run' README.md docs app/src/main app/src/test 2>/dev/null | head -240Repository: utkarshdalal/GameNative Length of output: 46402 🤖 get_repo_knowledge executed:
Length of output: 25533 🏁 Script executed: sed -n '1,190p' app/src/main/java/com/winlator/xenvironment/components/GuestProgramLauncherComponent.java
sed -n '190,360p' app/src/main/java/com/winlator/xenvironment/components/GuestProgramLauncherComponent.javaRepository: utkarshdalal/GameNative Length of output: 14727 Preserve per-installer success before writing VC run state.
Pass the selected entries and their individual confirmed outcomes through the completion callback. Mark only successful entries in 🤖 Prompt for AI Agents |
||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| private fun prefixDir(container: Container): File? = | ||||||||||||
| container.rootDir?.path?.takeIf { it.isNotEmpty() }?.let { File(it, ".wine") } | ||||||||||||
|
|
||||||||||||
| private fun pendingEntries(container: Container, gameDir: File): List<SteamInstallScriptRunProcess.Entry> { | ||||||||||||
| val prefixDir = prefixDir(container) ?: return candidates(gameDir) | ||||||||||||
| return candidates(gameDir).filter { !SteamInstallScriptRunProcess.hasRun(prefixDir, it) } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| private fun candidates(gameDir: File): List<SteamInstallScriptRunProcess.Entry> { | ||||||||||||
| val scripted = SteamInstallScriptRunProcess.entries(gameDir).filter { isVcRedistExe(it.exeName) } | ||||||||||||
| if (scripted.isNotEmpty()) return scripted | ||||||||||||
|
|
||||||||||||
| val fallback = mutableListOf<SteamInstallScriptRunProcess.Entry>() | ||||||||||||
| 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 | ||||||||||||
| if (rest.lastIndexOf('\\') < 0) continue | ||||||||||||
| val hostFile = File(gameDir, rest.replace('\\', '/')) | ||||||||||||
| if (!hostFile.isFile) continue | ||||||||||||
| parts.add(if (args.isEmpty()) winPath else "$winPath $args") | ||||||||||||
| 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 || (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 | ||||||||||||
| 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") | ||||||||||||
| fallback += SteamInstallScriptRunProcess.Entry(winPath, "/install /passive /norestart", exe) | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| return if (parts.isEmpty()) null else parts.joinToString(" & ") | ||||||||||||
| return fallback | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| private fun isVcRedistExe(name: String): Boolean { | ||||||||||||
| val lower = name.lowercase() | ||||||||||||
| return lower.endsWith(".exe") && (lower.startsWith("vc_redist") || lower.startsWith("vcredist")) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| private fun isV140Installer(exeName: String): Boolean = exeName.startsWith("vc_redist", ignoreCase = true) | ||||||||||||
|
|
||||||||||||
| private fun writeV140Overrides(container: Container) { | ||||||||||||
| val prefixDir = prefixDir(container) ?: return | ||||||||||||
| 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}") } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
Uh oh!
There was an error while loading. Please reload this page.