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/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 3628375b5..ff1b48282 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -144,6 +144,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 storeEnabled by mutableStateOf(false) private var appImagesEnabled by mutableStateOf(false) @@ -958,6 +959,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, @@ -993,6 +995,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 @@ -1119,6 +1122,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 16a9598e9..14df7559a 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -157,6 +157,7 @@ object DeviceReport { k("Guest host name", SessionPrefs.guestHostname(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/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)) } } 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 9eb8a1d35..687b0911a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -184,6 +184,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 f0197bf9b..fd51da14b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -540,6 +540,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 d9f26e6b4..2a2f4a561 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -68,6 +68,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: enable the SteamOS client interface and its performance controls. */ @@ -117,6 +118,7 @@ class ModeSettingsActions( val onPickGameStorageFolder: () -> Unit = {}, val onFexPreset: (String) -> Unit = {}, val onForceFullscreen: (Boolean) -> Unit = {}, + val onStretch16x9: (Boolean) -> Unit = {}, val onSteamChannel: (String) -> Unit = {}, val onSteamDeckMode: (Boolean) -> Unit = {}, val onMangoapp: (Boolean) -> Unit = {}, @@ -361,6 +363,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 89c35ce3e..1f55e3089 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -140,7 +140,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.