Skip to content

Commit 17bef95

Browse files
author
Utkarsh Dalal
committed
Run Steam install scripts for EA App titles before Bionic-Steam launches
Bionic Steam skips the Windows Steam client's first-run InstallScript processing, so EA titles never install the EA App. Parse installScript.vdf, honor each process's HasRunStringKey in the prefix registry for idempotency, and apply the script's registry strings and file copies. Limited to games that ship the EA installer bundle.
1 parent 1ad70ae commit 17bef95

3 files changed

Lines changed: 220 additions & 3 deletions

File tree

app/src/main/java/app/gamenative/utils/PreInstallSteps.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import java.io.File
1919
*/
2020
object PreInstallSteps {
2121
data class PreInstallCommand(
22-
val marker: Marker,
22+
val marker: Marker?,
2323
val executable: String,
2424
)
2525

@@ -30,11 +30,12 @@ object PreInstallSteps {
3030
XnaFrameworkStep,
3131
GogScriptInterpreterStep,
3232
UbisoftConnectStep,
33+
SteamInstallScriptStep,
3334
)
3435

3536
private var stepsProvider: () -> List<PreInstallStep> = { steps }
3637
private fun currentSteps(): List<PreInstallStep> = stepsProvider()
37-
private fun allMarkers(): List<Marker> = currentSteps().map { it.marker }.distinct()
38+
private fun allMarkers(): List<Marker> = currentSteps().mapNotNull { it.marker }.distinct()
3839

3940
/**
4041
* Returns a list of pre-install commands (marker + guest executable). Each entry is a

app/src/main/java/app/gamenative/utils/preInstallSteps/PreInstallStep.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import com.winlator.container.Container
66
import java.io.File
77

88
interface PreInstallStep {
9-
val marker: Marker
9+
val marker: Marker?
1010

1111
fun appliesTo(
1212
container: Container,
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package app.gamenative.utils
2+
3+
import app.gamenative.data.GameSource
4+
import app.gamenative.enums.Marker
5+
import com.winlator.container.Container
6+
import com.winlator.core.WineRegistryEditor
7+
import `in`.dragonbra.javasteam.types.KeyValue
8+
import java.io.File
9+
import java.nio.file.Files
10+
import java.nio.file.StandardCopyOption
11+
import timber.log.Timber
12+
13+
/**
14+
* Runs keyed Steam install-script processes before a direct Bionic-Steam launch.
15+
*
16+
* Bionic Steam supplies the native Steam API but intentionally does not run the
17+
* Windows Steam client, so Steam's normal first-run InstallScript processing is
18+
* otherwise skipped. Completion remains prefix-scoped by honoring each process'
19+
* HasRunStringKey instead of writing a marker into the shared game directory.
20+
*/
21+
object SteamInstallScriptStep : PreInstallStep {
22+
override val marker: Marker? = null
23+
24+
internal data class RunProcess(
25+
val executable: String,
26+
val arguments: String,
27+
val hasRunStringKey: String,
28+
val hasRunStringValue: String,
29+
)
30+
31+
override fun appliesTo(
32+
container: Container,
33+
gameSource: GameSource,
34+
gameDirPath: String,
35+
): Boolean {
36+
if (gameSource != GameSource.STEAM || !container.isLaunchBionicSteam) return false
37+
return parseKeyedRunProcesses(File(gameDirPath, "installScript.vdf"))
38+
.any { !isProcessComplete(container, it) }
39+
}
40+
41+
override fun buildCommand(
42+
container: Container,
43+
appId: String,
44+
gameSource: GameSource,
45+
gameDir: File,
46+
gameDirPath: String,
47+
): String? {
48+
if (gameSource != GameSource.STEAM || !container.isLaunchBionicSteam) return null
49+
val root = loadInstallScript(File(gameDir, "installScript.vdf")) ?: return null
50+
val pending = parseKeyedRunProcesses(root).filterNot { isProcessComplete(container, it) }
51+
if (pending.isEmpty()) return null
52+
53+
applyRegistryStrings(container, gameDir, root)
54+
applyCopyFiles(container, gameDir, root)
55+
56+
return pending.mapNotNull { process ->
57+
val guestExecutable = expandGuestInstallPath(process.executable) ?: return@mapNotNull null
58+
val hostExecutable = resolveHostInstallPath(gameDir, process.executable) ?: return@mapNotNull null
59+
if (!hostExecutable.isFile) {
60+
Timber.tag("SteamInstallScript").w("Install-script process is missing: ${hostExecutable.absolutePath}")
61+
return@mapNotNull null
62+
}
63+
val arguments = unattendedArguments(process)
64+
if (arguments.isBlank()) guestExecutable else "$guestExecutable $arguments"
65+
}.takeIf { it.isNotEmpty() }?.joinToString(" & ")
66+
}
67+
68+
private fun unattendedArguments(process: RunProcess): String {
69+
if (!process.executable.endsWith("EAappInstaller.exe", ignoreCase = true)) {
70+
return process.arguments
71+
}
72+
73+
val hasQuietFlag = Regex("(?:^|\\s)[/-](?:quiet|silent)(?:\\s|$)", RegexOption.IGNORE_CASE)
74+
.containsMatchIn(process.arguments)
75+
return buildList {
76+
if (!hasQuietFlag) add("/quiet /norestart")
77+
if (process.arguments.isNotBlank()) add(process.arguments)
78+
}.joinToString(" ")
79+
}
80+
81+
internal fun parseKeyedRunProcesses(scriptFile: File): List<RunProcess> =
82+
loadInstallScript(scriptFile)?.let(::parseKeyedRunProcesses).orEmpty()
83+
84+
private fun parseKeyedRunProcesses(root: KeyValue): List<RunProcess> {
85+
val runProcess = root.child("Run Process") ?: return emptyList()
86+
return runProcess.children.mapNotNull { entry ->
87+
val executable = entry.childValue("process 1")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
88+
val hasRunKey = entry.childValue("HasRunStringKey")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
89+
RunProcess(
90+
executable = executable,
91+
arguments = entry.childValue("command 1").orEmpty(),
92+
hasRunStringKey = hasRunKey,
93+
hasRunStringValue = entry.childValue("HasRunStringValue").orEmpty(),
94+
)
95+
}
96+
}
97+
98+
private fun loadInstallScript(scriptFile: File): KeyValue? {
99+
if (!scriptFile.isFile) return null
100+
val parsed = runCatching { KeyValue.loadFromString(scriptFile.readText()) }.getOrNull() ?: return null
101+
return if (parsed.name.equals("InstallScript", ignoreCase = true)) parsed else parsed.child("InstallScript")
102+
}
103+
104+
private fun isProcessComplete(container: Container, process: RunProcess): Boolean {
105+
val target = registryTarget(container, process.hasRunStringKey, includesValueName = true) ?: return false
106+
if (!target.file.isFile) return false
107+
return runCatching {
108+
WineRegistryEditor(target.file).use { editor ->
109+
val actual = editor.getStringValue(target.key, target.valueName, null) ?: return@use false
110+
process.hasRunStringValue.isBlank() || actual.equals(process.hasRunStringValue, ignoreCase = true)
111+
}
112+
}.getOrDefault(false)
113+
}
114+
115+
private fun applyRegistryStrings(container: Container, gameDir: File, root: KeyValue) {
116+
val registry = root.child("Registry") ?: return
117+
val windowsInstallDir = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\${gameDir.name}"
118+
for (hive in registry.children) {
119+
val hiveName = hive.name ?: continue
120+
val target = registryTarget(container, hiveName, includesValueName = false) ?: continue
121+
val strings = hive.child("string") ?: continue
122+
runCatching {
123+
WineRegistryEditor(target.file).use { editor ->
124+
editor.setCreateKeyIfNotExist(true)
125+
for (value in strings.children) {
126+
val raw = value.value ?: continue
127+
val valueName = value.name ?: continue
128+
if (valueName.equals("(default)", ignoreCase = true)) continue
129+
editor.setStringValue(
130+
target.key,
131+
valueName,
132+
raw.replace("%INSTALLDIR%", windowsInstallDir, ignoreCase = true),
133+
)
134+
}
135+
}
136+
}.onFailure { Timber.tag("SteamInstallScript").w(it, "Failed to apply install-script registry strings") }
137+
}
138+
}
139+
140+
private fun applyCopyFiles(container: Container, gameDir: File, root: KeyValue) {
141+
val copyFiles = root.child("Copy Files") ?: return
142+
val programData = File(container.rootDir, ".wine/drive_c/ProgramData")
143+
for (group in copyFiles.children) {
144+
val values = group.children.mapNotNull { child ->
145+
child.name?.let { it to child.value.orEmpty() }
146+
}.toMap()
147+
for ((name, sourceValue) in values) {
148+
if (!name.startsWith("SrcFile", ignoreCase = true)) continue
149+
val suffix = name.substring("SrcFile".length)
150+
val destinationValue = values.entries.firstOrNull {
151+
it.key.equals("DstFile$suffix", ignoreCase = true)
152+
}?.value ?: continue
153+
val source = resolveHostInstallPath(gameDir, sourceValue) ?: continue
154+
val destination = resolveHostDestination(programData, destinationValue) ?: continue
155+
if (!source.isFile) continue
156+
runCatching {
157+
destination.parentFile?.mkdirs()
158+
Files.copy(source.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING)
159+
}.onFailure { Timber.tag("SteamInstallScript").w(it, "Failed install-script copy to ${destination.absolutePath}") }
160+
}
161+
}
162+
}
163+
164+
private data class RegistryTarget(
165+
val file: File,
166+
val key: String,
167+
val valueName: String = "",
168+
)
169+
170+
private fun registryTarget(container: Container, rawPath: String, includesValueName: Boolean): RegistryTarget? {
171+
val normalized = rawPath.replace('/', '\\')
172+
val mappings = listOf(
173+
"HKEY_LOCAL_MACHINE_WOW64_32\\" to Pair("system.reg", "Software\\Wow6432Node\\"),
174+
"HKEY_LOCAL_MACHINE_WOW64_64\\" to Pair("system.reg", ""),
175+
"HKEY_LOCAL_MACHINE\\" to Pair("system.reg", ""),
176+
"HKLM\\" to Pair("system.reg", ""),
177+
"HKEY_CURRENT_USER\\" to Pair("user.reg", ""),
178+
"HKCU\\" to Pair("user.reg", ""),
179+
)
180+
val mapping = mappings.firstOrNull { normalized.startsWith(it.first, ignoreCase = true) } ?: return null
181+
var keyAndValue = normalized.substring(mapping.first.length)
182+
if (mapping.second.second.isNotEmpty() && keyAndValue.startsWith("SOFTWARE\\", ignoreCase = true)) {
183+
keyAndValue = mapping.second.second + keyAndValue.substring("SOFTWARE\\".length)
184+
}
185+
val valueName = if (includesValueName) keyAndValue.substringAfterLast('\\', "") else ""
186+
val key = if (includesValueName) keyAndValue.substringBeforeLast('\\', "") else keyAndValue
187+
if (key.isBlank() || (includesValueName && valueName.isBlank())) return null
188+
return RegistryTarget(
189+
file = File(container.rootDir, ".wine/${mapping.second.first}"),
190+
key = key,
191+
valueName = valueName,
192+
)
193+
}
194+
195+
private fun expandGuestInstallPath(rawPath: String): String? {
196+
if (!rawPath.startsWith("%INSTALLDIR%", ignoreCase = true)) return null
197+
return "A:" + rawPath.substring("%INSTALLDIR%".length).replace('/', '\\')
198+
}
199+
200+
private fun resolveHostInstallPath(gameDir: File, rawPath: String): File? {
201+
if (!rawPath.startsWith("%INSTALLDIR%", ignoreCase = true)) return null
202+
val relative = rawPath.substring("%INSTALLDIR%".length).trimStart('\\', '/').replace('\\', '/')
203+
return File(gameDir, relative)
204+
}
205+
206+
private fun resolveHostDestination(programData: File, rawPath: String): File? {
207+
if (!rawPath.startsWith("%PROGRAMDATA%", ignoreCase = true)) return null
208+
val relative = rawPath.substring("%PROGRAMDATA%".length).trimStart('\\', '/').replace('\\', '/')
209+
return File(programData, relative)
210+
}
211+
212+
private fun KeyValue.child(name: String): KeyValue? =
213+
children.firstOrNull { it.name.equals(name, ignoreCase = true) }
214+
215+
private fun KeyValue.childValue(name: String): String? = child(name)?.value
216+
}

0 commit comments

Comments
 (0)