Merge pull request #32 from The412Banner/feat/fullscreen-toggle

Stretch games to fill the screen (cog + live drawer), Quake-engine titles windowed at the session's size
This commit is contained in:
The412Banner authored and GitHub committed 2026-09-24 08:41:10 -04:00
commit 695923aed7
9 files changed
+101 -4

No files matched your search

@@ -105,6 +105,7 @@ class MainActivity : ComponentActivity() {
private var phantomWarning by mutableStateOf<String?>(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<List<DriverRow>>(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)
@@ -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)
@@ -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))
@@ -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.
@@ -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.
@@ -55,6 +55,8 @@ class ModeSettings(
val gameStorage: String? = null,
val storageOptions: List<Pair<String, String>> = 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)
@@ -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<SecondScreenDisplay>,
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}×"
@@ -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
@@ -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)