From 785bf23087fa99bdfe618c3ea5c85a04e8cdeef2 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Tue, 22 Sep 2026 05:17:21 -0400 Subject: [PATCH] =?UTF-8?q?Emulators=20run=20under=20gamescope=20(?= =?UTF-8?q?=E2=96=B6=20in=20Desktop=20&=20apps);=20the=20device's=20volume?= =?UTF-8?q?=20keys=20reach=20Android?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RPCS3 on the desktop died at its first frame with VK_ERROR_SURFACE_LOST: labwc composites in software (pixman) and initialises no dma-buf, so no Vulkan program on the desktop can get a swapchain. The GPU path that works is gamescope's, which Steam's games already render through. MODE_RUN starts a program inside the runtime as a session of its own under gamescope (the script's run mode: AppImage, script or binary), taking Steam's display/driver/HDR settings; the apps dialog offers ▶ for every installed emulator (AppImages, PPSSPP, RetroArch). Volume Up/Down/Mute were forwarded to the guest as keys and never reached Android, so the device's volume never changed during a session; they now fall through, on the media stream. --- README.md | 13 ++++++++++ .../com/steamdeck/launcher/MainActivity.kt | 15 +++++++++++- .../com/steamdeck/launcher/SessionActivity.kt | 22 +++++++++++++---- .../launcher/session/SessionPrefs.kt | 6 +++++ .../launcher/session/SessionService.kt | 24 +++++++++++++++++-- .../launcher/session/SessionState.kt | 2 ++ .../launcher/ui/DesktopAppsDialog.kt | 21 ++++++++++++++++ 7 files changed, 95 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e9eea42c8..761bdd632 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ ledger rather than a feature list — where something has not been run on hardwa | The cog beside Play / Desktop: resolution, shape, HDR, drivers, touch, audio, renderer | ⚠️ built; the resolution cap and HDR not yet seen on a device | | Leftover session processes swept at the next start | ⚠️ built; not yet forced on a device | | The drawer's "Steam menu" (Guide) | ⚠️ built; not yet tried | +| Emulators launched under gamescope (▶ in Desktop & apps) | ⚠️ built; not yet seen with a game | +| The device's volume keys during a session | ⚠️ fixed (they were swallowed); not yet re-tried | | Stopping a competing Steam client | ⚠️ it is asked to stop and says so, but one that restarts itself from boot wins the race — uninstall it | | DirectAudio for games, and the microphone | ⚠️ wired and the modules load; voice not yet confirmed in Steam's tester | | Client and game core masks | ⚠️ applied and logged; no measured difference yet | @@ -169,6 +171,17 @@ themselves through Vulkan and Turnip — it is the desktop's own compositing tha Programs whose child processes sandbox themselves with seccomp and namespaces cannot set those up under proot; Firefox's are turned off in the session's environment, without which its tabs crash. +### Running an emulator with the GPU + +The desktop composites in software (labwc on pixman) and so offers its programs no dma-buf: a +Vulkan program on it has no swapchain to draw into and dies at its first frame — RPCS3 with +`VK_ERROR_SURFACE_LOST`. The GPU path that works on this hardware is gamescope's, the one Steam's +games render through. So an emulator is started **as a session of its own**: ▶ beside an installed +emulator under **Desktop & apps** runs it fullscreen under gamescope (the session script's `run` +mode, an AppImage or a binary inside the runtime), with Steam's display, driver and HDR settings. +The desktop remains for files, Firefox and everything that draws in software. ⚠️ Built; not yet +seen with a game. + ### The device's files, and the ROMs folder Inside a session the phone's storage is at `/storage/emulated/0`, as it is outside - but no diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index 053846c8c..7afac4587 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -177,6 +177,12 @@ class MainActivity : ComponentActivity() { rows = packageRows, busyStage = pkgStage, busyPercent = pkgPercent, onInstall = { id -> installPackage(id) }, onRemove = { id -> catalog?.firstOrNull { it.id == id }?.let { DesktopCatalog.remove(this, it) }; refreshPackages() }, + onLaunch = { path -> + showApps = false + startActivity(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN) + .putExtra(SessionService.EXTRA_PROGRAM, path)) + }, onDismiss = { showApps = false }, ) if (showProtons) ProtonDialog( @@ -296,11 +302,18 @@ class MainActivity : ComponentActivity() { private fun refreshPackages() { packageRows = catalog?.sortedBy { it.tier }?.map { PackageRow(it.id, it.name, it.tier, it.version, FileUtils.sizeToString(it.size), it.notes, - DesktopCatalog.installed(this, it.id)) + DesktopCatalog.installed(this, it.id), launchersOf(it)) } desktopInstalled = DesktopCatalog.desktopInstalled(this) } + /** What a package can start on its own under gamescope; paths inside the runtime. */ + private fun launchersOf(entry: DesktopCatalog.Entry): List> = when { + entry.kind == "appimage" -> listOf(entry.name.substringBefore(" (").trim() to "/opt/appimages/${entry.id}.AppImage") + entry.id == "emulators" -> listOf("PPSSPP" to "/usr/bin/PPSSPPSDL", "RetroArch" to "/usr/bin/retroarch") + else -> emptyList() + } + private fun installPackage(id: String) { val entry = catalog?.firstOrNull { it.id == id } ?: return if (pkgStage != null) return diff --git a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt index c63e6eb09..d0f4fcc14 100644 --- a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt @@ -105,6 +105,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { super.onCreate(savedInstanceState) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) goFullscreen() + // The device's volume keys change the stream the session plays on (the relay and + // PulseAudio are media playback); they are never forwarded to the guest. + volumeControlStream = android.media.AudioManager.STREAM_MUSIC if (!LinuxRuntime.isInstalled(this)) { Log.e(TAG, "the Linux runtime is not installed") @@ -300,7 +303,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // process), from the panel's own word and the mode's setting. Zero-copy presentation is // what puts an HDR frame on a display layer tagged BT2020_PQ, so it is turned on with it. if (!CompositorHost.isStarted) { - val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM + val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) val probe = HdrSupport.probe(this) WaylandCompositor.nativeSetHdrDisplay( probe.displayId, probe.name, probe.formats, probe.hdr10, @@ -332,7 +335,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // start nothing, and leave the loading panel counting up over a dead session. if (!SessionState.running) { CompositorHost.newSession() - SessionService.start(this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + SessionService.start( + this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM, + intent.getStringExtra(SessionService.EXTRA_PROGRAM), + ) } applyFrameGen() } @@ -383,7 +389,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // 1080 tall at most by default: the client's CEF is the heaviest thing in the session, and // above 1080p it costs frames for nothing anyone can see on a handheld panel. The mode's // settings (the cog beside Play / Desktop) can lower the cap or lift it to the panel. - val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM + val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) val cap = SessionPrefs.resolutionCap(this, mode) val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt() val width = (height * aspect).toInt() @@ -444,6 +450,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // ── Input ─────────────────────────────────────────────────────────────────────────────── override fun dispatchKeyEvent(event: KeyEvent): Boolean { + // The device's own volume keys belong to Android: forwarded to the guest as keys they + // changed nothing anyone could hear (a Pocket FIT report, on the desktop). + when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE -> + return super.dispatchKeyEvent(event) + } if (padBridge?.onKeyEvent(event) == true) return true // A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the // activity, which opens the drawer. @@ -529,14 +541,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { cursorPos = androidx.compose.ui.geometry.Offset(x, y) cursorVisible = true uiHandler.removeCallbacks(cursorHide) - if (SessionState.mode != SessionService.MODE_DESKTOP) uiHandler.postDelayed(cursorHide, 2500) + if (SessionState.mode == SessionService.MODE_STEAM) uiHandler.postDelayed(cursorHide, 2500) } /** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */ private fun usingTouchpad(): Boolean = when (SessionPrefs.touchMode(this)) { SessionPrefs.TOUCH_PAD -> true SessionPrefs.TOUCH_DIRECT -> false - else -> SessionState.mode == SessionService.MODE_DESKTOP + else -> SessionState.mode != SessionService.MODE_STEAM } /** The picture's rectangle inside the view: where the pointer may go. */ diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt index 314899431..0cf9a5529 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt @@ -217,4 +217,10 @@ object SessionPrefs { fun setHdr(context: Context, mode: String, on: Boolean) { prefs(context).edit().putBoolean("hdr.$mode", on).apply() } + + /** + * The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a + * fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices. + */ + fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode } diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt index 2742cde1a..25b5d8051 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt @@ -66,6 +66,7 @@ class SessionService : Service() { startForeground(NOTIFICATION_ID, buildNotification()) if (SessionState.running) return START_NOT_STICKY SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM + SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM) // Another Steam client on the device signs ours out seconds after every login; the one that // does it here runs from boot without being opened. Only the Steam session signs in. if (SessionState.mode == MODE_STEAM) RivalClients.stopBeforeSession(this) @@ -170,7 +171,7 @@ class SessionService : Service() { // An imported glibc Turnip for this mode, when the user chose one: the session script checks // the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES, // so the runtime's own driver above stays untouched and is what a bad import falls back to. - val linuxDriverId = SessionPrefs.linuxDriver(this, SessionState.mode) + val linuxDriverId = SessionPrefs.linuxDriver(this, SessionPrefs.prefMode(SessionState.mode)) LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId) ?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) } // Turnip's own debug switches, for the runtime's driver and everything on it. The file in @@ -289,6 +290,21 @@ class SessionService : Service() { } guest.add(LinuxRuntime.SESSION_SCRIPT) guest.add(SessionState.mode) + // A program under gamescope: the script's run mode takes the path (an AppImage, a script + // or a binary inside the runtime). This is how an emulator gets the GPU - the desktop's + // labwc composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist + // there (RPCS3 died with VK_ERROR_SURFACE_LOST); gamescope's Xwayland is the path the + // Steam games already render through. + if (SessionState.mode == MODE_RUN) { + val program = SessionState.program + if (program.isNullOrEmpty()) { + Log.e(TAG, "run mode without a program") + stopSession(65) + return + } + guest.add(program) + Log.i(TAG, "run: $program under gamescope") + } // Android has no /dev/shm; the cache stands in for it and, unlike the real thing, keeps // whatever a session leaves behind. The client abandons tens of megabytes of streams a run. @@ -590,9 +606,13 @@ class SessionService : Service() { const val EXTRA_MODE = "mode" const val MODE_STEAM = "steam" const val MODE_DESKTOP = "lxqt" + /** A program inside the runtime, fullscreen under gamescope (EXTRA_PROGRAM = its path). */ + const val MODE_RUN = "run" + const val EXTRA_PROGRAM = "program" - fun start(context: Context, mode: String = MODE_STEAM) { + fun start(context: Context, mode: String = MODE_STEAM, program: String? = null) { val intent = Intent(context, SessionService::class.java).putExtra(EXTRA_MODE, mode) + if (program != null) intent.putExtra(EXTRA_PROGRAM, program) if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) } diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt index ada1de4b1..c5dafa7a4 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionState.kt @@ -10,6 +10,8 @@ import java.io.File object SessionState { @Volatile var running = false + /** MODE_RUN: the program inside the runtime the session was started for. */ + var program: String? = null /** HDR10 was asked for and the panel can show it: the compositor was told, and the session * gets DXVK_HDR=1 and gamescope --hdr-enabled. Decided by the activity before the compositor starts. */ @JvmStatic var hdr = false diff --git a/app/src/main/java/com/steamdeck/launcher/ui/DesktopAppsDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/DesktopAppsDialog.kt index 891e0d486..9d95651f9 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/DesktopAppsDialog.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/DesktopAppsDialog.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.unit.dp class PackageRow( val id: String, val name: String, val tier: Int, val version: String, val size: String, val notes: String, val installed: String?, + /** What the package can launch on its own under gamescope: label to path inside the runtime. */ + val launchers: List> = emptyList(), ) /** @@ -37,6 +39,8 @@ fun DesktopAppsDialog( busyPercent: Int, onInstall: (String) -> Unit, onRemove: (String) -> Unit, + /** Start [path] as its own session under gamescope. */ + onLaunch: (path: String) -> Unit, onDismiss: () -> Unit, ) { AlertDialog( @@ -53,6 +57,11 @@ fun DesktopAppsDialog( when { rows == null -> Text("Could not reach the package catalog", color = MaterialTheme.colorScheme.onSurfaceVariant) rows.isEmpty() -> Text("Loading the catalog…", color = MaterialTheme.colorScheme.onSurfaceVariant) + else -> Text( + "▶ starts an emulator fullscreen under gamescope, with the GPU — on the desktop, Vulkan programs cannot draw. " + + "ROMs are at /root/ROMs, the phone at /root/Storage.", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } var lastTier = 0 for (row in rows ?: emptyList()) { @@ -82,6 +91,18 @@ fun DesktopAppsDialog( if (row.installed != null) TextButton(enabled = busyStage == null, onClick = { onRemove(row.id) }) { Text("Remove") } else TextButton(enabled = busyStage == null, onClick = { onInstall(row.id) }) { Text("Install") } } + // An emulator starts as a session of its own, fullscreen under gamescope: that + // is where the GPU is. On the desktop (software-composited) a Vulkan program + // has no swapchain to draw into and dies at its first frame. + if (row.installed != null && row.launchers.isNotEmpty()) { + Row(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, bottom = 2.dp)) { + for ((label, path) in row.launchers) { + TextButton(enabled = busyStage == null, onClick = { onLaunch(path) }) { + Text("▶ $label", style = MaterialTheme.typography.labelMedium) + } + } + } + } } } },