From 82bb924d0bd18d1d1902310a3b2bf17966d037bb Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 06:26:12 -0400 Subject: [PATCH 1/3] Steam cog: stretch games to fill the screen, as a switch 0.1.6 forced every game window to the screen's size (--force-windows-fullscreen), which keeps FlatOut filling the screen but leaves Quake 3 drawing small in the bottom-left: it sets its own resolution and never looks at its window again, and OpenGL draws from that corner. The switch sits in the Steam page's Games group, on by default as before, and reaches the session as BL_GAMESCOPE_FORCE_FULLSCREEN, which the session script already reads. Listed in the device report. --- .../main/java/com/droiddeck/launcher/MainActivity.kt | 4 ++++ .../java/com/droiddeck/launcher/core/DeviceReport.kt | 1 + .../com/droiddeck/launcher/session/SessionPrefs.kt | 12 ++++++++++++ .../com/droiddeck/launcher/session/SessionService.kt | 1 + .../com/droiddeck/launcher/ui/ModeSettingsDialog.kt | 8 ++++++++ 5 files changed, 26 insertions(+) diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index e587aac8c..a55657c03 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -105,6 +105,7 @@ class MainActivity : ComponentActivity() { private var phantomWarning by mutableStateOf(null) private var directAudio by mutableStateOf(false) private var clientDirectAudio by mutableStateOf(false) + private var forceFullscreen by mutableStateOf(true) private var mic by mutableStateOf(false) private var linuxRows by mutableStateOf>(emptyList()) /** The latest Banners-Turnip release as each driver menu offers it (see [refreshReleaseRows]). */ @@ -518,6 +519,7 @@ class MainActivity : ComponentActivity() { oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null, directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null, clientDirectAudio = clientDirectAudio, + forceFullscreen = if (mode == SessionService.MODE_STEAM) forceFullscreen else null, mic = if (mode == SessionService.MODE_STEAM) mic else null, renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null, gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null, @@ -549,6 +551,7 @@ class MainActivity : ComponentActivity() { onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on }, onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on }, + onForceFullscreen = { on -> SessionPrefs.setForceFullscreen(this, on); forceFullscreen = on }, onMic = { on -> SessionPrefs.setMicEnabled(this, on) mic = on @@ -644,6 +647,7 @@ class MainActivity : ComponentActivity() { oscMode = SessionPrefs.oscMode(this) directAudio = SessionPrefs.directAudio(this) clientDirectAudio = SessionPrefs.clientDirectAudio(this) + forceFullscreen = SessionPrefs.forceFullscreen(this) mic = SessionPrefs.micEnabled(this) renderer = SessionPrefs.desktopRenderer(this) gameStorage = SessionPrefs.gameStorage(this) diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 060efb5b9..498eb1fd2 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -149,6 +149,7 @@ object DeviceReport { k("Skip xalia", SessionPrefs.noXalia(context)) k("proot without seccomp", SessionPrefs.prootNoSeccomp(context)) k("DirectAudio for games", SessionPrefs.directAudio(context)) + k("Stretch games to fill", SessionPrefs.forceFullscreen(context)) k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic") k("Microphone", SessionPrefs.micEnabled(context)) k("On-screen controls", SessionPrefs.oscMode(context)) 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 a2c1dbe77..bf2ac5d04 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -87,6 +87,18 @@ object SessionPrefs { prefs(context).edit().putBoolean("clientDirectAudio", on).apply() } + /** + * Steam only: gamescope makes every game window the size of the screen. A game that resizes + * its own window when it loses focus (FlatOut) otherwise comes back smaller, drawn in a + * corner; a game that sets its own resolution and never looks at its window again (Quake 3) + * instead draws small in the bottom-left of the stretched one. On unless turned off. + */ + fun forceFullscreen(context: Context): Boolean = prefs(context).getBoolean("forceFullscreen", true) + + fun setForceFullscreen(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("forceFullscreen", on).apply() + } + /** * DirectAudio for games: their Wine audio driver talks to the relay helper on this side. On * unless the user turned it off. 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 830cbe71b..c49c5586d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -304,6 +304,7 @@ class SessionService : Service() { 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)) + if (SessionState.mode == MODE_STEAM) guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0")) // Proton's own gate for its xalia helper (its `proton` script reads this, and sets // XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device // whose seccomp answers its syscalls normally there is no reason to take it away. diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 7460914bc..e7d26a4e9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -53,6 +53,8 @@ class ModeSettings( val gameStorage: String? = null, val storageOptions: List> = emptyList(), val fexPreset: String? = null, + /** Steam only: games are stretched to fill the screen (null = not a Steam page). */ + val forceFullscreen: Boolean? = null, /** Steam only: the client branch forced on the command line. */ val steamChannel: String? = null, /** Steam only: the user's own games folder and what was found in it. */ @@ -98,6 +100,7 @@ class ModeSettingsActions( val onGameStorage: (path: String, label: String) -> Unit = { _, _ -> }, val onPickGameStorageFolder: () -> Unit = {}, val onFexPreset: (String) -> Unit = {}, + val onForceFullscreen: (Boolean) -> Unit = {}, val onSteamChannel: (String) -> Unit = {}, val onPickAddedGamesDir: () -> Unit = {}, val onForgetAddedGamesDir: (path: String) -> Unit = {}, @@ -280,6 +283,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { FexPreset.all.map { it.id to it.label }, s.fexPreset, note = FexPreset.byId(s.fexPreset).detail, onPick = a.onFexPreset, ) + if (s.forceFullscreen != null) ToggleRow( + host, "fill", "Stretch games to fill the screen", + "Keeps games that resize their own window (FlatOut) full screen. Turn it off if a game shows up small in a corner (Quake 3). Applies next session.", + s.forceFullscreen, onChange = a.onForceFullscreen, + ) } if (steam && s.directAudio != null && s.mic != null) SettingsGroup("Audio") { ToggleRow(host, "da", "DirectAudio for games", "Bypasses PulseAudio for lower latency in games.", s.directAudio, onChange = a.onDirectAudio) From 921b38bfb463c849f9fc6625228bef0c7f9b59ac Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 06:29:33 -0400 Subject: [PATCH 2/3] Session drawer: stretch games to fill the screen, live The same choice as the Steam cog's, in the drawer's Now group, changed while the client runs: the app writes it to ~/.droiddeck-fill (and again at every session start, so a file an earlier session left never disagrees), and a watcher in the Steam session hands each change to gamescope as GAMESCOPE_FORCE_WINDOWS_FULLSCREEN on the root window, which gamescope reads whenever it changes. The session log says so when it does. --- .../com/droiddeck/launcher/SessionActivity.kt | 4 ++++ .../droiddeck/launcher/session/SessionPrefs.kt | 14 ++++++++++++++ .../droiddeck/launcher/session/SessionService.kt | 5 ++++- .../com/droiddeck/launcher/ui/SessionOverlay.kt | 7 +++++++ .../overlay/usr/local/bin/bannerlator-session | 16 ++++++++++++++++ 5 files changed, 45 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 a2d31962e..862286a8c 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -91,6 +91,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { /** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */ private var pcKeyboardOpen by mutableStateOf(false) private var hudOn by mutableStateOf(true) + private var fillScreen by mutableStateOf(true) private var frameGenLabel by mutableStateOf("Off") private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF) private var frameGenMultiplier by mutableStateOf(2) @@ -197,6 +198,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { isHomeApp = isHomeApp, androidApps = androidApps, hudOn = hudOn, + fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null, frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier, lsfgReady = LsfgNative.isInstalled(this@SessionActivity), oscMode = oscMode, touchMode = touchMode, @@ -206,6 +208,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { secondScreenDisplays = secondScreenDisplays, selectedSecondScreenDisplay = selectedSecondScreenDisplay, onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() }, + onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on }, onFrameGenPick = { engine, multiplier -> FrameGen.set(this@SessionActivity, engine, multiplier) readPrefs() @@ -288,6 +291,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun readPrefs() { hudOn = SessionPrefs.hudEnabled(this) + fillScreen = SessionPrefs.forceFullscreen(this) touchMode = SessionPrefs.touchMode(this) frameGenLabel = FrameGen.label(this) frameGenEngine = FrameGen.engine(this) 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 bf2ac5d04..c7afe842d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -97,6 +97,20 @@ object SessionPrefs { fun setForceFullscreen(context: Context, on: Boolean) { prefs(context).edit().putBoolean("forceFullscreen", on).apply() + writeForceFullscreenFlag(context) + } + + /** + * The same choice as a file the running session watches, so the drawer can change it live: + * the session hands every change to gamescope, which reads GAMESCOPE_FORCE_WINDOWS_FULLSCREEN + * off its root window whenever it changes. Written again at every session start so a file left + * by an earlier session never disagrees with the setting. + */ + fun writeForceFullscreenFlag(context: Context) { + runCatching { + java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.droiddeck-fill") + .writeText(if (forceFullscreen(context)) "1\n" else "0\n") + } } /** 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 c49c5586d..ec15c5c2c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -304,7 +304,10 @@ class SessionService : Service() { 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)) - if (SessionState.mode == MODE_STEAM) guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0")) + if (SessionState.mode == MODE_STEAM) { + guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0")) + SessionPrefs.writeForceFullscreenFlag(this) + } // Proton's own gate for its xalia helper (its `proton` script reads this, and sets // XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device // whose seccomp answers its syscalls normally there is no reason to take it away. 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 400caaeb8..1566a7d38 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -155,6 +155,8 @@ class DrawerActions( val touchAuto: String, val shapeMode: String, val fexPreset: String, + /** Steam only: games stretched to the screen's size, changed live (null = not Steam). */ + val fillScreen: Boolean? = null, val secondScreenMode: SecondScreenMode, val secondScreenDisplays: List, val selectedSecondScreenDisplay: Int, @@ -170,6 +172,7 @@ class DrawerActions( val onTouch: (String) -> Unit, val onShape: (String) -> Unit, val onFexPreset: (String) -> Unit, + val onFillScreen: (Boolean) -> Unit = {}, val onSecondScreenMode: (SecondScreenMode) -> Unit, val onSecondScreenDisplay: (Int) -> Unit, val onLaunchAndroidApp: (HomeApp.LaunchableApp) -> Unit, @@ -298,6 +301,10 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { host, "hud", "Performance HUD", null, a.hudOn, chipModifier = if (steamButtons) Modifier else Modifier.focusRequester(firstFocus), onChange = a.onHud, ) + if (a.fillScreen != null) ToggleRow( + host, "fill", "Stretch games to fill the screen", + "Off for a game that shows up small in a corner (Quake 3).", a.fillScreen, onChange = a.onFillScreen, + ) val fgOpen = host.open == "fg" val fgLabel = when (a.frameGenEngine) { FrameGen.ENGINE_WINFG -> "Win-FG ${a.frameGenMultiplier}×" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 0bc8477cd..bdcc653c9 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -347,6 +347,22 @@ case $mode in # moments later, so the seeder has only that gap to mark the redistributables as already run. # It stamps each prefix it has covered and skips it thereafter, so the extra passes cost # almost nothing. + # 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. + ( + last=${BL_GAMESCOPE_FORCE_FULLSCREEN:-1} + while :; do + sleep 1 + want=$(cat "$HOME/.droiddeck-fill" 2>/dev/null) || continue + case $want in 0|1) ;; *) continue ;; esac + [ "$want" = "$last" ] && continue + if xprop -root -f GAMESCOPE_FORCE_WINDOWS_FULLSCREEN 32c -set GAMESCOPE_FORCE_WINDOWS_FULLSCREEN "$want" 2>/dev/null; then + echo "== stretch games to fill the screen: $want (live)" + last=$want + fi + done + ) & ( beat=0 for _ in $(seq 1 720); do From cbcd3f802346c2bd493d887dcf907425aaa77d2f Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 08:26:51 -0400 Subject: [PATCH 3/3] Proton launch: Quake-engine titles stay windowed at the session's size Quake III Arena, Team Arena, Return to Castle Wolfenstein and Jedi Academy set their own resolution and switch the display to it. Under gamescope with the ARM64 Proton that switch is refused ("calling CDS: failed, bad mode") and the game never gets OpenGL - device-seen on a Retroid Pocket 5 on Proton Experimental build 25406135 - and a window left at the game's own size draws small in a corner. Both wrappers (Valve's depot and an adopted GE or CachyOS) now add +set r_fullscreen 0 +set r_mode -1 +set r_customwidth/height at BL_WIDTH x BL_HEIGHT for those app ids, unless the user's launch options already set the video mode; the choice is logged. --- .../usr/local/bin/bannerlator-steam-compat | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat index b7c0659ad..4156846a5 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat @@ -87,6 +87,31 @@ bl_game_cpus() { } """ +# Quake III engine games (id Tech 3) set their own resolution and switch the display to it. Under +# gamescope with the ARM64 Proton that switch is refused ("calling CDS: failed, bad mode") and the +# game never gets OpenGL; left at its own size in a window, it draws small in a corner. So these +# titles are told to stay windowed at the session's own size - unless the user's launch options +# already say something about the video mode, which are left as they are. +BL_IDTECH3_SETUP = """ +bl_idtech3() { + BL_EXTRA_ARGS=() + case "${SteamAppId:-${STEAM_COMPAT_APP_ID:-}}" in + # Quake III Arena, Quake III: Team Arena, Return to Castle Wolfenstein, Jedi Academy. + 2200|2350|9010|6020) ;; + *) return 0 ;; + esac + case " $* " in + *r_fullscreen*|*r_mode*|*r_customwidth*|*r_customheight*) + echo "bannerlator-proton: Quake-engine title; its launch options set the video mode, left as they are" >&2 + return 0 ;; + esac + w=${BL_WIDTH:-1280} + h=${BL_HEIGHT:-720} + BL_EXTRA_ARGS=(+set r_fullscreen 0 +set r_mode -1 +set r_customwidth "$w" +set r_customheight "$h") + echo "bannerlator-proton: Quake-engine title: windowed at ${w}x${h}" >&2 +} +""" + BL_DIRECTAUDIO_SETUP = """ bl_directaudio() { base=$1 @@ -152,7 +177,8 @@ for name in %s; do if [ -d "$depot/files/bin-arm64" ] && [ -x "$depot/proton" ]; then bl_directaudio "$depot" bl_game_cpus - exec ${BL_TASKSET:-} "$depot/proton" "$verb" "$@" + bl_idtech3 "$@" + exec ${BL_TASKSET:-} "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" fi done echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2 @@ -162,7 +188,7 @@ exit 1 # and a % format would eat that into a single %, leaving a shell expansion that silently does # something else. LAUNCHER_SH = LAUNCHER_SH.replace( - "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP, 1) + "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP, 1) # A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of # that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot @@ -196,9 +222,10 @@ if [ -f "$fake" ]; then fi bl_directaudio "$here" bl_game_cpus +bl_idtech3 "$@" # Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be # started from its own tree. -exec ${BL_TASKSET:-} "$here/%s" "$verb" "$@" +exec ${BL_TASKSET:-} "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" """ EXTRA_COMPAT_TOOL_VDF = """"compatibilitytools" { @@ -357,7 +384,7 @@ def adopt_extras(tools): # ${major%%.*} of its own, and putting it through the format would eat both. body = (EXTRA_WRAPPER_SH % entry_point).replace( 'here=$(dirname "$0")', - 'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP, 1) + 'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP, 1) with open(wrapper, "w", encoding="utf-8") as f: f.write(body) os.chmod(wrapper, 0o755)