Repository navigation
Gamescope: the container CPU list (Advanced tab) reaches the game #2342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bc48429
7eec47a
01781c2
0408790
691426e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| armed = listener | ||
| PluviaApp.events.once<AndroidEvent.GamescopeGameContent, Unit>(listener) | ||
|
Comment on lines
+53
to
+58
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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<AndroidEvent.GamescopeGameContent, Unit>(it) } | ||
| armed = null | ||
| } | ||
|
|
||
| private fun apply(winHandler: WinHandler, mask: Int, maskWoW64: Int) { | ||
| var processes: List<ProcessInfo>? = 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<ProcessInfo>? { | ||
| val snapshot = ArrayList<ProcessInfo>() | ||
| 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() } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
| if (debugRun) { | ||
| PerfSampler.start( | ||
| context, | ||
|
|
@@ -3081,6 +3091,7 @@ fun XServerScreen( | |
| if (!isX11Display) { | ||
| GameNativeDrm.gameContentListener = null | ||
| GameNativeDrm.setFrameRating(null) | ||
| ContainerAffinity.disarm() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: This teardown can be undone by the still-running setup callback: if the view is released before setup finishes, the later posted Prompt for AI agents |
||
| } | ||
|
|
||
| // 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() | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
|
||
| try { | ||
| winHandler?.stop() | ||
| } catch (e: Exception) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<OnGetProcessInfoListener> 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); | ||
|
Comment on lines
+637
to
+638
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This broadcasts uncorrelated process-list replies to every observer, so the affinity poller and exit watcher can complete against mixed snapshots when their requests overlap. Serialize process-list requests or correlate each observer with its own request before dispatching replies. Prompt for AI agents |
||
| return; | ||
| case RequestCodes.GET_GAMEPAD: | ||
| forgetLastSentGamepadState(port); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.