From 6e69f252f63d48319eb4f37c05a090101f1ebef8 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 18:56:24 -0400 Subject: [PATCH 1/5] Desktop: the Steam and Big Picture launchers run the client on the desktop itself --- .../launcher/session/SessionService.kt | 16 ++++--- .../overlay/usr/local/bin/bannerlator-session | 15 ++++++- .../usr/local/bin/bannerlator-steam-launch | 42 ++++++++++++++++--- .../usr/local/bin/bannerlator-steam-shim | 17 +++++--- 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index ec15c5c2c..7515762d7 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -170,7 +170,8 @@ class SessionService : Service() { SessionState.steamUrl = intent?.getStringExtra(EXTRA_STEAM_URL) // 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) + // The desktop too: its Steam launchers run the client right there (bannerlator-steam-launch). + if (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) RivalClients.stopBeforeSession(this) SessionState.running = true SessionState.firstFrameSeen = false // This session's number, claimed here and not when its process starts: the session it @@ -303,7 +304,10 @@ class SessionService : Service() { if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true") if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1") if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0")) - if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this)) + // The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the + // same set-up as a Steam session: it gets what the client and its games are started with. + val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP + if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this)) if (SessionState.mode == MODE_STEAM) { guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0")) SessionPrefs.writeForceFullscreenFlag(this) @@ -315,7 +319,7 @@ class SessionService : Service() { // The FEXCore preset for the x86 games the client launches: its FEX_* variables go in // here, before the script, so every game process inherits them from the client. The // default preset sets nothing, which is what every session ran on before. - if (SessionState.mode == MODE_STEAM) { + if (steamHere) { val preset = SessionPrefs.fexPreset(this) val vars = com.droiddeck.launcher.core.FexPreset.env(preset) vars.forEach { guest.add(it) } @@ -330,7 +334,7 @@ class SessionService : Service() { // on, even naming every core: it exists to undo the pin Steam applies to its own interface // renderer, and the scheduler's default is exactly what that pin takes away. A game's is // sent only when it is a real restriction - a game has no pin of its own to undo. - if (SessionState.mode == MODE_STEAM && SessionPrefs.clientCpusOverride(this)) { + if (steamHere && SessionPrefs.clientCpusOverride(this)) { guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this))) } // The game cores also pin the desktop and a program from the rail; without a choice the @@ -432,7 +436,7 @@ class SessionService : Service() { } // The user's own games, for the runtime's shortcuts writer to put in the client's library // before the client starts (see frontend/AddedGames and bannerlator-steam-shortcuts). - if (SessionState.mode == MODE_STEAM) { + if (steamHere) { val added = com.droiddeck.launcher.frontend.AddedGames.scan(this) val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added) guest.add("BL_ADDED_GAMES=" + listing.path) @@ -567,7 +571,7 @@ class SessionService : Service() { // Whether the client signs in to Valve or starts offline: read once, while it starts, and // rewritten by the client when it exits, so it is set again here at every session start. - if (SessionState.mode == MODE_STEAM) OfflineMode.apply(this, root) + if (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) OfflineMode.apply(this, root) val networkLink = LinuxNetworkLinkComponent(this, root) networkLink.attach(this) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index bdcc653c9..c54bcecd3 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -350,7 +350,8 @@ case $mode in # The drawer's "Stretch games to fill the screen" works live: the app writes its choice to # ~/.droiddeck-fill, and each change goes to gamescope as GAMESCOPE_FORCE_WINDOWS_FULLSCREEN on # the root window, which it watches. What gamescope was started with is where this begins. - ( + # Not on the desktop (BL_DESKTOP): no gamescope there, and the loop would outlive the client. + [ -z "${BL_DESKTOP:-}" ] && ( last=${BL_GAMESCOPE_FORCE_FULLSCREEN:-1} while :; do sleep 1 @@ -515,7 +516,17 @@ BUSCONF # The URL arguments were acted on the first time; a restart must not repeat them. set -- done - reap_orphans + if [ -n "${BL_DESKTOP:-}" ]; then + # Started from the desktop (bannerlator-steam-launch): the desktop's own programs share this + # script's parent, so reap_orphans - right under gamescope, where everything is the client's - + # is not for here. The helpers this run started (the cores and redistributables beats, the + # system bus and the NetworkManager stand-in) go with the client instead. + _bl_jobs=$(jobs -p) + [ -n "$_bl_jobs" ] && kill $_bl_jobs 2>/dev/null + echo "== steam: the client on the desktop has exited" + else + reap_orphans + fi exit "$rc" ;; *) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-launch b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-launch index 1b2afba0a..92d1c9d22 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-launch +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-launch @@ -1,12 +1,18 @@ #!/bin/bash -# Asks the app to start the Steam client, from inside a session (the desktop's menu entries and -# the per-game launchers bannerlator-desktop-games writes): +# Starts the Steam client from inside a session (the desktop's menu entries and the per-game +# launchers bannerlator-desktop-games writes): # bannerlator-steam-launch desktop [appid|steam-url] Steam's desktop UI, optionally a game # bannerlator-steam-launch bigpicture [appid|steam-url] Big Picture -# The client cannot run on the desktop: labwc composites in software and offers no dma-buf, so a -# game (or the client's own Vulkan window) would die at its first frame. It runs under gamescope, -# which is a session of its own - so this writes a request into the directory the app watches, -# and the app ends the desktop session and starts a Steam session in its place. +# +# On a desktop that draws with the GPU (labwc on vulkan or gles2 - the default since the GPU +# desktop) the client runs right there: a window on the desktop, through the desktop's Xwayland, +# with the same set-up a Steam session gives it (bannerlator-session steam, minus gamescope), and +# its games open in windows of their own. A client already running there gets the request instead +# of a second one being started. +# +# On a software desktop (pixman: the GPU renderers failed on this device) there is no dma-buf, so a +# game - or the client's own Vulkan window - would die at its first frame. There the request goes +# to the app, which ends the desktop and starts a Steam session under gamescope in its place. set -u ui=${1:-desktop} target=${2:-} @@ -18,6 +24,30 @@ case $target in *[!0-9]*) echo "not an app id or steam:// URL: $target" >&2; exit 64 ;; *) url=steam://rungameid/$target ;; esac + +steam_root=${XDG_DATA_HOME:-${HOME:-/root}/.local/share}/Steam +client=$steam_root/steamrtarm64/steam + +if [ "${BL_DESKTOP:-}" = 1 ] && [ -n "${WLR_RENDERER:-}" ] && [ "$WLR_RENDERER" != pixman ]; then + log=${BL_DEBUG_DIR:-$HOME/.cache/droiddeck}/steam-desktop.log + mkdir -p "$(dirname "$log")" 2>/dev/null + if pgrep -f 'steamrtarm64/steam( |$)' >/dev/null 2>&1 && [ -x "$client" ]; then + # One client at a time: the running one takes the request (a second start hands its command + # line over and exits). No game asked for = switch it to the UI that was clicked. + [ -n "$url" ] || { [ "$ui" = bigpicture ] && url=steam://open/bigpicture || url=steam://open/main; } + echo "bannerlator-steam-launch: the client is running on the desktop; handing it $url" | tee -a "$log" + exec "$client" "$url" + fi + echo "bannerlator-steam-launch: starting the Steam client on the desktop ($ui${url:+, $url}); log: $log" + # BL_INSIDE: the session script's Steam start-up without gamescope in front of it. The desktop UI + # unless Big Picture was asked for; never Deck mode (its Quick Access Menu needs gamescope). + export BL_INSIDE=1 BL_STEAMDECK=0 + if [ "$ui" = desktop ]; then export BL_STEAM_UI=desktop; else unset BL_STEAM_UI; fi + setsid /usr/local/bin/bannerlator-session steam ${url:+"$url"} >>"$log" 2>&1 < /dev/null & + exit 0 +fi + +# The software desktop, or outside the desktop: ask the app for a Steam session. dir=${BL_LAUNCH_DIR:-} if [ -z "$dir" ] || [ ! -d "$dir" ]; then echo "bannerlator-steam-launch: not inside a DroidDeck session (BL_LAUNCH_DIR unset)" >&2 diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shim b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shim index 66d1ee085..a30c2c8d8 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shim +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shim @@ -1,12 +1,19 @@ #!/bin/bash # `steam` on the desktop. The client writes a menu entry per installed game that runs -# `steam steam://rungameid/`, and its own launchers say `steam`; neither can run the client -# here (no dma-buf on the desktop), so on the desktop this hands off to the app for a Steam session -# in the client's desktop UI under gamescope. Anywhere else it is the client itself. +# `steam steam://rungameid/`, and its own launchers say `steam`. On the desktop that goes +# through bannerlator-steam-launch - which runs the client on the desktop itself when it draws with +# the GPU, hands the URL to a client already running there, and asks the app for a Steam session +# on a software desktop. Anywhere else it is the client itself. set -u if [ "${BL_DESKTOP:-}" = 1 ]; then url= - for arg in "$@"; do case $arg in steam://*) url=$arg ;; esac; done - exec /usr/local/bin/bannerlator-steam-launch desktop ${url:+"$url"} + ui=desktop + for arg in "$@"; do + case $arg in + steam://*) url=$arg ;; + -gamepadui|-bigpicture) ui=bigpicture ;; + esac + done + exec /usr/local/bin/bannerlator-steam-launch "$ui" ${url:+"$url"} fi exec "${HOME:-/root}/.local/share/Steam/steamrtarm64/steam" "$@" From 0c06868c5d5b582c84ed28da27e0c87ff1d2066e Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 19:10:29 -0400 Subject: [PATCH 2/5] Touchpad: one finger held, the other slides = drag (move windows, resize, drag icons) --- .../launcher/input/TouchpadGestures.kt | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt b/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt index 38f28c1f8..48e4642a6 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/TouchpadGestures.kt @@ -16,6 +16,10 @@ import kotlin.math.abs * - two fingers up / down ............ scroll wheel * - hold still, then drag ............ left-button drag * - double-tap, hold, drag ........... left-button drag as well + * - one finger held, another slides .. left-button drag, as on a laptop's pad: the held finger is + * the button, the other moves the pointer (a window by its + * title bar or edge, a desktop icon). The sliding finger may + * lift and slide again; lifting the held one drops. * * Positions handed to the listener are the pointer's, in the view's pixels; the caller keeps them * inside the picture. @@ -45,6 +49,16 @@ class TouchpadGestures( private var twoFingers = false private var scrollAnchorY = 0f private var scrolled = false + // Hold-and-slide: the first finger stays as the button, the second moves the pointer. + private var anchorId = -1 + private var anchorStartX = 0f + private var anchorStartY = 0f + private var moverId = -1 + private var moverStartX = 0f + private var moverStartY = 0f + private var moverLastX = 0f + private var moverLastY = 0f + private var holdDrag = false private val longPress = Runnable { held = true dragging = true @@ -63,6 +77,7 @@ class TouchpadGestures( lastX = event.x; lastY = event.y downAt = event.eventTime travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false + anchorId = event.getPointerId(0); moverId = -1; holdDrag = false // A tap shortly before this touch: holding now drags (double-tap-and-hold). if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) { dragging = true @@ -75,6 +90,17 @@ class TouchpadGestures( } MotionEvent.ACTION_POINTER_DOWN -> { handler.removeCallbacks(longPress) + val i = event.actionIndex + if (moverId == -1 && event.getPointerId(i) != anchorId) { + moverId = event.getPointerId(i) + moverStartX = event.getX(i); moverStartY = event.getY(i) + moverLastX = moverStartX; moverLastY = moverStartY + event.findPointerIndex(anchorId).takeIf { it >= 0 }?.let { + anchorStartX = event.getX(it); anchorStartY = event.getY(it) + } + } + // Mid-drag, a finger coming back only picks the sliding up again. + if (holdDrag) return true twoFingers = true secondDownAt = event.eventTime secondWasTap = true @@ -82,6 +108,38 @@ class TouchpadGestures( return true } MotionEvent.ACTION_MOVE -> { + val ai = event.findPointerIndex(anchorId) + val mi = if (moverId == -1) -1 else event.findPointerIndex(moverId) + if (ai >= 0 && mi >= 0) { + val mx = event.getX(mi); val my = event.getY(mi) + // One finger still, the other on the move: that is a drag, not a scroll (which + // moves both). Decided once, before anything has scrolled. + if (!holdDrag && !scrolled) { + val anchorMoved = abs(event.getX(ai) - anchorStartX) + abs(event.getY(ai) - anchorStartY) + val moverMoved = abs(mx - moverStartX) + abs(my - moverStartY) + if (moverMoved > slopPx && anchorMoved < slopPx) { + holdDrag = true + secondWasTap = false + if (!dragging) { + dragging = true + listener.onButton(PointerGestures.BTN_LEFT, true, x, y) + } + } + } + if (holdDrag) { + val dx = mx - moverLastX + val dy = my - moverLastY + moverLastX = mx; moverLastY = my + val speed = abs(dx) + abs(dy) + val gain = sensitivity * (1f + minOf(speed / 40f, 1.5f)) + place(x + dx * gain, y + dy * gain) + listener.onMove(x, y) + return true + } + } else if (holdDrag) { + // Only the held finger is down: the drag waits for the next slide. + return true + } if (twoFingers && event.pointerCount >= 2) { val mid = (event.getY(0) + event.getY(1)) / 2f val dy = mid - scrollAnchorY @@ -109,6 +167,30 @@ class TouchpadGestures( return true } MotionEvent.ACTION_POINTER_UP -> { + val gone = event.getPointerId(event.actionIndex) + if (holdDrag) { + if (gone == anchorId) { + // The held finger lifted: drop. The other finger carries on as the pointer. + listener.onButton(PointerGestures.BTN_LEFT, false, x, y) + dragging = false; holdDrag = false; twoFingers = false + anchorId = moverId; moverId = -1 + event.findPointerIndex(anchorId).takeIf { it >= 0 }?.let { + lastX = event.getX(it); lastY = event.getY(it) + } + travelled = slopPx + 1f + } else if (gone == moverId) { + moverId = -1 + } + return true + } + if (gone == moverId) moverId = -1 + if (gone == anchorId) { + // The first finger left a two-finger gesture: the other one is the pointer now. + anchorId = event.getPointerId(if (event.actionIndex == 0) 1 else 0) + event.findPointerIndex(anchorId).takeIf { it >= 0 }?.let { + lastX = event.getX(it); lastY = event.getY(it) + } + } // The second finger lifting quickly, with nothing scrolled: a right click. if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) { listener.onButton(PointerGestures.BTN_RIGHT, true, x, y) @@ -136,6 +218,7 @@ class TouchpadGestures( } } dragging = false; held = false; twoFingers = false + holdDrag = false; anchorId = -1; moverId = -1 return true } } From c0977d4344c17e5b96b38d71501f5b5068cc43ac Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 20:35:48 -0400 Subject: [PATCH 3/5] Dolphin: its data where it looks for it (fonts, per-game fixes), game folders, Vulkan, no NKit box; full screen from the front end --- .../droiddeck/launcher/frontend/Library.kt | 9 +- .../usr/local/bin/bannerlator-pad-defaults | 108 ++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index e255d314b..0bc6655d9 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -219,7 +219,14 @@ object Library { "armsx2" -> listOf("-batch", "-bigpicture", "-fullscreen", "--", guestPath) // DuckStation shares PCSX2's flags (src/duckstation-qt/qthost.cpp): the same launch and exit. "duckstation" -> listOf("-batch", "-bigpicture", "-fullscreen", "--", guestPath) - "dolphin" -> listOf("-e", guestPath) + // Dolphin: full screen, and gone when the game stops (-b), without its "stop the emulation?" + // question; no warning boxes either, which wait for a click a controller cannot give + // (they still go to Dolphin's log). -C sets a Dolphin.ini value for this run only, so + // Dolphin started from the desktop keeps its window and its warnings. + "dolphin" -> listOf( + "-b", "-C", "Dolphin.Display.Fullscreen=True", "-C", "Dolphin.Interface.ConfirmStop=False", + "-C", "Dolphin.Interface.UsePanicHandlers=False", "-e", guestPath, + ) "cemu" -> listOf("-g", guestPath) else -> listOf(guestPath) } diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index 88e56d76d..2251bedf3 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -510,10 +510,118 @@ def armsx2_bios(ini): print(f"bannerlator-pad-defaults: ARMSX2: BIOS {pick}") +DOLPHIN_APPIMAGE = "/opt/appimages/dolphin.AppImage" +DOLPHIN_DATA = "/usr/share/dolphin-emu" + + +def dolphin_data(): + """The Dolphin build in the catalog finds its own data - the GameCube fonts, the per-game fixes + (GameSettings: GUT.ini puts Ultimate Spider-Man on the MMU), its icons and shaders - only at the + fixed /usr/share/dolphin-emu it was built for; there is no setting or variable to move it. Here it + runs from a copy unpacked under /tmp (no FUSE), so it found none of it: a GameCube game stopped + on "Windows-1252 fonts are not loaded", another on an invalid write the missing game fix would + have prevented. So the folder is unpacked from the AppImage to that path, once per AppImage.""" + import shutil, subprocess, tempfile + try: + st = os.stat(DOLPHIN_APPIMAGE) + except OSError: + return + stamp = "%d:%d" % (st.st_size, int(st.st_mtime)) + marker = os.path.join(DOLPHIN_DATA, ".droiddeck-from") + try: + if open(marker).read() == stamp and os.path.isfile(os.path.join(DOLPHIN_DATA, "Sys/GC/font_western.bin")): + return + except OSError: + pass + work = tempfile.mkdtemp(prefix="dolphin-data-", dir="/tmp") + try: + subprocess.run([DOLPHIN_APPIMAGE, "--appimage-extract", "usr/share/dolphin-emu/*"], cwd=work, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=600) + src = os.path.join(work, "squashfs-root/usr/share/dolphin-emu") + if not os.path.isfile(os.path.join(src, "Sys/GC/font_western.bin")): + print("bannerlator-pad-defaults: Dolphin: could not unpack its data from the AppImage") + return + new = DOLPHIN_DATA + ".new" + shutil.rmtree(new, ignore_errors=True) + shutil.move(src, new) + with open(os.path.join(new, ".droiddeck-from"), "w") as f: + f.write(stamp) + shutil.rmtree(DOLPHIN_DATA, ignore_errors=True) + os.rename(new, DOLPHIN_DATA) + print("bannerlator-pad-defaults: Dolphin: its data (fonts, per-game fixes) in " + DOLPHIN_DATA) + except (OSError, subprocess.SubprocessError) as e: + print("bannerlator-pad-defaults: Dolphin: unpacking its data failed: %s" % e) + finally: + shutil.rmtree(work, ignore_errors=True) + + def dolphin(): + if not os.path.isfile(DOLPHIN_APPIMAGE): + return + dolphin_data() d = os.path.join(CONFIG, "dolphin-emu") seed(os.path.join(d, "GCPadNew.ini"), DOLPHIN_GC, "Dolphin (GameCube)") seed(os.path.join(d, "WiimoteNew.ini"), DOLPHIN_WII, "Dolphin (Wii)") + # Games from the ROMs folder's gamecube and wii folders, subfolders included; Vulkan; no + # NKit warning (it will not let a game start until a box is ticked - games run either way, + # the warning is about loading times, netplay and save states); no statistics question. + # Names from Dolphin's MainSettings.cpp / UISettings.cpp. Only what is not set yet is added, + # except the game folders, which are added to whatever the user has. + ini = os.path.join(d, "Dolphin.ini") + gc = system_folder(("gamecube", "gc", "ngc"), "gamecube") + wii = system_folder(("wii",), "wii") + if not os.path.isfile(ini): + os.makedirs(d, exist_ok=True) + with open(ini, "w", encoding="utf-8") as f: + f.write("[Analytics]\nEnabled = False\nPermissionAsked = True\n" + "[Core]\nGFXBackend = Vulkan\n" + "[General]\nISOPaths = 2\nISOPath0 = %s\nISOPath1 = %s\nRecursiveISOPaths = True\n" + "[Interface]\nSkipNKitWarning = True\n" % (gc, wii)) + print("bannerlator-pad-defaults: Dolphin: first-time setup (Vulkan, games from %s and %s)" % (gc, wii)) + return + try: + count = int(ini_get(ini, "General", "ISOPaths") or "0") + except ValueError: + count = 0 + paths = [ini_get(ini, "General", "ISOPath%d" % i) for i in range(count)] + changed = [] + # The whole ROMs folder as a game folder (what Dolphin's "set a games directory" was pointed at + # before) would, searched with its subfolders, have Dolphin open every PS2 and PS3 image too; + # the system folders below cover it. + if any((p or "").rstrip("/") == ROMS for p in paths): + paths = [p for p in paths if p and p.rstrip("/") != ROMS] + for i, p in enumerate(paths): + ini_set(ini, "General", "ISOPath%d" % i, p) + for i in range(len(paths), count): + ini_set(ini, "General", "ISOPath%d" % i, "") + count = len(paths) + ini_set(ini, "General", "ISOPaths", str(count)) + changed.append("not the whole ROMs folder") + for folder in (gc, wii): + if folder not in paths: + ini_set(ini, "General", "ISOPath%d" % count, folder) + count += 1 + paths.append(folder) + changed.append(folder) + if changed: + ini_set(ini, "General", "ISOPaths", str(count)) + if ini_get(ini, "General", "RecursiveISOPaths") != "True": + ini_set(ini, "General", "RecursiveISOPaths", "True") + changed.append("subfolders") + if changed: + print("bannerlator-pad-defaults: Dolphin: games from " + ", ".join(changed)) + # Once, for a Dolphin set up before this: Vulkan unless it was chosen already, and the NKit box. + marker = os.path.join(d, ".droiddeck-setup") + if os.path.exists(marker): + return + if not ini_get(ini, "Core", "GFXBackend"): + ini_set(ini, "Core", "GFXBackend", "Vulkan") + ini_set(ini, "Interface", "SkipNKitWarning", "True") + if ini_get(ini, "Analytics", "PermissionAsked") != "True": + ini_set(ini, "Analytics", "Enabled", "False") + ini_set(ini, "Analytics", "PermissionAsked", "True") + open(marker, "w").close() + print("bannerlator-pad-defaults: Dolphin: Vulkan, no NKit warning") # Cemu: an SDLController profile for the GamePad. The uuid is "_" and SDL's GUID From d38586edb14dd55e1639c39897f2604c0993f03c Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 21:00:43 -0400 Subject: [PATCH 4/5] Dolphin from the rail: guide toggles full screen (its window and settings), drawn in the main window --- .../com/droiddeck/launcher/SessionActivity.kt | 21 +++++++++++++++++++ .../droiddeck/launcher/frontend/Library.kt | 13 +++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 5a36bc60b..732773742 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -736,6 +736,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return super.dispatchKeyEvent(event) } if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchKeyEvent(event) + // Dolphin (PC) has no controller menu of its own: in a Dolphin game from the rail the guide + // button is its Toggle Fullscreen hotkey (Alt+Return, Dolphin's default) instead, which + // brings up Dolphin's window with its Config / Graphics / Stop buttons, and back again. + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE && guideIsDolphinFullscreen() && + event.device != null && PadBridge.isFromController(event.device) + ) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && CompositorHost.isStarted) { + WaylandCompositor.nativeSendKey(KEY_LEFTALT, 1) + WaylandCompositor.nativeSendKey(KEY_ENTER, 1) + WaylandCompositor.nativeSendKey(KEY_ENTER, 0) + WaylandCompositor.nativeSendKey(KEY_LEFTALT, 0) + } + return true + } if (event.keyCode != KeyEvent.KEYCODE_BACK && 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. @@ -994,6 +1008,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (reset) secondScreenMode = SecondScreenMode.NONE } + private fun guideIsDolphinFullscreen(): Boolean = + SessionState.mode == SessionService.MODE_RUN && + SessionState.program?.substringAfterLast('/')?.startsWith("dolphin") == true + /** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */ private fun usingTouchpad(): Boolean = when (SessionPrefs.touchMode(this)) { SessionPrefs.TOUCH_PAD -> true @@ -1191,6 +1209,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } companion object { + // evdev codes (linux/input-event-codes.h) for the Alt+Return sent to Dolphin. + private const val KEY_LEFTALT = 56 + private const val KEY_ENTER = 28 private const val TAG = "SessionActivity" private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L private const val DRAWER_HAT_THRESHOLD = 0.5f diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 0bc6655d9..fd5e78639 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -219,13 +219,16 @@ object Library { "armsx2" -> listOf("-batch", "-bigpicture", "-fullscreen", "--", guestPath) // DuckStation shares PCSX2's flags (src/duckstation-qt/qthost.cpp): the same launch and exit. "duckstation" -> listOf("-batch", "-bigpicture", "-fullscreen", "--", guestPath) - // Dolphin: full screen, and gone when the game stops (-b), without its "stop the emulation?" + // Dolphin: full screen, drawn inside its own main window, without its "stop the emulation?" // question; no warning boxes either, which wait for a click a controller cannot give - // (they still go to Dolphin's log). -C sets a Dolphin.ini value for this run only, so - // Dolphin started from the desktop keeps its window and its warnings. + // (they still go to Dolphin's log). The guide button toggles full screen (SessionActivity), + // which shows Dolphin's window and its settings - so no -b, which hides that window. + // Closing Dolphin ends the session. -C sets a Dolphin.ini value for this run only, so + // Dolphin started from the desktop keeps its own window and its warnings. "dolphin" -> listOf( - "-b", "-C", "Dolphin.Display.Fullscreen=True", "-C", "Dolphin.Interface.ConfirmStop=False", - "-C", "Dolphin.Interface.UsePanicHandlers=False", "-e", guestPath, + "-C", "Dolphin.Display.Fullscreen=True", "-C", "Dolphin.Display.RenderToMain=True", + "-C", "Dolphin.Interface.ConfirmStop=False", "-C", "Dolphin.Interface.UsePanicHandlers=False", + "-e", guestPath, ) "cemu" -> listOf("-g", guestPath) else -> listOf(guestPath) From 123a8f8fa32b224300a7e8ba6e85d973627b4afa Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 21:31:25 -0400 Subject: [PATCH 5/5] Dolphin: guide = its Toggle Fullscreen hotkey (full default Hotkeys.ini), Wii Home on R1, input without focus from the rail --- .../com/droiddeck/launcher/SessionActivity.kt | 21 ------ .../droiddeck/launcher/frontend/Library.kt | 11 +-- .../usr/local/bin/bannerlator-pad-defaults | 72 ++++++++++++++++++- 3 files changed, 78 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 732773742..5a36bc60b 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -736,20 +736,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return super.dispatchKeyEvent(event) } if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchKeyEvent(event) - // Dolphin (PC) has no controller menu of its own: in a Dolphin game from the rail the guide - // button is its Toggle Fullscreen hotkey (Alt+Return, Dolphin's default) instead, which - // brings up Dolphin's window with its Config / Graphics / Stop buttons, and back again. - if (event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE && guideIsDolphinFullscreen() && - event.device != null && PadBridge.isFromController(event.device) - ) { - if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && CompositorHost.isStarted) { - WaylandCompositor.nativeSendKey(KEY_LEFTALT, 1) - WaylandCompositor.nativeSendKey(KEY_ENTER, 1) - WaylandCompositor.nativeSendKey(KEY_ENTER, 0) - WaylandCompositor.nativeSendKey(KEY_LEFTALT, 0) - } - return true - } if (event.keyCode != KeyEvent.KEYCODE_BACK && 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. @@ -1008,10 +994,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (reset) secondScreenMode = SecondScreenMode.NONE } - private fun guideIsDolphinFullscreen(): Boolean = - SessionState.mode == SessionService.MODE_RUN && - SessionState.program?.substringAfterLast('/')?.startsWith("dolphin") == true - /** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */ private fun usingTouchpad(): Boolean = when (SessionPrefs.touchMode(this)) { SessionPrefs.TOUCH_PAD -> true @@ -1209,9 +1191,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } companion object { - // evdev codes (linux/input-event-codes.h) for the Alt+Return sent to Dolphin. - private const val KEY_LEFTALT = 56 - private const val KEY_ENTER = 28 private const val TAG = "SessionActivity" private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L private const val DRAWER_HAT_THRESHOLD = 0.5f diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index fd5e78639..d024d8614 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -221,13 +221,16 @@ object Library { "duckstation" -> listOf("-batch", "-bigpicture", "-fullscreen", "--", guestPath) // Dolphin: full screen, drawn inside its own main window, without its "stop the emulation?" // question; no warning boxes either, which wait for a click a controller cannot give - // (they still go to Dolphin's log). The guide button toggles full screen (SessionActivity), - // which shows Dolphin's window and its settings - so no -b, which hides that window. - // Closing Dolphin ends the session. -C sets a Dolphin.ini value for this run only, so - // Dolphin started from the desktop keeps its own window and its warnings. + // (they still go to Dolphin's log). The guide button is Dolphin's Toggle Fullscreen hotkey + // (bannerlator-pad-defaults), which shows Dolphin's window and its settings - so no -b, which + // hides that window; closing Dolphin ends the session. Under gamescope Dolphin does not + // always see its window as focused, and by default both its hotkeys and the game's + // controller then stop: HotkeysRequireFocus off, BackgroundInput on. -C sets a Dolphin.ini + // value for this run only, so Dolphin started from the desktop keeps its own settings. "dolphin" -> listOf( "-C", "Dolphin.Display.Fullscreen=True", "-C", "Dolphin.Display.RenderToMain=True", "-C", "Dolphin.Interface.ConfirmStop=False", "-C", "Dolphin.Interface.UsePanicHandlers=False", + "-C", "Dolphin.General.HotkeysRequireFocus=False", "-C", "Dolphin.Input.BackgroundInput=True", "-e", guestPath, ) "cemu" -> listOf("-g", guestPath) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index 2251bedf3..954916d29 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -383,7 +383,7 @@ Buttons/1 = `Button W` Buttons/2 = `Button N` Buttons/- = `Back` Buttons/+ = `Start` -Buttons/Home = `Guide` +Buttons/Home = `Shoulder R` D-Pad/Up = `Pad N` D-Pad/Down = `Pad S` D-Pad/Left = `Pad W` @@ -510,6 +510,63 @@ def armsx2_bios(ini): print(f"bannerlator-pad-defaults: ARMSX2: BIOS {pick}") +# Dolphin (PC) has no controller menu, so the guide button is its Toggle Fullscreen hotkey: out of full +# screen is Dolphin's own window with its Config / Graphics / Stop buttons. Hotkeys.ini is all-or- +# nothing - a file with one line drops every other default - so a new one carries Dolphin's defaults, +# generated from HotkeyManager.cpp's LoadDefaults (Linux branch, RetroAchievements on) plus the guide; +# an existing one only gets the guide added to its Toggle Fullscreen. The Wii remote's Home, which +# was on the guide button, is on the right shoulder button instead. +DOLPHIN_GUIDE = "`SDL/0/Xbox 360 Controller:Guide`" +DOLPHIN_HOTKEYS = """[Hotkeys] +Device = XInput2/0/Virtual core pointer +General/Open = @(Ctrl+O) +General/Toggle Pause = F10 +General/Stop = Escape +General/Toggle Fullscreen = @(Alt+Return) | `SDL/0/Xbox 360 Controller:Guide` +General/Take Screenshot = F9 +General/Open Achievements = @(Alt+A) +Emulation Speed/Disable Emulation Speed Limit = Tab +Stepping/Step Into = F11 +Stepping/Step Over = @(Shift+F10) +Stepping/Step Out = @(Shift+F11) +Breakpoint/Toggle Breakpoint = @(Shift+F9) +Wii/Connect Wii Remote 1 = @(Alt+F5) +Wii/Connect Wii Remote 2 = @(Alt+F6) +Wii/Connect Wii Remote 3 = @(Alt+F7) +Wii/Connect Wii Remote 4 = @(Alt+F8) +Wii/Connect Balance Board = @(Alt+F9) +Load State/Load State Slot 1 = F1 +Load State/Load State Slot 2 = F2 +Load State/Load State Slot 3 = F3 +Load State/Load State Slot 4 = F4 +Load State/Load State Slot 5 = F5 +Load State/Load State Slot 6 = F6 +Load State/Load State Slot 7 = F7 +Load State/Load State Slot 8 = F8 +Save State/Save State Slot 1 = @(Shift+F1) +Save State/Save State Slot 2 = @(Shift+F2) +Save State/Save State Slot 3 = @(Shift+F3) +Save State/Save State Slot 4 = @(Shift+F4) +Save State/Save State Slot 5 = @(Shift+F5) +Save State/Save State Slot 6 = @(Shift+F6) +Save State/Save State Slot 7 = @(Shift+F7) +Save State/Save State Slot 8 = @(Shift+F8) +Other State Hotkeys/Undo Load State = F12 +Other State Hotkeys/Undo Save State = @(Shift+F12) +GBA Core/Load ROM = @(`Ctrl`+`Shift`+`O`) +GBA Core/Unload ROM = @(`Ctrl`+`Shift`+`W`) +GBA Core/Reset = @(`Ctrl`+`Shift`+`R`) +GBA Volume/Volume Down = `KP_Subtract` +GBA Volume/Volume Up = `KP_Add` +GBA Volume/Volume Toggle Mute = `M` +GBA Window Size/1x = `KP_1` +GBA Window Size/2x = `KP_2` +GBA Window Size/3x = `KP_3` +GBA Window Size/4x = `KP_4` +USB Emulation Devices/Show Skylanders Portal = @(Ctrl+P) +USB Emulation Devices/Show Infinity Base = @(Ctrl+I) +""" + DOLPHIN_APPIMAGE = "/opt/appimages/dolphin.AppImage" DOLPHIN_DATA = "/usr/share/dolphin-emu" @@ -562,6 +619,19 @@ def dolphin(): d = os.path.join(CONFIG, "dolphin-emu") seed(os.path.join(d, "GCPadNew.ini"), DOLPHIN_GC, "Dolphin (GameCube)") seed(os.path.join(d, "WiimoteNew.ini"), DOLPHIN_WII, "Dolphin (Wii)") + wii_pad = os.path.join(d, "WiimoteNew.ini") + if ini_get(wii_pad, "Wiimote1", "Buttons/Home") == "`Guide`": + ini_set(wii_pad, "Wiimote1", "Buttons/Home", "`Shoulder R`") + print("bannerlator-pad-defaults: Dolphin (Wii): Home on the right shoulder button (guide = Dolphin's window)") + hotkeys = os.path.join(d, "Hotkeys.ini") + if not os.path.isfile(hotkeys): + seed(hotkeys, DOLPHIN_HOTKEYS, "Dolphin", "the guide button shows Dolphin's window (Toggle Fullscreen)") + else: + current = ini_get(hotkeys, "Hotkeys", "General/Toggle Fullscreen") + if current is not None and DOLPHIN_GUIDE not in current: + ini_set(hotkeys, "Hotkeys", "General/Toggle Fullscreen", + (current + " | " if current else "") + DOLPHIN_GUIDE) + print("bannerlator-pad-defaults: Dolphin: the guide button shows Dolphin's window (Toggle Fullscreen)") # Games from the ROMs folder's gamecube and wii folders, subfolders included; Vulkan; no # NKit warning (it will not let a game start until a box is ticked - games run either way, # the warning is about loading times, netplay and save states); no statistics question.