Skip to content

Commit 948acb4

Browse files
author
Utkarsh Dalal
committed
Launch EA App titles through a service-gated Link2EA handoff
Detect EA App games by the installer bundle in the game dir - no per-game registration. The gamefix self-provisions the headless installer (cmd generated in code, the 245MB MSI copied from a sibling EA install; sc create pre-registration stops wine msi rolling back the install on fresh prefixes), repairs the EA registry that EA's own self-heal fails to fix under wine (broken SDK paths crash Link2EA in rpcrt4), keeps EA's Qt hosts on builtin WineD3D with the CEF subprocess on software rendering, and writes a launch script that starts EABackgroundService and polls it to RUNNING before invoking Link2EA.exe directly - Link2EA races a cold service and EA rewrites its protocol registry keys on every service start, so neither the wait nor the handler can live in the registry. The gamefix is re-applied right before the game session for EA titles only: installer sessions overwrite its registry work on their wineserver flush, and on a cold install the EA binaries only exist afterwards. EA's DirtySDK cannot resolve hostnames through bionic's resolver, so a prebuilt LD_PRELOAD shim (source: gamenative-dns-shim repo) routes res_query/getaddrinfo through Android's resolver for any EA App container. Accounts linked to Steam sign in silently via the Link2EA exchange token. Requires Proton 11.
1 parent 17bef95 commit 948acb4

6 files changed

Lines changed: 492 additions & 5 deletions

File tree

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
1+
package app.gamenative.gamefixes
2+
3+
import android.content.Context
4+
import app.gamenative.data.GameSource
5+
import com.winlator.container.Container
6+
import com.winlator.core.WineRegistryEditor
7+
import java.io.File
8+
import timber.log.Timber
9+
10+
private val EA_SOFTWARE_RENDERER_EXES = listOf(
11+
"EACefSubProcess.exe",
12+
)
13+
private val EA_QT_HOST_EXES = listOf("EAappInstaller.exe", "EADesktop.exe", "EALaunchHelper.exe")
14+
private val EA_SOFTWARE_RENDERER_DLLS = listOf("dxgi", "d3d11", "d3d9")
15+
16+
private const val EA_DESKTOP_INSTALL_ROOT =
17+
".wine/drive_c/Program Files/Electronic Arts/EA Desktop"
18+
private const val EA_INSTALL_SUCCESS_KEY_UPPERCASE =
19+
"HKEY_LOCAL_MACHINE\\\\SOFTWARE\\\\Electronic Arts\\\\EA Desktop\\\\InstallSuccessful"
20+
private const val EA_INSTALL_SUCCESS_KEY_WINE_CASE =
21+
"HKEY_LOCAL_MACHINE\\\\Software\\\\Electronic Arts\\\\EA Desktop\\\\InstallSuccessful"
22+
private const val EA_INSTALLER_PROCESS =
23+
"%INSTALLDIR%\\\\__Installer\\\\Origin\\\\redist\\\\internal\\\\EAappInstaller.exe"
24+
private const val EA_HEADLESS_INSTALLER_PROCESS =
25+
"%INSTALLDIR%\\\\__Installer\\\\Origin\\\\redist\\\\internal\\\\EAapp-wine-install.cmd"
26+
private const val EA_HEADLESS_INSTALLER_RELATIVE_PATH =
27+
"__Installer/Origin/redist/internal/EAapp-wine-install.cmd"
28+
private const val EA_HEADLESS_MSI_RELATIVE_PATH =
29+
"__Installer/Origin/redist/internal/EAapp-wine-no-start.msi"
30+
31+
private fun configureEaInstallScript(installPath: String) {
32+
val installScript = File(installPath, "installscript.vdf")
33+
if (!installScript.isFile) return
34+
35+
val contents = installScript.readText()
36+
var configured = contents.replace(
37+
EA_INSTALL_SUCCESS_KEY_UPPERCASE,
38+
EA_INSTALL_SUCCESS_KEY_WINE_CASE,
39+
)
40+
if (
41+
File(installPath, EA_HEADLESS_INSTALLER_RELATIVE_PATH).isFile &&
42+
File(installPath, EA_HEADLESS_MSI_RELATIVE_PATH).isFile
43+
) {
44+
configured = configured.replace(EA_INSTALLER_PROCESS, EA_HEADLESS_INSTALLER_PROCESS)
45+
}
46+
if (configured != contents) installScript.writeText(configured)
47+
}
48+
49+
private fun findInstalledEaDesktop(container: Container): Pair<String, String>? {
50+
val installRoot = File(container.rootDir, EA_DESKTOP_INSTALL_ROOT)
51+
val versionDir = installRoot.listFiles()
52+
?.filter { it.isDirectory && File(it, "EA Desktop/EADesktop.exe").isFile }
53+
?.maxByOrNull { dir ->
54+
dir.name.split('.').fold(0L) { value, component ->
55+
value * 10_000L + (component.toLongOrNull() ?: 0L)
56+
}
57+
}
58+
?: return null
59+
60+
val version = versionDir.name
61+
val basePath = "C:\\Program Files\\Electronic Arts\\EA Desktop\\$version"
62+
return version to "$basePath\\EA Desktop"
63+
}
64+
65+
internal fun applyEaCompatibilityRegistry(container: Container, gameExeWindowsPath: String?) {
66+
val (version, eaDesktopDir) = findInstalledEaDesktop(container) ?: return
67+
val desktopPath = "$eaDesktopDir\\EADesktop.exe"
68+
val launcherPath = "$eaDesktopDir\\EALauncher.exe"
69+
val link2EaPath = "$eaDesktopDir\\Link2EA.exe"
70+
val steamPath = "C:\\Program Files (x86)\\Steam\\steam.exe"
71+
val useLegacySteamApi = container.isUseLegacyDRM &&
72+
!container.isLaunchRealSteam &&
73+
!container.isLaunchBionicSteam
74+
val systemRegFile = File(container.rootDir, ".wine/system.reg")
75+
if (!systemRegFile.isFile) return
76+
77+
WineRegistryEditor(systemRegFile).use { editor ->
78+
editor.setCreateKeyIfNotExist(true)
79+
80+
// Burn extracts the versioned EA files before its MSI/custom actions
81+
// register the service. Do not mistake that intermediate state for a
82+
// completed install or Steam will skip a grey/interrupted installer.
83+
// system.reg stores the concrete control set. CurrentControlSet is a
84+
// runtime registry alias and is not guaranteed to exist as a literal
85+
// key while we edit the prefix offline.
86+
val serviceKey = listOf(
87+
"System\\ControlSet001\\Services\\EABackgroundService",
88+
"System\\CurrentControlSet\\Services\\EABackgroundService",
89+
).firstOrNull { key ->
90+
!editor.getStringValue(key, "ImagePath", null).isNullOrEmpty()
91+
}
92+
if (serviceKey == null) {
93+
for (viewPrefix in listOf("Software", "Software\\Wow6432Node")) {
94+
editor.setStringValue(
95+
"$viewPrefix\\Electronic Arts\\EA Desktop",
96+
"InstallSuccessful",
97+
"false",
98+
)
99+
}
100+
return
101+
}
102+
editor.setStringValue(
103+
serviceKey,
104+
"ImagePath",
105+
"$eaDesktopDir\\EABackgroundService.exe -start",
106+
)
107+
108+
for (protocol in listOf("origin", "origin2")) {
109+
val protocolKey = "Software\\Classes\\$protocol"
110+
editor.setStringValue(protocolKey, null, "URL:Origin Protocol")
111+
editor.setStringValue(protocolKey, "URL Protocol", "")
112+
editor.setStringValue("$protocolKey\\DefaultIcon", null, "\"$launcherPath\",0")
113+
editor.setStringValue(
114+
"$protocolKey\\shell\\open\\command",
115+
null,
116+
"\"$launcherPath\" \"%1\"",
117+
)
118+
}
119+
120+
// Steam launches The Sims 4 through EA's link2ea:// URL. Some Wine
121+
// installs are missing this association even though Link2EA.exe is
122+
// installed, which makes start.exe return immediately without opening
123+
// EA or the game.
124+
val link2EaProtocolKey = "Software\\Classes\\link2ea"
125+
editor.setStringValue(link2EaProtocolKey, null, "URL:EA Link Protocol")
126+
editor.setStringValue(link2EaProtocolKey, "URL Protocol", "")
127+
editor.setStringValue("$link2EaProtocolKey\\DefaultIcon", null, "\"$link2EaPath\",0")
128+
editor.setStringValue(
129+
"$link2EaProtocolKey\\shell\\open\\command",
130+
null,
131+
// Link2EA starts the background service itself, but immediately
132+
// sends its launch request. On a clean prefix the service also
133+
// installs its VC runtimes before EADesktop can start; its IPC
134+
// listener becomes available before that bootstrap is finished.
135+
// Keep the service and protocol handler in the same Wine session,
136+
// but give the complete first-run bootstrap time to settle.
137+
"\"C:\\windows\\system32\\cmd.exe\" /d /s /c " +
138+
"\"\"C:\\windows\\system32\\sc.exe\" start EABackgroundService >nul 2>&1 & " +
139+
"\"C:\\windows\\system32\\timeout.exe\" /t 30 /nobreak >nul & " +
140+
"\"$link2EaPath\" \"%1\"\"",
141+
)
142+
143+
// Steam-owned EA entitlements are deliberately redirected back through
144+
// steam://run/<appid>. With a real/Bionic Steam client, hand that URL to
145+
// Proton's steam.exe. Legacy DRM replaces the game's steam_api64.dll
146+
// directly, so avoid the cold-client/Proton Steam bridge and start the
147+
// existing game executable after EA has authenticated the request.
148+
val steamProtocolKey = "Software\\Classes\\steam"
149+
val legacyGameExe = gameExeWindowsPath.takeIf { useLegacySteamApi && it != null }
150+
val steamProtocolTarget = legacyGameExe ?: steamPath
151+
val steamProtocolCommand = if (legacyGameExe != null) {
152+
"\"$legacyGameExe\""
153+
} else {
154+
"\"$steamPath\" \"%1\""
155+
}
156+
editor.setStringValue(steamProtocolKey, null, "URL:Steam Protocol")
157+
editor.setStringValue(steamProtocolKey, "URL Protocol", "")
158+
editor.setStringValue("$steamProtocolKey\\DefaultIcon", null, "\"$steamProtocolTarget\",0")
159+
editor.setStringValue(
160+
"$steamProtocolKey\\shell\\open\\command",
161+
null,
162+
steamProtocolCommand,
163+
)
164+
165+
val desktopValues = mapOf(
166+
"ClientPath" to desktopPath,
167+
"ClientVersion" to version,
168+
"CommonAppPathCreated" to "1",
169+
"DesktopAppPath" to desktopPath,
170+
"EaConnectLink2EAAppPath" to link2EaPath,
171+
"EaSteam2EAAppPath" to "$eaDesktopDir\\EASteamLauncher.exe",
172+
"ErrorReporterPath" to "$eaDesktopDir\\ErrorReporter.exe",
173+
"InstallLocation" to "C:\\Program Files\\Electronic Arts\\EA Desktop\\$version",
174+
"IsUnavailable" to "0",
175+
"LauncherAppPath" to launcherPath,
176+
"RazorMode" to "0",
177+
)
178+
179+
for (viewPrefix in listOf("Software", "Software\\Wow6432Node")) {
180+
editor.setStringValue("$viewPrefix\\Origin", "ClientPath", desktopPath)
181+
editor.setStringValue("$viewPrefix\\Electronic Arts\\EADM", "ClientPath", desktopPath)
182+
183+
val desktopKey = "$viewPrefix\\Electronic Arts\\EA Desktop"
184+
for ((name, value) in desktopValues) {
185+
if (name in setOf("CommonAppPathCreated", "IsUnavailable", "RazorMode")) {
186+
editor.setDwordValue(desktopKey, name, value.toInt())
187+
} else {
188+
editor.setStringValue(desktopKey, name, value)
189+
}
190+
}
191+
}
192+
}
193+
}
194+
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+
231+
const val EA_LINK2EA_LAUNCH_SCRIPT_WINDOWS_PATH =
232+
"C:\\\\ProgramData\\\\GameNative\\\\ea-link2ea-launch.cmd"
233+
234+
/**
235+
* Link2EA races EABackgroundService on cold boots: it starts the service and
236+
* immediately sends its launch request, so the first EA Desktop of a session
237+
* connects before the IPC listener exists and shows "disconnected, restart".
238+
* Gate the protocol handoff on the service actually reporting RUNNING. This
239+
* cannot live in the link2ea registry handler because EA rewrites its protocol
240+
* keys on every service start.
241+
*/
242+
private fun writeLink2EaLaunchScript(container: Container) {
243+
val link2EaPath = findInstalledEaDesktop(container)
244+
?.let { (_, eaDesktopDir) -> "$eaDesktopDir\\Link2EA.exe" }
245+
?: return
246+
val script = File(container.rootDir, ".wine/drive_c/ProgramData/GameNative/ea-link2ea-launch.cmd")
247+
script.parentFile?.mkdirs()
248+
script.writeText(
249+
"""
250+
@echo off
251+
"C:\windows\system32\sc.exe" start EABackgroundService >nul 2>&1
252+
for /L %%i in (1,1,30) do (
253+
"C:\windows\system32\sc.exe" query EABackgroundService | "C:\windows\system32\findstr.exe" /C:"RUNNING" >nul 2>&1 && goto launch
254+
"C:\windows\system32\timeout.exe" /t 1 /nobreak >nul 2>&1
255+
)
256+
:launch
257+
"C:\windows\system32\timeout.exe" /t 2 /nobreak >nul 2>&1
258+
"$link2EaPath" %1
259+
""".trimIndent().replace("\n", "\r\n"),
260+
)
261+
}
262+
263+
private const val EA_INSTALLER_RELATIVE_DIR = "__Installer/Origin/redist/internal"
264+
265+
/** An EA App title ships EA's installer bundle inside its game directory. */
266+
fun isEaAppGame(installPath: String): Boolean =
267+
File(installPath, "$EA_INSTALLER_RELATIVE_DIR/EAappInstaller.exe").isFile
268+
269+
/**
270+
* The headless-install pieces are EA-App-generic, but the MSI is ~245MB and
271+
* cannot ship inside the APK. Generate the cmd from code, and source the MSI
272+
* from any sibling EA game under the same steamapps/common root that already
273+
* has one.
274+
*/
275+
private fun ensureHeadlessInstallerFiles(installPath: String) {
276+
val internalDir = File(installPath, EA_INSTALLER_RELATIVE_DIR)
277+
if (!internalDir.isDirectory) return
278+
279+
val msi = File(internalDir, "EAapp-wine-no-start.msi")
280+
if (!msi.isFile) {
281+
val commonRoot = File(installPath).parentFile
282+
val donor = commonRoot?.listFiles()
283+
?.asSequence()
284+
?.filter { it.isDirectory }
285+
?.map { File(it, "$EA_INSTALLER_RELATIVE_DIR/EAapp-wine-no-start.msi") }
286+
?.firstOrNull { it.isFile }
287+
if (donor == null) {
288+
Timber.tag("GameFixes").w(
289+
"No EAapp-wine-no-start.msi found under %s; EA headless install unavailable",
290+
commonRoot?.absolutePath,
291+
)
292+
return
293+
}
294+
Timber.tag("GameFixes").i("Copying EA headless MSI from %s", donor.absolutePath)
295+
donor.copyTo(msi)
296+
}
297+
298+
val cmd = File(internalDir, "EAapp-wine-install.cmd")
299+
if (!cmd.isFile) {
300+
cmd.writeText(
301+
"""
302+
@echo off
303+
set EA_BUNDLE_KEY={0843d159-4e03-4026-8fa0-32432514dba6}
304+
set "EA_BUNDLE_CACHE=C:\ProgramData\Package Cache\%EA_BUNDLE_KEY%"
305+
if not exist "%EA_BUNDLE_CACHE%" mkdir "%EA_BUNDLE_CACHE%"
306+
copy /Y "%~dp0EAappInstaller.exe" "%EA_BUNDLE_CACHE%\EAappOfflineInstaller.exe"
307+
reg add "HKLM\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\%EA_BUNDLE_KEY%" /f
308+
reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d installing /f
309+
msiexec /x {C2622085-ABD2-49E5-8AB9-D3D6A642C091} /qn /norestart
310+
rem Pre-register the service: on a fresh prefix wine msi's
311+
rem StartServices cannot see the service its own ServiceInstall
312+
rem just registered (error 2) and rolls the whole install back.
313+
sc create EABackgroundService binPath= "\"C:\Program Files\Electronic Arts\EA Desktop\13.768.7.6285\EA Desktop\EABackgroundService.exe\" -start" >nul 2>&1
314+
msiexec /i "%~dp0EAapp-wine-no-start.msi" /qn /norestart /L*V "C:\windows\temp\EAapp-wine-install.log" ARPSYSTEMCOMPONENT=1 MSIFASTINSTALL=7 INSTALL_ROOT="C:\Program Files\Electronic Arts\EA Desktop" CLIENT_VERSION="13.768.7.6285" JUNO_CREATE_DESKTOP_SHORTCUT=1 INSTALLER_ERROR_REPORTER_SHORTCUT_TITLE="EA Error Reporter" INSTALLER_UPDATER_SHORTCUT_TITLE="EA app Updater" INSTALLER_RECOVERY_HELPER_SHORTCUT_TITLE="App Recovery" INSTALLER_ERROR_UNKNOWN="EA app encountered an error during the installation. Try again a bit later." BUNDLE_PROVIDERKEY="%EA_BUNDLE_KEY%" PRODUCT_DISPLAY_NAME="EA app" EAX_LAUNCH_CLIENT=0 INTREPID_ENABLE=0 EAX_ALLOW_WINDOWS_7=1 EAX_DISABLE_SYMLINKS=0 EAX_DOWNLOAD_IN_PLACE_DIR="C:\Program Files\EA Games" EAX_BUNDLE_EXECUTABLE_NAME="EAappOfflineInstaller.exe" EAX_RAZOR_MODE_ENABLE=0
315+
set EA_INSTALL_RC=%ERRORLEVEL%
316+
copy /Y "C:\windows\temp\EAapp-wine-install.log" "%~dp0EAapp-wine-install.log"
317+
if not "%EA_INSTALL_RC%"=="0" goto install_failed
318+
reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d true /f
319+
exit /b 0
320+
:install_failed
321+
reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d failed-%EA_INSTALL_RC% /f
322+
exit /b %EA_INSTALL_RC%
323+
""".trimIndent().replace("\n", "\r\n"),
324+
)
325+
}
326+
}
327+
328+
/**
329+
* EA App family fix: applies to any Steam game that ships the EA installer.
330+
*
331+
* Keep the game on DXVK, route EA's Qt hosts through builtin WineD3D without
332+
* disabling OpenGL, and force only the CEF subprocess to software rendering.
333+
*/
334+
val EaAppGameFix: GameFix = object : GameFix {
335+
override fun apply(
336+
context: Context,
337+
gameId: String,
338+
installPath: String,
339+
installPathWindows: String,
340+
container: Container,
341+
): Boolean = try {
342+
ensureHeadlessInstallerFiles(installPath)
343+
configureEaInstallScript(installPath)
344+
writeLink2EaLaunchScript(container)
345+
suppressEaSelfUpdate(container)
346+
val userRegFile = File(container.rootDir, ".wine/user.reg")
347+
if (!userRegFile.isFile) {
348+
userRegFile.parentFile?.mkdirs()
349+
userRegFile.writeText("WINE REGISTRY Version 2\n\n")
350+
}
351+
WineRegistryEditor(userRegFile).use { editor ->
352+
editor.setCreateKeyIfNotExist(true)
353+
for (exe in EA_QT_HOST_EXES) {
354+
val dllOverridesKey = "Software\\Wine\\AppDefaults\\$exe\\DllOverrides"
355+
val direct3dKey = "Software\\Wine\\AppDefaults\\$exe\\Direct3D"
356+
for (dll in EA_SOFTWARE_RENDERER_DLLS) {
357+
if (editor.getStringValue(dllOverridesKey, dll, null) != "builtin") {
358+
editor.setStringValue(dllOverridesKey, dll, "builtin")
359+
}
360+
}
361+
editor.removeValue(direct3dKey, "renderer")
362+
}
363+
for (exe in EA_SOFTWARE_RENDERER_EXES) {
364+
val dllOverridesKey = "Software\\Wine\\AppDefaults\\$exe\\DllOverrides"
365+
val direct3dKey = "Software\\Wine\\AppDefaults\\$exe\\Direct3D"
366+
for (dll in EA_SOFTWARE_RENDERER_DLLS) {
367+
if (editor.getStringValue(dllOverridesKey, dll, null) != "builtin") {
368+
editor.setStringValue(dllOverridesKey, dll, "builtin")
369+
}
370+
}
371+
if (editor.getStringValue(direct3dKey, "renderer", null) != "no3d") {
372+
editor.setStringValue(direct3dKey, "renderer", "no3d")
373+
}
374+
}
375+
}
376+
val gameExeWindowsPath = container.executablePath
377+
.takeIf { it.isNotBlank() }
378+
?.let { exe ->
379+
"C:\\Program Files (x86)\\Steam\\steamapps\\common\\" +
380+
"${File(installPath).name}\\${exe.replace('/', '\\')}"
381+
}
382+
applyEaCompatibilityRegistry(container, gameExeWindowsPath)
383+
true
384+
} catch (e: Exception) {
385+
Timber.tag("GameFixes").e(e, "Failed to apply EA App software renderer overrides")
386+
false
387+
}
388+
}

0 commit comments

Comments
 (0)