Emulators run under gamescope (▶ in Desktop & apps); the device's volume keys reach Android

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.
This commit is contained in:
The412Banner committed 2026-09-22 05:17:21 -04:00
1 parent 903c83718a
commit 785bf23087
7 files changed
+95 -8

No files matched your search

+13
View File
@@ -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
@@ -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<Pair<String, String>> = 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
@@ -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. */
@@ -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
}
@@ -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)
}
@@ -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
@@ -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<Pair<String, String>> = 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)
}
}
}
}
}
}
},