mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 19:11:08 +00:00
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:
1 parent
903c83718a
commit
785bf23087
7 files changed
+95
-8
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user