From 5179b54745771c33109d5dbbaca314e463f98a6f Mon Sep 17 00:00:00 2001 From: magartus Date: Mon, 28 Sep 2026 21:58:39 +0200 Subject: [PATCH 1/2] Add an opt-in 16:9 stretch for screens narrower than 16:9 On a foldable's inner panel (Galaxy Z Fold8: 2448x1848, ~4:3) gamescope is started with the nested size equal to the output, so games are asked for the panel's own shape. Many cannot do it and letterbox themselves, and gamescope's scaler has nothing to stretch. "Stretch games to fill the screen" only forces window size, so it does not help. New Steam setting "Stretch 16:9 games to a narrower screen" (off by default): bannerlator-session then gives games a 16:9 surface at the full output height (-w) and adds -S stretch, so they render at a shape they support and fill the panel with no bars and no crop. An output that is already 16:9 or wider is left untouched. Passed as BL_GAMESCOPE_STRETCH_16X9 and listed in device.txt. --- .../main/java/com/droiddeck/launcher/MainActivity.kt | 4 ++++ .../java/com/droiddeck/launcher/core/DeviceReport.kt | 1 + .../com/droiddeck/launcher/session/SessionPrefs.kt | 6 ++++++ .../com/droiddeck/launcher/session/SessionService.kt | 1 + .../com/droiddeck/launcher/ui/ModeSettingsDialog.kt | 7 +++++++ .../linuxfs/overlay/usr/local/bin/bannerlator-session | 11 ++++++++++- 6 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index eaec48d8f..7207f4cae 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -130,6 +130,7 @@ class MainActivity : ComponentActivity() { private var directAudio by mutableStateOf(false) private var clientDirectAudio by mutableStateOf(false) private var forceFullscreen by mutableStateOf(true) + private var stretch16x9 by mutableStateOf(false) private var launcherFullscreen by mutableStateOf(true) private var mic by mutableStateOf(false) @@ -776,6 +777,7 @@ class MainActivity : ComponentActivity() { directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null, clientDirectAudio = clientDirectAudio, forceFullscreen = if (mode == SessionService.MODE_STEAM) forceFullscreen else null, + stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 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, @@ -817,6 +819,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 }, + onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on }, onMic = { on -> SessionPrefs.setMicEnabled(this, on) mic = on @@ -928,6 +931,7 @@ class MainActivity : ComponentActivity() { directAudio = SessionPrefs.directAudio(this) clientDirectAudio = SessionPrefs.clientDirectAudio(this) forceFullscreen = SessionPrefs.forceFullscreen(this) + stretch16x9 = SessionPrefs.stretch16x9(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 9ff0c002a..cae44712c 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("proot without seccomp", SessionPrefs.prootNoSeccomp(context)) k("DirectAudio for games", SessionPrefs.directAudio(context)) k("Stretch games to fill", SessionPrefs.forceFullscreen(context)) + k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(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 0f3aefe6e..74ce0a9e2 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -120,6 +120,12 @@ object SessionPrefs { writeForceFullscreenFlag(context) } + fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false) + + fun setStretch16x9(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("stretch16x9", on).apply() + } + /** * 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 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 a071e2686..888d37fa7 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -522,6 +522,7 @@ class SessionService : Service() { if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this)) if (SessionState.mode == MODE_STEAM) { guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0")) + guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0")) SessionPrefs.writeForceFullscreenFlag(this) } // Proton's own gate for its xalia helper (its `proton` script reads this, and sets 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 0d6bb8003..66d0c5f52 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -66,6 +66,7 @@ class ModeSettings( val fexPreset: String? = null, /** Steam only: games are stretched to fill the screen (null = not a Steam page). */ val forceFullscreen: Boolean? = null, + val stretch16x9: Boolean? = null, /** Steam only: the client branch forced on the command line. */ val steamChannel: String? = null, /** Steam only: start a Steam session when DroidDeck opens. */ @@ -122,6 +123,7 @@ class ModeSettingsActions( val onPickGameStorageFolder: () -> Unit = {}, val onFexPreset: (String) -> Unit = {}, val onForceFullscreen: (Boolean) -> Unit = {}, + val onStretch16x9: (Boolean) -> Unit = {}, val onSteamChannel: (String) -> Unit = {}, val onRunSteamAtStartup: (Boolean) -> Unit = {}, val onPickAddedGamesDir: () -> Unit = {}, @@ -382,6 +384,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { "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 (s.stretch16x9 != null) ToggleRow( + host, "stretch169", "Stretch 16:9 games to a narrower screen", + "On a screen squarer than 16:9 (a foldable's inner panel), games render at 16:9 and are stretched to fill it: no bars, slightly wider-looking. Applies next session.", + s.stretch16x9, onChange = a.onStretch16x9, + ) } 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/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index c0adc95e7..ab1f6f568 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -125,7 +125,16 @@ if [ -z "${BL_INSIDE:-}" ]; then fi width=${BL_WIDTH:-1280} height=${BL_HEIGHT:-720} - args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$width" -h "$height") + nested=$width + if [ "$mode" = steam ] && [ "${BL_GAMESCOPE_STRETCH_16X9:-0}" = 1 ]; then + wide=$(( (height * 16 + 4) / 9 / 2 * 2 )) + if [ "$wide" -gt "$width" ]; then + nested=$wide + echo "== gamescope: games at ${nested}x${height} stretched to the ${width}x${height} output" + fi + fi + args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$height") + [ "$nested" != "$width" ] && args+=(-S stretch) # -r is what gamescope advertises as the refresh rate, and what a game reads as the display's. # Left unset it defaults to 60, so a 120 Hz panel offers only 60 Hz in game settings. The FPS # limiter wins when it is on (it is a deliberate cap); otherwise use the panel's own rate. From 05933a5ee3db4516e44fc5dab1a806431d5e848d Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Thu, 1 Oct 2026 11:57:58 -0400 Subject: [PATCH 2/2] Clock frame generation by the game's frames With frame generation on, a scene drawn for another window (Steam's overlay) went into the engine as a game frame. Adaptive counted it as source rate and planned against that (36 base for a game capped at 30), and a fixed multiplier gave it present slots while game frames were dropped (26 -> 51 at 2x). Such a change now waits for the game's next frame. The HUD's base rate is the game window's own frames, as Steam's overlay counts them, instead of the compositor's scene rate. --- app/src/main/cpp/waylandcomp/src/compositor.c | 14 ++++++++++++-- .../java/com/droiddeck/launcher/session/PerfHud.kt | 10 ++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 63f0eed8a..75a16311e 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -750,6 +750,8 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res 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; +/* That window committed a frame the screen has not drawn yet. */ +static int g_hud_fresh; 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 @@ -852,7 +854,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) { g_hud_last_ns = now_ns(); banner_on_game_frame(); } + if (s == g_hud_surface) { g_hud_last_ns = now_ns(); g_hud_fresh = 1; banner_on_game_frame(); } } /* ---- hooks for ahb_swapchain.c (zero-copy layers) */ @@ -2021,6 +2023,7 @@ static void render_scene(void) { int copy = 0; /* this scene went through the screen swapchain (the perf line's "copy") */ g_dirty = 0; + g_hud_fresh = 0; g_hdr_unimported = NULL; /* found again while the scene is built (HDR gate open only) */ wl_list_for_each(s, &g_surfaces, link) s->drawn = 0; scene_size(&w, &h); @@ -2236,6 +2239,13 @@ static void schedule_render(void) { wl_event_source_timer_update(g_fallback_timer, 8); } +/* With frame generation on, every scene is the engine's next source frame, so one drawn for + * another window (Steam's overlay) counted as a game frame and took its present slots. Such a + * change waits for the game's next frame, unless the game has been still for 100 ms. */ +static int scene_waits_for_game(int64_t now) { + return g_hud_surface && !g_hud_fresh && now - g_hud_last_ns < 100000000LL && vkp_framegen_active(); +} + /* A screen refresh (Choreographer tick): draw the newest state once. */ static void on_vsync(int64_t frame_time_ns) { int64_t now = now_ns(); @@ -2251,7 +2261,7 @@ static void on_vsync(int64_t frame_time_ns) { if (vkp_apply_window_request()) g_dirty = 1; /* A screen-effect setting changed (JNI, any thread): redraw so it shows on a static scene too. */ if (vkp_effects_sync()) g_dirty = 1; - if (g_dirty) render_scene(); + if (g_dirty && !scene_waits_for_game(now)) render_scene(); } /* ------------------------------------------------------------------ wl_seat */ diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt index a9b5b0459..b3c3fd6ea 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfHud.kt @@ -21,8 +21,9 @@ import java.util.concurrent.atomic.AtomicInteger * is on, what the screen is actually being shown - "60 → 118 fps" is the proof that the engine * is doing something, and "FG starting" or a reason is the proof that it is not. * - * The base rate is counted here from the compositor's per-frame callback, so it is right with or - * without an engine. The presented rate comes from the engine's own telemetry while it generates. + * The base rate is the game window's own frames, counted here from the compositor's per-frame + * callback with or without an engine - what Steam's overlay counts too. The presented rate comes + * from the engine's telemetry while it generates. */ class PerfHud(context: Context) { /** Empty when the HUD is off or not yet started. */ @@ -99,10 +100,7 @@ class PerfHud(context: Context) { return when { problem == 1 -> context.getString(R.string.hud_fg_unsupported, name) problem == 2 -> context.getString(R.string.hud_fg_failed, name) - stats != null && stats[3] > 1f -> { - val source = if (stats[2] > 1f) stats[2] else base - String.format(Locale.US, "%s %.0f → %.0f fps", label, source, stats[3]) - } + stats != null && stats[3] > 1f -> String.format(Locale.US, "%s %.0f → %.0f fps", label, base, stats[3]) else -> context.getString(R.string.hud_fg_starting, label, fps(base)) } }