From bf221ffbbd689d63212eff43e67e284557cf52a7 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:19:46 -0400 Subject: [PATCH] fix: resolution presets keep the panel shape at 720p, 900p and 1080p (#337) --- .../droiddeck/launcher/core/DeviceReport.kt | 7 +- .../launcher/session/SessionDisplay.kt | 39 ++-- .../launcher/session/SessionPrefs.kt | 4 +- .../launcher/session/SessionDisplayTest.kt | 24 ++- .../session/SessionScalingPrefsTest.kt | 4 +- docs/development/steam-display-scaling.md | 179 ++++++++++++++++++ 6 files changed, 237 insertions(+), 20 deletions(-) create mode 100644 docs/development/steam-display-scaling.md 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 48992e1e..f6a09cd3 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -109,8 +109,11 @@ object DeviceReport { k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" }) k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz)) val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context) - k("Resolution (Steam)", SessionPrefs.resolutionChoice(context, SessionService.MODE_STEAM, panelSize)) - k("Resolution (Desktop)", SessionPrefs.resolutionChoice(context, SessionService.MODE_DESKTOP, panelSize)) + for ((label, mode) in listOf("Resolution (Steam)" to SessionService.MODE_STEAM, "Resolution (Desktop)" to SessionService.MODE_DESKTOP)) { + val choice = SessionPrefs.resolutionChoice(context, mode, panelSize) + val size = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panelSize, choice) + k(label, "$choice (${size.first}x${size.second})") + } k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) h("Drivers") diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt index f1a342e1..7963875e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionDisplay.kt @@ -6,23 +6,40 @@ import android.view.WindowManager /** The virtual display advertised to the guest, before it is fitted onto the Android panel. */ object SessionDisplay { const val MATCH_SCREEN = "screen" - const val DEFAULT_RESOLUTION = "1280x720" + + /** + * Presets are heights, not sizes: "720p" is the panel's own shape at 720 lines, never narrower + * than 16:9 and never taller than the panel (SHAPE_AUTO). The client treats the output as the + * games' native resolution, so the height is what bounds a game's default size on a 1440p + * phone; the shape is what keeps the client's interface edge to edge on a 20:9 one (1600x720) + * and keeps games at 16:9 or wider on a foldable's near-square panel. + */ + const val DEFAULT_RESOLUTION = "720p" + private val PRESET_HEIGHTS = listOf(720, 900, 1080) + + /** The height of a preset choice ("900p"), or null for Match screen and fixed sizes. */ + fun presetHeight(choice: String): Int? = + if (choice.endsWith("p")) choice.dropLast(1).toIntOrNull()?.takeIf { it > 0 } else null fun screenSize(panel: Pair): Pair = (maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv()) - fun resolveChoice(panel: Pair, choice: String): Pair = - if (choice == MATCH_SCREEN) screenSize(panel) else { - // Legacy displays may exceed the custom dialog's size limit. Keep their exact size. - val parts = choice.split('x').map { it.toIntOrNull() } - if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!! - else 1280 to 720 - } + fun resolveChoice(panel: Pair, choice: String): Pair { + if (choice == MATCH_SCREEN) return screenSize(panel) + presetHeight(choice)?.let { return resolve(panel, it, SessionPrefs.SHAPE_AUTO) } + // Fixed sizes (custom, or a preset saved before presets were heights) keep their exact size, + // including legacy ones past the custom dialog's limit. + val parts = choice.split('x').map { it.toIntOrNull() } + return if (parts.size == 2 && parts.all { it != null && it > 0 }) parts[0]!! to parts[1]!! + else resolve(panel, 720, SessionPrefs.SHAPE_AUTO) + } - /** Fixed dimensions and the current panel, with no duplicate panel-size preset. */ - fun resolutionOptions(panel: Pair): List = - listOf(DEFAULT_RESOLUTION, "1600x900", "1920x1080") + /** The height presets the panel is tall enough for, then the panel itself, with no duplicate sizes. */ + fun resolutionOptions(panel: Pair): List { + val panelHeight = minOf(panel.first, panel.second) + return PRESET_HEIGHTS.filter { it <= panelHeight }.map { "${it}p" } .filter { resolveChoice(panel, it) != screenSize(panel) } + MATCH_SCREEN + } fun panelSize(context: Context): Pair { val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager 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 8f9e34e9..8c0753f7 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -516,7 +516,7 @@ object SessionPrefs { fun resolutionChoice(context: Context, mode: String, panel: Pair): String { val saved = prefs(context) saved.getString("displayResolution.$mode", null)?.let { value -> - if (value == SessionDisplay.MATCH_SCREEN) return value + if (value == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(value) != null) return value parseResolution(value)?.let { return "${it.first}x${it.second}" } } if (!resolutionChosen(context, mode) && !saved.contains("shape")) return SessionDisplay.DEFAULT_RESOLUTION @@ -526,7 +526,7 @@ object SessionPrefs { } fun setResolutionChoice(context: Context, mode: String, choice: String) { - val value = if (choice == SessionDisplay.MATCH_SCREEN) choice else { + val value = if (choice == SessionDisplay.MATCH_SCREEN || SessionDisplay.presetHeight(choice) != null) choice else { val size = requireNotNull(parseResolution(choice)) { "Invalid resolution" } "${size.first}x${size.second}" } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt index b432110b..373af869 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionDisplayTest.kt @@ -4,18 +4,34 @@ import org.junit.Assert.* import org.junit.Test class SessionDisplayTest { - @Test fun explicitPresetsAndMatchScreenResolveWithoutAnyAspectSetting() { + @Test fun fixedSizesAndMatchScreenResolveWithoutAnyAspectSetting() { assertEquals(1280 to 720, SessionDisplay.resolveChoice(1440 to 1080, "1280x720")) assertEquals(1600 to 900, SessionDisplay.resolveChoice(1280 to 720, "1600x900")) assertEquals(1440 to 1080, SessionDisplay.resolveChoice(1080 to 1440, SessionDisplay.MATCH_SCREEN)) assertEquals(2400 to 1080, SessionDisplay.resolveChoice(1080 to 2400, SessionDisplay.MATCH_SCREEN)) } - @Test fun panelOptionReplacesTheIdenticalPresetAndTracksPanelChanges() { + @Test fun heightPresetsKeepThePanelShapeWithASixteenByNineFloor() { + // 20:9 phone: edge to edge, no bars for the client's interface. + assertEquals(1600 to 720, SessionDisplay.resolveChoice(2400 to 1080, SessionDisplay.DEFAULT_RESOLUTION)) + assertEquals(1608 to 720, SessionDisplay.resolveChoice(2412 to 1080, "720p")) + // 1440p phone: bounded by the preset height, not the panel. + assertEquals(1560 to 720, SessionDisplay.resolveChoice(3120 to 1440, "720p")) + assertEquals(2340 to 1080, SessionDisplay.resolveChoice(3120 to 1440, "1080p")) + // Foldable inner panel and 4:3 handhelds: never narrower than 16:9. + assertEquals(1280 to 720, SessionDisplay.resolveChoice(2208 to 1840, "720p")) + assertEquals(1280 to 720, SessionDisplay.resolveChoice(1440 to 1080, "720p")) + // Never taller than the panel. + assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, "1080p")) + } + + @Test fun optionsListTheHeightsThePanelCanShowThenThePanel() { val thor = SessionDisplay.resolutionOptions(1920 to 1080) - assertEquals(listOf("1280x720", "1600x900", SessionDisplay.MATCH_SCREEN), thor) + assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN), thor) + assertEquals(listOf("720p", "900p", SessionDisplay.MATCH_SCREEN), SessionDisplay.resolutionOptions(2400 to 1080)) + assertEquals(listOf("720p", "900p", "1080p", SessionDisplay.MATCH_SCREEN), SessionDisplay.resolutionOptions(3120 to 1440)) val small = SessionDisplay.resolutionOptions(1280 to 720) - assertFalse(small.contains("1280x720")) + assertEquals(listOf(SessionDisplay.MATCH_SCREEN), small) assertEquals(1280 to 720, SessionDisplay.resolveChoice(1280 to 720, SessionDisplay.MATCH_SCREEN)) assertEquals(2560 to 1440, SessionDisplay.resolveChoice(2560 to 1440, SessionDisplay.MATCH_SCREEN)) } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt index aba88831..62d8e476 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionScalingPrefsTest.kt @@ -42,7 +42,9 @@ class SessionScalingPrefsTest { @Test fun freshSettingsUseTheConcreteDefaultAndCustomSizesAreNormalized() { val context = RuntimeEnvironment.getApplication() - assertEquals("1280x720", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080)) + assertEquals(SessionDisplay.DEFAULT_RESOLUTION, SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080)) + SessionPrefs.setResolutionChoice(context, "steam", "900p") + assertEquals("900p", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080)) SessionPrefs.setResolutionChoice(context, "steam", "1025×769") assertEquals("1024x768", SessionPrefs.resolutionChoice(context, "steam", 2400 to 1080)) } diff --git a/docs/development/steam-display-scaling.md b/docs/development/steam-display-scaling.md new file mode 100644 index 00000000..d31d22aa --- /dev/null +++ b/docs/development/steam-display-scaling.md @@ -0,0 +1,179 @@ +# Steam session display sizing: research and device tests + +Started 2026-10-06. This is a working log. Findings are added as they are measured on a device, so +it reads in order. + +## Question + +Should the Steam session run gamescope at the panel's own size and let gamescope scale games, the +way SteamOS does, instead of one session resolution shared by the Steam client and every game +(1280x720 16:9 by default) that our compositor stretches onto the panel? What does that mean on +very high-resolution panels and on foldables? + +## How the session is sized today (main @ c636eea3) + +- `droiddeck-session` starts gamescope with `-W/-H` (output) = `-w/-h` (nested, what games and the + client see) = `BL_WIDTH x BL_HEIGHT`, the per-mode "Resolution" setting. The default is + `1280x720` (`SessionDisplay.DEFAULT_RESOLUTION`). One Xwayland server holds both the Steam + client and the games. +- The Android compositor (`waylandcomp`) draws gamescope's output into a panel-sized swapchain + (2414x1080 on a 20:9 phone) and fits it with bars where the shapes differ. +- "Force game windows fullscreen" (`--force-windows-fullscreen`, off by default since #276) makes + every window the nested size. A game whose own resolution differs fights it, and the resize + storm that follows piles up GPU buffers until the low-memory killer ends the session (seen + 2026-10-06 on the AYN Thor with Alan Wake's American Nightmare). + +Why the current choices were made, from the history: + +| Choice | Reason given | Source | +|---|---|---| +| 720p default | The Steam client's CEF is the heaviest thing in a session, and 720p keeps Big Picture responsive on a regular flagship | `SessionPrefs.resolutionCap` comment | +| Never narrower than 16:9 | Games handed a near-square display (Fold inner panel) crop their own menus | `d2ea9b89` | +| 16:9 stretch option | Games on a narrow panel letterbox themselves; stretch fills it | #63, #133 | +| Custom resolution | Parity with Bannerlator, 4:3 and 16:10 presets | `ad2f9f8a` | +| Force fullscreen | A game that shrinks its window on focus loss (FlatOut) comes back small in a corner | #276, `droiddeck-session` | + +## How SteamOS-style sessions do it + +- gamescope output at the panel's size, `--xwayland-count 2`, and `STEAM_MULTIPLE_XWAYLANDS=1` + (ChimeraOS `gamescope-session-plus` defaults to two Xwayland servers). The client lives on + one server and games on the other. +- Per-game "Game Resolution" (Deck mode game properties): Steam writes + `GAMESCOPE_XWAYLAND_MODE_CONTROL = {server, width, height, allow_super_res}` on the root window, + and gamescope resizes that Xwayland server (`steamcompmgr.cpp`, the `gamescopeXWaylandModeControl` + handler). gamescope then scales the game to the output with the scaler and filter chosen in the QAM. +- `GAMESCOPE_STEAM_MAX_HEIGHT` on the root window caps only the Steam client's window height, and + gamescope scales it up (`steamcompmgr.cpp`, the `g_nSteamMaxHeight` branch). So a native output + need not make the client render natively. +- Our gamescope (3.16.29-p5) contains both atoms (checked in the installed binary on the Thor). We + use neither, and we don't pass `--xwayland-count`. + +## Android side + +- Hardware scaler: a surface whose buffer is smaller than the view is scaled by the display + hardware at no GPU cost (`SurfaceHolder.setFixedSize`; Android Developers blog 2013, NVIDIA). + Our compositor always renders a panel-sized swapchain and scales on the GPU. +- Android 16 ignores orientation and aspect-ratio locks on large screens, except for games. The + manifest's game category covers us. +- `game_mode_config.xml` already refuses OEM downscaling and FPS overrides. + +## Device tests + +All runs on the AYN Thor (SD 8 Gen 2, Adreno 740, 1920x1080 top panel), Deck mode on, Force +fullscreen off, with a spike build of `feat/steam-native-output`. Each run sets +`Download/droiddeck-env` and starts the session with +`am start -n com.droiddeck.launcher/.SessionActivity --es mode steam [--es steamUrl ...]`. + +### Run 1: baseline (Resolution = Match screen, 1920x1080) + +Steam UI idle: GPU 0%, gamescope 27 dma-bufs, Xwayland 39. Everything renders at 1920x1080. + +### Run 2: client height cap (`BL_STEAM_MAX_HEIGHT=720`) + +- Log: `== steam: the client's height capped at 720 (was unset)`. The client never wrote its own + value during the run, so a one-time set would hold. +- On screen: the Steam UI lays out at 1280x720 and gamescope scales it to fill 1920x1080. The + mangoapp overlay stays at native size and sharpness, so the cap affects only the client's window. + +### Run 3: two Xwayland servers (`--xwayland-count 2`, `STEAM_MULTIPLE_XWAYLANDS=1`), Alan Wake's American Nightmare, Force fullscreen off + +- gamescope starts Xwayland on `:0` and `:1`. The client stays on `:0`, and the game runs on `:1`: + the second Xwayland's dma-buf count rises when the game starts. +- The game runs full screen at about 60 fps with one swapchain, no resize churn, and stable buffer + counts. Nothing visibly broke in the client. + +### Run 4: as run 3, Force fullscreen on + +The storm still happens. The games' Xwayland went from 16 to 840 dma-bufs in about 13 s, and the +memory guard (kills the session under 2.5 GB free) stopped it. gamescope logged no mode change for +server `#1`, so it stayed at the output size (1920x1080) while the game draws at 1280x720. + +### Run 5: as run 4, writing `GAMESCOPE_XWAYLAND_MODE_CONTROL = 1,1280,720,0` at session start + +gamescope applied it (`Updating mode for xwayland server #1: 1280x720`), then the client set it +back to `1920x1080` when it launched the game, and the storm followed. **The client manages the games' +Xwayland size itself:** at each launch it writes the game's "Game Resolution", which defaults to +native. + +### Run 6: as run 5, writing the mode 5 s after the game takes focus (what a chosen Game Resolution does) + +**No storm.** Force fullscreen on, games' Xwayland at 1280x720: 10 dma-bufs on `:1` and 41 in +gamescope for the whole run, 9 frame-size changes in total (runs 4/5: 835 and 1591), memory +steady, 60 fps. gamescope scales the 1280x720 game to fill 1920x1080. + +**Conclusion so far:** the resize storm comes from Force fullscreen resizing a game window to a +display size the game won't accept. With the games on their own Xwayland server at the game's own +resolution, Force fullscreen is harmless. Two Xwayland servers alone don't help, because the client +sizes the games' server to native unless the game's resolution is set. + +### Run 7: two Xwayland servers, output 1920x1080, `-w 1280 -h 720` (games 720p), Alan Wake, Force fullscreen off + +The client ignores gamescope's nested size. It set both servers to the output size +(`#0: 1920x1080`, `#1: 1920x1080`). **The client's "native" game resolution is gamescope's output +size.** A panel-sized output on a 3120x1440 phone would therefore make every game default to +3120x1440 until a per-game resolution is chosen. + +Side finding: gamescope passed the game's 1280x720 buffers straight through to our compositor (its +frame size changed to 1280x720) instead of compositing at the output size. Our compositor does the +final scale either way. + +### Default resolution history + +Before #247 the default was `resolutionCap = 720` with shape `auto`: the panel's own shape at 720 +lines, never narrower than 16:9 (`SessionDisplay.resolve`). On a 2412x1080 phone that is 1608x720, +which is exactly the "custom" size a tester had been using. #247 replaced it with fixed 16:9 +presets (`1280x720` default, `1600x900`, `1920x1080`). Those draw the client with bars on any panel +wider than 16:9, which is the black border the tester reported after #247. + +## Conclusions + +1. **The output size sets the default game resolution** (run 7), so it must stay bounded on + very high-resolution panels. A native output is only safe on panels about 1080 lines or shorter. +2. **The output should keep the panel's shape**, so the client's interface is edge to edge, with + the 16:9 floor from `d2ea9b89` so a foldable's inner panel doesn't make games crop themselves. +3. **The resize storm is Force fullscreen against a display size the game won't accept** (runs + 4 to 6). The SteamOS model fixes it: two Xwayland servers, with the games' server at the game's + resolution. That needs a per-game Game Resolution, so it is a follow-up, not a default. +4. `GAMESCOPE_STEAM_MAX_HEIGHT` caps only the client and works on our gamescope (run 2). It + becomes useful when the output is larger than the client should render, so it belongs with + any future native-output option. + +## Change made (this branch) + +The resolution presets are heights: **720p (default), 900p, 1080p**, each in the panel's shape, +never narrower than 16:9 and never taller than the panel. Then Match screen (exact panel), then +Custom. Saved fixed sizes, custom sizes and Match screen are unchanged. Only the default and the +preset list change. Resolved sizes: + +| Panel | 720p | 900p | 1080p | +|---|---|---|---| +| 1920x1080 (Thor, 16:9) | 1280x720 | 1600x900 | Match screen | +| 2412x1080 (20:9 phone) | 1608x720 | 2010x900 | Match screen | +| 3120x1440 (1440p phone) | 1560x720 | 1950x900 | 2340x1080 | +| 2208x1840 (foldable inner) | 1280x720 | 1600x900 | 1920x1080 | + +### Run 9: default on the Thor + +With no saved resolution (the saved choice, legacy shape and cap removed from `session.xml`, then +restored): the session starts at `size=1280x720` and the device report shows `720p (1280x720)`. + +## Follow-ups + +- **Two Xwayland servers and per-game Game Resolution.** Still to verify: whether the client shows + Game Resolution under game Properties in our session, the Android clipboard bridge for games on + `:1`, and the drawer's live Force fullscreen toggle (it writes `GAMESCOPE_FORCE_WINDOWS_FULLSCREEN` + only on `:0`'s root, and gamescope reads it per server). The client's devtools (port 8080 when + `.cef-enable-remote-debugging` exists) accept guest processes only, so test it from inside the + session. +- **Hardware scaler.** The compositor draws a panel-sized swapchain and scales on the GPU. + `setFixedSize` to the session size, when no compositor effect is on, would hand that scale to + the display hardware. +- **Force fullscreen per game**, the way Quake-engine titles are handled (`cbcd3f80`), instead of a + global switch. + +### How runs 2 to 6 were driven + +Two temporary session-script hooks, not kept on this branch: `BL_STEAM_MAX_HEIGHT=N` wrote +`GAMESCOPE_STEAM_MAX_HEIGHT` on the root window, and `BL_GAME_XWAYLAND_MODE=WxH` wrote +`GAMESCOPE_XWAYLAND_MODE_CONTROL = 1,W,H,0` five seconds after `GAMESCOPE_FOCUSED_APP` showed a +game. `--xwayland-count 2` and `STEAM_MULTIPLE_XWAYLANDS=1` went through `Download/droiddeck-env`.