diff --git a/app/src/main/java/app/gamenative/display/GameNativeDrm.kt b/app/src/main/java/app/gamenative/display/GameNativeDrm.kt index afa11e581d..b10086f279 100644 --- a/app/src/main/java/app/gamenative/display/GameNativeDrm.kt +++ b/app/src/main/java/app/gamenative/display/GameNativeDrm.kt @@ -84,6 +84,10 @@ object GameNativeDrm { private val publishedFrames = java.util.concurrent.atomic.AtomicInteger(0) private val gameContentSignalled = java.util.concurrent.atomic.AtomicBoolean(false) + /** Whether this session has already signalled game content (the event fires once). */ + @JvmStatic + val gameContentSeen: Boolean get() = gameContentSignalled.get() + /** Set by the session screen; runs on the main thread once per session when game content is first seen. */ @Volatile @JvmStatic diff --git a/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt b/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt new file mode 100644 index 0000000000..dd3766b201 --- /dev/null +++ b/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt @@ -0,0 +1,145 @@ +package app.gamenative.powercontrol + +import app.gamenative.PluviaApp +import app.gamenative.display.GameNativeDrm +import app.gamenative.events.AndroidEvent +import com.winlator.winhandler.OnGetProcessInfoListener +import com.winlator.winhandler.ProcessInfo +import com.winlator.winhandler.WinHandler +import timber.log.Timber +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Applies the container's CPU list ("Processor affinity" on the Advanced tab) in a session + * without the in-app X server. + * + * On X11 the list reaches a process through [com.winlator.core.Win32AppWorkarounds] when the + * process maps its first top-level window. Under gamescope the windows map on Xwayland, which + * this app never sees; what it does see is the session's first game content + * ([AndroidEvent.GamescopeGameContent], the gamescope stand-in for the mapped game window). On + * that event this takes one process list from the winhandler and sends the mask to each process, + * 64-bit or WoW64 as the winhandler reports it. Left out: steam.exe as on X11, the game when + * power control holds its affinity, and the processes in [skippedProcesses]. + */ +object ContainerAffinity { + private const val TAG = "ContainerAffinity" + private const val REPLY_TIMEOUT_MS = 2000L + private const val REPLY_ATTEMPTS = 3 + + /** + * Names the mask is never sent to: steam.exe as on X11, the processes power control's + * background pin manages, and Wine's own service processes, which map no window on X11 and so + * never got the list there either. start.exe is the launcher process that pin found as + * winhandler.exe by command line. + */ + internal val skippedProcesses = setOf( + "steam.exe", "winhandler.exe", "services.exe", "wineserver", + "start.exe", "wineboot.exe", "winedevice.exe", "plugplay.exe", "svchost.exe", "rpcss.exe", "conhost.exe", + ) + + private var armed: ((AndroidEvent.GamescopeGameContent) -> Unit)? = null + + /** + * Arms the session: the list goes out once, when the game first shows content. A zero [mask] + * means no CPU list, nothing is armed. Call from the main thread, like the other event users. + */ + fun arm(winHandler: WinHandler, mask: Int, maskWoW64: Int) { + disarm() + if (mask == 0) { + Timber.tag(TAG).i("No container CPU list, nothing to apply") + return + } + val listener: (AndroidEvent.GamescopeGameContent) -> Unit = { + armed = null + Thread({ apply(winHandler, mask, maskWoW64) }, "container-affinity").start() + } + armed = listener + PluviaApp.events.once(listener) + Timber.tag(TAG).i( + "Container CPU list armed for the first game content (mask 0x${Integer.toHexString(mask)}, wow64 0x${Integer.toHexString(maskWoW64)})" + ) + if (GameNativeDrm.gameContentSeen) { + // The event fires once per session and has already gone by. + disarm() + listener(AndroidEvent.GamescopeGameContent) + } + } + + fun disarm() { + armed?.let { PluviaApp.events.off(it) } + armed = null + } + + private fun apply(winHandler: WinHandler, mask: Int, maskWoW64: Int) { + var processes: List? = null + for (attempt in 1..REPLY_ATTEMPTS) { + if (WinHandler.getActiveInstance() !== winHandler) return + processes = listProcesses(winHandler) + if (processes != null) break + Timber.tag(TAG).w("No process list from the winhandler within $REPLY_TIMEOUT_MS ms (attempt $attempt of $REPLY_ATTEMPTS)") + } + if (processes == null) { + Timber.tag(TAG).w("Container CPU list not applied, the winhandler never answered") + return + } + if (WinHandler.getActiveInstance() !== winHandler) return + for (info in processes) { + val target = targetMask(info, mask, maskWoW64) ?: continue + if (sameMask(info.affinityMask, target)) { + Timber.tag(TAG).d("${info.name} (PID ${info.pid}) already on mask 0x${Integer.toHexString(target)}") + continue + } + winHandler.setProcessAffinity(info.pid, target) + Timber.tag(TAG).i( + "Applied mask 0x${Integer.toHexString(target)} to ${info.name} (PID ${info.pid}, wow64=${info.wow64Process}, was 0x${Integer.toHexString(info.affinityMask)})" + ) + } + } + + /** + * The mask [info] should get, or null when the process is left alone. + */ + internal fun targetMask( + info: ProcessInfo, + mask: Int, + maskWoW64: Int, + pinnedGame: String? = PowerManager.pinnedGameProcessName, + powerControlOwnsGame: Boolean = PowerManager.ownsGameAffinity, + ): Int? { + val name = info.name.trim() + if (name.isEmpty() || skippedProcesses.any { it.equals(name, ignoreCase = true) }) return null + if (powerControlOwnsGame && pinnedGame != null && pinnedGame.equals(name, ignoreCase = true)) return null + val target = if (info.wow64Process) maskWoW64 else mask + return target.takeIf { it != 0 } + } + + /** The winhandler reports a 32-bit mask; the container masks are 16-bit. */ + internal fun sameMask(reported: Int, target: Int): Boolean = (reported and 0xFFFF) == (target and 0xFFFF) + + /** + * One LIST_PROCESSES round trip, or null when the reply did not arrive in time. + */ + private fun listProcesses(winHandler: WinHandler): List? { + val snapshot = ArrayList() + val done = CountDownLatch(1) + val observer = OnGetProcessInfoListener { index, count, info -> + synchronized(snapshot) { + if (index == 0) snapshot.clear() + if (info != null) snapshot.add(info) + if (count == 0 || snapshot.size >= count) done.countDown() + } + } + winHandler.addProcessInfoObserver(observer) + try { + winHandler.listProcesses() + if (!done.await(REPLY_TIMEOUT_MS, TimeUnit.MILLISECONDS)) return null + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return null + } finally { + winHandler.removeProcessInfoObserver(observer) + } + return synchronized(snapshot) { snapshot.toList() } + } +} 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 05157dceb7..d1fb78f9c2 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 @@ -113,6 +113,7 @@ import java.util.EnumSet import app.gamenative.externaldisplay.ExternalDisplayInputController import app.gamenative.externaldisplay.ExternalDisplaySwapController import app.gamenative.externaldisplay.SwapInputOverlayView +import app.gamenative.powercontrol.ContainerAffinity import app.gamenative.powercontrol.PowerManager import app.gamenative.service.AchievementWatcher import app.gamenative.service.SteamHostAchievementPoller @@ -2568,6 +2569,15 @@ fun XServerScreen( // Autostart performance driver after environment is set up PowerManager.autoStart(container.rootDir) + // Without the in-app X server no window maps here, so the container + // CPU list goes out over the winhandler on the first game content. + if (sessionDisplay == SessionDisplay.GAMESCOPE) { + val handler = getxServer().winHandler + android.os.Handler(android.os.Looper.getMainLooper()).post { + ContainerAffinity.arm(handler, taskAffinityMask, taskAffinityMaskWoW64) + } + } + if (debugRun) { PerfSampler.start( context, @@ -3081,6 +3091,7 @@ fun XServerScreen( if (!isX11Display) { GameNativeDrm.gameContentListener = null GameNativeDrm.setFrameRating(null) + ContainerAffinity.disarm() } // A view released without exit() (the activity-destroyed fallback, a restart) still ends @@ -5472,6 +5483,7 @@ private fun exit( GameSessionId.end() // only needed in exit() — OS reclaims on process death, so onDestroy fallback skips this + ContainerAffinity.disarm() try { winHandler?.stop() } catch (e: Exception) { diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index 9646846067..a3447609f5 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -74,6 +74,8 @@ public class WinHandler { private Thread initTimeoutThread; private InetAddress localhost; private OnGetProcessInfoListener onGetProcessInfoListener; + // Extra receivers of GET_PROCESS replies, alongside the UI's listener. + private final CopyOnWriteArrayList processInfoObservers = new CopyOnWriteArrayList<>(); private PreferredInputApi preferredInputApi; private final ByteBuffer receiveData; private final DatagramPacket receivePacket; @@ -628,6 +630,18 @@ public void setOnGetProcessInfoListener(OnGetProcessInfoListener onGetProcessInf } } + /** + * Receives every GET_PROCESS reply in addition to the listener above, which the UI swaps in + * and out around its own requests. + */ + public void addProcessInfoObserver(OnGetProcessInfoListener observer) { + processInfoObservers.addIfAbsent(observer); + } + + public void removeProcessInfoObserver(OnGetProcessInfoListener observer) { + processInfoObservers.remove(observer); + } + private void startSendThread() { initTimeoutThread = new Thread(() -> { try { @@ -737,7 +751,8 @@ private void handleRequest(byte requestCode, final int port) throws IOException } return; case RequestCodes.GET_PROCESS: - if (this.onGetProcessInfoListener == null) { + OnGetProcessInfoListener processInfoListener = this.onGetProcessInfoListener; + if (processInfoListener == null && processInfoObservers.isEmpty()) { return; } ByteBuffer byteBuffer = this.receiveData; @@ -751,7 +766,9 @@ private void handleRequest(byte requestCode, final int port) throws IOException byte[] bytes = new byte[32]; this.receiveData.get(bytes); String name = StringUtils.fromANSIString(bytes); - this.onGetProcessInfoListener.onGetProcessInfo(index, numProcesses, new ProcessInfo(pid, name, memoryUsage, affinityMask, wow64Process)); + ProcessInfo processInfo = new ProcessInfo(pid, name, memoryUsage, affinityMask, wow64Process); + if (processInfoListener != null) processInfoListener.onGetProcessInfo(index, numProcesses, processInfo); + for (OnGetProcessInfoListener observer : processInfoObservers) observer.onGetProcessInfo(index, numProcesses, processInfo); return; case RequestCodes.GET_GAMEPAD: forgetLastSentGamepadState(port); diff --git a/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt b/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt new file mode 100644 index 0000000000..c2d81aec67 --- /dev/null +++ b/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt @@ -0,0 +1,63 @@ +package app.gamenative.powercontrol + +import com.winlator.winhandler.ProcessInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Which winhandler-reported process gets which container mask under gamescope: the same rules + * the X11 window-map path applies in Win32AppWorkarounds. + */ +class ContainerAffinityTest { + private val mask = 0xF0 // cores 4-7 + private val maskWoW64 = 0xC0 // cores 6-7 + + private fun process(name: String, wow64: Boolean = false, affinity: Int = 0xFF) = + ProcessInfo(1234, name, 0L, affinity, wow64) + + @Test + fun sixtyFourBitProcessGetsTheMainList() { + assertEquals(mask, ContainerAffinity.targetMask(process("Game.exe"), mask, maskWoW64, pinnedGame = null, powerControlOwnsGame = false)) + } + + @Test + fun wow64ProcessGetsTheWoW64List() { + assertEquals(maskWoW64, ContainerAffinity.targetMask(process("Game32.exe", wow64 = true), mask, maskWoW64, pinnedGame = null, powerControlOwnsGame = false)) + } + + @Test + fun steamWineServicesAndBackgroundPinnedProcessesStayOut() { + for (name in listOf("steam.exe", "Steam.exe", "winhandler.exe", "services.exe", "wineserver", "start.exe", "wineboot.exe", "winedevice.exe", "plugplay.exe", "svchost.exe", "rpcss.exe", "conhost.exe")) { + assertNull(name, ContainerAffinity.targetMask(process(name), mask, maskWoW64, pinnedGame = null, powerControlOwnsGame = false)) + } + } + + @Test + fun pinnedGameStaysOutOnlyWhilePowerControlOwnsIt() { + assertNull(ContainerAffinity.targetMask(process("Game.exe"), mask, maskWoW64, pinnedGame = "game.exe", powerControlOwnsGame = true)) + assertEquals(mask, ContainerAffinity.targetMask(process("Game.exe"), mask, maskWoW64, pinnedGame = "game.exe", powerControlOwnsGame = false)) + assertEquals(mask, ContainerAffinity.targetMask(process("Launcher.exe"), mask, maskWoW64, pinnedGame = "game.exe", powerControlOwnsGame = true)) + } + + @Test + fun windowedHelpersStillGetTheList() { + for (name in listOf("explorer.exe", "tabtip.exe", "UnityCrashHandler64.exe")) { + assertEquals(name, mask, ContainerAffinity.targetMask(process(name), mask, maskWoW64, pinnedGame = null, powerControlOwnsGame = false)) + } + } + + @Test + fun emptyMaskMeansNoRequest() { + assertNull(ContainerAffinity.targetMask(process("Game32.exe", wow64 = true), mask, 0, pinnedGame = null, powerControlOwnsGame = false)) + } + + @Test + fun reportedMaskComparesOnTheLowSixteenBits() { + assertTrue(ContainerAffinity.sameMask(0xF0, 0xF0)) + assertTrue(ContainerAffinity.sameMask(0xFFFF00F0.toInt(), 0xF0)) + assertFalse(ContainerAffinity.sameMask(0xFF, 0xF0)) + } +}