The main screen, the loading overlay, the HUD line, the drawer and the three
dialogs are composables now, driven by state the activities own. The session's
SurfaceView and the on-screen pad stay Views underneath a single Compose layer,
which is what they are in any Compose app: a compositor surface and a canvas,
not menus. The XML layouts are gone; the activities extend ComponentActivity
and Back goes through its dispatcher.
gamescope's display cannot change size once the session is up, so a foldable
that starts on one panel and opens onto the other can only be letterboxed
differently, never re-shaped. A fixed 16:9 sits with modest bars on either
panel; the panel's own shape fits one and leaves a strip on the other. A device
with a hinge sensor defaults to 16:9; the drawer toggles it for the next
session.
A foldable's inner panel is nearly square, and the session sized its output to
match it. A game given a square display draws for the frame it was made for
and cuts the sides off itself — FlatOut on a Fold lost the edges of its own
menus. The output now keeps the panel's aspect only above 16:9 (a phone's 20:9
is fine), the compositor's scale mode is set to fit rather than its stretch
default so the picture keeps its proportions with bars, and touch is mapped
through the fitted rectangle instead of the whole view.
The engine cannot run without the DLL, so a pickable LSFG row only produced a
setting that quietly did nothing. The rows are dimmed and unselectable until
the DLL is in the runtime's Steam library, and say what to install.
Back used to background the app outright. It now opens a slim panel on the
right with the switches a player reaches for mid-game — the performance HUD,
the frame-generation picker (shared with the main screen), the on-screen
controls' mode (auto / always / never, replacing the file-only override) —
and the two things that leave the session: send it to the background, or stop
it. Back again, or a tap outside, closes it.
Whether frame generation is doing anything was only knowable from a log. The
top-right line counts the game's own frames from the compositor's per-frame
callback, and while an engine generates it shows the engine's telemetry beside
it — "LSFG 2× 60 → 118 fps" — or says the engine is starting, unsupported or
failed. Touches pass through it. steamdeck-no-hud in Downloads hides it.
The compositor was built with its frame-generation leg stubbed out. Bannerlator's
two engines and the DXBC translator they need now ride along, so extra frames
are generated on gamescope's final output — any game, nothing changed in the
runtime. Win-FG needs nothing; LSFG takes its shader chain from the user's own
Lossless Scaling, which in this app is installed from the Steam client and read
straight out of the runtime's library. One picker on the main screen: Off, or an
engine at 2x/3x/4x, applied live on the next return to the session.
The compositor lives for the whole process, so the second Play after a session
had ended re-attached the Surface, started nothing, and left the loading panel
counting up over a dead session — it looked like a login that never came. The
service now starts whenever no session is running; the compositor's one-shot
first-frame notice is re-armed for the new session, or the panel would never
leave; and whatever the previous session left behind (gamescopereaper, the
session script, a dead client's helpers, the audio server) is killed first,
since it is our uid and still holds the socket the new one needs.
Between Play and the client's first frame there was a line of text in a corner
of a black screen, for minutes on a first run. The panel in the middle now
shows the runtime's milestone, the client's own download percentage lifted out
of its log (its bootstrap prints "Downloading update (a of b KB)", which the
milestones never reflected), a clock, and a rotating hint about what is
happening. It reads the log's tail only, twice a second; a session that has
already drawn a frame does not get the panel again.
On a landscape phone the install button sat under the navigation bar with no
way to reach it. The screen now scrolls, everything on it is smaller, and the
side gutter grows with the window width (24dp, 96dp from 600dp, 240dp from
960dp) so the buttons stay a sensible width on a wide panel instead of spanning
it. The logo is the icon clipped to a circle; shown raw, an adaptive icon is a
bare square.
It only checks the schemes an apk's own minSdk requires, so at 26 it reported
v1 as false while the JAR signature was sitting in META-INF the whole time.
The file check beside it says the same thing a second way.
A phone had no way to answer Big Picture: touch moved the pointer and nothing
pressed a button. The on-screen pad writes into the same rings the physical one
does, so the client sees a single Xbox pad whichever is in use and both work at
once. An InputManager listener takes the controls away when a controller
appears and brings them back when it goes; steamdeck-osc in Downloads pins it
either way. Virtual input devices no longer count as controllers — the system's
own synthetic devices would otherwise hide the controls with nothing to
replace them.
AGP drops JAR signing at minSdk 24 and above and the DSL flag does not move it,
release build or not. zipalign then apksigner with the three schemes named
explicitly is the only version of this that can be checked.
A release build fails on ExpiredTargetSdkVersion, which exists because Play
will not take an sdk that old. It is right about the rule and wrong about this
app: 29+ forbids executing a file from the app's own data directory, which is
the one thing this app exists to do.
AGP drops JAR signing from a debug build when minSdk is 24 or higher, whatever
the signing config says, so the check added with the testkey failed on its
first run. Nothing is minified in either variant; this only changes which one
CI hands over.
The session now belongs to a foreground service rather than the activity.
Android demotes a process as soon as its last visible activity goes and the
low-memory killer reaps the guest, so leaving Big Picture to answer a message
came back to a dead Steam; the service holds the process at perceptible
priority, a partial wake lock keeps the CPU from dropping the guest's threads,
and a high-performance WiFi lock keeps a backgrounded download from being
throttled to nothing. The notification is silent (IMPORTANCE_LOW), carries a
Stop action, and returns to the session when tapped. Back now puts the app away
and leaves Steam running; a swipe out of recents ends the session.
The compositor moved with it: it is process-wide, started once, and merely
swaps the Surface it presents into as the activity comes and goes.
Package is com.steamdeck.launcher, signed with the AOSP testkey (v1+v2+v3) so
every build installs over the last one, and CI asserts all three schemes.
The launcher icon is our own controller on a purple glow — the spirit of the
Steam tile the client entry uses, none of its geometry.
The repo is private, so each run is billed. Three caches now carry a rebuild:
Gradle's own through setup-gradle (dependencies, build cache, configuration
cache), the CMake/ninja tree keyed on the C sources and the NDK, and the two
cross-compiled preloads — the last of which also skips installing the cross
toolchain, since a run that changes only Kotlin has no reason to touch a C
compiler. Superseded pushes are cancelled, documentation pushes do not build,
and the apk is stored rather than recompressed.
The preload assertions moved from the cross binutils to readelf, which reads
any ELF whatever the host, so they keep running on a cache hit.
Credits name The412Banner and maxjivi05: the runtime under all of this is
Max's gamescope work from WinNative.
resources.displayMetrics is the window after the system bars and the cutout are
subtracted, so the first session on a 1080p device came up 1920x968 and
gamescope patched its EDID to suit. The panel's own bounds are what a client
should be told the display is.
Matches the exec path this was ported from; the guest command already starts
with env -i, so clearing the host side changed nothing except how far the code
sits from a configuration proven on a device.
Caught by the closure check the previous commit added, before a device had to
find it: the daemon would have failed to start and the session would have run
without audio.
libwayland-server.so links libffi, so loading the compositor threw
UnsatisfiedLinkError the moment Play started a session. The library was there,
its dependency was not — which is exactly the kind of gap a build cannot see
and a device finds immediately, so CI now walks every NEEDED of every .so we
package and fails on anything that resolves to neither the apk nor the
platform.
The session script redirects its own output into BL_LOG with exec, and a
redirection a non-interactive shell cannot open ends that shell — so a denied
storage permission would have killed the session rather than just losing the
log. Downloads is used once proven writable, files/logs otherwise.
Keyboard events now reach wl_keyboard, which is what the client's first-run
login needs on a device with a keyboard attached.
The compositor links libwayland-server.so as an imported target, so CMake never
copies it into the apk; it has to travel in jniLibs like the PulseAudio set.
Lifts Bannerlator's Linux/gamescope runtime out into an app of its own: the
Wayland compositor, the proot session, the runtime installer, the fake-evdev
controller path and the PulseAudio server, with none of the Wine, container or
store machinery around them.
One activity installs the runtime from the same winlator-contents catalog and a
second one runs the session: compositor on a SurfaceView, gamescope and Valve's
native arm64 client under proot, a physical pad republished into the fake-input
rings, and the session's milestones on screen until the first frame arrives.