diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index ca68e8e05..96f0c8a39 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]). */ @@ -526,6 +527,7 @@ class MainActivity : ComponentActivity() { backActionsInverted = backActionsInverted, 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, @@ -561,6 +563,7 @@ class MainActivity : ComponentActivity() { }, 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 @@ -657,6 +660,7 @@ class MainActivity : ComponentActivity() { backActionsInverted = SessionPrefs.backActionsInverted(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/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index f2a088b7a..bc38fb9e8 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -101,6 +101,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) @@ -217,6 +218,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, @@ -226,6 +228,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() @@ -340,6 +343,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/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 64bf7226f..3498e525c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -100,6 +100,32 @@ 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() + 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") + } + } + /** * 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..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,6 +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")) + 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/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index f18bd20d6..5b4ff99e1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -55,6 +55,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. */ @@ -101,6 +103,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 = {}, @@ -290,6 +293,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) 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 c0bbd47b5..675cceefa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -161,6 +161,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, @@ -177,6 +179,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, @@ -317,6 +320,10 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { chipModifier = if (steamButtons) Modifier else Modifier.focusRequester(firstFocus) .onFocusChanged { firstControlFocused = it.isFocused }, 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 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)