Skip to content

Commit ea0fe69

Browse files
author
Utkarsh Dalal
committed
Revert to the exact cold-validated tree while bisecting a Link2EA startup failure
The minimalization work after validation correlates with Link2EA dying during services init before its IPC connect; reverting wholesale to bisect forward with cold tests. The minimal shape is preserved on ea-app-support-minimal.
1 parent 948acb4 commit ea0fe69

5 files changed

Lines changed: 181 additions & 108 deletions

File tree

app/src/main/java/app/gamenative/gamefixes/EaAppGameFix.kt

Lines changed: 118 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package app.gamenative.gamefixes
33
import android.content.Context
44
import app.gamenative.data.GameSource
55
import com.winlator.container.Container
6+
import com.winlator.contents.ContentsManager
67
import com.winlator.core.WineRegistryEditor
8+
import com.winlator.core.WineInfo
9+
import com.winlator.core.envvars.EnvVars
710
import java.io.File
811
import timber.log.Timber
912

@@ -28,6 +31,113 @@ private const val EA_HEADLESS_INSTALLER_RELATIVE_PATH =
2831
private const val EA_HEADLESS_MSI_RELATIVE_PATH =
2932
"__Installer/Origin/redist/internal/EAapp-wine-no-start.msi"
3033

34+
private fun ascii(value: String) = value.toByteArray(Charsets.US_ASCII)
35+
36+
private fun utf16Le(value: String) = value.toByteArray(Charsets.UTF_16LE)
37+
38+
private fun replaceEqualLengthBytes(file: File, from: ByteArray, to: ByteArray): Int {
39+
require(from.size == to.size)
40+
if (!file.isFile) return 0
41+
42+
val contents = file.readBytes()
43+
var replacements = 0
44+
var offset = 0
45+
while (offset <= contents.size - from.size) {
46+
var matches = true
47+
for (index in from.indices) {
48+
if (contents[offset + index] != from[index]) {
49+
matches = false
50+
break
51+
}
52+
}
53+
if (!matches) {
54+
offset++
55+
continue
56+
}
57+
to.copyInto(contents, offset)
58+
replacements++
59+
offset += from.size
60+
}
61+
62+
if (replacements > 0) {
63+
val backup = File(file.parentFile, "${file.name}.ea-gamefix-backup")
64+
if (!backup.exists()) file.copyTo(backup)
65+
file.writeBytes(contents)
66+
}
67+
return replacements
68+
}
69+
70+
private fun applyEaProtonCompatibilityPatches(context: Context, container: Container) {
71+
// Content identifiers include the profile revision (for example
72+
// proton-11.0-1-arm64ec-1), while the extracted directory does not
73+
// necessarily include it. Resolve the profile exactly as the launcher does
74+
// instead of assuming that the identifier is also a directory name.
75+
val contentsManager = ContentsManager(context)
76+
val wineInfo = WineInfo.fromIdentifier(context, contentsManager, container.wineVersion)
77+
val protonRoot = wineInfo.path
78+
?.takeIf { it.isNotBlank() }
79+
?.let(::File)
80+
if (protonRoot == null || !protonRoot.isDirectory) {
81+
Timber.tag("GameFixes").w(
82+
"Cannot apply Sims 4 EA compatibility patches: no installed path for %s",
83+
container.wineVersion,
84+
)
85+
return
86+
}
87+
Timber.tag("GameFixes").i(
88+
"Applying Sims 4 EA compatibility patches to %s",
89+
protonRoot.absolutePath,
90+
)
91+
92+
// Proton's EA hook forces the Qt host through Mesa software OpenGL. Zink
93+
// has no CPU device here, so Qt cannot create a pixel format at all.
94+
for (path in listOf(
95+
"lib/wine/x86_64-unix/ntdll.so",
96+
"lib/wine/aarch64-unix/ntdll.so",
97+
)) {
98+
replaceEqualLengthBytes(
99+
File(protonRoot, path),
100+
ascii("LIBGL_ALWAYS_SOFTWARE"),
101+
ascii("XIBGL_ALWAYS_SOFTWARE"),
102+
)
103+
}
104+
105+
// Proton also force-appends ANGLE Vulkan to EA's CEF subprocess. Keep the
106+
// Qt host on the normal graphics path and let the per-exe compatibility
107+
// configuration force the CEF helper onto its software path instead.
108+
for (path in listOf(
109+
"lib/wine/x86_64-windows/kernelbase.dll",
110+
"lib/wine/aarch64-windows/kernelbase.dll",
111+
"lib/wine/i386-windows/kernelbase.dll",
112+
)) {
113+
val file = File(protonRoot, path)
114+
replaceEqualLengthBytes(
115+
file,
116+
ascii("EACefSubProcess.exe"),
117+
ascii("XACefSubProcess.exe"),
118+
)
119+
replaceEqualLengthBytes(
120+
file,
121+
utf16Le("EACefSubProcess.exe"),
122+
utf16Le("XACefSubProcess.exe"),
123+
)
124+
125+
// This Proton hook forces desktop OpenGL for EA's Qt shell. Box64's
126+
// Zink path exits during initialization, while EA's bundled ANGLE
127+
// libraries render correctly.
128+
replaceEqualLengthBytes(
129+
file,
130+
ascii("QT_OPENGL"),
131+
ascii("XT_OPENGL"),
132+
)
133+
replaceEqualLengthBytes(
134+
file,
135+
utf16Le("QT_OPENGL"),
136+
utf16Le("XT_OPENGL"),
137+
)
138+
}
139+
}
140+
31141
private fun configureEaInstallScript(installPath: String) {
32142
val installScript = File(installPath, "installscript.vdf")
33143
if (!installScript.isFile) return
@@ -192,42 +302,6 @@ internal fun applyEaCompatibilityRegistry(container: Container, gameExeWindowsPa
192302
}
193303
}
194304

195-
196-
/**
197-
* A staged self-update makes EA Desktop demand a client restart mid-session,
198-
* which tears down any running game with it. Drop staged payloads, clear the
199-
* pending flag, and keep the version directory unwritable so an update can't
200-
* re-stage. (Remove the write protection deliberately when an EA update is
201-
* actually wanted.)
202-
*/
203-
private fun suppressEaSelfUpdate(container: Container) {
204-
val (version, _) = findInstalledEaDesktop(container) ?: return
205-
val versionDir = File(container.rootDir, "$EA_DESKTOP_INSTALL_ROOT/$version")
206-
207-
versionDir.setWritable(true, false)
208-
versionDir.listFiles()?.forEach { entry ->
209-
val staged = entry.name != "EA Desktop" && entry.name != "VC" &&
210-
(entry.isDirectory || entry.name.endsWith(".zip") || entry.name.endsWith(".zip.sig"))
211-
if (staged) {
212-
Timber.tag("GameFixes").i("Removing staged EA update: %s", entry.name)
213-
entry.deleteRecursively()
214-
}
215-
}
216-
versionDir.setWritable(false, false)
217-
218-
val machineIni = File(container.rootDir, ".wine/drive_c/ProgramData/EA Desktop/machine.ini")
219-
if (machineIni.isFile) {
220-
val lines = machineIni.readLines().map { line ->
221-
when {
222-
line.startsWith("machine.updatepending=") -> "machine.updatepending=0"
223-
line.startsWith("machine.updateinfo=") -> "machine.updateinfo="
224-
else -> line
225-
}
226-
}
227-
machineIni.writeText(lines.joinToString("\n"))
228-
}
229-
}
230-
231305
const val EA_LINK2EA_LAUNCH_SCRIPT_WINDOWS_PATH =
232306
"C:\\\\ProgramData\\\\GameNative\\\\ea-link2ea-launch.cmd"
233307

@@ -342,7 +416,14 @@ val EaAppGameFix: GameFix = object : GameFix {
342416
ensureHeadlessInstallerFiles(installPath)
343417
configureEaInstallScript(installPath)
344418
writeLink2EaLaunchScript(container)
345-
suppressEaSelfUpdate(container)
419+
container.putExtra("dnsV4MappedShim", "1")
420+
applyEaProtonCompatibilityPatches(context, container)
421+
val envVars = EnvVars(container.envVars)
422+
if (envVars.get("QT_OPENGL") != "angle") {
423+
envVars.put("QT_OPENGL", "angle")
424+
container.envVars = envVars.toString()
425+
container.saveData()
426+
}
346427
val userRegFile = File(container.rootDir, ".wine/user.reg")
347428
if (!userRegFile.isFile) {
348429
userRegFile.parentFile?.mkdirs()

app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,9 @@ object GameFixesRegistry {
6868
else -> gameId
6969
}
7070
Timber.i("GameFixesRegistry: Applying fixes for game: $source $catalogId if available")
71-
val keyedFix = fixesProvider()[source to catalogId]
72-
// Master behavior for everything without a fix: return before touching
73-
// path resolution. EA App titles (Steam only) are detected by their
74-
// installer bundle and get the family fix without registration.
75-
if (keyedFix == null && source != GameSource.STEAM) return
7671
val (installPath, installPathWindows) = resolvePaths(context, source, gameId) ?: return
77-
val fix = keyedFix
78-
?: EaAppGameFix.takeIf { isEaAppGame(installPath) }
72+
val fix = fixesProvider()[source to catalogId]
73+
?: EaAppGameFix.takeIf { source == GameSource.STEAM && isEaAppGame(installPath) }
7974
?: return
8075
fix.apply(context, catalogId, installPath, installPathWindows, container)
8176
}

app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ import app.gamenative.PrefManager
9393
import app.gamenative.SteamBootstrap
9494
import app.gamenative.data.GameSource
9595
import app.gamenative.gamefixes.GameFixesRegistry
96-
import app.gamenative.gamefixes.isEaAppGame
9796
import app.gamenative.gamefixes.GameInputCompatibility
9897
import app.gamenative.data.LaunchInfo
9998
import app.gamenative.data.LibraryItem
@@ -3783,20 +3782,11 @@ private fun setupXEnvironment(
37833782

37843783
var preInstallCommands: List<PreInstallSteps.PreInstallCommand> = emptyList()
37853784
var gameExecutable = ""
3786-
3787-
// EA App prefixes need the gamefix re-applied right before the game
3788-
// session: installer sessions overwrite its registry work on their
3789-
// wineserver flush, and on a cold install the EA binaries it targets only
3790-
// exist after the installer has run.
3791-
val reapplyEaGameFixes = {
3792-
val gameDir = ContainerUtils.extractGameIdFromContainerId(appId)
3793-
?.let { SteamService.getAppDirPath(it) }.orEmpty()
3794-
if (gameDir.isNotEmpty() && isEaAppGame(gameDir)) {
3795-
try {
3796-
GameFixesRegistry.applyFor(context, appId, container)
3797-
} catch (e: Exception) {
3798-
Timber.tag("GameFixes").w(e, "EA gamefix reapply failed")
3799-
}
3785+
val applyGameFixesImmediatelyBeforeGuestLaunch = Runnable {
3786+
try {
3787+
GameFixesRegistry.applyFor(context, appId, container)
3788+
} catch (e: Exception) {
3789+
Timber.tag("GameFixes").w(e, "Game fixes failed immediately before guest launch")
38003790
}
38013791
}
38023792

@@ -3896,7 +3886,10 @@ private fun setupXEnvironment(
38963886
if (preInstallCommands.isNotEmpty()) {
38973887
PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing prerequisites..."))
38983888
} else {
3899-
reapplyEaGameFixes()
3889+
// start() has already performed its final wineserver shutdown
3890+
// before invoking this callback, so registry changes made here
3891+
// cannot be overwritten by the previous Wine session.
3892+
applyGameFixesImmediatelyBeforeGuestLaunch.run()
39003893
PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Launching game..."))
39013894
}
39023895
}
@@ -3978,6 +3971,13 @@ private fun setupXEnvironment(
39783971

39793972
fun chainPreInstallSteps(remaining: List<PreInstallSteps.PreInstallCommand>) {
39803973
if (remaining.isEmpty()) {
3974+
// Installers can create or replace the registry values and binaries
3975+
// that a gamefix targets. Apply the fix again after the final
3976+
// installer has fully exited. Defer the reapply to preUnpack: the
3977+
// component's start() performs one final wineserver shutdown before
3978+
// invoking it, then immediately starts the game. This prevents the
3979+
// old Wine registry server from overwriting the repaired protocol.
3980+
guestProgramLauncherComponent.setPreUnpack(applyGameFixesImmediatelyBeforeGuestLaunch)
39813981
guestProgramLauncherComponent.setGuestExecutable(gameExecutable)
39823982
guestProgramLauncherComponent.setTerminationCallback(gameTerminationCallback)
39833983
return
@@ -3994,7 +3994,6 @@ private fun setupXEnvironment(
39943994
}
39953995
val nextRemaining = remaining.drop(1)
39963996
if (nextRemaining.isEmpty()) {
3997-
reapplyEaGameFixes()
39983997
PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Launching game..."))
39993998
} else {
40003999
PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing prerequisites..."))
@@ -4526,7 +4525,21 @@ private fun getWineStartCommand(
45264525
guestProgramLauncherComponent.workingDir = File(executableDir)
45274526
Timber.i("Bionic-Steam working directory is $executableDir")
45284527
val gameFolderName = appDirPath.substringAfterLast('/').ifEmpty { gameId.toString() }
4529-
SteamUtils.buildBionicSteamLaunchCommand(gameFolderName, exePath, appLaunchInfo, gameId, appDirPath)
4528+
SteamUtils.buildBionicSteamLaunchCommand(
4529+
gameFolderName = gameFolderName,
4530+
executablePath = exePath,
4531+
appLaunchInfo = appLaunchInfo,
4532+
protocolTargetOverride = if (app.gamenative.gamefixes.isEaAppGame(appDirPath)) {
4533+
"link2ea://launchgame/$gameId?platform=steam"
4534+
} else {
4535+
null
4536+
},
4537+
protocolLaunchScriptWindowsPath = if (app.gamenative.gamefixes.isEaAppGame(appDirPath)) {
4538+
app.gamenative.gamefixes.EA_LINK2EA_LAUNCH_SCRIPT_WINDOWS_PATH
4539+
} else {
4540+
null
4541+
},
4542+
)
45304543
} else if (container.isLaunchRealSteam) {
45314544
// Launch Steam with the applaunch parameter to start the game
45324545
"\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" -silent -vgui -tcp " +

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

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package app.gamenative.utils
22

3-
import app.gamenative.gamefixes.EA_LINK2EA_LAUNCH_SCRIPT_WINDOWS_PATH
4-
import app.gamenative.gamefixes.isEaAppGame
53
import android.annotation.SuppressLint
64
import android.content.Context
75
import android.provider.Settings
@@ -62,26 +60,14 @@ object SteamUtils {
6260
gameFolderName: String,
6361
executablePath: String,
6462
appLaunchInfo: LaunchInfo?,
65-
gameId: Int,
66-
appDirPath: String,
63+
protocolTargetOverride: String? = null,
64+
protocolLaunchScriptWindowsPath: String? = null,
6765
): String {
68-
// EA App titles must launch through EA: Steam hands the game to EA via
69-
// its link2ea:// protocol, and EA authenticates the session with an
70-
// exchange token minted from the Steam login.
71-
// Only EA App titles take the protocol path; every other game launches
72-
// exactly as before. Steam's launch config carries link2ea:// for most
73-
// EA titles; fall back to building the URL from the appid.
74-
val isEaApp = isEaAppGame(appDirPath)
75-
val protocolTarget = if (!isEaApp) {
76-
null
77-
} else {
78-
(appLaunchInfo?.executable ?: "link2ea://launchgame/$gameId?platform=steam")
79-
.trim()
80-
.takeIf { target ->
81-
target.matches(Regex("^[A-Za-z][A-Za-z0-9+.-]*://[^\\s\\\"]+$"))
82-
}
83-
?: "link2ea://launchgame/$gameId?platform=steam"
84-
}
66+
val protocolTarget = (appLaunchInfo?.executable ?: protocolTargetOverride)
67+
?.trim()
68+
?.takeIf { target ->
69+
target.matches(Regex("^[A-Za-z][A-Za-z0-9+.-]*://[^\\s\\\"]+$"))
70+
}
8571
if (protocolTarget != null) {
8672
// A gamefix-owned launch script can gate the protocol handoff (for
8773
// example waiting for EABackgroundService before Link2EA fires).
@@ -90,9 +76,9 @@ object SteamUtils {
9076
// The script path must not contain spaces: the command goes through
9177
// winhandler's argument parsing, which breaks on cmd's nested-quote
9278
// form, so the script is passed as a bare token.
93-
if (isEaApp) {
79+
if (protocolLaunchScriptWindowsPath != null) {
9480
return "\"C:\\\\windows\\\\system32\\\\cmd.exe\" /d /c " +
95-
"$EA_LINK2EA_LAUNCH_SCRIPT_WINDOWS_PATH \"$protocolTarget\""
81+
"$protocolLaunchScriptWindowsPath \"$protocolTarget\""
9682
}
9783
return "\"C:\\\\windows\\\\system32\\\\start.exe\" \"$protocolTarget\""
9884
}

0 commit comments

Comments
 (0)