fix: resolution presets keep the panel shape at 720p, 900p and 1080p (#337)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-06 13:19:46 -04:00
1 parent c636eea355
commit bf221ffbbd
6 files changed
+237 -20

No files matched your search

@@ -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")
@@ -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<Int, Int>): Pair<Int, Int> =
(maxOf(panel.first, panel.second) and 1.inv()) to (minOf(panel.first, panel.second) and 1.inv())
fun resolveChoice(panel: Pair<Int, Int>, choice: String): Pair<Int, Int> =
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<Int, Int>, choice: String): Pair<Int, Int> {
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<Int, Int>): List<String> =
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<Int, Int>): List<String> {
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<Int, Int> {
val manager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@@ -516,7 +516,7 @@ object SessionPrefs {
fun resolutionChoice(context: Context, mode: String, panel: Pair<Int, Int>): 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}"
}
@@ -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))
}
@@ -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))
}
+179
View File
@@ -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`.