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..a36735bf0a --- /dev/null +++ b/app/src/main/java/app/gamenative/gamefixes/EaAppGameFix.kt @@ -0,0 +1,525 @@ +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". + * 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. 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 (_, 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 + "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 + +/** + * 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, 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(context: Context, 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").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 under %s and extraction failed; EA headless install unavailable", + commonRoot?.absolutePath, + ) + return + } + } + + val cmd = File(internalDir, "EAapp-wine-install.cmd") + if (!cmd.isFile) { + 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" >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" >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 >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 + 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"), + ) + } +} + +/** + * 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(context, 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/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/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/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 +} 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/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; 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 0000000000..6e43105546 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libgamenative_dns_v4mapped.so differ