-
-
Notifications
You must be signed in to change notification settings - Fork 441
Apply Steam install-script registry entries generically #1925
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
Merged
utkarshdalal
merged 7 commits into
utkarshdalal:master
from
butilly:fix/steam-install-script-registry
Sep 14, 2026
+407
−1
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
83ffefc
Add Spore registry pre-install step
e6d9598
Revert "Add Spore registry pre-install step"
37f04da
Apply Steam install-script registry entries
eb14b27
Write install-script registry entries directly into the prefix
54a6c8d
Handle language blocks, Steam path tokens and (Default) in install sc…
0e838fa
Fix nullable KeyValue names
383e2c4
Use (Default) in install-script parser test
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
187 changes: 187 additions & 0 deletions
187
app/src/main/java/app/gamenative/utils/SteamInstallScriptRegistry.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, String> = 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<Entry> { | ||
| 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<Entry>() | ||
| 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<Entry>) { | ||
| 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<Entry>, | ||
| hive: Hive, | ||
| key: String, | ||
| values: KeyValue, | ||
| type: ValueType, | ||
| tokens: Map<String, String>, | ||
| 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<Hive, String>? { | ||
| 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, String>): 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 } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Gate completion markers on a successful registry commit.
WineRegistryEditor.setRawValue()logs caughtIOExceptionbut returns no failure status.WineRegistryEditor.close()also ignores the result ofcloneFile.renameTo(file). Therefore,SteamInstallScriptRegistry.write()can return after a failed or partial update.applyForLaunch()then adds both completion markers without validating the registry files. If both markers are created, later launches return early and do not retry. No transaction coverssystem.reganduser.reg.Propagate write and commit failures through
write(). Create the markers only after every required registry file commits successfully.🤖 Prompt for AI Agents