mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
fix: resolution presets keep the panel shape at 720p, 900p and 1080p (#337)
This commit is contained in:
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))
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
Reference in new issue
Block a user