From e753ab9c6606b6b78c21400e25ed659cfddc5a3d Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 07:37:07 -0400 Subject: [PATCH 01/14] melonDS: Hybrid screens, full screen from the front end, guide toggles full screen, BIOS found in the ROMs folder - bannerlator-pad-defaults: new installs start on the Hybrid layout with sharp pixels; an existing install is moved once, only where still on melonDS's defaults (marker keeps later choices). Guide = HK_FullscreenToggle. The user's DS/DSi BIOS, firmware and NAND are found in ROMs/nds/{bios,firmware} and filled in only where melonDS has no path. - Front end starts melonDS with -f, at the panel's own resolution unless the user chose one. --- .../com/droiddeck/launcher/SessionActivity.kt | 8 +- .../droiddeck/launcher/frontend/Library.kt | 16 +- .../launcher/session/SessionPrefs.kt | 4 + .../usr/local/bin/bannerlator-pad-defaults | 174 ++++++++++++++++-- 4 files changed, 181 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 3df0052db..13dd3e5eb 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.gpu.LsfgNative import com.droiddeck.launcher.gpu.TurnipDriver @@ -646,7 +647,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) // A custom resolution is taken as given; the compositor fits it to the panel. SessionPrefs.customResolution(this, mode)?.let { return it } - val cap = SessionPrefs.resolutionCap(this, mode) + // An emulator whose frames cost next to nothing (Library.drawsAtPanel) gets the panel, + // unless the user chose a resolution for the mode. + val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM) + val cap = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN && + Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode)) 0 + else SessionPrefs.resolutionCap(this, mode) val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt() val width = (height * aspect).toInt() // Odd sizes upset the scaler; both dimensions even is what every mode here would be. 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 63d8db5d8..a735b2925 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -94,7 +94,8 @@ object Library { .firstOrNull { it.isFile && it.length() > 0L } } - private class Spec(val id: String, val name: String, val system: String, val program: String, val folders: List, val exts: Set) + /** [atPanel]: frames cheap enough to draw at the panel's own size (see [drawsAtPanel]). */ + private class Spec(val id: String, val name: String, val system: String, val program: String, val folders: List, val exts: Set, val atPanel: Boolean = false) private val specs = listOf( Spec("rpcs3", "RPCS3", "PS3", "/opt/appimages/rpcs3.AppImage", listOf("ps3"), setOf("iso")), // PS2: ARMSX2, the PCSX2 fork with ARM64 recompilers. Upstream PCSX2 interprets the PS2's @@ -102,7 +103,7 @@ object Library { Spec("armsx2", "ARMSX2", "PS2", "/opt/appimages/armsx2.AppImage", listOf("ps2"), setOf("iso", "chd", "cso", "gz")), Spec("dolphin", "Dolphin", "GameCube / Wii", "/opt/appimages/dolphin.AppImage", listOf("gc", "gamecube", "wii"), setOf("iso", "rvz", "gcz", "wbfs", "ciso")), Spec("duckstation", "DuckStation", "PS1", "/opt/appimages/duckstation.AppImage", listOf("ps1", "psx"), setOf("cue", "chd", "pbp", "iso", "bin", "img", "ecm", "m3u")), - Spec("melonds", "melonDS", "DS", "/opt/appimages/melonds.AppImage", listOf("ds", "nds"), setOf("nds", "dsi")), + Spec("melonds", "melonDS", "DS", "/opt/appimages/melonds.AppImage", listOf("ds", "nds"), setOf("nds", "dsi"), atPanel = true), Spec("cemu", "Cemu", "Wii U", "/opt/appimages/cemu.AppImage", listOf("wiiu", "wii u"), setOf("wua", "wud", "wux", "rpx")), Spec("ppsspp", "PPSSPP", "PSP", "/usr/bin/PPSSPPSDL", listOf("psp"), setOf("iso", "cso", "pbp", "chd")), Spec("retroarch", "RetroArch", "many systems", "/usr/bin/retroarch", emptyList(), emptySet()), @@ -125,6 +126,14 @@ object Library { /** The emulator's name for a program path from the rail ("ARMSX2"), or null. */ fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name + /** + * Whether a program from the rail runs at the panel's own resolution rather than the session's + * 720p default. melonDS draws two 256x192 screens on the CPU: the panel's size costs it + * nothing, and 720p scaled up to the panel blurs the sharp pixels its Hybrid layout is set + * up for (bannerlator-pad-defaults). + */ + fun drawsAtPanel(program: String?): Boolean = specs.any { it.program == program && it.atPanel } + /** Desktop catalog package that supplies this emulator. */ fun packageId(emulatorId: String): String? = installedIds[emulatorId] @@ -270,6 +279,9 @@ object Library { "-e", guestPath, ) "cemu" -> listOf("-g", guestPath) + // Full screen (CLI.cpp --fullscreen); the guide button leaves it for melonDS's menus and + // comes back (HK_FullscreenToggle, bannerlator-pad-defaults). + "melonds" -> listOf("-f", guestPath) else -> listOf(guestPath) } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index c44ead89a..7498b4ad9 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -282,6 +282,10 @@ object SessionPrefs { */ fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", defaultResolutionCap(mode)) + /** Whether the user chose the mode's resolution (a cap or a custom size) rather than the default. */ + fun resolutionChosen(context: Context, mode: String): Boolean = + prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null + /** The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. */ fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "") ?: "" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index 954916d29..ae0513142 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -723,8 +723,11 @@ def cemu(): # melonDS 1.x: raw SDL joystick indices. The session's pad has the kernel's Xbox 360 layout # (buttons A B X Y LB RB Back Start Guide LS RS = 0..10, hat 0 for the d-pad); the d-pad values -# also take the left stick (EmuInstanceInput.cpp; encoding InputConfig/MapButton.h). -MELONDS_JOY = """[Instance0.Joystick] +# also take the left stick (EmuInstanceInput.cpp; encoding InputConfig/MapButton.h). The guide +# button toggles full screen (HK_FullscreenToggle), as it does in Dolphin: the front end starts +# melonDS full screen (-f), and leaving it shows the window's menus for settings. +MELONDS_GUIDE = 8 +MELONDS_JOY = f"""[Instance0.Joystick] A = 1 B = 0 X = 3 @@ -737,14 +740,130 @@ Up = 17891585 Down = 16843012 Left = 1114376 Right = 65794 +HK_FullscreenToggle = {MELONDS_GUIDE} """ +# The screen a handheld's wide panel suits best: Hybrid (the top screen large, both screens small +# beside it - ScreenLayout.h screenLayout_Hybrid = 3), sharp pixels (no filtering, no integer +# steps that leave the panel half used). Keys as melonDS writes them (Window.cpp windowCfg). +MELONDS_WINDOW = """[Instance0.Window0] +ScreenLayout = 3 +ScreenSizing = 0 +IntegerScaling = false +ScreenFilter = false +""" +# melonDS's own defaults for those, which an existing install is moved off once. +MELONDS_WINDOW_DEFAULTS = {"ScreenLayout": "0", "ScreenFilter": "true"} + + +def toml_get(lines, table, key): + """The raw value of key in [table], or None.""" + inside = False + for l in lines: + t = l.strip() + if t.startswith("["): + inside = t == f"[{table}]" + elif inside and "=" in t and t.split("=", 1)[0].strip() == key: + return t.split("=", 1)[1].strip() + return None + + +def toml_set(lines, table, key, value): + """key = value in [table]; the table is added at the end when missing.""" + start = None + for i, l in enumerate(lines): + t = l.strip() + if t.startswith("["): + if start is not None: + break + if t == f"[{table}]": + start = i + elif start is not None and "=" in t and t.split("=", 1)[0].strip() == key: + lines[i] = f"{key} = {value}" + return + if start is None: + lines += ["", f"[{table}]", f"{key} = {value}"] + return + end = start + 1 + while end < len(lines) and not lines[end].strip().startswith("["): + end += 1 + while end > start + 1 and not lines[end - 1].strip(): + end -= 1 + lines.insert(end, f"{key} = {value}") + + +def toml_str(path): + return '"' + path.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +# The user's own BIOS, firmware and DSi NAND, found in the ROMs folder's nds/bios (or firmware) +# folder by what each file is: the DS BIOSes by size and checksum (the GBA BIOS is also 16 KB), +# firmware and NAND by size, the DSi boot ROMs (both 64 KB) by the 7 or 9 in their names. A path +# is only filled where melonDS has none, and external files are only switched on when a whole set +# was found - without them melonDS runs DS games on its built-in free BIOS. +DS_BIOS7_MD5 = "df692a80a5b1bc90728bc3dfc76cd948" +DS_BIOS9_MD5 = "a392174eb3e572fed6447e956bde4b25" + + +def nds_firmware_files(): + import hashlib + base = system_folder(("nds", "ds"), "nds") + roots = [os.path.join(base, n) for n in ("bios", "BIOS", "firmware") if os.path.isdir(os.path.join(base, n))] + found = {} + for root in roots: + for d, _, files in os.walk(root): + if d[len(root):].count(os.sep) > 2: + continue + for name in sorted(files): + f = os.path.join(d, name) + try: + size = os.path.getsize(f) + except OSError: + continue + low = name.lower() + if size == 16384 or size == 4096: + with open(f, "rb") as h: + md5 = hashlib.md5(h.read()).hexdigest() + if md5 == DS_BIOS7_MD5: + found.setdefault("DS.BIOS7Path", f) + elif md5 == DS_BIOS9_MD5: + found.setdefault("DS.BIOS9Path", f) + elif size in (262144, 524288) and "dsi" not in low: + found.setdefault("DS.FirmwarePath", f) + elif size == 65536: + if "arm7" in low or "bios7" in low: + found.setdefault("DSi.BIOS7Path", f) + elif "arm9" in low or "bios9" in low: + found.setdefault("DSi.BIOS9Path", f) + elif size == 131072: + found.setdefault("DSi.FirmwarePath", f) + elif size >= 200 * 1024 * 1024 and "nand" in low: + found.setdefault("DSi.NANDPath", f) + return found + + +def melonds_bios(lines): + found = nds_firmware_files() + changed = [] + for key, f in found.items(): + table, k = key.split(".", 1) + if (toml_get(lines, table, k) or '""') == '""': + toml_set(lines, table, k, toml_str(f)) + changed.append(key) + for table, flag, keys in (("Emu", "ExternalBIOSEnable", ("DS.BIOS7Path", "DS.BIOS9Path", "DS.FirmwarePath")), + ("DSi", "ExternalBIOSEnable", ("DSi.BIOS7Path", "DSi.BIOS9Path", "DSi.FirmwarePath", "DSi.NANDPath"))): + if all(k in found for k in keys) and any(k in changed for k in keys): + toml_set(lines, table, flag, "true") + return changed + def melonds(): path = os.path.join(CONFIG, "melonDS/melonDS.toml") + marker = os.path.join(os.path.dirname(path), ".droiddeck-display") if not os.path.isfile(path): - seed(path, "[Instance0]\nJoystickID = 0\n\n" + MELONDS_JOY, "melonDS") - return + seed(path, "[Instance0]\nJoystickID = 0\n\n" + MELONDS_JOY + "\n" + MELONDS_WINDOW, "melonDS", + "Player 1 on the controller, Hybrid screens") + open(marker, "w").close() with open(path, encoding="utf-8", errors="replace") as f: text = f.read() try: @@ -752,20 +871,39 @@ def melonds(): joy = tomllib.loads(text).get("Instance0", {}).get("Joystick", {}) except Exception: return - if any(isinstance(v, int) and v != -1 for v in joy.values()): - return # something is bound already - # Replace melonDS's own all-unbound table with ours (a TOML table can be defined only once). - out, skipping = [], False - for line in text.splitlines(): - t = line.strip() - if t.startswith("["): - skipping = t == "[Instance0.Joystick]" - if not skipping: - out.append(line) - with open(path + ".tmp", "w", encoding="utf-8") as f: - f.write("\n".join(out).rstrip("\n") + "\n\n" + MELONDS_JOY) - os.replace(path + ".tmp", path) - print(f"bannerlator-pad-defaults: melonDS: Player 1 on the controller ({path})") + lines = text.splitlines() + done = [] + if not any(isinstance(v, int) and v != -1 and k != "HK_FullscreenToggle" for k, v in joy.items()): + # Replace melonDS's own all-unbound table with ours (a TOML table can be defined only once). + out, skipping = [], False + for line in lines: + t = line.strip() + if t.startswith("["): + skipping = t == "[Instance0.Joystick]" + if not skipping: + out.append(line) + lines = "\n".join(out).rstrip("\n").splitlines() + [""] + MELONDS_JOY.rstrip("\n").splitlines() + done.append("Player 1 on the controller") + elif joy.get("HK_FullscreenToggle", -1) == -1 and MELONDS_GUIDE not in joy.values(): + toml_set(lines, "Instance0.Joystick", "HK_FullscreenToggle", str(MELONDS_GUIDE)) + done.append("guide button toggles full screen") + # Once, for a melonDS set up before this: the Hybrid screens, where it is still on its own + # defaults. The marker keeps whatever the user picks in melonDS's View menu afterwards. + if not os.path.exists(marker): + for l in MELONDS_WINDOW.splitlines()[1:]: + k, v = (x.strip() for x in l.split("=", 1)) + if toml_get(lines, "Instance0.Window0", k) in (None, MELONDS_WINDOW_DEFAULTS.get(k)): + toml_set(lines, "Instance0.Window0", k, v) + done.append("Hybrid screens") + open(marker, "w").close() + bios = melonds_bios(lines) + if bios: + done.append("BIOS " + ", ".join(bios)) + if done: + with open(path + ".tmp", "w", encoding="utf-8") as f: + f.write("\n".join(lines).rstrip("\n") + "\n") + os.replace(path + ".tmp", path) + print(f"bannerlator-pad-defaults: melonDS: {'; '.join(done)} ({path})") # PPSSPP (SDL): its pad defaults leave the shoulders, the right stick and the triggers unbound on From e199d7a4214e8965a399640c29a6e6e9ff1fd5e7 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 07:39:16 -0400 Subject: [PATCH 02/14] HUD: follow the game window, not a program's cursor or menus A program's small surfaces (gamescope's 1x1 cursor, Qt's menus and tooltips) announced themselves after the game's window and took the HUD, which then read 0 fps while melonDS ran at 60. A new window now takes the HUD only when it is at least as big, and a window quiet for a second gives way to the next one that draws. --- app/src/main/cpp/waylandcomp/src/compositor.c | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 5b7642bb7..2199e275d 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -686,9 +686,11 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res if (ci && ci->asked_feedback) ci->shm_frames++; /* since it asked for GPU buffers */ } -/* The window the app's performance HUD follows: the latest one to start presenting GPU frames - * (X11 binds the HUD to the _MESA_DRV window and counts X presents instead). JNI upcalls. */ +/* The window the app's performance HUD follows: the latest one at least as big as the last to + * start presenting GPU frames (take_dmabuf) (X11 binds the HUD to the _MESA_DRV window and counts X presents instead). JNI upcalls. */ static struct surface *g_hud_surface; +/* When the HUD's window last presented a frame (now_ns), to let a replacement take over. */ +static int64_t g_hud_last_ns; extern void banner_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */ extern void banner_on_game_frame(void); /* The program behind that window: its Linux pid (the Wayland client's credentials) and executable name @@ -731,6 +733,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re s->buf_h = b->height; s->has_content = b->img != NULL; g_stat_dmabuf++; + const int announced_now = !s->announced_vulkan; if (!s->announced_vulkan) { char name[160]; s->announced_vulkan = 1; @@ -751,14 +754,23 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re else banner_log("error", "could not import GPU frames from %s (%dx%d, modifier %#llx)", name, b->width, b->height, (unsigned long long)b->modifier); - /* The HUD follows the window whether its frames are copied or go straight to the layer: - * a zero-copy frame the compositor never imported is still a presented game frame. */ - if (b->img || ahb_swapchain_has_ahb(b)) { - g_hud_surface = s; - banner_on_game_surface(name, vkp_gpu_name()); - struct client_info *ci = client_info_of(wl_resource_get_client(s->resource)); - if (ci) banner_on_game_program((int)ci->pid, strncmp(ci->name, "pid ", 4) ? ci->name : ""); - } + } + /* The HUD follows the window whether its frames are copied or go straight to the layer: a + * zero-copy frame the compositor never imported is still a presented game frame. A + * program's small surfaces - gamescope's 1x1 cursor, Qt's menus and tooltips (melonDS) - + * came after the game's and drew too rarely to count, which left the HUD on 0 fps while the + * game ran at 60. So a new window takes the HUD only when it is at least as big (a + * full-screen gamescope over the desktop still does), and a window gone quiet for a second + * gives way to whatever draws next. */ + if (s != g_hud_surface && (b->img || ahb_swapchain_has_ahb(b)) && + (!g_hud_surface || now_ns() - g_hud_last_ns > 1000000000LL || + (announced_now && (int64_t)b->width * b->height >= (int64_t)g_hud_surface->buf_w * g_hud_surface->buf_h))) { + char name[160]; + describe(s, name, sizeof(name)); + g_hud_surface = s; + banner_on_game_surface(name, vkp_gpu_name()); + struct client_info *ci = client_info_of(wl_resource_get_client(s->resource)); + if (ci) banner_on_game_program((int)ci->pid, strncmp(ci->name, "pid ", 4) ? ci->name : ""); } /* HDR session (gate open): a DXVK game switches to HDR by REBUILDING its swapchain on the same * surface, so the one-shot line above never sees the 10-bit buffers - name every format change. */ @@ -780,7 +792,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re * Asking whether the compositor could import it, as this used to, is a question about the * copy path and not about whether a frame happened: a game whose buffers go straight to * the display layer draws on screen while the counter sat at 0.0 fps and 1000.0 ms. */ - if (s == g_hud_surface) banner_on_game_frame(); + if (s == g_hud_surface) { g_hud_last_ns = now_ns(); banner_on_game_frame(); } } /* ---- hooks for ahb_swapchain.c (zero-copy layers) */ From 9adcb3d91f86b2a96067a88d5d7264afb9038f5d Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 07:47:59 -0400 Subject: [PATCH 03/14] melonDS: big top screen with the bottom screen alone beside it Hybrid repeats the big screen, small, above the bottom one. Horizontal with the top screen emphasised fills the panel's height with the top screen and centres the bottom screen in the rest. Installs set up with Hybrid (or still on melonDS's defaults) move once. --- .../droiddeck/launcher/frontend/Library.kt | 2 +- .../usr/local/bin/bannerlator-pad-defaults | 27 ++++++++++--------- 2 files changed, 16 insertions(+), 13 deletions(-) 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 a735b2925..3ed6de0a4 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -129,7 +129,7 @@ object Library { /** * Whether a program from the rail runs at the panel's own resolution rather than the session's * 720p default. melonDS draws two 256x192 screens on the CPU: the panel's size costs it - * nothing, and 720p scaled up to the panel blurs the sharp pixels its Hybrid layout is set + * nothing, and 720p scaled up to the panel blurs the sharp pixels its screen layout is set * up for (bannerlator-pad-defaults). */ fun drawsAtPanel(program: String?): Boolean = specs.any { it.program == program && it.atPanel } diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index ae0513142..e23a73677 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -743,17 +743,20 @@ Right = 65794 HK_FullscreenToggle = {MELONDS_GUIDE} """ -# The screen a handheld's wide panel suits best: Hybrid (the top screen large, both screens small -# beside it - ScreenLayout.h screenLayout_Hybrid = 3), sharp pixels (no filtering, no integer -# steps that leave the panel half used). Keys as melonDS writes them (Window.cpp windowCfg). +# The screen a handheld's wide panel suits best: the top screen as big as the panel's height allows +# with the bottom screen alone beside it, centred (ScreenLayout.h: Horizontal = 2, EmphTop = 1; +# melonDS's Hybrid shows the big screen a second time, small, above the bottom one; nobody needs +# it twice). Sharp pixels: no filtering, and no integer steps that leave the panel half used. Keys as +# melonDS writes them (Window.cpp windowCfg). MELONDS_WINDOW = """[Instance0.Window0] -ScreenLayout = 3 -ScreenSizing = 0 +ScreenLayout = 2 +ScreenSizing = 1 IntegerScaling = false ScreenFilter = false """ -# melonDS's own defaults for those, which an existing install is moved off once. -MELONDS_WINDOW_DEFAULTS = {"ScreenLayout": "0", "ScreenFilter": "true"} +# melonDS's own defaults for those, which an existing install is moved off once. Hybrid (3) was +# this preset's first layout, and moves on too. +MELONDS_WINDOW_DEFAULTS = {"ScreenLayout": ("0", "3"), "ScreenSizing": ("0",), "ScreenFilter": ("true",)} def toml_get(lines, table, key): @@ -859,10 +862,10 @@ def melonds_bios(lines): def melonds(): path = os.path.join(CONFIG, "melonDS/melonDS.toml") - marker = os.path.join(os.path.dirname(path), ".droiddeck-display") + marker = os.path.join(os.path.dirname(path), ".droiddeck-display-2") if not os.path.isfile(path): seed(path, "[Instance0]\nJoystickID = 0\n\n" + MELONDS_JOY + "\n" + MELONDS_WINDOW, "melonDS", - "Player 1 on the controller, Hybrid screens") + "Player 1 on the controller, big top screen") open(marker, "w").close() with open(path, encoding="utf-8", errors="replace") as f: text = f.read() @@ -887,14 +890,14 @@ def melonds(): elif joy.get("HK_FullscreenToggle", -1) == -1 and MELONDS_GUIDE not in joy.values(): toml_set(lines, "Instance0.Joystick", "HK_FullscreenToggle", str(MELONDS_GUIDE)) done.append("guide button toggles full screen") - # Once, for a melonDS set up before this: the Hybrid screens, where it is still on its own + # Once, for a melonDS set up before this: the big top screen, where it is still on its own # defaults. The marker keeps whatever the user picks in melonDS's View menu afterwards. if not os.path.exists(marker): for l in MELONDS_WINDOW.splitlines()[1:]: k, v = (x.strip() for x in l.split("=", 1)) - if toml_get(lines, "Instance0.Window0", k) in (None, MELONDS_WINDOW_DEFAULTS.get(k)): + if toml_get(lines, "Instance0.Window0", k) in (None,) + MELONDS_WINDOW_DEFAULTS.get(k, ()): toml_set(lines, "Instance0.Window0", k, v) - done.append("Hybrid screens") + done.append("big top screen") open(marker, "w").close() bios = melonds_bios(lines) if bios: From 5f7f6bd67830ea13d19eced3596f527e08a93e5c Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 07:52:26 -0400 Subject: [PATCH 04/14] melonDS: leave full screen to a 1x window, so its menus are big enough to use The guide button's toggle returns to melonDS's saved window size. A 512x288 window (1x wide, 16:9) is stretched by gamescope to fill the panel, menu bar and cursor with it; at 2x and up the menus stay at their real size, tiny on a handheld. Written once, with the layout. --- .../usr/local/bin/bannerlator-pad-defaults | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index e23a73677..5b254a223 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -759,6 +759,24 @@ ScreenFilter = false MELONDS_WINDOW_DEFAULTS = {"ScreenLayout": ("0", "3"), "ScreenSizing": ("0",), "ScreenFilter": ("true",)} +# The window melonDS returns to when the guide button leaves full screen: 1x wide (the width of +# two DS screens side by side, Screen.cpp screenGetMinSize) and 16:9, so gamescope stretches it to +# fill the panel and the menu bar and cursor come out large enough to use. A bigger window keeps +# its menus at their real size, tiny on a handheld. Qt's saveGeometry() v3 blob: magic, version, +# frame and normal rects (x, y, right, bottom), screen, maximized, full screen, screen width, and +# the normal rect again (QWidget::restoreGeometry). +def melonds_geometry(): + import base64, struct + w, h = 512, 288 + try: + screen_w = int(os.environ.get("BL_WIDTH", "1920")) + except ValueError: + screen_w = 1920 + rect = (0, 0, w - 1, h - 1) + blob = struct.pack(">IHH4i4iiBBi4i", 0x1D9D0CB, 3, 0, *rect, *rect, 0, 0, 0, screen_w, *rect) + return base64.b64encode(blob).decode() + + def toml_get(lines, table, key): """The raw value of key in [table], or None.""" inside = False @@ -864,7 +882,8 @@ def melonds(): path = os.path.join(CONFIG, "melonDS/melonDS.toml") marker = os.path.join(os.path.dirname(path), ".droiddeck-display-2") if not os.path.isfile(path): - seed(path, "[Instance0]\nJoystickID = 0\n\n" + MELONDS_JOY + "\n" + MELONDS_WINDOW, "melonDS", + seed(path, "[Instance0]\nJoystickID = 0\n\n" + MELONDS_JOY + "\n" + MELONDS_WINDOW + + f'Geometry = "{melonds_geometry()}"\n', "melonDS", "Player 1 on the controller, big top screen") open(marker, "w").close() with open(path, encoding="utf-8", errors="replace") as f: @@ -897,7 +916,8 @@ def melonds(): k, v = (x.strip() for x in l.split("=", 1)) if toml_get(lines, "Instance0.Window0", k) in (None,) + MELONDS_WINDOW_DEFAULTS.get(k, ()): toml_set(lines, "Instance0.Window0", k, v) - done.append("big top screen") + toml_set(lines, "Instance0.Window0", "Geometry", f'"{melonds_geometry()}"') + done.append("big top screen, 1x window") open(marker, "w").close() bios = melonds_bios(lines) if bios: From 79a7f2685230ac6dd09695e3f7ca8fb167808fdd Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 08:08:09 -0400 Subject: [PATCH 05/14] Session: hide the mouse cursor while a controller or the on-screen controls are in use The arrow is drawn by the app and, outside a Steam session, never went away once shown - on the desktop and over emulators started from the front end alike. A button press, a stick, trigger or d-pad past halfway, or any on-screen control now hides it (PadBridge tells the activity); the next touchpad or mouse move brings it back, once the pad has been quiet for 1.2 s so a touch during play does not flash it up (Bannerlator's rule for its Wayland pointer). --- .../com/droiddeck/launcher/SessionActivity.kt | 17 +++++++++++- .../droiddeck/launcher/input/PadBridge.java | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 13dd3e5eb..9a29329c5 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -94,6 +94,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var cursorPos by mutableStateOf(androidx.compose.ui.geometry.Offset(-100f, -100f)) private var cursorVisible by mutableStateOf(false) private val cursorHide = Runnable { cursorVisible = false } + /** When the pad or the on-screen controls were last used (uptimeMillis); see [showCursor]. */ + private var lastPadInputMs = 0L private val uiHandler = Handler(Looper.getMainLooper()) private var pendingBackAction: Runnable? = null private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN @@ -197,6 +199,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input")) padBridge = bridge + // A player on the pad or the on-screen controls has no use for the mouse arrow; the next + // touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment. + bridge.setOnPlayerInput { + lastPadInputMs = android.os.SystemClock.uptimeMillis() + uiHandler.removeCallbacks(cursorHide) + cursorVisible = false + } onScreenControls = OnScreenControls(this, bridge).also { root.addView(it) } keyboard = KeyboardHost(this).also { root.addView(it) } touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener) @@ -984,9 +993,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { showCursor(x.coerceIn(left, left + drawnW), y.coerceIn(top, top + drawnH)) } - /** The arrow stays on a desktop; in a Steam session it shows for a moment after each move. */ + /** + * The arrow stays on a desktop; in a Steam session it shows for a moment after each move. While + * a controller is driving (input in the last [CURSOR_PAD_HOLD_MS]) it stays hidden, so a touch + * during play does not flash it up - Bannerlator's rule for its Wayland pointer. + */ private fun showCursor(x: Float, y: Float) { cursorPos = androidx.compose.ui.geometry.Offset(x, y) + if (android.os.SystemClock.uptimeMillis() - lastPadInputMs < CURSOR_PAD_HOLD_MS) return cursorVisible = true uiHandler.removeCallbacks(cursorHide) if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") uiHandler.postDelayed(cursorHide, 2500) @@ -1304,6 +1318,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private const val TAG = "SessionActivity" private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L + private const val CURSOR_PAD_HOLD_MS = 1200L private const val DRAWER_HAT_THRESHOLD = 0.5f private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f private const val DRAWER_STICK_EXIT_THRESHOLD = 0.35f diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index d07ab82a0..518904973 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -40,6 +40,9 @@ public final class PadBridge { private boolean qamChordActive; private boolean qamSyntheticAPressed; private int qamChordGeneration; + /** Told on the main thread when a player uses the pad or the on-screen controls; see [setOnPlayerInput]. */ + private volatile Runnable onPlayerInput; + private final java.util.concurrent.atomic.AtomicBoolean playerInputPosted = new java.util.concurrent.atomic.AtomicBoolean(); public PadBridge(File fakeInputDir) { writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT); @@ -87,6 +90,24 @@ public final class PadBridge { return false; } + /** + * Called (main thread) when a player presses a button, pushes a stick, trigger or d-pad past + * halfway, or uses the on-screen controls - the session hides its mouse cursor then. Resting + * sticks and trigger noise do not count. + */ + public void setOnPlayerInput(Runnable listener) { + onPlayerInput = listener; + } + + private void notePlayerInput() { + Runnable listener = onPlayerInput; + if (listener == null || !playerInputPosted.compareAndSet(false, true)) return; + mainHandler.post(() -> { + playerInputPosted.set(false); + listener.run(); + }); + } + /** @return true when the event was a pad button and has been consumed. */ public synchronized boolean onKeyEvent(KeyEvent event) { if (!isFromController(event.getDevice())) return false; @@ -116,6 +137,7 @@ public final class PadBridge { case KeyEvent.KEYCODE_DPAD_LEFT: state.left = pressed; break; default: return false; } + if (pressed) notePlayerInput(); publish(); return true; } @@ -143,6 +165,10 @@ public final class PadBridge { state.right = hatX > 0.5f; state.down = hatY > 0.5f; state.left = hatX < -0.5f; + if (Math.max(Math.max(Math.abs(state.leftX), Math.abs(state.leftY)), Math.max(Math.abs(state.rightX), Math.abs(state.rightY))) > 0.5f + || lt > 0.5f || rt > 0.5f || state.up || state.right || state.down || state.left) { + notePlayerInput(); + } publish(); return true; } @@ -153,6 +179,7 @@ public final class PadBridge { */ public synchronized void applyTouch(java.util.function.Consumer mutation) { mutation.accept(state); + notePlayerInput(); publish(); } From 347a5a4dcbf38b241263a22e2778602e99c3d246 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 08:18:43 -0400 Subject: [PATCH 06/14] Desktop: Qt falls back to X11, so melonDS opens from the menu The desktop set QT_QPA_PLATFORM=wayland for every program. The melonDS AppImage bundles only Qt's xcb plugin, so from the desktop it exited at once: "Could not find the Qt platform plugin wayland ... Available platform plugins are: xcb" (exit 127). "wayland;xcb" keeps LXQt on Wayland and lets such an AppImage use Xwayland. Proven in a running desktop: labwc mapped "melonDS 1.1". --- tools/linuxfs/desktop/droiddeck-desktop | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 3f2ace30b..55adbdf07 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -6,7 +6,9 @@ # installed desktop without re-downloading the package. set -u export XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=LXQt XDG_SESSION_DESKTOP=lxqt -export QT_QPA_PLATFORM=wayland GDK_BACKEND=wayland,x11 SDL_VIDEODRIVER=wayland,x11 +# Qt tries Wayland and falls back to X11 (Xwayland): an AppImage that bundles only Qt's xcb +# plugin - melonDS - exits at once on a plain "wayland" ("Could not find the Qt platform plugin"). +export QT_QPA_PLATFORM="wayland;xcb" GDK_BACKEND=wayland,x11 SDL_VIDEODRIVER=wayland,x11 export QT_QPA_PLATFORMTHEME=lxqt # Firefox's child processes sandbox themselves with seccomp and namespaces, neither of which # behaves under proot: the content process ends up unable to read the font files its parent can, From c331836bce63be71cc3ff3f4ed528d06065a864d Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 08:28:26 -0400 Subject: [PATCH 07/14] Session: draw the program's own pointer - resize arrows at window edges, I-beams, its hide The compositor threw every wl_pointer.set_cursor image away and the app drew one fixed arrow, so the desktop never showed labwc's resize arrows at a window's edges and corners. Ported from Bannerlator (7f19c669): the cursor surface gets a role, its image is snapshotted on commit with its hotspot, and a NULL surface or buffer is an authoritative hide; the app's overlay draws the image at the session's scale. New here: labwc on Vulkan/GLES hands its cursor over as a dma-buf (wlroots' nested backend, usually UBWC), so it is read back through a small linear host-visible copy (vkp_image_readback). The built-in arrow stays until a program sets a cursor. --- app/src/main/cpp/waylandcomp/src/compositor.c | 104 +++++++++++++++++- app/src/main/cpp/waylandcomp/src/vk_present.c | 98 +++++++++++++++++ app/src/main/cpp/waylandcomp/src/vk_present.h | 3 + .../cpp/waylandcomp/src/waylandcomp_jni.c | 16 +++ .../com/droiddeck/launcher/SessionActivity.kt | 55 ++++++++- .../droiddeck/launcher/ui/CursorOverlay.kt | 24 +++- .../launcher/wayland/WaylandCompositor.java | 11 ++ 7 files changed, 305 insertions(+), 6 deletions(-) diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 2199e275d..aba2baa58 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -226,7 +226,7 @@ static void on_client_created(struct wl_listener *l, void *data) { /* ------------------------------------------------------------------ surfaces */ -enum surface_role { ROLE_NONE, ROLE_TOPLEVEL, ROLE_SUBSURFACE, ROLE_DESKTOP }; +enum surface_role { ROLE_NONE, ROLE_TOPLEVEL, ROLE_SUBSURFACE, ROLE_DESKTOP, ROLE_CURSOR }; struct surface { struct wl_resource *resource; @@ -474,6 +474,69 @@ static struct seat_keyboard g_kbs[MAX_PTRS]; static int g_nkbs; static struct seat_touch g_touches[MAX_PTRS]; static int g_ntouches; + +/* ---- the client's cursor (wl_pointer.set_cursor) ------------------------------------------- + * Not composited: under zero-copy the program's buffer goes straight to an Android layer and there + * is nothing to composite into. The cursor surface is snapshotted here instead and the app draws it + * as its overlay, above every present path (Bannerlator's design). What it buys: the real SHAPE - + * labwc's resize arrows at a window's edges and corners, a text field's I-beam - and an + * authoritative HIDE (set_cursor with a NULL surface, what a mouse-look game does). + * wl_shm cursors (Wine, pixman) are copied directly; a GPU cursor (labwc on Vulkan/GLES, which + * wlroots hands over as a dma-buf, usually UBWC) is read back through vkp_image_readback. */ +#define CURSOR_MAX_PX (256 * 256) +static pthread_mutex_t g_cursor_lock = PTHREAD_MUTEX_INITIALIZER; +static struct surface *g_cursor_surface; /* compositor thread only */ +static int g_cursor_hx, g_cursor_hy; /* hotspot, surface-local */ +static uint32_t g_cursor_px[CURSOR_MAX_PX]; /* guarded by g_cursor_lock: ARGB8888 snapshot */ +static uint32_t g_cursor_rb[CURSOR_MAX_PX]; /* compositor thread: GPU readback staging */ +static int g_cursor_w, g_cursor_h; /* 0 = nothing to draw */ +static int g_cursor_hidden = 1; /* the client asked for no pointer */ +static int g_cursor_serial; /* bumped on every change; the app polls it */ + +static void cursor_publish_hidden(void) { + pthread_mutex_lock(&g_cursor_lock); + if (!g_cursor_hidden) { g_cursor_hidden = 1; g_cursor_serial++; } + pthread_mutex_unlock(&g_cursor_lock); +} + +static void cursor_publish_pixels(const uint8_t *src, int w, int h, size_t stride, int hx, int hy) { + if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return; + pthread_mutex_lock(&g_cursor_lock); + for (int y = 0; y < h; y++) + memcpy(&g_cursor_px[y * w], src + (size_t)y * stride, (size_t)w * 4); + g_cursor_w = w; g_cursor_h = h; + g_cursor_hx = hx; g_cursor_hy = hy; + g_cursor_hidden = 0; + g_cursor_serial++; + pthread_mutex_unlock(&g_cursor_lock); +} + +static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) { + int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm); + if (w <= 0 || h <= 0 || w * h > CURSOR_MAX_PX) return; + wl_shm_buffer_begin_access(shm); + cursor_publish_pixels((const uint8_t *)wl_shm_buffer_get_data(shm), w, h, + (size_t)wl_shm_buffer_get_stride(shm), hx, hy); + wl_shm_buffer_end_access(shm); +} + +/* Read by the app (UI thread). out = [serial, hidden, w, h, hotspotX, hotspotY, pixels...]. + * Returns the number of ints written, or 0 if out is too small. */ +int banner_cursor_snapshot(int *out, int cap) { + int n = 0; + pthread_mutex_lock(&g_cursor_lock); + int need = 6 + (g_cursor_hidden ? 0 : g_cursor_w * g_cursor_h); + if (cap >= need) { + out[0] = g_cursor_serial; out[1] = g_cursor_hidden; + out[2] = g_cursor_w; out[3] = g_cursor_h; + out[4] = g_cursor_hx; out[5] = g_cursor_hy; + if (!g_cursor_hidden) + memcpy(out + 6, g_cursor_px, (size_t)g_cursor_w * g_cursor_h * 4); + n = need; + } + pthread_mutex_unlock(&g_cursor_lock); + return n; +} struct active_touch { struct surface *surface; int pointer_fallback; }; static struct active_touch g_touch_points[MAX_TOUCH_POINTS]; static void touch_cancel_surface(struct surface *surface); @@ -948,8 +1011,26 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) { s->pending_buffer = NULL; s->pending_attach = 0; - if (s->role == ROLE_NONE) { - /* Cursor or role-less surface: never drawn (the app draws its own pointer). */ + if (s->role == ROLE_CURSOR) { + /* The client's pointer image, copied out for the app's overlay (see cursor_publish_*) + * rather than composited, so it survives the zero-copy and HDR layer paths. */ + if (!buffer) { + cursor_publish_hidden(); /* wlroots clears its cursor surface to hide the pointer */ + } else if (shm) { + cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy); + } else if (db && db->n_planes >= 1 && db->width * db->height <= CURSOR_MAX_PX) { + if (!db->img && !db->import_failed) { + db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height, + db->stride[0], db->offset[0]); + if (!db->img) db->import_failed = 1; + } + if (db->img && vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) == 0) + cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height, + (size_t)db->width * 4, g_cursor_hx, g_cursor_hy); + } + if (buffer) wl_buffer_send_release(buffer); + } else if (s->role == ROLE_NONE) { + /* Role-less surface: never drawn. */ if (buffer) wl_buffer_send_release(buffer); } else if (db) { take_dmabuf(s, db, buffer); @@ -1021,6 +1102,7 @@ static void surface_resource_destroy(struct wl_resource *r) { for (int i = 0; i < g_nptrs; i++) if (g_ptrs[i].focus == r) g_ptrs[i].focus = NULL; for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus == r) g_kbs[i].focus = NULL; touch_cancel_surface(s); + if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); } if (g_grab == s) g_grab = NULL; if (g_key_target == s) g_key_target = NULL; if (g_ime_click == s) g_ime_click = NULL; @@ -2397,9 +2479,23 @@ static uint32_t now_ms(void) { return (uint32_t)(ts.tv_sec * 1000 + ts.tv_nsec / 1000000); } +/* wl_pointer.set_cursor: the client tells us its pointer image, or asks for none. A NULL surface + * means "no cursor" and is authoritative. The surface itself is snapshotted on its next commit. */ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint32_t serial, struct wl_resource *surface, int32_t hx, int32_t hy) { - /* The app draws its own pointer; cursor surfaces stay role-less and aren't drawn. */ + if (!surface) { + if (g_cursor_surface) g_cursor_surface->role = ROLE_NONE; + g_cursor_surface = NULL; + cursor_publish_hidden(); + return; + } + struct surface *s = wl_resource_get_user_data(surface); + if (!s) return; + if (s->role != ROLE_NONE && s->role != ROLE_CURSOR) return; /* already has another role */ + s->role = ROLE_CURSOR; + g_cursor_surface = s; + g_cursor_hx = hx; + g_cursor_hy = hy; } static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } static const struct wl_pointer_interface pointer_impl = { diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c index 17846fb95..8c1d897fc 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.c +++ b/app/src/main/cpp/waylandcomp/src/vk_present.c @@ -1910,6 +1910,104 @@ int vkp_blit_image(struct vkp_image *src, struct vkp_image *dst, int wait_fd) { return 0; } +/* A client's GPU cursor read back to the CPU, for the app's pointer overlay (compositor.c, + * wl_pointer.set_cursor). wlroots hands a nested compositor its cursor as a dma-buf from the parent's + * formats - on Adreno usually UBWC, which the CPU cannot read - so it is blitted into a small linear, + * host-visible image first. Rare (a cursor changes shape, it is not redrawn per frame) and waited + * for on the CPU. out: w*h pixels, B8G8R8A8 in memory = 0xAARRGGBB ints. 0 = ok. */ +static struct vkp_image *g_rb; /* the readback image, kept for the next shape */ + +int vkp_image_readback(struct vkp_image *src, uint32_t *out, int max_px) { + if (!src || !out || src->w <= 0 || src->h <= 0 || src->w * src->h > max_px || + g_dev_state == -2 || dev_init() != 0) + return -1; + if (g_rb && (g_rb->w != src->w || g_rb->h != src->h)) { vkp_image_destroy(g_rb); g_rb = NULL; } + if (!g_rb) { + struct vkp_image *img = calloc(1, sizeof(*img)); + if (!img) return -1; + img->w = src->w; img->h = src->h; + VkImageCreateInfo ici = { + .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .imageType = VK_IMAGE_TYPE_2D, + .format = VK_FORMAT_B8G8R8A8_UNORM, .extent = {src->w, src->h, 1}, .mipLevels = 1, + .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_LINEAR, + .usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT, .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED}; + if (g_vk.CreateImage(g_dev, &ici, NULL, &img->image) != VK_SUCCESS) { free(img); return -1; } + VkMemoryRequirements req; + g_vk.GetImageMemoryRequirements(g_dev, img->image, &req); + int idx = memory_type(req.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + VkMemoryAllocateInfo mai = {.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = req.size, .memoryTypeIndex = (uint32_t)idx}; + if (idx < 0 || g_vk.AllocateMemory(g_dev, &mai, NULL, &img->mem) != VK_SUCCESS) { + g_vk.DestroyImage(g_dev, img->image, NULL); free(img); return -1; + } + g_vk.BindImageMemory(g_dev, img->image, img->mem, 0); + VkImageSubresource subr = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0}; + VkSubresourceLayout lay; + g_vk.GetImageSubresourceLayout(g_dev, img->image, &subr, &lay); + img->offset = lay.offset; + img->row_pitch = lay.rowPitch; + if (g_vk.MapMemory(g_dev, img->mem, 0, req.size, 0, &img->map) != VK_SUCCESS) { + vkp_image_destroy(img); return -1; + } + g_rb = img; + } + VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + VkCommandBuffer cmd = g_cmds[0]; + g_vk.ResetCommandBuffer(cmd, 0); + VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, + .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT}; + g_vk.BeginCommandBuffer(cmd, &bi); + VkImageMemoryBarrier acq[2] = { + {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + .srcQueueFamilyIndex = src->dmabuf ? VK_QUEUE_FAMILY_FOREIGN_EXT : VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = src->dmabuf ? g_qfam : VK_QUEUE_FAMILY_IGNORED, + .image = src->image, .subresourceRange = range, .dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT}, + {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_UNDEFINED, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .image = g_rb->image, .subresourceRange = range, + .dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT}}; + g_vk.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 0, NULL, 0, NULL, 2, acq); + VkImageBlit blit = {.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, + .srcOffsets = {{0, 0, 0}, {src->w, src->h, 1}}, + .dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, + .dstOffsets = {{0, 0, 0}, {src->w, src->h, 1}}}; + g_vk.CmdBlitImage(cmd, src->image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + g_rb->image, VK_IMAGE_LAYOUT_GENERAL, 1, &blit, VK_FILTER_NEAREST); + VkImageMemoryBarrier done[2] = { + {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_GENERAL, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .image = g_rb->image, .subresourceRange = range, + .srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT, .dstAccessMask = VK_ACCESS_HOST_READ_BIT}, + /* the client's buffer goes back to it */ + {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + .newLayout = VK_IMAGE_LAYOUT_GENERAL, + .srcQueueFamilyIndex = src->dmabuf ? g_qfam : VK_QUEUE_FAMILY_IGNORED, + .dstQueueFamilyIndex = src->dmabuf ? VK_QUEUE_FAMILY_FOREIGN_EXT : VK_QUEUE_FAMILY_IGNORED, + .image = src->image, .subresourceRange = range, .srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT}}; + g_vk.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_HOST_BIT | VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, + 0, 0, NULL, 0, NULL, 2, done); + g_vk.EndCommandBuffer(cmd); + VkSubmitInfo si = {.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO, .commandBufferCount = 1, .pCommandBuffers = &cmd}; + g_vk.ResetFences(g_dev, 1, &g_fence); + VkResult qr = g_vk.QueueSubmit(g_queue, 1, &si, g_fence); + if (qr != VK_SUCCESS) { + if (qr == VK_ERROR_DEVICE_LOST) device_lost("cursor readback"); + return -1; + } + VkResult fr = timed_wait(g_fence, 1000000000ULL); + if (fr == VK_ERROR_DEVICE_LOST) { device_lost("cursor readback"); return -1; } + if (fr != VK_SUCCESS) return -1; + for (int y = 0; y < src->h; y++) + memcpy(out + (size_t)y * src->w, (const uint8_t *)g_rb->map + g_rb->offset + (size_t)y * g_rb->row_pitch, + (size_t)src->w * 4); + return 0; +} + /* ---------------------------------------------------------------- the compositor pass, off-screen * * Layer mode with screen effects on: the game's frame still has to go through the compositor pass diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h index 4d77eda7c..899736520 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.h +++ b/app/src/main/cpp/waylandcomp/src/vk_present.h @@ -146,6 +146,9 @@ int vkp_pass_begin_hdr(int scene_w, int scene_h, const struct vkp_draw *draws, i * wait_fd: a sync_file the copy must wait for before writing dst (the display's release fence of that * buffer), -1 = none. Always consumed (handed to the GPU, or waited for and closed). */ int vkp_blit_image(struct vkp_image *src, struct vkp_image *dst, int wait_fd); +/* A client's GPU cursor (an imported dma-buf, often UBWC) read back as w*h 0xAARRGGBB pixels through a + * linear host-visible copy; waited for on the CPU. Compositor thread, between frames. 0 = ok. */ +int vkp_image_readback(struct vkp_image *src, uint32_t *out, int max_px); /* Refresh the scene -> output mapping for this scene size without presenting (creates the * swapchain if needed, since the mapping is in output pixels). 0 = mapping valid. */ int vkp_update_map(int scene_w, int scene_h); diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index 2125a5c08..10283f570 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -24,6 +24,7 @@ extern int banner_wayland_run(void); extern void banner_wayland_send_pointer(int action, int x, int y); extern void banner_wayland_send_touch(int action, int pointer_id, int x, int y); +extern int banner_cursor_snapshot(int *out, int cap); extern void banner_wayland_send_key(int evdev, int state); extern void banner_wayland_send_scene_input(int type, int a, int b); extern void banner_wayland_vsync(int64_t frame_time_ns); @@ -278,6 +279,21 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendTouch( banner_wayland_send_touch(action, pointer_id, x, y); } +/* The client's pointer image and whether it wants one at all (wl_pointer.set_cursor). + * out = [serial, hidden, w, h, hotspotX, hotspotY, ARGB pixels...]; returns ints written, 0 if the + * array is too small. The app polls the serial and only rebuilds its overlay when it changes. */ +JNIEXPORT jint JNICALL +Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot( + JNIEnv *env, jclass clazz, jintArray out) { + if (!out) return 0; + jint cap = (*env)->GetArrayLength(env, out); + jint *buf = (*env)->GetIntArrayElements(env, out, NULL); + if (!buf) return 0; + int n = banner_cursor_snapshot((int *)buf, (int)cap); + (*env)->ReleaseIntArrayElements(env, out, buf, 0); + return n; +} + /* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey( diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 9a29329c5..a1a2be2a9 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -57,6 +57,7 @@ import com.droiddeck.launcher.wayland.HdrSupport import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState import com.droiddeck.launcher.ui.CursorOverlay +import androidx.compose.ui.graphics.asImageBitmap import com.droiddeck.launcher.ui.DrawerActions import com.droiddeck.launcher.ui.HudText import com.droiddeck.launcher.ui.LoadingOverlay @@ -96,6 +97,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val cursorHide = Runnable { cursorVisible = false } /** When the pad or the on-screen controls were last used (uptimeMillis); see [showCursor]. */ private var lastPadInputMs = 0L + // The program's own pointer, from wl_pointer.set_cursor (see syncClientCursor): its image in + // session pixels, hotspot, and whether it asked for no pointer at all. + private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS) + private var cursorSerial = 0 + private var clientHidesCursor = false + private var cursorImage by mutableStateOf(null) + private var cursorHotX by mutableStateOf(0) + private var cursorHotY by mutableStateOf(0) + private var cursorImageScale by mutableStateOf(1f) + /** labwc changes the shape a moment after the move that reached an edge: look again then. */ + private val cursorResync = Runnable { if (syncClientCursor() && clientHidesCursor) cursorVisible = false } private val uiHandler = Handler(Looper.getMainLooper()) private var pendingBackAction: Runnable? = null private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN @@ -240,7 +252,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { isFocusableInTouchMode = true setContent { DroidDeckTheme { - CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density) + CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, + cursorImage, cursorHotX, cursorHotY, cursorImageScale) if (hud.text.isNotEmpty()) HudText(hud.text) if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended) // Opening the drawer takes the controller away from the game: release its pad. @@ -1000,12 +1013,52 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { */ private fun showCursor(x: Float, y: Float) { cursorPos = androidx.compose.ui.geometry.Offset(x, y) + syncClientCursor() + uiHandler.removeCallbacks(cursorResync) + uiHandler.postDelayed(cursorResync, 60) + // A program that asked for no pointer (a mouse-look game) wins over everything below. + if (clientHidesCursor) { cursorVisible = false; return } if (android.os.SystemClock.uptimeMillis() - lastPadInputMs < CURSOR_PAD_HOLD_MS) return cursorVisible = true uiHandler.removeCallbacks(cursorHide) if (SessionState.mode == SessionService.MODE_STEAM && SessionState.steamUi != "desktop") uiHandler.postDelayed(cursorHide, 2500) } + /** + * Pulls the program's pointer (wl_pointer.set_cursor) if it changed: labwc's resize arrows at a + * window's edges and corners, a text field's I-beam, or a request for no pointer. Serial 0 = no + * program has set one yet, and the built-in arrow stays. Returns true when something changed. + */ + private fun syncClientCursor(): Boolean { + if (!CompositorHost.isStarted) return false + val n = WaylandCompositor.nativeCursorSnapshot(cursorBuf) + if (n < 6) return false + val serial = cursorBuf[0] + if (serial == 0 || serial == cursorSerial) return false + cursorSerial = serial + clientHidesCursor = cursorBuf[1] != 0 + if (clientHidesCursor) return true + val w = cursorBuf[2] + val h = cursorBuf[3] + if (w <= 0 || h <= 0 || n < 6 + w * h) return false + val bitmap = try { + android.graphics.Bitmap.createBitmap(cursorBuf, 6, w, w, h, android.graphics.Bitmap.Config.ARGB_8888) + } catch (e: Exception) { + Log.w(TAG, "cursor bitmap failed", e) + return false + } + cursorImage = bitmap.asImageBitmap() + cursorHotX = cursorBuf[4] + cursorHotY = cursorBuf[5] + // Session pixels -> screen pixels, as the session's picture is scaled (movePointer). + val width = surfaceView.width.takeIf { it > 0 } + val height = surfaceView.height.takeIf { it > 0 } + val out = SessionState.outputSize + if (width != null && height != null) + cursorImageScale = minOf(width / out.first.toFloat(), height / out.second.toFloat()) + return true + } + private fun refreshSecondScreenDisplays() { if (!::displayManager.isInitialized) return val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId diff --git a/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt index 3854526b3..6b527c97c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/CursorOverlay.kt @@ -6,13 +6,35 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke +/** + * The session's pointer. [image]: the program's own cursor (wl_pointer.set_cursor - labwc's resize + * arrows, a text field's I-beam), in session pixels, drawn [imageScale] times larger with its hotspot + * ([hotX], [hotY]) on [position]; null = the program has not set one, and the built-in arrow is drawn. + */ @Composable -fun CursorOverlay(position: Offset, visible: Boolean, scale: Float) { +fun CursorOverlay( + position: Offset, visible: Boolean, scale: Float, + image: ImageBitmap? = null, hotX: Int = 0, hotY: Int = 0, imageScale: Float = 1f, +) { if (!visible) return Canvas(modifier = Modifier.fillMaxSize()) { + if (image != null) { + val w = (image.width * imageScale).toInt().coerceAtLeast(1) + val h = (image.height * imageScale).toInt().coerceAtLeast(1) + drawImage( + image, + dstOffset = androidx.compose.ui.unit.IntOffset( + (position.x - hotX * imageScale).toInt(), (position.y - hotY * imageScale).toInt()), + dstSize = androidx.compose.ui.unit.IntSize(w, h), + filterQuality = FilterQuality.Low, + ) + return@Canvas + } val s = 11f * scale val path = Path().apply { moveTo(position.x, position.y) diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index b9b2eb5d1..ffd1a28e5 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -171,6 +171,17 @@ public final class WaylandCompositor { public static native void nativeSendTouch(int action, int pointerId, int x, int y); + /** + * The client's pointer image, straight from wl_pointer.set_cursor (labwc's resize arrows, a text + * field's I-beam). out = [serial, hidden, w, h, hotspotX, hotspotY, ARGB8888 pixels...]; returns + * the number of ints written, or 0 if {@code out} is too small. Serial 0 = the client has not + * set one yet; [1] = 1 means it asked for NO pointer. + */ + public static native int nativeCursorSnapshot(int[] out); + + /** Enough for the header plus a 256x256 cursor, the largest the compositor snapshots. */ + public static final int CURSOR_BUF_INTS = 6 + 256 * 256; + /** Inject a key event. evdev = Linux input keycode (KEY_A=30…); state: 1=down, 0=up. */ public static native void nativeSendKey(int evdev, int state); From 132b434d78ffe86978b75e62934c8a6929960a58 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 08:28:56 -0400 Subject: [PATCH 08/14] Desktop: melonDS opens as an 800x600 window, centred melonDS remembers a 1x window for the front end's full-screen toggle, too small on a desktop. A labwc window rule (only the desktop reads rc.xml) resizes it to 800x600 on open and places it again by the desktop's centre policy. --- tools/linuxfs/desktop/rc.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tools/linuxfs/desktop/rc.xml b/tools/linuxfs/desktop/rc.xml index 632c0a109..7b064af47 100644 --- a/tools/linuxfs/desktop/rc.xml +++ b/tools/linuxfs/desktop/rc.xml @@ -19,4 +19,13 @@ 500 + + + + + + + From 09e5172052f6d249a09eb6fd1df4f1582f55a2c3 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 08:36:04 -0400 Subject: [PATCH 09/14] Desktop: centre melonDS's 800x600 window AutoPlace on its own left the resized window in the top-left corner; with policy="center" it opens in the middle of the desktop. Tried in a running desktop (labwc --reconfigure). --- tools/linuxfs/desktop/rc.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/linuxfs/desktop/rc.xml b/tools/linuxfs/desktop/rc.xml index 7b064af47..e32c083d6 100644 --- a/tools/linuxfs/desktop/rc.xml +++ b/tools/linuxfs/desktop/rc.xml @@ -21,11 +21,11 @@ 500 + opens at 800x600, centred (AutoPlace alone kept the top-left corner). Only the desktop reads this file, so the front end is unaffected. --> - + From 7c6df51bfa12c744753dc4a0705c69d045b91186 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 08:59:06 -0400 Subject: [PATCH 10/14] Steam desktop UI on the desktop; Big Picture's Switch to Desktop works - The front end's "Steam Desktop UI" started a Steam session without -gamepadui, and the client put itself into Big Picture under gamescope anyway. It now opens the desktop with Steam's desktop client started in it (BL_DESKTOP_STEAM, the desktop's autostart, the same bannerlator-steam-launch the menu's Steam entry uses). - Big Picture's power menu "Switch to Desktop" runs steamos-session-select, which did not exist, so it sat on "Switching to Desktop..." for ever. The new stub leaves the app a request (session=desktop in the steam-launch file); the app ends the Steam session and starts the desktop with Steam's desktop client open. --- .../com/droiddeck/launcher/MainActivity.kt | 3 +++ .../droiddeck/launcher/session/SessionFiles.kt | 2 ++ .../launcher/session/SessionService.kt | 15 ++++++++++++--- tools/linuxfs/desktop/autostart | 4 ++++ .../overlay/usr/bin/steamos-session-select | 18 ++++++++++++++++++ 5 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 tools/linuxfs/overlay/usr/bin/steamos-session-select diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 825cecdc3..c7b253dd7 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -272,8 +272,11 @@ class MainActivity : ComponentActivity() { ), FrontEndActions( onPlay = { startSession(Intent(this, SessionActivity::class.java)) }, + // Steam's desktop client as a window on the desktop: under gamescope the + // client puts itself into Big Picture whatever it is started with. onPlayDesktopUi = { startSession(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP) .putExtra(SessionService.EXTRA_STEAM_UI, "desktop")) }, onSteamGame = { g -> diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 37d937a48..5cd53d1b6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -43,6 +43,8 @@ object SessionFiles { // the three under /usr/bin, all no-ops that answer "nothing to do" (see each file). "usr/bin/steamos-update" to "usr/bin/steamos-update", "usr/bin/steamos-select-branch" to "usr/bin/steamos-select-branch", + // Big Picture's "Switch to Desktop": asks the app for the desktop with Steam in it. + "usr/bin/steamos-session-select" to "usr/bin/steamos-session-select", "usr/bin/jupiter-biosupdate" to "usr/bin/jupiter-biosupdate", "usr/bin/steamos-polkit-helpers/steamos-priv-write" to "usr/bin/steamos-polkit-helpers/steamos-priv-write", "usr/bin/steamos-polkit-helpers/steamos-set-timezone" to "usr/bin/steamos-polkit-helpers/steamos-set-timezone", 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 11b87b05b..b7a759838 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -469,6 +469,9 @@ class SessionService : Service() { // The second library's name, for bannerlator-steam-library; the bind itself is made below. GameStorage.effective(this)?.let { guest.add("BL_LIBRARY_LABEL=" + it.label.replace('"', ' ')) } if (SessionState.mode == MODE_STEAM && SessionState.steamUi == "desktop") guest.add("BL_STEAM_UI=desktop") + // The desktop asked for with Steam in it (the front end's "Steam Desktop UI", Big Picture's + // "Switch to Desktop"): the desktop's autostart opens the client's desktop UI there. + if (SessionState.mode == MODE_DESKTOP && SessionState.steamUi == "desktop") guest.add("BL_DESKTOP_STEAM=1") val shellGuest = ArrayList(guest).apply { add("SHELL=/bin/bash") add("TERM=xterm-256color") @@ -734,6 +737,8 @@ class SessionService : Service() { * The desktop's Steam launchers cannot start the client where they are (no dma-buf on the * desktop), so they leave `steam-launch` in the session directory instead: which UI, and a * steam:// URL or nothing. This ends the session and hands the activity the one to start. + * `session=desktop` (Big Picture's "Switch to Desktop", steamos-session-select) asks for the + * desktop instead, with Steam's desktop client started in it. */ private var launchWatcher: android.os.FileObserver? = null @@ -748,15 +753,18 @@ class SessionService : Service() { file.delete() var ui = "desktop" var url = "" + var session = MODE_STEAM text.lineSequence().forEach { line -> when { line.startsWith("ui=") -> ui = line.removePrefix("ui=").trim() line.startsWith("url=") -> url = line.removePrefix("url=").trim() + line.startsWith("session=") && line.removePrefix("session=").trim() == "desktop" -> session = MODE_DESKTOP } } - Log.i(TAG, "launch request from the session: Steam $ui" + (if (url.isNotEmpty()) " $url" else "")) + Log.i(TAG, "launch request from the session: " + (if (session == MODE_DESKTOP) "the desktop with " else "") + + "Steam $ui" + (if (url.isNotEmpty()) " $url" else "")) val next = Intent(this@SessionService, com.droiddeck.launcher.SessionActivity::class.java) - .putExtra(EXTRA_MODE, MODE_STEAM) + .putExtra(EXTRA_MODE, session) .putExtra(EXTRA_STEAM_UI, ui) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) if (url.isNotEmpty()) next.putExtra(EXTRA_STEAM_URL, url) @@ -1048,7 +1056,8 @@ class SessionService : Service() { const val EXTRA_PROGRAM = "program" /** MODE_RUN: arguments after the program (a game to boot). */ const val EXTRA_PROGRAM_ARGS = "programArgs" - /** MODE_STEAM: "desktop" for the client's desktop UI (default Big Picture); a steam:// URL to hand it. */ + /** MODE_STEAM: "desktop" for the client's desktop UI (default Big Picture); a steam:// URL to hand it. + * MODE_DESKTOP: "desktop" = open Steam's desktop client in the desktop once it is up. */ const val EXTRA_STEAM_UI = "steamUi" const val EXTRA_STEAM_URL = "steamUrl" diff --git a/tools/linuxfs/desktop/autostart b/tools/linuxfs/desktop/autostart index 3aa88e906..b28f23535 100644 --- a/tools/linuxfs/desktop/autostart +++ b/tools/linuxfs/desktop/autostart @@ -36,6 +36,10 @@ swaybg -m fill -i /usr/share/backgrounds/steamdeck.png & run pcmanfm-qt pcmanfm-qt --desktop --profile lxqt run lxqt-panel lxqt-panel /usr/lib/xdg-desktop-portal-wlr >/dev/null 2>&1 & +# The desktop was asked for with Steam in it (the front end's "Steam Desktop UI", Big Picture's +# "Switch to Desktop"): the client's desktop UI, as the menu's Steam entry opens it. A running +# client just gets the request; on a software desktop it goes back to the app as a Steam session. +[ "${BL_DESKTOP_STEAM:-0}" = 1 ] && run steam /usr/local/bin/bannerlator-steam-launch desktop # Remote runs, for driving the desktop from a debugger: with ~/.droiddeck-remote-run present (only # the app or root can create it), each script dropped into ~/.cache/droiddeck/run/ is run once, # inside the session with its environment, its output tagged into the desktop log. diff --git a/tools/linuxfs/overlay/usr/bin/steamos-session-select b/tools/linuxfs/overlay/usr/bin/steamos-session-select new file mode 100644 index 000000000..30141e79f --- /dev/null +++ b/tools/linuxfs/overlay/usr/bin/steamos-session-select @@ -0,0 +1,18 @@ +#!/bin/sh +# The SteamOS session switch. Big Picture's power menu runs it for "Switch to Desktop" (a Plasma +# session: "plasma", "plasma-wayland-persistent", ...) and waits for Game Mode to end; with nothing +# here it sat on "Switching to Desktop..." for ever. Here the desktop is DroidDeck's own: the app is +# asked for a desktop session with Steam's desktop client open in it (the request file +# bannerlator-steam-launch uses; the app ends this session and starts that one). Anything else - +# "gamescope", the session Game Mode already is - has nothing to do. +echo "steamos-session-select: $*" >&2 +case "${1:-}" in + ""|gamescope) exit 0 ;; +esac +dir=${BL_LAUNCH_DIR:-} +if [ -z "$dir" ] || [ ! -d "$dir" ]; then + echo "steamos-session-select: not inside a DroidDeck session (BL_LAUNCH_DIR unset)" >&2 + exit 1 +fi +printf 'session=desktop\nui=desktop\nurl=\n' > "$dir/steam-launch.tmp" && mv -f "$dir/steam-launch.tmp" "$dir/steam-launch" +exit 0 From c8620017a5ac2dc8a18037ba7791fe35631bef60 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 09:41:21 -0400 Subject: [PATCH 11/14] Steam session: a D-Bus session bus, so Switch to Desktop reaches steamos-session-select Big Picture's Switch to Desktop asks SteamOS Manager over the session bus before falling back to steamos-session-select (steamui.so). The Steam session had no session bus, the ask never finished, and the client sat on "Switching to Desktop..."; the new stub was never called. A bus with nothing on it answers at once, and the client takes its legacy path. --- .../overlay/usr/local/bin/bannerlator-session | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index c54bcecd3..5c9fdd78f 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -487,6 +487,23 @@ BUSCONF # An update that cannot install asks for a restart (rc=42) every time; the count is capped so a # session can never again loop in silence, which reads as a menu opening and closing by itself. steam_updates=0 + # A session bus for the client under gamescope (the desktop's labwc already runs in one). Big + # Picture's "Switch to Desktop" asks SteamOS Manager on it first and only then falls back to + # steamos-session-select (steamui.so: "No SteamOSManager support for SwitchToDesktop, using + # legacy path"); with no session bus at all the ask never finished and the screen sat on + # "Switching to Desktop..." for ever. On a bus with nothing on it the ask fails at once. + if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ] && [ -z "${BL_DESKTOP:-}" ] && command -v dbus-daemon >/dev/null 2>&1; then + _bl_bus=/tmp/bl-session-bus-$$ + rm -f "$_bl_bus" + dbus-daemon --session --address="unix:path=$_bl_bus" --nofork --nopidfile >/dev/null 2>&1 & + for _i in 1 2 3 4 5 6 7 8 9 10; do [ -S "$_bl_bus" ] && break; sleep 0.2; done + if [ -S "$_bl_bus" ]; then + export DBUS_SESSION_BUS_ADDRESS="unix:path=$_bl_bus" + echo "== steam: session bus at $_bl_bus" + else + echo "== steam: the session bus did not come up; Switch to Desktop will not work" + fi + fi while :; do rm -f "$HOME/.bl-restart-once" if [ -f "$steam_beta" ] && [ "$(cat "$steam_beta" 2>/dev/null)" != "$steam_channel" ]; then From 0bec2a54359a6d46c4919115cf08f99b3babd98a Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 09:55:52 -0400 Subject: [PATCH 12/14] Revert the Steam session's D-Bus session bus Tried on device: with a session bus the client still sat on "Switching to Desktop..." and never reached its legacy path (no "No SteamOSManager support" line, steamos-session-select not called), so the bus was not what it waited on. Sign-in was unaffected. This reverts commit 40fc5463f3fb2a9a1f8f27823590943375aa6e21. --- .../overlay/usr/local/bin/bannerlator-session | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 5c9fdd78f..c54bcecd3 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -487,23 +487,6 @@ BUSCONF # An update that cannot install asks for a restart (rc=42) every time; the count is capped so a # session can never again loop in silence, which reads as a menu opening and closing by itself. steam_updates=0 - # A session bus for the client under gamescope (the desktop's labwc already runs in one). Big - # Picture's "Switch to Desktop" asks SteamOS Manager on it first and only then falls back to - # steamos-session-select (steamui.so: "No SteamOSManager support for SwitchToDesktop, using - # legacy path"); with no session bus at all the ask never finished and the screen sat on - # "Switching to Desktop..." for ever. On a bus with nothing on it the ask fails at once. - if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ] && [ -z "${BL_DESKTOP:-}" ] && command -v dbus-daemon >/dev/null 2>&1; then - _bl_bus=/tmp/bl-session-bus-$$ - rm -f "$_bl_bus" - dbus-daemon --session --address="unix:path=$_bl_bus" --nofork --nopidfile >/dev/null 2>&1 & - for _i in 1 2 3 4 5 6 7 8 9 10; do [ -S "$_bl_bus" ] && break; sleep 0.2; done - if [ -S "$_bl_bus" ]; then - export DBUS_SESSION_BUS_ADDRESS="unix:path=$_bl_bus" - echo "== steam: session bus at $_bl_bus" - else - echo "== steam: the session bus did not come up; Switch to Desktop will not work" - fi - fi while :; do rm -f "$HOME/.bl-restart-once" if [ -f "$steam_beta" ] && [ "$(cat "$steam_beta" 2>/dev/null)" != "$steam_channel" ]; then From dad9d65701056e24fdc7a3234b31151954cb83a4 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 09:56:33 -0400 Subject: [PATCH 13/14] Session drawer: DESKTOP in Steam sessions opens the desktop with Steam in it Big Picture's own Switch to Desktop waits on SteamOS Manager and never finishes here. The drawer's Steam row gets a DESKTOP button beside STEAM and QAM: the session ends and the desktop starts in its place with Steam's desktop client open (the relaunch the Steam Desktop UI button uses). --- .../com/droiddeck/launcher/SessionActivity.kt | 10 +++++++++ .../droiddeck/launcher/ui/SessionOverlay.kt | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index a1a2be2a9..58132bc1a 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -301,6 +301,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen }, onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null, onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null, + // The desktop with Steam's desktop client in it, in this session's place: the + // session ends with status 0 and onSessionEnded starts the relaunch (Stop's + // finish() would skip it). + onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({ + drawerOpen = false + SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP) + .putExtra(SessionService.EXTRA_STEAM_UI, "desktop") + SessionService.stop(this@SessionActivity) + }) else null, backActionsInverted = backActionsInverted, onBackActionsInverted = { inverted -> SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index fc949bda6..afbc86ef6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -213,6 +213,8 @@ class DrawerActions( val onHardwareKeyboard: () -> Unit, val onSteamMenu: (() -> Unit)?, val onQam: (() -> Unit)?, + /** Steam sessions: end Big Picture and open the desktop with Steam's desktop client in it. */ + val onSwitchToDesktop: (() -> Unit)? = null, val onOsc: (String) -> Unit, val onSuspendPolicy: (String) -> Unit, val onBackActionsInverted: (Boolean) -> Unit, @@ -346,6 +348,26 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha ) { Text("QAM", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp) } + // Big Picture's own "Switch to Desktop" waits on SteamOS Manager for ever + // here; this does the switch without the client. + if (a.onSwitchToDesktop != null) { + val deskSrc = remember { MutableInteractionSource() } + val deskHot = deskSrc.collectIsFocusedAsState().value || deskSrc.collectIsHoveredAsState().value + OutlinedButton( + onClick = { host.open = null; a.onSwitchToDesktop.invoke() }, + interactionSource = deskSrc, + modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "desktop")) + .semantics { contentDescription = "Switch to Desktop" }.controllerConfirm { + host.open = null + a.onSwitchToDesktop.invoke() + }, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(if (deskHot) 2.dp else 1.dp, if (deskHot) pal.signal else colors.outline), + colors = ButtonDefaults.outlinedButtonColors(containerColor = if (deskHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent), + ) { + Text("DESKTOP", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp) + } + } } } From fa1ce6fb9cfba56f26cd523600dfa14d0f14591b Mon Sep 17 00:00:00 2001 From: The412Banner Date: Fri, 25 Sep 2026 10:09:55 -0400 Subject: [PATCH 14/14] Session: start a relaunch request in place, so DESKTOP reaches the desktop The drawer's DESKTOP ended Big Picture and landed on the front end. The session activity is singleTop: startActivity(next) from it went to its own onNewIntent and was dropped when it finished (on device the next session came up as "steam", not the desktop). It now restarts on the request's intent (setIntent + recreate), as an agent start does; the desktop's Steam launchers' handoff to a Steam session goes the same way. --- .../java/com/droiddeck/launcher/SessionActivity.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 58132bc1a..33b97e735 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -740,11 +740,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (status == 0) { runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread - // A session the guest asked for (the desktop's Steam launchers) takes this one's - // place: the compositor stays, a new activity attaches and starts the service. + // A session asked for in this one's place (the desktop's Steam launchers, the + // drawer's DESKTOP) starts here: this activity restarts on its intent, as an agent + // start does. startActivity(next) could not do it - the activity is singleTop, so + // the request landed in this instance's onNewIntent and went with its finish(). SessionState.relaunch?.let { next -> SessionState.relaunch = null - startActivity(next) + setIntent(next) + recreate() + return@runOnUiThread } finish() }