From bc484295b4c7beaabb2c3e5542853c1b8d120e85 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Wed, 4 Nov 2026 13:01:57 +0530 Subject: [PATCH 1/5] Gamescope: the container CPU list reaches the game Under gamescope no window maps on the in-app X server, so the "Processor affinity" list on the Advanced tab was never sent and, because an explicit list hands the game affinity to the container, power control left the game alone too. The session now polls the winhandler's process list and sends the mask once to each new process, 64-bit or WoW64 as the winhandler reports it, with the same exclusions as the X11 window-map path. --- .../powercontrol/ContainerAffinity.kt | 127 ++++++++++++++++++ .../ui/screen/xserver/XServerScreen.kt | 8 ++ .../com/winlator/winhandler/WinHandler.java | 21 ++- .../powercontrol/ContainerAffinityTest.kt | 56 ++++++++ 4 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt create mode 100644 app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt 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..a40d130f89 --- /dev/null +++ b/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt @@ -0,0 +1,127 @@ +package app.gamenative.powercontrol + +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, so this + * poller asks the winhandler for its process list instead and sends the mask once to each new + * process, 64-bit or WoW64 as the winhandler reports it. The same processes stay out as on X11: + * steam.exe, and the game when power control holds its affinity. The processes power control's + * background pin manages stay out as well. + */ +object ContainerAffinity { + private const val TAG = "ContainerAffinity" + private const val POLL_MS = 5000L + private const val REPLY_TIMEOUT_MS = 2000L + + /** Names the mask is never sent to. */ + internal val skippedProcesses = setOf("steam.exe", "winhandler.exe", "services.exe") + + @Volatile + private var poller: Thread? = null + + /** + * Starts polling [winHandler] for the session; stops on [stop] or when the handler is no + * longer the live instance. A zero [mask] means no CPU list, nothing runs. + */ + fun start(winHandler: WinHandler, mask: Int, maskWoW64: Int) { + stop() + if (mask == 0) { + Timber.tag(TAG).i("No container CPU list, nothing to apply") + return + } + val applied = mutableSetOf() + val thread = Thread({ + Timber.tag(TAG).i( + "Applying the container CPU list over the winhandler (mask 0x${Integer.toHexString(mask)}, wow64 0x${Integer.toHexString(maskWoW64)})" + ) + while (!Thread.currentThread().isInterrupted && WinHandler.getActiveInstance() === winHandler) { + val processes = listProcesses(winHandler) ?: emptyList() + for (info in processes) { + if (info.pid in applied) continue + val target = targetMask(info, mask, maskWoW64) ?: continue + applied.add(info.pid) + 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)})" + ) + } + try { + Thread.sleep(POLL_MS) + } catch (_: InterruptedException) { + break + } + } + Timber.tag(TAG).i("Container CPU list poller ended") + }, "container-affinity") + poller = thread + thread.start() + } + + fun stop() { + poller?.interrupt() + poller = null + } + + /** + * 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)) { + Timber.tag(TAG).d("No process list from the winhandler within $REPLY_TIMEOUT_MS ms") + 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..de6c79f45b 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,12 @@ 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 to each process over the winhandler instead. + if (sessionDisplay == SessionDisplay.GAMESCOPE) { + ContainerAffinity.start(getxServer().winHandler, taskAffinityMask, taskAffinityMaskWoW64) + } + if (debugRun) { PerfSampler.start( context, @@ -5472,6 +5479,7 @@ private fun exit( GameSessionId.end() // only needed in exit() — OS reclaims on process death, so onDestroy fallback skips this + ContainerAffinity.stop() 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..bf737147c3 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 List 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..96bc607795 --- /dev/null +++ b/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt @@ -0,0 +1,56 @@ +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 steamAndBackgroundPinnedProcessesStayOut() { + for (name in listOf("steam.exe", "Steam.exe", "winhandler.exe", "services.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 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)) + } +} From 7eec47ac0fb5242f105f13f57560720aaa28504b Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Wed, 4 Nov 2026 13:07:27 +0530 Subject: [PATCH 2/5] WinHandler: concrete observer list type --- app/src/main/java/com/winlator/winhandler/WinHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/winlator/winhandler/WinHandler.java b/app/src/main/java/com/winlator/winhandler/WinHandler.java index bf737147c3..a3447609f5 100644 --- a/app/src/main/java/com/winlator/winhandler/WinHandler.java +++ b/app/src/main/java/com/winlator/winhandler/WinHandler.java @@ -75,7 +75,7 @@ public class WinHandler { private InetAddress localhost; private OnGetProcessInfoListener onGetProcessInfoListener; // Extra receivers of GET_PROCESS replies, alongside the UI's listener. - private final List processInfoObservers = new CopyOnWriteArrayList<>(); + private final CopyOnWriteArrayList processInfoObservers = new CopyOnWriteArrayList<>(); private PreferredInputApi preferredInputApi; private final ByteBuffer receiveData; private final DatagramPacket receivePacket; From 01781c20d9b726bd6b7b3f35f5f45eab029507a8 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Wed, 4 Nov 2026 13:15:37 +0530 Subject: [PATCH 3/5] Gamescope: send the container CPU list once, on the first game content No poller. The session's first game content (the gamescope stand-in for the mapped game window) triggers one winhandler process list and one mask per process, the same shape as the X11 window-map path. --- .../powercontrol/ContainerAffinity.kt | 96 +++++++++---------- .../ui/screen/xserver/XServerScreen.kt | 9 +- 2 files changed, 53 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt b/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt index a40d130f89..e5d917b949 100644 --- a/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt +++ b/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt @@ -1,5 +1,7 @@ package app.gamenative.powercontrol +import app.gamenative.PluviaApp +import app.gamenative.events.AndroidEvent import com.winlator.winhandler.OnGetProcessInfoListener import com.winlator.winhandler.ProcessInfo import com.winlator.winhandler.WinHandler @@ -12,68 +14,67 @@ import java.util.concurrent.TimeUnit * 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, so this - * poller asks the winhandler for its process list instead and sends the mask once to each new - * process, 64-bit or WoW64 as the winhandler reports it. The same processes stay out as on X11: - * steam.exe, and the game when power control holds its affinity. The processes power control's - * background pin manages stay out as well. + * 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. The same processes stay out as on X11: steam.exe, + * and the game when power control holds its affinity. The processes power control's background + * pin manages stay out as well. */ object ContainerAffinity { private const val TAG = "ContainerAffinity" - private const val POLL_MS = 5000L private const val REPLY_TIMEOUT_MS = 2000L /** Names the mask is never sent to. */ internal val skippedProcesses = setOf("steam.exe", "winhandler.exe", "services.exe") - @Volatile - private var poller: Thread? = null + private var armed: ((AndroidEvent.GamescopeGameContent) -> Unit)? = null /** - * Starts polling [winHandler] for the session; stops on [stop] or when the handler is no - * longer the live instance. A zero [mask] means no CPU list, nothing runs. + * 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 start(winHandler: WinHandler, mask: Int, maskWoW64: Int) { - stop() + 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 applied = mutableSetOf() - val thread = Thread({ - Timber.tag(TAG).i( - "Applying the container CPU list over the winhandler (mask 0x${Integer.toHexString(mask)}, wow64 0x${Integer.toHexString(maskWoW64)})" - ) - while (!Thread.currentThread().isInterrupted && WinHandler.getActiveInstance() === winHandler) { - val processes = listProcesses(winHandler) ?: emptyList() - for (info in processes) { - if (info.pid in applied) continue - val target = targetMask(info, mask, maskWoW64) ?: continue - applied.add(info.pid) - 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)})" - ) - } - try { - Thread.sleep(POLL_MS) - } catch (_: InterruptedException) { - break - } - } - Timber.tag(TAG).i("Container CPU list poller ended") - }, "container-affinity") - poller = thread - thread.start() + 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)})" + ) } - fun stop() { - poller?.interrupt() - poller = null + fun disarm() { + armed?.let { PluviaApp.events.off(it) } + armed = null + } + + private fun apply(winHandler: WinHandler, mask: Int, maskWoW64: Int) { + if (WinHandler.getActiveInstance() !== winHandler) return + val processes = listProcesses(winHandler) + if (processes == null) { + Timber.tag(TAG).w("No process list from the winhandler within $REPLY_TIMEOUT_MS ms, container CPU list not applied") + 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)})" + ) + } } /** @@ -112,10 +113,7 @@ object ContainerAffinity { winHandler.addProcessInfoObserver(observer) try { winHandler.listProcesses() - if (!done.await(REPLY_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { - Timber.tag(TAG).d("No process list from the winhandler within $REPLY_TIMEOUT_MS ms") - return null - } + if (!done.await(REPLY_TIMEOUT_MS, TimeUnit.MILLISECONDS)) return null } catch (_: InterruptedException) { Thread.currentThread().interrupt() return null 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 de6c79f45b..2a278897a6 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 @@ -2570,9 +2570,12 @@ fun XServerScreen( PowerManager.autoStart(container.rootDir) // Without the in-app X server no window maps here, so the container - // CPU list goes to each process over the winhandler instead. + // CPU list goes out over the winhandler on the first game content. if (sessionDisplay == SessionDisplay.GAMESCOPE) { - ContainerAffinity.start(getxServer().winHandler, taskAffinityMask, taskAffinityMaskWoW64) + val handler = getxServer().winHandler + android.os.Handler(android.os.Looper.getMainLooper()).post { + ContainerAffinity.arm(handler, taskAffinityMask, taskAffinityMaskWoW64) + } } if (debugRun) { @@ -5479,7 +5482,7 @@ private fun exit( GameSessionId.end() // only needed in exit() — OS reclaims on process death, so onDestroy fallback skips this - ContainerAffinity.stop() + ContainerAffinity.disarm() try { winHandler?.stop() } catch (e: Exception) { From 0408790df0c348c00d1a198afdccfd81a8edaea3 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Wed, 4 Nov 2026 13:24:02 +0530 Subject: [PATCH 4/5] Gamescope CPU list: leave Wine's service processes alone On X11 they map no window and never got the list; on the RP6 the launcher (start.exe) also lost power control's background pin. --- .../powercontrol/ContainerAffinity.kt | 17 ++++++++++++----- .../powercontrol/ContainerAffinityTest.kt | 11 +++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt b/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt index e5d917b949..b0be09219f 100644 --- a/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt +++ b/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt @@ -18,16 +18,23 @@ import java.util.concurrent.TimeUnit * 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. The same processes stay out as on X11: steam.exe, - * and the game when power control holds its affinity. The processes power control's background - * pin manages stay out as well. + * 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 - /** Names the mask is never sent to. */ - internal val skippedProcesses = setOf("steam.exe", "winhandler.exe", "services.exe") + /** + * 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 diff --git a/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt b/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt index 96bc607795..fd9d3eefc0 100644 --- a/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt +++ b/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt @@ -29,8 +29,8 @@ class ContainerAffinityTest { } @Test - fun steamAndBackgroundPinnedProcessesStayOut() { - for (name in listOf("steam.exe", "Steam.exe", "winhandler.exe", "services.exe")) { + fun steamWineServicesAndBackgroundPinnedProcessesStayOut() { + for (name in listOf("steam.exe", "Steam.exe", "winhandler.exe", "services.exe", "start.exe", "winedevice.exe", "plugplay.exe", "svchost.exe", "rpcss.exe")) { assertNull(name, ContainerAffinity.targetMask(process(name), mask, maskWoW64, pinnedGame = null, powerControlOwnsGame = false)) } } @@ -42,6 +42,13 @@ class ContainerAffinityTest { 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)) From 691426ee560bce16ad984254bc0c9dd01afa9fad Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Wed, 4 Nov 2026 13:40:31 +0530 Subject: [PATCH 5/5] Gamescope CPU list: cover a content event that already fired, retry the process list, disarm on view release --- .../app/gamenative/display/GameNativeDrm.kt | 4 ++++ .../powercontrol/ContainerAffinity.kt | 19 ++++++++++++++++--- .../ui/screen/xserver/XServerScreen.kt | 1 + .../powercontrol/ContainerAffinityTest.kt | 2 +- 4 files changed, 22 insertions(+), 4 deletions(-) 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 index b0be09219f..dd3766b201 100644 --- a/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt +++ b/app/src/main/java/app/gamenative/powercontrol/ContainerAffinity.kt @@ -1,6 +1,7 @@ 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 @@ -24,6 +25,7 @@ import java.util.concurrent.TimeUnit 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 @@ -57,6 +59,11 @@ object ContainerAffinity { 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() { @@ -65,12 +72,18 @@ object ContainerAffinity { } private fun apply(winHandler: WinHandler, mask: Int, maskWoW64: Int) { - if (WinHandler.getActiveInstance() !== winHandler) return - val processes = listProcesses(winHandler) + 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("No process list from the winhandler within $REPLY_TIMEOUT_MS ms, container CPU list not applied") + 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)) { 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 2a278897a6..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 @@ -3091,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 diff --git a/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt b/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt index fd9d3eefc0..c2d81aec67 100644 --- a/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt +++ b/app/src/test/java/app/gamenative/powercontrol/ContainerAffinityTest.kt @@ -30,7 +30,7 @@ class ContainerAffinityTest { @Test fun steamWineServicesAndBackgroundPinnedProcessesStayOut() { - for (name in listOf("steam.exe", "Steam.exe", "winhandler.exe", "services.exe", "start.exe", "winedevice.exe", "plugplay.exe", "svchost.exe", "rpcss.exe")) { + 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)) } }