From 17bef95693fd9bc111032d6d7e6de50c547179fc Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 27 Aug 2026 09:59:51 +0530 Subject: [PATCH 1/4] 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. --- .../app/gamenative/utils/PreInstallSteps.kt | 5 +- .../utils/preInstallSteps/PreInstallStep.kt | 2 +- .../preInstallSteps/SteamInstallScriptStep.kt | 216 ++++++++++++++++++ 3 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt diff --git a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt index c1a76d766e..89d2fd68e9 100644 --- a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt +++ b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt @@ -19,7 +19,7 @@ import java.io.File */ object PreInstallSteps { data class PreInstallCommand( - val marker: Marker, + val marker: Marker?, val executable: String, ) @@ -30,11 +30,12 @@ object PreInstallSteps { XnaFrameworkStep, GogScriptInterpreterStep, UbisoftConnectStep, + SteamInstallScriptStep, ) private var stepsProvider: () -> List = { steps } private fun currentSteps(): List = stepsProvider() - private fun allMarkers(): List = currentSteps().map { it.marker }.distinct() + private fun allMarkers(): List = currentSteps().mapNotNull { it.marker }.distinct() /** * Returns a list of pre-install commands (marker + guest executable). Each entry is a diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/PreInstallStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/PreInstallStep.kt index 4e9832b927..fa692a6287 100644 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/PreInstallStep.kt +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/PreInstallStep.kt @@ -6,7 +6,7 @@ import com.winlator.container.Container import java.io.File interface PreInstallStep { - val marker: Marker + val marker: Marker? fun appliesTo( container: Container, diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt new file mode 100644 index 0000000000..37ede25d06 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/SteamInstallScriptStep.kt @@ -0,0 +1,216 @@ +package app.gamenative.utils + +import app.gamenative.data.GameSource +import app.gamenative.enums.Marker +import com.winlator.container.Container +import com.winlator.core.WineRegistryEditor +import `in`.dragonbra.javasteam.types.KeyValue +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import timber.log.Timber + +/** + * Runs keyed Steam install-script processes before a direct Bionic-Steam launch. + * + * Bionic Steam supplies the native Steam API but intentionally does not run the + * Windows Steam client, so Steam's normal first-run InstallScript processing is + * otherwise skipped. Completion remains prefix-scoped by honoring each process' + * HasRunStringKey instead of writing a marker into the shared game directory. + */ +object SteamInstallScriptStep : PreInstallStep { + override val marker: Marker? = null + + internal data class RunProcess( + val executable: String, + val arguments: String, + val hasRunStringKey: String, + val hasRunStringValue: String, + ) + + override fun appliesTo( + container: Container, + gameSource: GameSource, + gameDirPath: String, + ): Boolean { + if (gameSource != GameSource.STEAM || !container.isLaunchBionicSteam) return false + return parseKeyedRunProcesses(File(gameDirPath, "installScript.vdf")) + .any { !isProcessComplete(container, it) } + } + + override fun buildCommand( + container: Container, + appId: String, + gameSource: GameSource, + gameDir: File, + gameDirPath: String, + ): String? { + if (gameSource != GameSource.STEAM || !container.isLaunchBionicSteam) return null + val root = loadInstallScript(File(gameDir, "installScript.vdf")) ?: return null + val pending = parseKeyedRunProcesses(root).filterNot { isProcessComplete(container, it) } + if (pending.isEmpty()) return null + + applyRegistryStrings(container, gameDir, root) + applyCopyFiles(container, gameDir, root) + + return pending.mapNotNull { process -> + val guestExecutable = expandGuestInstallPath(process.executable) ?: return@mapNotNull null + val hostExecutable = resolveHostInstallPath(gameDir, process.executable) ?: return@mapNotNull null + if (!hostExecutable.isFile) { + Timber.tag("SteamInstallScript").w("Install-script process is missing: ${hostExecutable.absolutePath}") + return@mapNotNull null + } + val arguments = unattendedArguments(process) + if (arguments.isBlank()) guestExecutable else "$guestExecutable $arguments" + }.takeIf { it.isNotEmpty() }?.joinToString(" & ") + } + + private fun unattendedArguments(process: RunProcess): String { + if (!process.executable.endsWith("EAappInstaller.exe", ignoreCase = true)) { + return process.arguments + } + + val hasQuietFlag = Regex("(?:^|\\s)[/-](?:quiet|silent)(?:\\s|$)", RegexOption.IGNORE_CASE) + .containsMatchIn(process.arguments) + return buildList { + if (!hasQuietFlag) add("/quiet /norestart") + if (process.arguments.isNotBlank()) add(process.arguments) + }.joinToString(" ") + } + + internal fun parseKeyedRunProcesses(scriptFile: File): List = + loadInstallScript(scriptFile)?.let(::parseKeyedRunProcesses).orEmpty() + + private fun parseKeyedRunProcesses(root: KeyValue): List { + val runProcess = root.child("Run Process") ?: return emptyList() + return runProcess.children.mapNotNull { entry -> + val executable = entry.childValue("process 1")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val hasRunKey = entry.childValue("HasRunStringKey")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + RunProcess( + executable = executable, + arguments = entry.childValue("command 1").orEmpty(), + hasRunStringKey = hasRunKey, + hasRunStringValue = entry.childValue("HasRunStringValue").orEmpty(), + ) + } + } + + private fun loadInstallScript(scriptFile: File): KeyValue? { + if (!scriptFile.isFile) return null + val parsed = runCatching { KeyValue.loadFromString(scriptFile.readText()) }.getOrNull() ?: return null + return if (parsed.name.equals("InstallScript", ignoreCase = true)) parsed else parsed.child("InstallScript") + } + + private fun isProcessComplete(container: Container, process: RunProcess): Boolean { + val target = registryTarget(container, process.hasRunStringKey, includesValueName = true) ?: return false + if (!target.file.isFile) return false + return runCatching { + WineRegistryEditor(target.file).use { editor -> + val actual = editor.getStringValue(target.key, target.valueName, null) ?: return@use false + process.hasRunStringValue.isBlank() || actual.equals(process.hasRunStringValue, ignoreCase = true) + } + }.getOrDefault(false) + } + + private fun applyRegistryStrings(container: Container, gameDir: File, root: KeyValue) { + val registry = root.child("Registry") ?: return + val windowsInstallDir = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\${gameDir.name}" + for (hive in registry.children) { + val hiveName = hive.name ?: continue + val target = registryTarget(container, hiveName, includesValueName = false) ?: continue + val strings = hive.child("string") ?: continue + runCatching { + WineRegistryEditor(target.file).use { editor -> + editor.setCreateKeyIfNotExist(true) + for (value in strings.children) { + val raw = value.value ?: continue + val valueName = value.name ?: continue + if (valueName.equals("(default)", ignoreCase = true)) continue + editor.setStringValue( + target.key, + valueName, + raw.replace("%INSTALLDIR%", windowsInstallDir, ignoreCase = true), + ) + } + } + }.onFailure { Timber.tag("SteamInstallScript").w(it, "Failed to apply install-script registry strings") } + } + } + + private fun applyCopyFiles(container: Container, gameDir: File, root: KeyValue) { + val copyFiles = root.child("Copy Files") ?: return + val programData = File(container.rootDir, ".wine/drive_c/ProgramData") + for (group in copyFiles.children) { + val values = group.children.mapNotNull { child -> + child.name?.let { it to child.value.orEmpty() } + }.toMap() + for ((name, sourceValue) in values) { + if (!name.startsWith("SrcFile", ignoreCase = true)) continue + val suffix = name.substring("SrcFile".length) + val destinationValue = values.entries.firstOrNull { + it.key.equals("DstFile$suffix", ignoreCase = true) + }?.value ?: continue + val source = resolveHostInstallPath(gameDir, sourceValue) ?: continue + val destination = resolveHostDestination(programData, destinationValue) ?: continue + if (!source.isFile) continue + runCatching { + destination.parentFile?.mkdirs() + Files.copy(source.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + }.onFailure { Timber.tag("SteamInstallScript").w(it, "Failed install-script copy to ${destination.absolutePath}") } + } + } + } + + private data class RegistryTarget( + val file: File, + val key: String, + val valueName: String = "", + ) + + private fun registryTarget(container: Container, rawPath: String, includesValueName: Boolean): RegistryTarget? { + val normalized = rawPath.replace('/', '\\') + val mappings = listOf( + "HKEY_LOCAL_MACHINE_WOW64_32\\" to Pair("system.reg", "Software\\Wow6432Node\\"), + "HKEY_LOCAL_MACHINE_WOW64_64\\" to Pair("system.reg", ""), + "HKEY_LOCAL_MACHINE\\" to Pair("system.reg", ""), + "HKLM\\" to Pair("system.reg", ""), + "HKEY_CURRENT_USER\\" to Pair("user.reg", ""), + "HKCU\\" to Pair("user.reg", ""), + ) + val mapping = mappings.firstOrNull { normalized.startsWith(it.first, ignoreCase = true) } ?: return null + var keyAndValue = normalized.substring(mapping.first.length) + if (mapping.second.second.isNotEmpty() && keyAndValue.startsWith("SOFTWARE\\", ignoreCase = true)) { + keyAndValue = mapping.second.second + keyAndValue.substring("SOFTWARE\\".length) + } + val valueName = if (includesValueName) keyAndValue.substringAfterLast('\\', "") else "" + val key = if (includesValueName) keyAndValue.substringBeforeLast('\\', "") else keyAndValue + if (key.isBlank() || (includesValueName && valueName.isBlank())) return null + return RegistryTarget( + file = File(container.rootDir, ".wine/${mapping.second.first}"), + key = key, + valueName = valueName, + ) + } + + private fun expandGuestInstallPath(rawPath: String): String? { + if (!rawPath.startsWith("%INSTALLDIR%", ignoreCase = true)) return null + return "A:" + rawPath.substring("%INSTALLDIR%".length).replace('/', '\\') + } + + private fun resolveHostInstallPath(gameDir: File, rawPath: String): File? { + if (!rawPath.startsWith("%INSTALLDIR%", ignoreCase = true)) return null + val relative = rawPath.substring("%INSTALLDIR%".length).trimStart('\\', '/').replace('\\', '/') + return File(gameDir, relative) + } + + private fun resolveHostDestination(programData: File, rawPath: String): File? { + if (!rawPath.startsWith("%PROGRAMDATA%", ignoreCase = true)) return null + val relative = rawPath.substring("%PROGRAMDATA%".length).trimStart('\\', '/').replace('\\', '/') + return File(programData, relative) + } + + private fun KeyValue.child(name: String): KeyValue? = + children.firstOrNull { it.name.equals(name, ignoreCase = true) } + + private fun KeyValue.childValue(name: String): String? = child(name)?.value +} From 948acb48b33f17eed380274a860754f97f46c036 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 27 Aug 2026 09:59:51 +0530 Subject: [PATCH 2/4] 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. --- .../app/gamenative/gamefixes/EaAppGameFix.kt | 388 ++++++++++++++++++ .../gamenative/gamefixes/GameFixesRegistry.kt | 9 +- .../ui/screen/xserver/XServerScreen.kt | 24 +- .../java/app/gamenative/utils/SteamUtils.kt | 52 ++- .../BionicProgramLauncherComponent.java | 24 ++ .../arm64-v8a/libgamenative_dns_v4mapped.so | Bin 0 -> 11080 bytes 6 files changed, 492 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/app/gamenative/gamefixes/EaAppGameFix.kt create mode 100755 app/src/main/jniLibs/arm64-v8a/libgamenative_dns_v4mapped.so diff --git a/app/src/main/java/app/gamenative/gamefixes/EaAppGameFix.kt b/app/src/main/java/app/gamenative/gamefixes/EaAppGameFix.kt new file mode 100644 index 0000000000..aa57d053ea --- /dev/null +++ b/app/src/main/java/app/gamenative/gamefixes/EaAppGameFix.kt @@ -0,0 +1,388 @@ +package app.gamenative.gamefixes + +import android.content.Context +import app.gamenative.data.GameSource +import com.winlator.container.Container +import com.winlator.core.WineRegistryEditor +import java.io.File +import timber.log.Timber + +private val EA_SOFTWARE_RENDERER_EXES = listOf( + "EACefSubProcess.exe", +) +private val EA_QT_HOST_EXES = listOf("EAappInstaller.exe", "EADesktop.exe", "EALaunchHelper.exe") +private val EA_SOFTWARE_RENDERER_DLLS = listOf("dxgi", "d3d11", "d3d9") + +private const val EA_DESKTOP_INSTALL_ROOT = + ".wine/drive_c/Program Files/Electronic Arts/EA Desktop" +private const val EA_INSTALL_SUCCESS_KEY_UPPERCASE = + "HKEY_LOCAL_MACHINE\\\\SOFTWARE\\\\Electronic Arts\\\\EA Desktop\\\\InstallSuccessful" +private const val EA_INSTALL_SUCCESS_KEY_WINE_CASE = + "HKEY_LOCAL_MACHINE\\\\Software\\\\Electronic Arts\\\\EA Desktop\\\\InstallSuccessful" +private const val EA_INSTALLER_PROCESS = + "%INSTALLDIR%\\\\__Installer\\\\Origin\\\\redist\\\\internal\\\\EAappInstaller.exe" +private const val EA_HEADLESS_INSTALLER_PROCESS = + "%INSTALLDIR%\\\\__Installer\\\\Origin\\\\redist\\\\internal\\\\EAapp-wine-install.cmd" +private const val EA_HEADLESS_INSTALLER_RELATIVE_PATH = + "__Installer/Origin/redist/internal/EAapp-wine-install.cmd" +private const val EA_HEADLESS_MSI_RELATIVE_PATH = + "__Installer/Origin/redist/internal/EAapp-wine-no-start.msi" + +private fun configureEaInstallScript(installPath: String) { + val installScript = File(installPath, "installscript.vdf") + if (!installScript.isFile) return + + val contents = installScript.readText() + var configured = contents.replace( + EA_INSTALL_SUCCESS_KEY_UPPERCASE, + EA_INSTALL_SUCCESS_KEY_WINE_CASE, + ) + if ( + File(installPath, EA_HEADLESS_INSTALLER_RELATIVE_PATH).isFile && + File(installPath, EA_HEADLESS_MSI_RELATIVE_PATH).isFile + ) { + configured = configured.replace(EA_INSTALLER_PROCESS, EA_HEADLESS_INSTALLER_PROCESS) + } + if (configured != contents) installScript.writeText(configured) +} + +private fun findInstalledEaDesktop(container: Container): Pair? { + val installRoot = File(container.rootDir, EA_DESKTOP_INSTALL_ROOT) + val versionDir = installRoot.listFiles() + ?.filter { it.isDirectory && File(it, "EA Desktop/EADesktop.exe").isFile } + ?.maxByOrNull { dir -> + dir.name.split('.').fold(0L) { value, component -> + value * 10_000L + (component.toLongOrNull() ?: 0L) + } + } + ?: return null + + val version = versionDir.name + val basePath = "C:\\Program Files\\Electronic Arts\\EA Desktop\\$version" + return version to "$basePath\\EA Desktop" +} + +internal fun applyEaCompatibilityRegistry(container: Container, gameExeWindowsPath: String?) { + val (version, eaDesktopDir) = findInstalledEaDesktop(container) ?: return + val desktopPath = "$eaDesktopDir\\EADesktop.exe" + val launcherPath = "$eaDesktopDir\\EALauncher.exe" + val link2EaPath = "$eaDesktopDir\\Link2EA.exe" + val steamPath = "C:\\Program Files (x86)\\Steam\\steam.exe" + val useLegacySteamApi = container.isUseLegacyDRM && + !container.isLaunchRealSteam && + !container.isLaunchBionicSteam + val systemRegFile = File(container.rootDir, ".wine/system.reg") + if (!systemRegFile.isFile) return + + WineRegistryEditor(systemRegFile).use { editor -> + editor.setCreateKeyIfNotExist(true) + + // Burn extracts the versioned EA files before its MSI/custom actions + // register the service. Do not mistake that intermediate state for a + // completed install or Steam will skip a grey/interrupted installer. + // system.reg stores the concrete control set. CurrentControlSet is a + // runtime registry alias and is not guaranteed to exist as a literal + // key while we edit the prefix offline. + val serviceKey = listOf( + "System\\ControlSet001\\Services\\EABackgroundService", + "System\\CurrentControlSet\\Services\\EABackgroundService", + ).firstOrNull { key -> + !editor.getStringValue(key, "ImagePath", null).isNullOrEmpty() + } + if (serviceKey == null) { + for (viewPrefix in listOf("Software", "Software\\Wow6432Node")) { + editor.setStringValue( + "$viewPrefix\\Electronic Arts\\EA Desktop", + "InstallSuccessful", + "false", + ) + } + return + } + editor.setStringValue( + serviceKey, + "ImagePath", + "$eaDesktopDir\\EABackgroundService.exe -start", + ) + + for (protocol in listOf("origin", "origin2")) { + val protocolKey = "Software\\Classes\\$protocol" + editor.setStringValue(protocolKey, null, "URL:Origin Protocol") + editor.setStringValue(protocolKey, "URL Protocol", "") + editor.setStringValue("$protocolKey\\DefaultIcon", null, "\"$launcherPath\",0") + editor.setStringValue( + "$protocolKey\\shell\\open\\command", + null, + "\"$launcherPath\" \"%1\"", + ) + } + + // Steam launches The Sims 4 through EA's link2ea:// URL. Some Wine + // installs are missing this association even though Link2EA.exe is + // installed, which makes start.exe return immediately without opening + // EA or the game. + val link2EaProtocolKey = "Software\\Classes\\link2ea" + editor.setStringValue(link2EaProtocolKey, null, "URL:EA Link Protocol") + editor.setStringValue(link2EaProtocolKey, "URL Protocol", "") + editor.setStringValue("$link2EaProtocolKey\\DefaultIcon", null, "\"$link2EaPath\",0") + editor.setStringValue( + "$link2EaProtocolKey\\shell\\open\\command", + null, + // Link2EA starts the background service itself, but immediately + // sends its launch request. On a clean prefix the service also + // installs its VC runtimes before EADesktop can start; its IPC + // listener becomes available before that bootstrap is finished. + // Keep the service and protocol handler in the same Wine session, + // but give the complete first-run bootstrap time to settle. + "\"C:\\windows\\system32\\cmd.exe\" /d /s /c " + + "\"\"C:\\windows\\system32\\sc.exe\" start EABackgroundService >nul 2>&1 & " + + "\"C:\\windows\\system32\\timeout.exe\" /t 30 /nobreak >nul & " + + "\"$link2EaPath\" \"%1\"\"", + ) + + // Steam-owned EA entitlements are deliberately redirected back through + // steam://run/. With a real/Bionic Steam client, hand that URL to + // Proton's steam.exe. Legacy DRM replaces the game's steam_api64.dll + // directly, so avoid the cold-client/Proton Steam bridge and start the + // existing game executable after EA has authenticated the request. + val steamProtocolKey = "Software\\Classes\\steam" + val legacyGameExe = gameExeWindowsPath.takeIf { useLegacySteamApi && it != null } + val steamProtocolTarget = legacyGameExe ?: steamPath + val steamProtocolCommand = if (legacyGameExe != null) { + "\"$legacyGameExe\"" + } else { + "\"$steamPath\" \"%1\"" + } + editor.setStringValue(steamProtocolKey, null, "URL:Steam Protocol") + editor.setStringValue(steamProtocolKey, "URL Protocol", "") + editor.setStringValue("$steamProtocolKey\\DefaultIcon", null, "\"$steamProtocolTarget\",0") + editor.setStringValue( + "$steamProtocolKey\\shell\\open\\command", + null, + steamProtocolCommand, + ) + + val desktopValues = mapOf( + "ClientPath" to desktopPath, + "ClientVersion" to version, + "CommonAppPathCreated" to "1", + "DesktopAppPath" to desktopPath, + "EaConnectLink2EAAppPath" to link2EaPath, + "EaSteam2EAAppPath" to "$eaDesktopDir\\EASteamLauncher.exe", + "ErrorReporterPath" to "$eaDesktopDir\\ErrorReporter.exe", + "InstallLocation" to "C:\\Program Files\\Electronic Arts\\EA Desktop\\$version", + "IsUnavailable" to "0", + "LauncherAppPath" to launcherPath, + "RazorMode" to "0", + ) + + for (viewPrefix in listOf("Software", "Software\\Wow6432Node")) { + editor.setStringValue("$viewPrefix\\Origin", "ClientPath", desktopPath) + editor.setStringValue("$viewPrefix\\Electronic Arts\\EADM", "ClientPath", desktopPath) + + val desktopKey = "$viewPrefix\\Electronic Arts\\EA Desktop" + for ((name, value) in desktopValues) { + if (name in setOf("CommonAppPathCreated", "IsUnavailable", "RazorMode")) { + editor.setDwordValue(desktopKey, name, value.toInt()) + } else { + editor.setStringValue(desktopKey, name, value) + } + } + } + } +} + + +/** + * A staged self-update makes EA Desktop demand a client restart mid-session, + * which tears down any running game with it. Drop staged payloads, clear the + * pending flag, and keep the version directory unwritable so an update can't + * re-stage. (Remove the write protection deliberately when an EA update is + * actually wanted.) + */ +private fun suppressEaSelfUpdate(container: Container) { + val (version, _) = findInstalledEaDesktop(container) ?: return + val versionDir = File(container.rootDir, "$EA_DESKTOP_INSTALL_ROOT/$version") + + versionDir.setWritable(true, false) + versionDir.listFiles()?.forEach { entry -> + val staged = entry.name != "EA Desktop" && entry.name != "VC" && + (entry.isDirectory || entry.name.endsWith(".zip") || entry.name.endsWith(".zip.sig")) + if (staged) { + Timber.tag("GameFixes").i("Removing staged EA update: %s", entry.name) + entry.deleteRecursively() + } + } + versionDir.setWritable(false, false) + + val machineIni = File(container.rootDir, ".wine/drive_c/ProgramData/EA Desktop/machine.ini") + if (machineIni.isFile) { + val lines = machineIni.readLines().map { line -> + when { + line.startsWith("machine.updatepending=") -> "machine.updatepending=0" + line.startsWith("machine.updateinfo=") -> "machine.updateinfo=" + else -> line + } + } + machineIni.writeText(lines.joinToString("\n")) + } +} + +const val EA_LINK2EA_LAUNCH_SCRIPT_WINDOWS_PATH = + "C:\\\\ProgramData\\\\GameNative\\\\ea-link2ea-launch.cmd" + +/** + * Link2EA races EABackgroundService on cold boots: it starts the service and + * immediately sends its launch request, so the first EA Desktop of a session + * connects before the IPC listener exists and shows "disconnected, restart". + * Gate the protocol handoff on the service actually reporting RUNNING. This + * cannot live in the link2ea registry handler because EA rewrites its protocol + * keys on every service start. + */ +private fun writeLink2EaLaunchScript(container: Container) { + val link2EaPath = findInstalledEaDesktop(container) + ?.let { (_, eaDesktopDir) -> "$eaDesktopDir\\Link2EA.exe" } + ?: return + val script = File(container.rootDir, ".wine/drive_c/ProgramData/GameNative/ea-link2ea-launch.cmd") + script.parentFile?.mkdirs() + script.writeText( + """ + @echo off + "C:\windows\system32\sc.exe" start EABackgroundService >nul 2>&1 + for /L %%i in (1,1,30) do ( + "C:\windows\system32\sc.exe" query EABackgroundService | "C:\windows\system32\findstr.exe" /C:"RUNNING" >nul 2>&1 && goto launch + "C:\windows\system32\timeout.exe" /t 1 /nobreak >nul 2>&1 + ) + :launch + "C:\windows\system32\timeout.exe" /t 2 /nobreak >nul 2>&1 + "$link2EaPath" %1 + """.trimIndent().replace("\n", "\r\n"), + ) +} + +private const val EA_INSTALLER_RELATIVE_DIR = "__Installer/Origin/redist/internal" + +/** An EA App title ships EA's installer bundle inside its game directory. */ +fun isEaAppGame(installPath: String): Boolean = + File(installPath, "$EA_INSTALLER_RELATIVE_DIR/EAappInstaller.exe").isFile + +/** + * The headless-install pieces are EA-App-generic, but the MSI is ~245MB and + * cannot ship inside the APK. Generate the cmd from code, and source the MSI + * from any sibling EA game under the same steamapps/common root that already + * has one. + */ +private fun ensureHeadlessInstallerFiles(installPath: String) { + val internalDir = File(installPath, EA_INSTALLER_RELATIVE_DIR) + if (!internalDir.isDirectory) return + + val msi = File(internalDir, "EAapp-wine-no-start.msi") + if (!msi.isFile) { + val commonRoot = File(installPath).parentFile + val donor = commonRoot?.listFiles() + ?.asSequence() + ?.filter { it.isDirectory } + ?.map { File(it, "$EA_INSTALLER_RELATIVE_DIR/EAapp-wine-no-start.msi") } + ?.firstOrNull { it.isFile } + if (donor == null) { + Timber.tag("GameFixes").w( + "No EAapp-wine-no-start.msi found under %s; EA headless install unavailable", + commonRoot?.absolutePath, + ) + return + } + Timber.tag("GameFixes").i("Copying EA headless MSI from %s", donor.absolutePath) + donor.copyTo(msi) + } + + val cmd = File(internalDir, "EAapp-wine-install.cmd") + if (!cmd.isFile) { + cmd.writeText( + """ + @echo off + set EA_BUNDLE_KEY={0843d159-4e03-4026-8fa0-32432514dba6} + set "EA_BUNDLE_CACHE=C:\ProgramData\Package Cache\%EA_BUNDLE_KEY%" + if not exist "%EA_BUNDLE_CACHE%" mkdir "%EA_BUNDLE_CACHE%" + copy /Y "%~dp0EAappInstaller.exe" "%EA_BUNDLE_CACHE%\EAappOfflineInstaller.exe" + reg add "HKLM\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\%EA_BUNDLE_KEY%" /f + reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d installing /f + msiexec /x {C2622085-ABD2-49E5-8AB9-D3D6A642C091} /qn /norestart + rem Pre-register the service: on a fresh prefix wine msi's + rem StartServices cannot see the service its own ServiceInstall + rem just registered (error 2) and rolls the whole install back. + sc create EABackgroundService binPath= "\"C:\Program Files\Electronic Arts\EA Desktop\13.768.7.6285\EA Desktop\EABackgroundService.exe\" -start" >nul 2>&1 + 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 + set EA_INSTALL_RC=%ERRORLEVEL% + copy /Y "C:\windows\temp\EAapp-wine-install.log" "%~dp0EAapp-wine-install.log" + if not "%EA_INSTALL_RC%"=="0" goto install_failed + reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d true /f + exit /b 0 + :install_failed + reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d failed-%EA_INSTALL_RC% /f + exit /b %EA_INSTALL_RC% + """.trimIndent().replace("\n", "\r\n"), + ) + } +} + +/** + * EA App family fix: applies to any Steam game that ships the EA installer. + * + * Keep the game on DXVK, route EA's Qt hosts through builtin WineD3D without + * disabling OpenGL, and force only the CEF subprocess to software rendering. + */ +val EaAppGameFix: GameFix = object : GameFix { + override fun apply( + context: Context, + gameId: String, + installPath: String, + installPathWindows: String, + container: Container, + ): Boolean = try { + ensureHeadlessInstallerFiles(installPath) + configureEaInstallScript(installPath) + writeLink2EaLaunchScript(container) + suppressEaSelfUpdate(container) + val userRegFile = File(container.rootDir, ".wine/user.reg") + if (!userRegFile.isFile) { + userRegFile.parentFile?.mkdirs() + userRegFile.writeText("WINE REGISTRY Version 2\n\n") + } + WineRegistryEditor(userRegFile).use { editor -> + editor.setCreateKeyIfNotExist(true) + for (exe in EA_QT_HOST_EXES) { + val dllOverridesKey = "Software\\Wine\\AppDefaults\\$exe\\DllOverrides" + val direct3dKey = "Software\\Wine\\AppDefaults\\$exe\\Direct3D" + for (dll in EA_SOFTWARE_RENDERER_DLLS) { + if (editor.getStringValue(dllOverridesKey, dll, null) != "builtin") { + editor.setStringValue(dllOverridesKey, dll, "builtin") + } + } + editor.removeValue(direct3dKey, "renderer") + } + for (exe in EA_SOFTWARE_RENDERER_EXES) { + val dllOverridesKey = "Software\\Wine\\AppDefaults\\$exe\\DllOverrides" + val direct3dKey = "Software\\Wine\\AppDefaults\\$exe\\Direct3D" + for (dll in EA_SOFTWARE_RENDERER_DLLS) { + if (editor.getStringValue(dllOverridesKey, dll, null) != "builtin") { + editor.setStringValue(dllOverridesKey, dll, "builtin") + } + } + if (editor.getStringValue(direct3dKey, "renderer", null) != "no3d") { + editor.setStringValue(direct3dKey, "renderer", "no3d") + } + } + } + val gameExeWindowsPath = container.executablePath + .takeIf { it.isNotBlank() } + ?.let { exe -> + "C:\\Program Files (x86)\\Steam\\steamapps\\common\\" + + "${File(installPath).name}\\${exe.replace('/', '\\')}" + } + applyEaCompatibilityRegistry(container, gameExeWindowsPath) + true + } catch (e: Exception) { + Timber.tag("GameFixes").e(e, "Failed to apply EA App software renderer overrides") + false + } +} diff --git a/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt b/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt index 5e48651e6e..e08784fb17 100644 --- a/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt +++ b/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt @@ -68,8 +68,15 @@ object GameFixesRegistry { else -> gameId } Timber.i("GameFixesRegistry: Applying fixes for game: $source $catalogId if available") - val fix = fixesProvider()[source to catalogId] ?: return + val keyedFix = fixesProvider()[source to catalogId] + // Master behavior for everything without a fix: return before touching + // path resolution. EA App titles (Steam only) are detected by their + // installer bundle and get the family fix without registration. + if (keyedFix == null && source != GameSource.STEAM) return val (installPath, installPathWindows) = resolvePaths(context, source, gameId) ?: return + val fix = keyedFix + ?: EaAppGameFix.takeIf { isEaAppGame(installPath) } + ?: return fix.apply(context, catalogId, installPath, installPathWindows, container) } diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index db9ac4efa8..2736ee3b18 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -93,6 +93,7 @@ import app.gamenative.PrefManager import app.gamenative.SteamBootstrap import app.gamenative.data.GameSource import app.gamenative.gamefixes.GameFixesRegistry +import app.gamenative.gamefixes.isEaAppGame import app.gamenative.gamefixes.GameInputCompatibility import app.gamenative.data.LaunchInfo import app.gamenative.data.LibraryItem @@ -3783,6 +3784,22 @@ private fun setupXEnvironment( var preInstallCommands: List = emptyList() var gameExecutable = "" + // EA App prefixes need the gamefix re-applied right before the game + // session: installer sessions overwrite its registry work on their + // wineserver flush, and on a cold install the EA binaries it targets only + // exist after the installer has run. + val reapplyEaGameFixes = { + val gameDir = ContainerUtils.extractGameIdFromContainerId(appId) + ?.let { SteamService.getAppDirPath(it) }.orEmpty() + if (gameDir.isNotEmpty() && isEaAppGame(gameDir)) { + try { + GameFixesRegistry.applyFor(context, appId, container) + } catch (e: Exception) { + Timber.tag("GameFixes").w(e, "EA gamefix reapply failed") + } + } + } + if (container != null) { try { GameFixesRegistry.applyFor(context, appId, container) @@ -3879,6 +3896,7 @@ private fun setupXEnvironment( if (preInstallCommands.isNotEmpty()) { PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing prerequisites...")) } else { + reapplyEaGameFixes() PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Launching game...")) } } @@ -3967,7 +3985,7 @@ private fun setupXEnvironment( guestProgramLauncherComponent.setGuestExecutable(remaining.first().executable) guestProgramLauncherComponent.setTerminationCallback { _ -> val current = remaining.first() - PreInstallSteps.markStepDone(container, current.marker) + current.marker?.let { PreInstallSteps.markStepDone(container, it) } guestProgramLauncherComponent.setPreUnpack(null) try { guestProgramLauncherComponent.execShellCommand("wineserver -k") @@ -3976,6 +3994,7 @@ private fun setupXEnvironment( } val nextRemaining = remaining.drop(1) if (nextRemaining.isEmpty()) { + reapplyEaGameFixes() PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Launching game...")) } else { PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing prerequisites...")) @@ -4503,12 +4522,11 @@ private fun getWineStartCommand( // and will monitor the game via nativeWaitAppExit. val appDirPath = SteamService.getAppDirPath(gameId) val exePath = container.executablePath.ifEmpty { SteamService.getInstalledExe(gameId) } - val normalizedExe = exePath.replace('/', '\\').trimStart('\\') val executableDir = appDirPath + "/" + exePath.substringBeforeLast("/", "") guestProgramLauncherComponent.workingDir = File(executableDir) Timber.i("Bionic-Steam working directory is $executableDir") val gameFolderName = appDirPath.substringAfterLast('/').ifEmpty { gameId.toString() } - "\"C:\\\\Program Files (x86)\\\\Steam\\\\steamapps\\\\common\\\\$gameFolderName\\\\$normalizedExe\"" + SteamUtils.buildBionicSteamLaunchCommand(gameFolderName, exePath, appLaunchInfo, gameId, appDirPath) } else if (container.isLaunchRealSteam) { // Launch Steam with the applaunch parameter to start the game "\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" -silent -vgui -tcp " + diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 2cf0efb8f0..77aa6eaeba 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -1,5 +1,7 @@ package app.gamenative.utils +import app.gamenative.gamefixes.EA_LINK2EA_LAUNCH_SCRIPT_WINDOWS_PATH +import app.gamenative.gamefixes.isEaAppGame import android.annotation.SuppressLint import android.content.Context import android.provider.Settings @@ -56,6 +58,49 @@ object SteamUtils { val exeRunDirOverride: String? = null, ) + internal fun buildBionicSteamLaunchCommand( + gameFolderName: String, + executablePath: String, + appLaunchInfo: LaunchInfo?, + gameId: Int, + appDirPath: String, + ): String { + // EA App titles must launch through EA: Steam hands the game to EA via + // its link2ea:// protocol, and EA authenticates the session with an + // exchange token minted from the Steam login. + // Only EA App titles take the protocol path; every other game launches + // exactly as before. Steam's launch config carries link2ea:// for most + // EA titles; fall back to building the URL from the appid. + val isEaApp = isEaAppGame(appDirPath) + val protocolTarget = if (!isEaApp) { + null + } else { + (appLaunchInfo?.executable ?: "link2ea://launchgame/$gameId?platform=steam") + .trim() + .takeIf { target -> + target.matches(Regex("^[A-Za-z][A-Za-z0-9+.-]*://[^\\s\\\"]+$")) + } + ?: "link2ea://launchgame/$gameId?platform=steam" + } + if (protocolTarget != null) { + // A gamefix-owned launch script can gate the protocol handoff (for + // example waiting for EABackgroundService before Link2EA fires). + // The registry protocol handler is not a reliable place for that + // gate: EA rewrites its own protocol keys on every service start. + // The script path must not contain spaces: the command goes through + // winhandler's argument parsing, which breaks on cmd's nested-quote + // form, so the script is passed as a bare token. + if (isEaApp) { + return "\"C:\\\\windows\\\\system32\\\\cmd.exe\" /d /c " + + "$EA_LINK2EA_LAUNCH_SCRIPT_WINDOWS_PATH \"$protocolTarget\"" + } + return "\"C:\\\\windows\\\\system32\\\\start.exe\" \"$protocolTarget\"" + } + + val normalizedExe = executablePath.replace('/', '\\').trimStart('\\') + return "\"C:\\\\Program Files (x86)\\\\Steam\\\\steamapps\\\\common\\\\$gameFolderName\\\\$normalizedExe\"" + } + /** * True when a stored Steam session exists (offline-launch gate). * Matches GOG/Epic/Amazon AuthManager.hasStoredCredentials convention. @@ -885,6 +930,12 @@ object SteamUtils { cfgFile.writeText("BootStrapperInhibitAll=Enable\nBootStrapperForceSelfUpdate=False") } + val steamAccountId = getSteam3AccountId()?.toString() + if (steamAccountId == null) { + Timber.w("Deferring restoreSteamApi for appId $appId: Steam user is not available") + return + } + // Update or modify localconfig.vdf val steam3AccountId = getSteam3AccountId()?.toString().orEmpty() updateOrModifyLocalConfig(imageFs, container, steamAppId.toString(), steam3AccountId) @@ -1669,4 +1720,3 @@ object SteamUtils { } } } - diff --git a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index c8a2065dee..1e087c7572 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -184,6 +184,23 @@ public void setWorkingDir(File workingDir) { this.workingDir = workingDir; } + private boolean isEaAppContainer() { + if (container == null || container.getDrives() == null) return false; + // Drive spec format: "A:/path/to/gameB:/other/path"; A: maps the game dir. + String drives = container.getDrives(); + int idx = drives.indexOf("A:"); + if (idx < 0) return false; + int end = drives.length(); + for (int i = idx + 2; i < drives.length() - 1; i++) { + if (Character.isLetter(drives.charAt(i)) && drives.charAt(i + 1) == ':') { + end = i; + break; + } + } + String gameDir = drives.substring(idx + 2, end); + return new File(gameDir, "__Installer/Origin/redist/internal/EAappInstaller.exe").isFile(); + } + private int execGuestProgram() { final int MAX_PLAYERS = 4; @@ -301,12 +318,19 @@ private int execGuestProgram() { String ld_preload = ""; String sysvPath = imageFs.getLibDir() + "/libandroid-sysvshm.so"; String evshimPath = context.getApplicationInfo().nativeLibraryDir + "/libevshim.so"; + String dnsV4MappedPath = context.getApplicationInfo().nativeLibraryDir + "/libgamenative_dns_v4mapped.so"; String replacePath = imageFs.getLibDir() + "/" + BuildConfig.PRELOAD_BIONIC_SO; if (new File(sysvPath).exists()) ld_preload += sysvPath; ld_preload += ":" + evshimPath; + // EA's DirtySDK stack cannot resolve hostnames through bionic's + // resolver (legacy res_query without the app network context, + // AI_V4MAPPED rejected); route lookups through Android's resolver. + if (isEaAppContainer() && new File(dnsV4MappedPath).exists()) { + ld_preload += ":" + dnsV4MappedPath; + } ld_preload += ":" + replacePath; envVars.put("LD_PRELOAD", ld_preload); diff --git a/app/src/main/jniLibs/arm64-v8a/libgamenative_dns_v4mapped.so b/app/src/main/jniLibs/arm64-v8a/libgamenative_dns_v4mapped.so new file mode 100755 index 0000000000000000000000000000000000000000..6e43105546ef8464b10d9a564280c25e457b774c GIT binary patch literal 11080 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 literal 0 HcmV?d00001 From 88776cbf595851575c9f3dfafc98973436690a74 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 27 Aug 2026 19:07:43 +0530 Subject: [PATCH 3/4] EA App: self-extract the headless MSI, verbose install with first sign-in Source the 245MB install MSI from the game's own EAappInstaller.exe when no copy exists: scan the WiX Burn bundle for the payload CAB, carve it, and pull the MSI out with imagefs cabextract. The install cmd now narrates each phase instead of sitting on a blank prompt, and opens the EA Desktop UI after a successful install so the user can sign in before the launch chain continues. The link2ea gate script is straight-line now: wine cmd nondeterministically deadlocks spawning children inside for-loops, and sc start already blocks until the service is up. --- .../app/gamenative/gamefixes/EaAppGameFix.kt | 185 +++++++++++++++--- 1 file changed, 161 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/app/gamenative/gamefixes/EaAppGameFix.kt b/app/src/main/java/app/gamenative/gamefixes/EaAppGameFix.kt index aa57d053ea..a36735bf0a 100644 --- a/app/src/main/java/app/gamenative/gamefixes/EaAppGameFix.kt +++ b/app/src/main/java/app/gamenative/gamefixes/EaAppGameFix.kt @@ -235,33 +235,158 @@ const val EA_LINK2EA_LAUNCH_SCRIPT_WINDOWS_PATH = * Link2EA races EABackgroundService on cold boots: it starts the service and * immediately sends its launch request, so the first EA Desktop of a session * connects before the IPC listener exists and shows "disconnected, restart". - * Gate the protocol handoff on the service actually reporting RUNNING. This + * Start the service and give it a settle window before the handoff. This * cannot live in the link2ea registry handler because EA rewrites its protocol - * keys on every service start. + * keys on every service start. First-run login is handled before this ever + * runs: the headless install cmd opens the EA Desktop UI after installing so + * the user can sign in, and closing that window resumes the launch chain. + * + * KNOWN REQUIREMENT: on a fresh prefix the game must be launched once WITHOUT + * Bionic Steam (the coldclient path) before Bionic Steam launches work with + * the EA launcher. That first boot leaves durable state behind (EA Desktop's + * compiled UI cache and the coldclient Steam files are the candidates; exact + * mechanism not yet isolated) without which EA parks the link2ea request as + * PendingLink2EARequest and silently drops it. */ private fun writeLink2EaLaunchScript(container: Container) { - val link2EaPath = findInstalledEaDesktop(container) - ?.let { (_, eaDesktopDir) -> "$eaDesktopDir\\Link2EA.exe" } - ?: return + val (_, eaDesktopDir) = findInstalledEaDesktop(container) ?: return + val link2EaPath = "$eaDesktopDir\\Link2EA.exe" val script = File(container.rootDir, ".wine/drive_c/ProgramData/GameNative/ea-link2ea-launch.cmd") script.parentFile?.mkdirs() + // Straight-line on purpose: wine cmd nondeterministically deadlocks + // spawning children inside for-loops, so no loops, no polling. script.writeText( """ @echo off + echo Starting EA Background Service... "C:\windows\system32\sc.exe" start EABackgroundService >nul 2>&1 - for /L %%i in (1,1,30) do ( - "C:\windows\system32\sc.exe" query EABackgroundService | "C:\windows\system32\findstr.exe" /C:"RUNNING" >nul 2>&1 && goto launch - "C:\windows\system32\timeout.exe" /t 1 /nobreak >nul 2>&1 - ) - :launch - "C:\windows\system32\timeout.exe" /t 2 /nobreak >nul 2>&1 + "C:\windows\system32\timeout.exe" /t 5 /nobreak >nul 2>&1 + echo Asking EA to launch the game... "$link2EaPath" %1 """.trimIndent().replace("\n", "\r\n"), ) } + private const val EA_INSTALLER_RELATIVE_DIR = "__Installer/Origin/redist/internal" +/** + * Carve the payload CAB out of EAappInstaller.exe (WiX Burn attached + * container) and pull the MSI from it with imagefs cabextract. The CAB is + * found by scanning for MSCF headers whose declared size (u32 LE at +8) is + * plausible for the ~240MB payload; the extracted MSI is recognized by its + * OLE compound-file signature. + */ +private fun extractMsiFromEaInstaller(context: Context, internalDir: File, msi: File): Boolean { + val installer = File(internalDir, "EAappInstaller.exe") + if (!installer.isFile) return false + val workDir = File(internalDir, ".gn-msi-extract") + try { + val (cabOffset, cabSize) = findPayloadCab(installer) ?: run { + Timber.tag("GameFixes").w("No payload CAB found in %s", installer.absolutePath) + return false + } + workDir.deleteRecursively() + workDir.mkdirs() + val cab = File(workDir, "payload.cab") + java.io.RandomAccessFile(installer, "r").use { raf -> + raf.seek(cabOffset) + cab.outputStream().use { out -> + val buf = ByteArray(1 shl 20) + var remaining = cabSize + while (remaining > 0) { + val n = raf.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt()) + if (n <= 0) break + out.write(buf, 0, n) + remaining -= n + } + } + } + val imageFsRoot = com.winlator.xenvironment.ImageFs.find(context).rootDir.absolutePath + val cmd = mutableListOf() + if (app.gamenative.BuildConfig.MODERN_ANDROID) cmd.add("/system/bin/linker64") + cmd.add("$imageFsRoot/usr/bin/cabextract") + cmd.addAll(listOf("-q", "-d", workDir.absolutePath, cab.absolutePath)) + val proc = ProcessBuilder(cmd).redirectErrorStream(true).apply { + environment()["LD_LIBRARY_PATH"] = "$imageFsRoot/usr/lib" + }.start() + val output = proc.inputStream.bufferedReader().readText() + if (proc.waitFor() != 0) { + Timber.tag("GameFixes").w("cabextract failed: %s", output.take(500)) + return false + } + val oleSig = byteArrayOf(0xD0.toByte(), 0xCF.toByte(), 0x11, 0xE0.toByte()) + val extracted = workDir.walkTopDown() + .filter { it.isFile && it != cab && it.length() > 50L * 1024 * 1024 } + .filter { f -> f.inputStream().use { s -> ByteArray(4).let { s.read(it); it.contentEquals(oleSig) } } } + .maxByOrNull { it.length() } + ?: run { + Timber.tag("GameFixes").w("cabextract produced no MSI-signature payload") + return false + } + if (!extracted.renameTo(msi)) extracted.copyTo(msi, overwrite = true) + Timber.tag("GameFixes").i( + "Extracted EA headless MSI (%d bytes) from %s", msi.length(), installer.name, + ) + return true + } catch (e: Exception) { + Timber.tag("GameFixes").e(e, "EA MSI extraction failed") + return false + } finally { + workDir.deleteRecursively() + } +} + +/** Scan for an MSCF header whose declared cbCabinet spans a payload-sized region. */ +private fun findPayloadCab(installer: File): Pair? { + val fileLen = installer.length() + val magic = "MSCF".toByteArray(Charsets.US_ASCII) + java.io.RandomAccessFile(installer, "r").use { raf -> + val buf = ByteArray(1 shl 20) + val overlap = 16 + var base = 0L + var carry = ByteArray(0) + while (base < fileLen) { + raf.seek(base) + val n = raf.read(buf) + if (n <= 0) break + val window = carry + buf.copyOf(n) + var i = 0 + while (true) { + i = indexOfBytes(window, magic, i) + if (i < 0) break + val offset = base - carry.size + i + if (offset + 12 <= fileLen) { + raf.seek(offset + 8) + val b = ByteArray(4) + raf.readFully(b) + val cbCabinet = ((b[3].toLong() and 0xFF) shl 24) or + ((b[2].toLong() and 0xFF) shl 16) or + ((b[1].toLong() and 0xFF) shl 8) or + (b[0].toLong() and 0xFF) + if (cbCabinet > 100L * 1024 * 1024 && offset + cbCabinet <= fileLen) { + return offset to cbCabinet + } + } + i++ + } + carry = window.copyOfRange(maxOf(0, window.size - overlap), window.size) + base += n + } + } + return null +} + +private fun indexOfBytes(haystack: ByteArray, needle: ByteArray, from: Int): Int { + outer@ for (i in from..haystack.size - needle.size) { + for (j in needle.indices) { + if (haystack[i + j] != needle[j]) continue@outer + } + return i + } + return -1 +} + /** An EA App title ships EA's installer bundle inside its game directory. */ fun isEaAppGame(installPath: String): Boolean = File(installPath, "$EA_INSTALLER_RELATIVE_DIR/EAappInstaller.exe").isFile @@ -270,9 +395,10 @@ fun isEaAppGame(installPath: String): Boolean = * The headless-install pieces are EA-App-generic, but the MSI is ~245MB and * cannot ship inside the APK. Generate the cmd from code, and source the MSI * from any sibling EA game under the same steamapps/common root that already - * has one. + * has one, or extract it from the game's own EAappInstaller.exe (a WiX Burn + * bundle whose attached payload container is a plain CAB holding the MSI). */ -private fun ensureHeadlessInstallerFiles(installPath: String) { +private fun ensureHeadlessInstallerFiles(context: Context, installPath: String) { val internalDir = File(installPath, EA_INSTALLER_RELATIVE_DIR) if (!internalDir.isDirectory) return @@ -284,15 +410,16 @@ private fun ensureHeadlessInstallerFiles(installPath: String) { ?.filter { it.isDirectory } ?.map { File(it, "$EA_INSTALLER_RELATIVE_DIR/EAapp-wine-no-start.msi") } ?.firstOrNull { it.isFile } - if (donor == null) { + if (donor != null) { + Timber.tag("GameFixes").i("Copying EA headless MSI from %s", donor.absolutePath) + donor.copyTo(msi) + } else if (!extractMsiFromEaInstaller(context, internalDir, msi)) { Timber.tag("GameFixes").w( - "No EAapp-wine-no-start.msi found under %s; EA headless install unavailable", + "No EAapp-wine-no-start.msi under %s and extraction failed; EA headless install unavailable", commonRoot?.absolutePath, ) return } - Timber.tag("GameFixes").i("Copying EA headless MSI from %s", donor.absolutePath) - donor.copyTo(msi) } val cmd = File(internalDir, "EAapp-wine-install.cmd") @@ -300,25 +427,35 @@ private fun ensureHeadlessInstallerFiles(installPath: String) { cmd.writeText( """ @echo off + echo Preparing the EA app installer... set EA_BUNDLE_KEY={0843d159-4e03-4026-8fa0-32432514dba6} set "EA_BUNDLE_CACHE=C:\ProgramData\Package Cache\%EA_BUNDLE_KEY%" if not exist "%EA_BUNDLE_CACHE%" mkdir "%EA_BUNDLE_CACHE%" - copy /Y "%~dp0EAappInstaller.exe" "%EA_BUNDLE_CACHE%\EAappOfflineInstaller.exe" - reg add "HKLM\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\%EA_BUNDLE_KEY%" /f - reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d installing /f + copy /Y "%~dp0EAappInstaller.exe" "%EA_BUNDLE_CACHE%\EAappOfflineInstaller.exe" >nul + reg add "HKLM\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\%EA_BUNDLE_KEY%" /f >nul + reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d installing /f >nul + echo Removing any previous EA app install... msiexec /x {C2622085-ABD2-49E5-8AB9-D3D6A642C091} /qn /norestart rem Pre-register the service: on a fresh prefix wine msi's rem StartServices cannot see the service its own ServiceInstall rem just registered (error 2) and rolls the whole install back. sc create EABackgroundService binPath= "\"C:\Program Files\Electronic Arts\EA Desktop\13.768.7.6285\EA Desktop\EABackgroundService.exe\" -start" >nul 2>&1 + echo Installing the EA app. This takes a few minutes - do not close this window. 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 set EA_INSTALL_RC=%ERRORLEVEL% - copy /Y "C:\windows\temp\EAapp-wine-install.log" "%~dp0EAapp-wine-install.log" + copy /Y "C:\windows\temp\EAapp-wine-install.log" "%~dp0EAapp-wine-install.log" >nul if not "%EA_INSTALL_RC%"=="0" goto install_failed - reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d true /f + reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d true /f >nul + echo EA app installed successfully. + echo. + echo Opening the EA app so you can sign in. + echo After signing in, CLOSE the EA app window to continue to the game. + "C:\Program Files\Electronic Arts\EA Desktop\13.768.7.6285\EA Desktop\EADesktop.exe" + echo Continuing to the game... exit /b 0 :install_failed - reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d failed-%EA_INSTALL_RC% /f + echo EA app install failed with code %EA_INSTALL_RC%. The game may not start. + reg add "HKLM\SOFTWARE\Electronic Arts\EA Desktop" /v InstallSuccessful /t REG_SZ /d failed-%EA_INSTALL_RC% /f >nul exit /b %EA_INSTALL_RC% """.trimIndent().replace("\n", "\r\n"), ) @@ -339,7 +476,7 @@ val EaAppGameFix: GameFix = object : GameFix { installPathWindows: String, container: Container, ): Boolean = try { - ensureHeadlessInstallerFiles(installPath) + ensureHeadlessInstallerFiles(context, installPath) configureEaInstallScript(installPath) writeLink2EaLaunchScript(container) suppressEaSelfUpdate(container) From d4f6b5939c1bfc3a7aedc8611266320ebab87e37 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Thu, 27 Aug 2026 19:07:44 +0530 Subject: [PATCH 4/4] xserver: answer XInput1 ListInputDevices instead of erroring EA Desktop's UI issues the legacy XInput ListInputDevices request; replying BadImplementation is a fatal X error that kills the client before its window exists. Return a valid empty device list. --- .../xserver/extensions/XInput2Extension.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/src/main/java/com/winlator/xserver/extensions/XInput2Extension.java b/app/src/main/java/com/winlator/xserver/extensions/XInput2Extension.java index 4ee937cb5d..bc6c08a2a7 100644 --- a/app/src/main/java/com/winlator/xserver/extensions/XInput2Extension.java +++ b/app/src/main/java/com/winlator/xserver/extensions/XInput2Extension.java @@ -48,6 +48,7 @@ public class XInput2Extension implements Extension { private static abstract class ClientOpcodes { private static final byte GET_EXTENSION_VERSION = 1; // X_GetExtensionVersion (XI 1.x) + private static final byte LIST_INPUT_DEVICES = 2; // X_ListInputDevices (XI 1.x) private static final byte GET_CLIENT_POINTER = 45; // X_XIGetClientPointer (XI 2.x) private static final byte SELECT_EVENTS = 46; // X_XISelectEvents (XI 2.x) private static final byte QUERY_VERSION = 47; // X_XIQueryVersion (XI 2.x) @@ -135,6 +136,27 @@ private static void getExtensionVersion(XClient client, XInputStream inputStream } } + private static void listInputDevices(XClient client, XInputStream inputStream, XOutputStream outputStream) throws IOException { + inputStream.skip(client.getRemainingRequestLength()); + + try (XStreamLock lock = outputStream.lock()) { + // typedef struct + // CARD8 repType; /* X_Reply */ + // CARD8 RepType; /* always X_ListInputDevices */ + // CARD16 sequenceNumber; + // CARD32 length; + // CARD8 ndevices; + // CARD8 pad1..pad23; + // xListInputDevicesReply; + outputStream.writeByte(RESPONSE_CODE_SUCCESS); + outputStream.writeByte((byte) 2); + outputStream.writeShort(client.getSequenceNumber()); + outputStream.writeInt(0); + outputStream.writeByte((byte) 0); + outputStream.writePad(23); + } + } + private static void getClientPointer(XClient client, XInputStream inputStream, XOutputStream outputStream) throws IOException { inputStream.skip(client.getRemainingRequestLength()); @@ -412,6 +434,9 @@ public void handleRequest(XClient client, XInputStream inputStream, XOutputStrea case ClientOpcodes.GET_EXTENSION_VERSION: getExtensionVersion(client, inputStream, outputStream); break; + case ClientOpcodes.LIST_INPUT_DEVICES: + listInputDevices(client, inputStream, outputStream); + break; case ClientOpcodes.GET_CLIENT_POINTER: getClientPointer(client, inputStream, outputStream); break;