The two tunables the Fold experiment needs, as switches under Performance
rather than files in Downloads: TU_DEBUG=sysmem for the runtime's Turnip and
ZINK_DESCRIPTORS=lazy for the client's menus. The Downloads files still win.
Downloads/steamdeck-env holds KEY=VALUE lines that go into the session's
environment as written, after the app's own, so a line here wins. For a device
that cannot be reached with a debugger and an experiment that would otherwise
cost a build per attempt: Zink and Turnip tunables, gamescope's, the client's.
The case at hand is a Fold whose Steam menus run at 14 fps on the A8xx Turnip
while a game on the same session runs at 89 - the client's Chromium goes
through ANGLE and Zink, and ZINK_DESCRIPTORS=lazy is the first thing to try.
A Steam session runs two things that want different cores at the same time,
and one affinity cannot serve both (Bannerlator cfca3912).
The client is the case that prompted it: Steam pins its own interface renderer
to a subset of cores it chooses - five of eight on one device, leaving out both
little cores and the fastest one - reasonable while a game is running and wrong
when the client is all there is, where it shows as Big Picture feeling sluggish.
BL_CLIENT_CPUS overrides it, re-applied on a beat by the session script because
steamwebhelper spawns children continuously and each inherits Steam's choice.
That block is Bannerlator's, verbatim. One deliberate difference: the client's
mask is sent whenever the override is on, even when it names every core,
because undoing Steam's pin is the whole point and the scheduler's default is
exactly what that pin takes away.
A game gets BL_GAME_CPUS instead, consumed by the Proton wrappers that r27
already carries, and as in Bannerlator it is sent only when it is a real
restriction: a list naming every core is what the scheduler does unaided.
Both live behind a Performance dialog with a checkbox per core, labelled with
its ceiling, and apply at the next session start.
The bundle carried a PulseAudio 17.0 glibc build of module-pipe-source (and
-sink): NEEDED libpulsecore-17.0.so and ld-linux, beside a 13.0 bionic daemon.
It could not dlopen, the DirectAudioMic source never existed, and the Steam
client reported no input device - the first thing the microphone port ran into
on a device. Bannerlator replaced both modules with its own 13.0 builds
(997acba2); this is that bundle, byte for byte, the other 75 files unchanged.
The component also unpacked the bundle only once, ever - a device kept the
modules from its first run whatever a later apk shipped. It now unpacks whenever
BUNDLE_STAMP changes, so this fix and the next one reach an installed app.
Two opt-ins on a new Audio screen, both ported from Bannerlator's gamescope
runtime where they are proven with voice in Steam's own tester.
DirectAudio replaces the audio driver inside the games the client launches -
Wine's mmdevapi driver, glibc build - with one that talks to a helper on the
Android side over a unix socket. The client keeps PulseAudio either way: it is
a native Linux program with no other way to make a sound. The helper runs here
because nothing inside the session can open Android's audio: a game is a glibc
process in a rootfs that binds no /system, and the uid the helper runs under is
what the recording permission is checked against.
The microphone is its own opt-in. The helper opens the input stream only when
asked and writes it to a pipe; PulseAudio's module-pipe-source turns that into
a source the client sees as DirectAudioMic. The daemon makes the pipe and the
helper waits for it - the two used to race, and the module fails on EEXIST.
The Proton wrappers gain Bannerlator's bl_directaudio, which adds the driver's
directory to WINEDLLPATH and names the driver in the prefix, and refuses on any
Proton that is not Wine 11 rather than pair a driver with an interface it was
not built against, which would be silence rather than an error. bl_game_cpus
comes along in the same splice and is inert until BL_GAME_CPUS is set.
The driver is staged into the runtime at every launch like the session scripts;
the helper ships as libdirectaudiorelay.so beside the PulseAudio daemon. Both
are the binaries Bannerlator carries, built by The412Banner/directaudio's relay
workflow (linux-wine11 build against ValveSoftware/wine proton_11.0).
Turnip's debug flags reach the runtime's driver and everything on it through
TU_DEBUG. Downloads/steamdeck-tu-debug now sets it verbatim, like the other
device switches. With nothing there, an imported driver from the A710/A720/A722
legs gets "sysmem" on its own: both the patch's author and our release notes
advise it for those GPUs, and nothing else in the list needs it.
Two deviations from a normal Steam launch, both removed rather than worked
around — Bannerlator's fix (334955ea), brought across unchanged.
The overlay was being stripped from every game because it landed in front of
open() and read() on /dev/input, letting Wine open the session's pad and then
answering every read of it with nothing. That was the wrong cure for an
ordering problem: glibc reads LD_PRELOAD before /etc/ld.so.preload, and the
interposer lives in the latter, so the overlay was simply ahead of it. Naming
the interposer first in LD_PRELOAD puts it back in front and the overlay loads
as Valve intends, in both wrappers — Valve's depot and an adopted GE-Proton or
proton-cachyos. Every difference between the launch Valve ships and what runs
here is a difference this runtime would otherwise have to account for.
Second, the competing Steam client is asked to stop rather than only named.
killBackgroundProcesses is a normal permission and ends exactly the case that
keeps recurring: GameHub declares boot receivers, so it is running from the
moment the phone starts without ever being opened, and it takes the login off
the client seconds after every sign-in. Done at Steam-session start only; the
desktop signs into nothing.
README: the GPU requirement now says which Adrenos Turnip actually drives. A
user's session log ended at "device (chip_id = 7010000, gpu_id = 710) is
unsupported" — an Adreno 710, which Mesa has no entry for.
Ported from Bannerlator's driver selection so the standalone stops being pinned
to whatever Turnip the runtime image was built with (Mesa 26.2.2 today, while
Bannerlator already runs 26.3.0 in the same role).
Linux runtime driver: LinuxVulkanDriverManager imports a "-Linux" (glibc)
Turnip zip under files/linux_vulkan_drivers/<id>/ with a generated ICD manifest
whose library_path is absolute, validated by the libc the driver links against
(libc.so.6, never libc.so) and by its ELF header. The choice is per mode, so
Steam and the desktop can differ. SessionService hands the session BL_VK_DRIVER,
and the session script — staged from the apk at every launch, so no runtime
re-host — checks the manifest and its library from inside before exporting
VK_DRIVER_FILES; the runtime's own driver is what anything unreadable falls
back to. That block is Bannerlator's, verbatim.
Display driver: TurnipDriver now also imports AdrenoTools zips beside the two
bundled builds and honours a chosen id ahead of the GPU pick, with the same
refusals as Bannerlator's AdrenotoolsManager — a zip with no libraryName, a
Linux runtime driver, or a glibc library dressed as one is refused with the
list it belongs in, never installed as a black session.
One dialog on the main screen carries both lists; a removed import falls back
at once. Documented in the README.
The client runs without a connection - installed games launch, Proton and the
frame-gen are all local - but only with credentials from an earlier sign-in,
and it decides by two keys in loginusers.vdf that it reads once while it
starts. That is why the switch belongs here and not in the session's drawer:
changed during a session it would write the file and change nothing until the
next launch. The button is disabled, and says so, until the client has signed
in once. The client rewrites the file when it exits, so the request is kept as
the app's own preference and written in again at every session start.
Firefox decides whether it is online by dumping the routing table over
netlink, which Android denies to apps; it sees no default route, switches to
Work Offline and every page fails while the network is fine. A defaults file
staged into Firefox's pref directory turns that off, and drops the process
sandboxes that have nothing to set up under proot. Debug runs now log a name
lookup and a fetch over IPv4 and IPv6 from inside the session.
labwc's Wayland backend, configured with 0x0, opened wlroots' default
1280x720 window and the whole desktop was upscaled from it - the r29 and r30
logs both show the output at 1280x720 whatever rc.xml and wlr-randr asked.
The configure now carries the output's size; gamescope is told the same
numbers on its command line, so nothing changes for the Steam client.
The panel was there all along, a bare strip at the top: the first package's
panel.conf described a panel it never named in [General] panels=, so
lxqt-panel built itself a default one with no plugins. panel.conf now travels
with the app like the rest of the desktop's defaults, and the old nameless
file on a device is replaced once. The nested labwc output was 1280x720 -
wlroots' default window - with the desktop upscaled from that; rc.xml now
carries the app's surface size and the autostart sets it again via wlr-randr.
Debug runs keep each program's full output under ~/.cache/steamdeck/.
The desktop came up with a wallpaper and nothing on it the second time. The
runtime directory outlives a session, and lxqt-panel and pcmanfm-qt are
single-instance programs that leave a lock and a socket there; finding last
session's, they decided they were already running and exited without a word.
The launcher now clears everything but the compositor's own socket, runs labwc
under dbus-run-session so the panel's menus and the file manager have the bus
they expect, and logs what the panel and the file manager say. The launcher
and labwc's defaults are staged by the app over the desktop package's copies,
so a fix here no longer costs a 426 MB re-download.
Touch on the desktop now works the way Bannerlator's desktop containers do:
the arrow stays where it is and a finger dragged anywhere moves it from there,
tap clicks, a second finger tapping while one holds (or a two-finger tap)
right-clicks, two fingers scroll, hold-then-drag and double-tap-hold drag.
Steam keeps the direct pointer; the drawer pins either.
The runtime's resolv.conf is written from the active network's DNS servers on
every link change — IPv4 first, never Android's link-local IPv6 ones, the
public pair as fallback — which is what glibc, and so Firefox, resolve with.
Routing needs nothing: proot makes no network namespace.
The pointer moved but nothing drew it — the nested desktop asks its parent
compositor for a cursor and ours ignores the request — and touch knew only
move and tap. Now the app draws the arrow where it last sent the pointer, touch
works like a remote desktop (tap clicks, hold then lift right-clicks, hold then
drag drags, two fingers scroll), and a mouse arrives as itself: hover moves,
every button presses, the wheel scrolls, with Android's own pointer hidden so
there is one arrow.
labwc died at "unable to create allocator": wlroots allocates through gbm on a
DRM render node, and ours is a KGSL stand-in gbm cannot use — the wall the
freedreno spike hit, which gamescope only clears with its own Vulkan-WSI path.
pixman uses a shm allocator and no DRM at all, so the desktop boots; clients
on it pay a CPU copy. steamdeck-wlr-renderer in Downloads overrides the
choice for a device with a real node.
A second thing to launch beside Steam: the LXQt/labwc desktop, a hosted package
the app installs into the runtime like the runtime itself, with the emulators
and apps as further packages from the same catalog (desktop.json in
winlator-contents). A tar package extracts over the rootfs with the runtime's
own extractor; an AppImage lands in /opt/appimages with a .desktop entry and
runs extracted, since proot has no FUSE. The session script's lxqt mode runs
the desktop's labwc in place of gamescope.
With Keyboard and Compatibility tools added, a landscape phone no longer had
the height for it and Stop session fell off the bottom. The panel scrolls, and
the two session-leaving actions ride at the end of the list rather than pinned.
A session had no way to type without a hardware keyboard. The drawer's Keyboard
opens the soft keyboard onto an invisible host View whose InputConnection turns
what the IME commits into the key events that would have typed it, and the
key path carries Bannerlator's two soft-keyboard fixes: a symbol key with no
scan code is worked back from the character it stands for, and a capital gets
the Shift a soft keyboard reports but never sends — not a hardware one, whose
own Shift would be released early by a second.
Compatibility tools, on the main screen and in the drawer, queues a GE-Proton
or proton-cachyos install as a line in the runtime's ~/.bl-proton-extra, which
the session script hands to bannerlator-proton-extra before the client starts;
it shows what is installed and removes a build.
From Bannerlator's feat/linux-gamescope-runtime since this repo was cut:
- Session teardown (4509d788): proot was SIGKILLed, which never lets its
--kill-on-exit cleanup run; its tracees lived on with no tracer, every
seccomp-trapped syscall failing with ENOSYS, spinning at a full core for over
an hour. Now SIGTERM, a grace for proot's own cleanup, SIGKILL only if it
will not go, then a sweep of the tree it had — snapshotted first, each pid
checked against its start time.
- The Steam client's controller interposer is its own source
(fakeinput_steam.cpp, 28922e7c), separate from the one preloaded into Wine.
- The seeder runs on a beat so a prefix the client creates mid-session is
covered before its install scripts run (6ee86bff).
- GE-Proton and proton-cachyos as compatibility tools: the registrar
bannerlator-proton-extra and the compat script's adopt_extras (03867656).
- space.c in the session shim: a library's own free space, not the phone's.
The env-vs-args ordering bug (98d38931) does not apply here: nothing is added
after the script name.
The audio stack, the gamepad model and the shape of a session's host-side
components are brunodev85's, and both apps this was lifted from are Winlator
lineage. The credits and README named the descendants and not the ancestor.
Opening the Steam menu over a fullscreen game and closing it again left the
game as a strip in a corner with an engine on. gamescope forwards a fullscreen
game's own buffers straight through and hands over its composited output while
the menu is up; each is a different window to the compositor, and the engine's
ring and history straddled the switch. The compositor already reports every
change of presenting window, so the engine is disarmed on it and re-armed
against the new one, as it is across a Surface resize.
With the session activity resizable the fold works — with frame generation off.
With an engine on the picture still came back stretched: its ring, effects
chain and multi-present pacing were built against the old extent. The engine is
now disarmed before the window is rebound and re-armed once the new swapchain
has settled, so everything on its side is built again for the new size.
It was declared non-resizable. On a foldable that means the system keeps a
fixed-shape window across the fold and stretches it onto the other panel — the
app never receives a size change, so the compositor's re-fit, the swapchain
rebind and the letterboxing all had nothing to react to. Resizable, the window
follows the panel and the picture keeps its shape with bars.
A foldable opening or closing resizes the Surface rather than recreating it.
The compositor's swapchain, built for the old size, kept presenting, and the
system scaled those buffers onto the new window — on a Fold a 16:9 picture
came back stretched to the square panel, the same width and 2.09x the height.
Rebinding the window on a size change rebuilds the swapchain at the new size,
and the fit mode letterboxes as it should.
Panels and densities vary too much for fixed dp: a 7" 1080p handheld in
landscape has barely 400dp of height and the column did not fit it, with the
logo under the status bar and the credits off the bottom. The column is now
designed at one size and scaled by the height available — logo, text, buttons
and spacing shrink together until it fits, down to 55% — with scrolling as the
last resort and a width cap so the buttons never span a wide panel.
gamescope's display is sized once when the session starts and cannot change,
but the size was recomputed on every surfaceCreated. A foldable recreates the
Surface on the other panel, so a session started on the 21:9 outer screen and
opened onto the square inner one told the compositor its 21:9 buffer was 16:9
and the picture came back squashed sideways. While a session runs its size is
kept; the compositor letterboxes it onto whatever panel is current.
Also the Compose build: BoxWithConstraints' maxWidth read outside its scope.
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.
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.
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.
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.