Compare commits

...
Author SHA1 Message Date
maxjivi05 b31ab5fa96 Merge remote-tracking branch 'origin/main' into fix/pr103-conflicts
# Conflicts:
#	app/src/main/java/com/droiddeck/launcher/App.kt
#	app/src/main/java/com/droiddeck/launcher/MainActivity.kt
#	app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
2026-09-30 16:56:59 -04:00
MaxsTechReview 8ee1cbe3eb Additional performance fixes (#87)
* perf: answer Turnip's zero-timeout KGSL timestamp polls without blocking

Turnip polls a timeline point with IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID
and a timeout of 0, which KGSL reads as wait forever; Mesa does it under
a lock on every signalling submit, so vkd3d-proton, DXVK, gamescope and
Zink ran in lock-step with the GPU. The session preload now answers
those polls from the context's retired timestamp and passes everything
else to the kernel. Bannerlator measured a D3D12 demo at 614 -> 4335 fps
with the same answer in its Vulkan adapter. BL_NO_KGSL_POLL_FIX=1 turns
it off.

* perf: frame limiter, live panel refresh, opt-in GPU clock pin, cheaper cursor sync

- A per-mode frame limit (Steam and desktop settings, off by default):
  gamescope's -r, the compositor's buffer-release pacer, the display
  layer's frame-rate vote and a panel mode the cap divides evenly, as
  WinNative's per-shortcut limit does. BL_FPS was always 0 and the
  pacer's limit was never set.
- The compositor follows the panel's refresh rate when it changes
  mid-session instead of reporting the rate read at start. (WinNative.)
- Performance > GPU: hold the Adreno clock at its top through KGSL's
  power-control property, no root (Bannerlator's PerfGpuTurbo). Off by
  default; cleared when the session stops and at the next app start if
  a killed process left it set.
- The cursor image is copied across JNI only when its serial changes,
  not on every pointer move.

* perf: stop re-seeding every prefix every 5 s, and run the library helpers only on change

- bannerlator-seed-redists stamped its change check from a block that
  carried the current time, so it never matched: every prefix had ~32
  keys appended to system.reg on every beat. The stamp now comes from
  the names; Proton's prefix template is seeded too (date kept), with
  WinNative's built-in list until the client has the install scripts.
- The library beat runs the seeder every 15 s and steam-compat only
  when a directory it reads has changed, or once a minute.
- The client-core pin reads /proc itself and runs taskset -a only for
  new processes or ones that grew threads.
- The games watcher backs off after a failed ownership probe and does
  not probe while a game runs.
- The Proton launcher runs the newest ARM64 depot by build number, and
  PROTON_USE_PIPEWIRE defaults to 0. (WinNative.)

* perf: Mesa's database shader cache, and proot's tracer above the guest

- MESA_DISK_CACHE_DATABASE=1 for the session: the file cache opens a
  file per lookup and opens and renames one per store, each a proot
  stop; the database is opened once and used with pread/pwrite.
- proot's single tracer thread is raised to nice -6 (under the
  compositor's -8) two seconds after it starts, so the first guest
  process it forks keeps nice 0. setpriority() directly, not
  Process.setThreadPriority, which would also move its cgroup.
2026-09-30 16:43:52 -04:00
MaxsTechReview 6c5d6cda73 Added LSFG/Import/License Checking (#96)
* feat(lsfg): take Lossless Scaling from Steam by itself, import it once owned, rebuild on update

- Lossless.kt owns the DLL and its shader cache. The copy the Steam
  client installs (runtime library or the card's) is taken by itself
  and taken again when the client updates it (size/mtime stamp). The
  DLL is copied in and built into a cache named after its SHA-256, so
  a new DLL hands the compositor a new path; a build that fails keeps
  the working cache. Builds are serialized.
- The Steam session asks the signed-in client once per session whether
  the account owns app 993090 until the answer is yes, then records it
  and the app stops asking (BL_LOSSLESS_OWNED). Once owned, the frame
  generation menu offers "Import Lossless.dll…", on the main screen and
  in the session drawer; an import stays until Steam's copy changes.
- The compositor restarts the LSFG engine when its cache path changes,
  and a new engine selection clears an earlier start failure, so an
  updated DLL or a retry no longer needs the app restarted.
- The HUD says when LSFG is waiting on Lossless Scaling instead of
  "starting"; frame generation strings are resources.
- Removed LsfgNative's unused DLL variant and cache-match calls.

* Find Lossless.dll in added Games libraries too

* LSFG: 120, 90 and 60 fps targets alongside 2x-4x

The pacer's target-rate mode picks 0-3 generations per game frame. While it
is warm the input ring and shared chain follow every frame, so frames that
generate nothing don't leave the next one without history.

* Credit LSFG: Camille LaVey / Eden, ported to WinNative and DroidDeck by @maxjivi05

* LSFG: aim 0.1 fps under the target so output never passes the refresh rate
2026-09-30 16:40:46 -04:00
Kurt Himebauch 38c9b733b5 Feat/step out stop confirm (#105) 2026-09-30 16:26:39 -04:00
Kurt Himebauch 02fa7983e9 Merge branch 'main' into feat/gpu-driver-auto 2026-09-30 16:20:58 -04:00
MaxsTechReview 5e55189d03 DD-Turnip Driver Support (#106) 2026-09-30 16:20:28 -04:00
Kurt Himebauch 27e5f86793 docs: add a join-the-Discord badge and community section to the README (#104) 2026-09-30 14:54:35 -04:00
xXJsonDeruloXx f76a58ea1e feat: pick GPU drivers as matched pairs, set automatically for the device's GPU 2026-09-30 14:32:58 -04:00
Kurt Himebauch f53b844add feat: turn Deck mode on for new installs and add a performance overlay toggle (#101)
* feat: turn Deck mode on for new installs and add a performance overlay toggle

* feat: hide the session fps HUD where Deck mode's performance overlay runs
2026-09-30 13:06:12 -04:00
Kurt Himebauch c729f4c8e7 fix: keep Deck mode on the Steam Deck client branch so Steam stops offering an update (#100) 2026-09-30 12:47:57 -04:00
Kurt Himebauch 9f343278c3 fix: stop update channel cards flashing as a controller moves between them (#99) 2026-09-30 11:45:44 -04:00
Kurt Himebauch 6beeb29486 feat: add an Updates page that installs Stable, Nightly or test builds in-app (#98)
* feat: add an Updates page that installs Stable, Nightly or test builds in-app

* style: put update channels on the left and the build on one line

* style: make the update card read like a system updater

* feat: expand update notes, show the build beside the title, fit with button hints

* style: check for updates with the refresh icon Components uses
2026-09-30 11:25:18 -04:00
Kurt Himebauch ade2e982b6 ci: sign PR test builds in every variant and take stable from the org repo (#97) 2026-09-30 10:53:26 -04:00
Kurt Himebauch 8a61a1898b Merge pull request #94 from Droid-Deck/fix/remove-pr-approval-gates
ci: remove PR approval gates from signed releases
2026-09-30 09:19:58 -04:00
xXJsonDeruloXx dfa3b12f48 ci: remove PR approval gates from signed releases 2026-09-30 09:14:30 -04:00
Kurt HimebauchandMaxsTechReview 9ed25d8ae5 feat: Flatpak apps and a Flathub store (#77)
* feat: run Flatpak apps through a proot bwrap stand-in and add a Flathub store

* feat: give Flatpak apps the KGSL GPU, the controllers and a CPU LLVM can target

* feat: Flatpak games on the desktop, Store apps on the Desktop page, safer remove

* test: cover the sandbox planner and store helpers; docs: explain Flatpak on DroidDeck

* fix: keep controller focus through Store tabs and app pages; show progress per Flatpak step

* fix: let Chromium, Electron and Firefox Flatpaks start without their own sandboxes

* fix: fetch Flatpak's supporting libraries when the runtime lacks them

* fix: add Flathub without a download, log store commands, keep Electron apps on X11 under gamescope

* feat: import ARM64 AppImages from storage and run them from the Desktop page

* fix: report failed Flatpak installs as failures and deploy runtimes that ship a resolv.conf link

* fix: let Flatpak create its repo where Android denies hard links

* fix: point store commands at the current network's DNS

* fix: keep failed Flatpak extensions non-fatal, give Electron AppImages a bus, X11 and time to relaunch

* docs: AppImage import and what x86 apps would take

* feat: put the Flathub Store and AppImage import behind beta toggles in Setup

---------

Co-authored-by: MaxsTechReview <[email protected]>
2026-09-30 00:35:49 -04:00
The412Banner b4b31d5a08 ci: say what each DroidDeck-CI build is at the top of its release notes (#92) 2026-09-30 00:11:55 -04:00
The412Banner f394906726 Merge pull request #91 from Droid-Deck/feat/game-saves
Game saves: import and export save zips for added games
2026-09-29 23:49:50 -04:00
The412Banner b50dfa76d1 Game saves: show Manage saves only on added games 2026-09-29 23:36:49 -04:00
The412Banner 3f81ddff9b Game saves: Manage saves on each game's page instead of a separate page
The floppy disk beside the Steam cog and its Game saves page are gone. A game's
page now has Manage saves beside Proton prefix, a menu with Import saves (a
GameHub or Winlator zip, picked in the app's file picker; the saves there now are
backed up first), Export as GameHub zip, Export as Winlator zip (a folder picked
the same way) and Open save folder. The game's saves are looked for when the menu
opens. The Proton list and config.vdf reader only that page needed are removed.
2026-09-29 23:21:40 -04:00
The412Banner d1dec8d7a4 Game saves: import and export save zips per game
A floppy disk beside the Steam page's cog opens a Game saves page, laid out like
Components: the Proton at the top (from the client's compatibility tools, with
bannerlator-proton-arm64 shown as Proton Experimental), the zip type beside it
(GameHub or Winlator, LB / RB turn it), and the games the client runs with that
Proton (CompatToolMapping, its default for the rest), each with Import and Export.

Import picks a zip in the app's file picker, backs up the game's steamuser profile
to Download/DroidDeck/Saves/backups, then unzips drive_c with any user folder but
Public renamed to steamuser, so GameHub and Winlator zips both land. Export finds
the save folders by name under the usual roots (Bannerlator's SaveLocator), or
takes the whole profile, and writes <Game>_<date>.zip to a folder picked the same
way, under steamuser (GameHub) or xuser (Winlator). Imports wait while a session
runs. Ported from Bannerlator's GameSaveBackup and SaveLocator.
2026-09-29 23:01:21 -04:00
The412Banner e60097e5cb Merge pull request #89 from Droid-Deck/fix/resume-restore-minimized
Restore a game that minimized itself behind the Steam menu
2026-09-29 22:14:53 -04:00
The412Banner 7c7bf2dcbe Merge branch 'main' into fix/resume-restore-minimized 2026-09-29 22:10:04 -04:00
Kurt Himebauch 75593cfd29 feat: the pad as SteamOS has it - Steam Input's virtual pads for games, a Steam Deck controller for the client (#86)
* feat: stand in for /dev/uinput so Steam Input's virtual pads reach games

* feat: present the pad to the Steam client as a Steam Deck controller over hidraw

* docs: log the uinput stand-in and the Steam Deck controller

* feat: feed the handheld's gyro and accelerometer to the Steam Deck controller

* feat: let the Steam page choose between the Steam Deck controller and the Xbox 360 pad

* feat: Deck back grips and trackpads on the second screen

* fix: serialize ring teardown with its writers, fall back to the Xbox pad when the Deck cannot be set up, and read motion only for a Deck

* fix: keep the second screen's choice through sleep and bring it back on wake
2026-09-29 21:57:52 -04:00
The412Banner 00d0f8cd2b gamescope-3.16.29-p4: restore a game minimized behind the Steam menu (patch 0112) 2026-09-29 21:56:42 -04:00
The412Banner faf4b4e8b5 Restore a game that minimized itself behind the Steam menu
The Steam menu is an overlay that takes input without changing gamescope's focus
window. A fullscreen wine game minimizes itself when it loses input, and gamescope
only takes a window out of iconic when the focus window changes, so after Resume
the game stayed minimized: a black screen with its caption in the top-left corner
(Titanfall 2 on GE-Proton 11, proven on device).

gamescope patch 0112 remembers the iconify request and puts the window back to
NormalState before input returns to it, then hands focus over again so wine
restores and activates it. The session script also asks gamescope to restore the
game once input comes back from the client (GAMESCOPE_RESTORE_FOCUS_WINDOW);
BL_RESTORE_ON_RESUME=0 turns that off.
2026-09-29 21:52:34 -04:00
Kurt Himebauch 5a61d37c5d perf: faster local build, skip xalia and gamescope realtime by default, mic off by default, show the saved Deck mode (#85)
* perf: cache pinned downloads, skip unchanged proot rebuilds, and wait out a Docker Desktop restart in the local build

* perf: skip xalia and gamescope realtime queues by default, add a realtime toggle, default the mic off

* fix: show the saved Steam Deck mode on the Steam page before Performance has been opened
2026-09-29 20:00:10 -04:00
Kurt Himebauch 25079ab819 Merge pull request #84 from maxjivi05/fix/performance-misc 2026-09-29 19:41:38 -04:00
maxjivi05 736b7bed5e perf(gamescope): ask X for the pointer only while a cursor is drawn
The gamepad-cursor port queried the X pointer on every vblank, a
blocking round trip to Xwayland on the paint thread. With no cursor
image the position comes from wlserver as upstream; while the cursor is
hidden for inactivity X is asked every 50 ms. The realtime-queue comment
now says it is a no-op on KGSL Turnip.
2026-09-29 18:44:32 -04:00
maxjivi05 a3f4c911e5 perf: poll without starting processes, and stop rewriting unchanged Steam files
The session's watchers slept with sleep(1) and read files with cat, an
exec each under proot; they now wait in bash itself. steam-compat
writes its tool, the adopted wrappers and config.vdf only when they
change, the games watcher probes ownership when its matches change or
every five minutes instead of every 30 s, and Proton no longer inherits
the client's steamrtarm64 library path.
2026-09-29 18:44:32 -04:00
maxjivi05 caa7682c6f perf: default to Turnip sysmem, compact Zink descriptors and the Performance + TSO FEX preset
WinNative's defaults for every Linux session. Choices already saved by
a user are kept.
2026-09-29 18:44:32 -04:00
maxjivi05 e217cbe66d perf: pace buffer releases to the screen, read the requested refresh, drop sustained mode and ADPF
A replaced buffer went straight back to a game with no frame limit, so a
MAILBOX/IMMEDIATE swapchain drew frames that were never shown; it now
comes back once per refresh as in WinNative. gamescope's -r and the
compositor read the rate of the mode the activity asked for rather than
the one the panel was still in, and the zero-copy layer votes it.
Sustained performance mode, which caps clocks on most power HALs, and
the ADPF session, which reported frame intervals as work, are gone.
2026-09-29 18:44:32 -04:00
maxjivi05 4914b2cc69 perf: stop proot's fake_id0 and ioctl traps from stopping every stat, sendmsg and GPU ioctl
fake_id0 (loaded by -i for Xwayland's set*id) now traces only the set*id
family and the xattr fixups when the ids given are proot's own, and the
seccomp filter traces ioctl only for the termios and FICLONE requests
proot rewrites. The faccessat2 and statx exit stops are dropped on
kernels that have the calls.
2026-09-29 18:44:32 -04:00
Kurt Himebauch 6ecd6fa1d0 Merge pull request #81 from Droid-Deck/fix/proot-kompat-utsname-only
perf: stop proot's kompat from trapping hot syscalls, and stop respawning a crashing mangoapp
2026-09-29 15:38:52 -04:00
xXJsonDeruloXx 0f4e83eefd fix: stop gamescope respawning a mangoapp that keeps dying at start 2026-09-29 15:08:28 -04:00
xXJsonDeruloXx d62525f5af perf: stop proot's kompat from trapping futex/epoll/fcntl and stripping the vDSO when only the hostname changes 2026-09-29 14:39:53 -04:00
Kurt Himebauch f4d2c37fbd Merge pull request #78 from Droid-Deck/fix/ci-publish-retention
Fix DroidDeck-CI main build publishing
2026-09-29 10:48:38 -04:00
xXJsonDeruloXx ffd9f92d7b ci: keep main DroidDeck-CI releases published 2026-09-29 10:46:50 -04:00
Kurt Himebauch 213d110766 Merge pull request #55 from Droid-Deck/fix/steamdeck-performance-overlay
Deck mode: working Steam performance overlay (MangoHud)
2026-09-29 09:41:51 -04:00
xXJsonDeruloXx 1a8c91b49b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	app/src/main/java/com/droiddeck/launcher/MainActivity.kt
2026-09-29 09:41:11 -04:00
Kurt Himebauch 9644a4cc7f fix: use Android's 300 ms double-tap window for Back so the single-press action comes sooner (#73) 2026-09-29 02:10:51 -04:00
Kurt Himebauch 9d891d4db1 Merge branch 'main' into fix/steamdeck-performance-overlay 2026-09-28 16:36:33 -04:00
xXJSONDeruloXx e797bc5f01 mangoapp: stat sources for more Snapdragon generations
KGSL's files and thermal zone names differ between Snapdragon generations.
Each value now takes the first source that exists, in the order GameNative
and the Winlator forks read them: GPU load from gpu_busy_percentage or
devfreq/gpu_load, GPU clock from devfreq/cur_freq or gpuclk, GPU temperature
from KGSL's temp, devfreq/temp or a zone whose sensor name has "gpu". CPU
zones are any whose name has "cpu" and not "gpu" (cpuss-0, cpu0-silver-usr,
apc1-cpu0-usr, ...), matched without regard to case.
2026-09-28 12:28:45 -04:00
xXJSONDeruloXx 0cc3fe6bca mangoapp: GPU memory from KGSL
mangoapp's VRAM figure on a Qualcomm driver comes only from the gpu_mem_total
trace event. The tracefs stand-in's event list now declares that event as
Android kernels do, and reading the stand-in's events answers with one
record carrying the total KGSL reports in /sys/class/kgsl/kgsl/page_alloc,
which every Adreno under Android has and the app can read.
2026-09-28 12:28:45 -04:00
xXJSONDeruloXx ab8542e4e5 mangoapp: show Adreno GPU and CPU stats
Valve's mangoapp reads an Adreno GPU's load, clock and temperatures from the
paths of its own hardware, and the CPU temperature from thermal zones named as
mainline kernels name them. Every Adreno under Android keeps the GPU values in
KGSL's sysfs, readable by the app, and Android kernels name the CPU sensors
cpuss-N / cpu-N-M; the session binds each where mangoapp looks. Thermal zones
are found by sensor name, since their numbering differs per device, and
anything that cannot be found is left alone.
2026-09-28 12:19:01 -04:00
xXJSONDeruloXx c2bcf20172 mangoapp: run the performance overlay under Android
Three things kept Valve's mangoapp from drawing the overlay in a session:

- GLFW 3.4 takes XDG_SESSION_TYPE=wayland with WAYLAND_DISPLAY set as
  Wayland-only, and mangoapp needs gamescope's X server; the wrapper sets
  XDG_SESSION_TYPE=x11.
- On a Qualcomm display driver it opens a tracefs instance for the GPU memory
  total and aborts when that fails, which it always does for an Android app.
  The session preload now places the instance in an empty directory of its own
  when the real one cannot be made.
- gamescope sends it each frame's timings through a System V message queue,
  and Android kernels have no System V IPC. sysv.c now emulates the queue
  operations gamescope and mangoapp use, beside its semaphores and shared
  memory.
2026-09-28 12:19:01 -04:00
xXJSONDeruloXx 2341659cea Deck mode: enable Steam's performance overlay controls
The Quick Access menu's performance overlay toggle and level slider stayed
greyed out: the client exposes SteamClient.System.Perf only with -steamos3,
which SteamOS and Armada pass alongside -steamdeck. Deck mode now launches the
client with both. The session already puts package/beta back to the forced
branch before every start, which keeps -steamos3's own branch switch from
turning into an update loop.

The Deck mode switch moves from the Performance page to the Steam page's
Client settings, next to the client branch it selects by default.
2026-09-28 12:19:01 -04:00
xXJSONDeruloXx b2eaf380dc Blend translucent Wayland surfaces
The compositor composed every surface with a blit, which replaces what is under it. gamescope's
Wayland backend hands its overlay and notification planes to the host as full-size translucent
subsurfaces, so a Steam toast, the Quick Access menu or Deck mode's performance overlay blacked
out what was beneath it.

Surfaces whose buffer has alpha and that are not declared opaque now go through an alpha pass
(premultiplied source-over); everything else, and any image the driver cannot sample, keeps the
blit. wl_surface.set_opaque_region is honoured, and only for programs that declare opaque regions
at all, so Wine windows with a meaningless alpha channel are drawn as before. On the SurfaceControl
path a translucent plane is never promoted to the game layer, and the overlay layer is marked
translucent when its plane is.

From WinNative (maxjivi05, feature/wayland-gamescope 69218fc; GPL-3.0), with the SurfaceControl
layer handling added.
2026-09-28 12:18:50 -04:00
165 changed files with 11846 additions and 1150 deletions

No files matched your search

+57 -8
View File
@@ -22,8 +22,10 @@ permissions:
pull-requests: write
concurrency:
group: build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# Replace older builds only for the same PR. Each push/dispatch gets its own group so one main
# build cannot cancel another before it signs and publishes.
group: build-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PRELOAD_CACHE_VERSION: v2
@@ -146,6 +148,8 @@ jobs:
label="${BUILD_REF} at ${BUILD_SHA:0:7}"
fi
echo "label=$label" >> "$GITHUB_OUTPUT"
echo "DROIDDECK_SOURCE_COMMIT=${PR_HEAD_SHA:-$BUILD_SHA}" >> "$GITHUB_ENV"
echo "DROIDDECK_PR_NUMBER=${PR_NUMBER:-0}" >> "$GITHUB_ENV"
echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY"
- name: Cache the preloads
@@ -383,7 +387,7 @@ jobs:
success)
echo "### Build passed"
echo
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key. After approval, a maintainer can run **PR Release Publish** from \`main\` to create an APK that installs over the release."
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key. A maintainer can run **PR Release Publish** from \`main\` to create an APK that installs over the release."
;;
failure)
echo "### ERROR: Build failed"
@@ -452,6 +456,7 @@ jobs:
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
env:
CI_REPO: Droid-Deck/DroidDeck-CI
KEEP: 10
@@ -470,6 +475,34 @@ jobs:
owner: Droid-Deck
repositories: DroidDeck-CI
# What this build is, for the top of the release notes: the merged PR's title and the first
# paragraph of its description, or the commit's subject for a push made without a PR.
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
--jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty')
if [ -n "$pr" ]; then
number=$(jq -r .number <<<"$pr")
title=$(jq -r .title <<<"$pr")
merged=$(jq -r '.merged_at[:10]' <<<"$pr")
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
{
echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)"
if [ -n "$summary" ]; then
echo
echo "$summary"
fi
} > notes-head.md
else
subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]')
echo "**Pushed to main** without a PR - $subject" > notes-head.md
fi
cat notes-head.md
- name: Publish to DroidDeck-CI
id: publish
env:
@@ -479,7 +512,9 @@ jobs:
tag=main-${GITHUB_SHA::7}
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
rm signed/SHA256SUMS-*.txt
notes="Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - unreleased, for testing.
notes="$(cat notes-head.md)
Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing.
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
SHA-256:
@@ -489,10 +524,24 @@ jobs:
gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true
gh release create "$tag" -R "$CI_REPO" --target main --prerelease \
--title "main ${GITHUB_SHA::7}" --notes "$notes" signed/*
# Only the newest KEEP main builds stay; PR releases (pr-N) are left alone.
gh release list -R "$CI_REPO" -L 200 --json tagName,createdAt \
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.createdAt) | reverse | .['"$KEEP"':][] | .tagName' |
while IFS= read -r old; do gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag; done
# Keep the newest main builds by publication time. GitHub App releases can share createdAt.
gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' |
while IFS= read -r old; do
# Never remove this run's release, even if the API returns an unexpected ordering.
if [ "$old" = "$tag" ]; then
continue
fi
# Another main publish may have removed the same old release concurrently.
if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then
if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then
echo "::error::Could not delete old release $old"
exit 1
fi
fi
done
published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets)
jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null
url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag"
apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }')
echo "release=$url" >> "$GITHUB_OUTPUT"
+39
View File
@@ -0,0 +1,39 @@
name: PR Release Cleanup
run-name: remove PR #${{ github.event.pull_request.number }}'s test build
# A merged or closed PR's test build leaves DroidDeck-CI, so the app's Updates page stops offering it
# (a merged fix is in the next Nightly). pull_request_target for the publisher app's secrets; nothing
# from the PR is checked out or run.
on:
pull_request_target:
types: [ closed ]
branches: [ main ]
permissions: {}
jobs:
cleanup:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
- uses: actions/create-github-app-token@v2
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Delete the test build
env:
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
tag="pr-$PR_NUMBER"
if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
echo "Deleted $tag"
else
echo "No test build for PR #$PR_NUMBER"
fi
+51 -29
View File
@@ -5,7 +5,7 @@ on:
workflow_dispatch:
inputs:
pr_number:
description: Open, approved pull request
description: Open pull request
required: true
type: number
@@ -26,8 +26,16 @@ jobs:
run_id: ${{ steps.gate.outputs.run_id }}
head_sha: ${{ steps.gate.outputs.head_sha }}
label: ${{ steps.gate.outputs.label }}
variants: ${{ steps.variants.outputs.list }}
steps:
- name: Check the PR, review, and build
- uses: actions/checkout@v4
with:
persist-credentials: false
# Every variant a main build ships, so a PR build can be tried under any of them.
- name: The packages a signed build ships as
id: variants
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
- name: Check the PR and build
id: gate
env:
GH_TOKEN: ${{ github.token }}
@@ -52,24 +60,11 @@ jobs:
any(.pull_requests[]?; .number == $number and .head.sha == $head and .base.sha == $base)
' <<<"$run" >/dev/null
approved=$(gh api --paginate --slurp \
"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100" |
jq -r --arg head "$head" '[.[][] | select(.commit_id == $head)] |
group_by(.user.login) | map(max_by(.id)) | .[] |
select(.state == "APPROVED") | .user.login')
maintainer_approved=false
while IFS= read -r reviewer; do
[[ -n "$reviewer" ]] || continue
permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$reviewer/permission" --jq .permission)
[[ "$permission" == write || "$permission" == admin ]] && maintainer_approved=true
done <<<"$approved"
[[ "$maintainer_approved" == true ]] || { echo '::error::A maintainer must approve this commit'; exit 1; }
sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}
tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)
stable_tag=$(gh api repos/The412Banner/DroidDeck/releases/latest --jq .tag_name)
stable_tag=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)
mkdir -p "$RUNNER_TEMP/stable" "$RUNNER_TEMP/pr"
gh release download "$stable_tag" -R The412Banner/DroidDeck \
gh release download "$stable_tag" -R "$GITHUB_REPOSITORY" \
-p "DroidDeck-$stable_tag.apk" -D "$RUNNER_TEMP/stable"
gh run download "$run_id" -R "$GITHUB_REPOSITORY" -n droiddeck-apk -D "$RUNNER_TEMP/pr"
mapfile -t apks < <(find "$RUNNER_TEMP/pr" -type f -name '*.apk')
@@ -89,9 +84,6 @@ jobs:
.state == "open" and .draft == false and .base.ref == "main" and
.head.sha == $head and .base.sha == $base
' <<<"$current" >/dev/null
[[ $(gh pr view "$PR_NUMBER" -R "$GITHUB_REPOSITORY" --json reviewDecision --jq .reviewDecision) == APPROVED ]] || {
echo '::error::The current PR revision still needs approval'; exit 1;
}
{
echo "run_id=$run_id"
echo "head_sha=$head"
@@ -103,7 +95,7 @@ jobs:
uses: ./.github/workflows/sign.yml
with:
environment: signing-main
variants: '["standard"]'
variants: ${{ needs.authorize.outputs.variants }}
run_id: ${{ needs.authorize.outputs.run_id }}
label: ${{ needs.authorize.outputs.label }}
artifact: droiddeck-pr-signed
@@ -115,11 +107,33 @@ jobs:
permissions:
actions: read
contents: read
pull-requests: read
steps:
- uses: actions/download-artifact@v4
with:
name: droiddeck-pr-signed-standard
pattern: droiddeck-pr-signed-*
merge-multiple: true
path: signed
# What this build is, for the top of the release notes: the PR's title and the first
# paragraph of its description.
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ inputs.pr_number }}
run: |
set -euo pipefail
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
title=$(jq -r .title <<<"$pr")
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
{
echo "**Not merged** - a test build of open [PR #$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
if [ -n "$summary" ]; then
echo
echo "$summary"
fi
} > notes-head.md
cat notes-head.md
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
- uses: actions/create-github-app-token@v2
id: ci-token
@@ -133,23 +147,31 @@ jobs:
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
PR_NUMBER: ${{ inputs.pr_number }}
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
LABEL: ${{ needs.authorize.outputs.label }}
run: |
set -euo pipefail
repo=Droid-Deck/DroidDeck-CI
tag="pr-$PR_NUMBER"
apk="DroidDeck-$LABEL.apk"
sha=$(sha256sum "signed/$apk" | cut -d ' ' -f 1)
notes="Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
rm signed/SHA256SUMS-*.txt
notes="$(cat notes-head.md)
Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
Commit: $HEAD_SHA
APK SHA-256: $sha"
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
SHA-256:
\`\`\`
$(cat signed/SHA256SUMS.txt)
\`\`\`"
if ! gh release view "$tag" -R "$repo" >/dev/null 2>&1; then
gh release create "$tag" -R "$repo" --target main --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
fi
gh release upload "$tag" "signed/$apk" -R "$repo" --clobber
gh release upload "$tag" signed/* -R "$repo" --clobber
gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
# Drop the previous build's files: anything this build did not just upload.
keep=$(cd signed && ls | jq -R . | jq -sc .)
gh api "repos/$repo/releases/tags/$tag" |
jq -r --arg keep "$apk" '.assets[] | select(.name | endswith(".apk")) | select(.name != $keep) | .id' |
jq -r --argjson keep "$keep" '.assets[] | select(.name as $n | $keep | index($n) | not) | .id' |
while IFS= read -r id; do
gh api --method DELETE "repos/$repo/releases/assets/$id" >/dev/null
done
+1
View File
@@ -12,6 +12,7 @@ captures/
# copies cannot drift.
app/src/main/assets/linuxfs/
app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/.proot-inputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
# Kotlin compiler session files (build-tool state, never source).
+8 -2
View File
@@ -7,14 +7,20 @@
DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big Picture on your Adreno handheld, with Windows games through Valve's ARM64 Proton.
<p align="center"><a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a></p>
<p align="center"><img src="docs/releases/media/0.2.0/launch-into-steam.gif" width="80%" alt="Tapping DroidDeck on the Android home screen and landing in Steam Big Picture"></p>
## Requirements and install
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators.
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; see [docs/development/flatpak.md](docs/development/flatpak.md).
On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options.
## Community
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, nightly builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it.
## Build
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`.
@@ -25,4 +31,4 @@ Compatibility and performance vary by device; hardware validation is limited. De
## Credits and licence
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
+19
View File
@@ -49,6 +49,20 @@ if (!(treeState in ['clean', 'dirty'])) {
buildLabel = "${buildOrigin} ${treeState} · ${buildLabel}"
def escapedBuildLabel = buildLabel.replace('\\', '\\\\').replace('"', '\\"')
// What the in-app updater compares against the published builds: the commit this build is of (a PR
// build's head, not the merge CI makes of it), its PR, and whether CI made it at all.
def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getOrNull()
?: providers.environmentVariable('GITHUB_SHA').getOrNull() ?: gitValue(['rev-parse', 'HEAD']) ?: ''
def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' }
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
// When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds.
def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
.findAll { it.trim() && !it.startsWith('#') }
.collect { def f = it.trim().split(/\s+/); "${f[1]}=${f[2] == '-' ? '' : f[2]}" }
.join(';')
android {
namespace 'com.droiddeck.launcher'
compileSdk 34
@@ -66,6 +80,11 @@ android {
versionCode 9
versionName "0.2.0"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
buildConfigField "boolean", "CI_BUILD", "${ciBuild}"
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
ndk {
abiFilters 'arm64-v8a'
+2
View File
@@ -22,6 +22,8 @@
relay helper opens the input stream under this uid, and PulseAudio exposes it to the
Steam client as DirectAudioMic. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
File diff suppressed because it is too large. Load diff
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#include "lsfg_alpha.hpp"
#include "lsfg_shaders.hpp"
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#pragma once
+3 -4
View File
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#include "lsfg_beta.hpp"
#include "lsfg_shaders.hpp"
+3 -4
View File
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#pragma once
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#include "lsfg_chain.hpp"
#include "lsfg_shaders.hpp"
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#pragma once
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#include "lsfg_common.hpp"
#include "lsfg_shaders.hpp"
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#pragma once
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#include "lsfg_delta.hpp"
#include "lsfg_shaders.hpp"
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#pragma once
+3 -33
View File
@@ -1,9 +1,9 @@
// See lsfg_dll.h. PE resource walk + on-device SPIR-V cache for the Lossless
// Scaling frame-generation chain.
//
// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), LSFG port credited to
// Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk
// (PancakeTAS). Bannerlator is GPL-3.0.
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck
// by @maxjivi05. GPL-3.0-or-later.
#include "lsfg_dll.h"
#include "lsfg_dxbc.h"
@@ -478,20 +478,6 @@ DllStatus validateDll(const std::string& dllPath) {
return status;
}
Variant dllVariant(const std::string& dllPath, bool preferFp16) {
if (dllPath.empty()) return Variant::None;
PeImage img;
if (!img.open(dllPath)) return Variant::None;
ResourceTable table;
if (parseResources(img, table) != DllStatus::Ok) return Variant::None;
Variant v = selectVariant(table, preferFp16);
if (v == Variant::None && hasBaseChain(table)) v = Variant::DxbcTranslated;
return v;
}
DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16) {
if (dllPath.empty() || cachePath.empty()) return DllStatus::NotInstalled;
@@ -547,22 +533,6 @@ DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, b
return DllStatus::Ok;
}
DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath,
bool& outMatches) {
outMatches = false;
if (cachePath.empty() || dllPath.empty()) return DllStatus::CacheUnusable;
CacheHeader header{};
if (!readCacheHeader(cachePath, header)) return DllStatus::CacheUnusable;
PeImage img;
if (!img.open(dllPath)) return DllStatus::NotInstalled;
outMatches = header.sourceSize == (uint64_t)img.size()
&& header.sourceHash == fnv1a64(img.data(), img.size());
return DllStatus::Ok;
}
DllStatus cacheVariant(const std::string& cachePath, Variant& outVariant) {
outVariant = Variant::None;
CacheHeader header{};
+4 -11
View File
@@ -7,7 +7,7 @@
// ship as RCDATA resources inside Lossless.dll from the user's own paid copy
// of Lossless Scaling (Steam app 993090). Every implementation - upstream
// lsfg-vk, LSFG-Android, eden, WinNative - requires the user to supply their
// own DLL and extracts the resources on device. Bannerlator does the same, and
// own DLL and extracts the resources on device. DroidDeck does the same, and
// bundles nothing.
//
// The DLL is mmap'd READ-ONLY and parsed as data. It is never loaded as a
@@ -26,9 +26,9 @@
// Both producers fill the same shader-id -> SPIR-V words map, so everything
// downstream of this file is source-agnostic.
//
// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), whose LSFG port is
// credited to Camille LaVey / the Eden Emulator Project, and which follows
// upstream lsfg-vk (PancakeTAS). Bannerlator is GPL-3.0; see THIRD-PARTY-LICENSES.md.
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck
// by @maxjivi05. GPL-3.0-or-later.
// ============================================================================
#include <cstdint>
@@ -98,18 +98,11 @@ const char* variantName(Variant v);
// reported as valid - the translator handles it.
DllStatus validateDll(const std::string& dllPath);
// Which producer would be used for this DLL.
Variant dllVariant(const std::string& dllPath, bool preferFp16);
// Parse the DLL, produce all 25 SPIR-V modules, and write them to cachePath
// (via temp file + rename, so a failed build cannot leave a half-written
// cache behind). Records which producer ran in the cache header.
DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16);
// Is the cache current for this DLL? Compares source size + content hash.
DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath,
bool& outMatches);
// Load a previously built cache.
DllStatus loadModules(const std::string& cachePath, ModuleSet& outSet);
+4 -3
View File
@@ -1,9 +1,10 @@
// See lsfg_dxbc.h. DXBC -> SPIR-V via DXVK's vendored translator.
//
// Follows upstream lsfg-vk's src/extract/trans.cpp, including its
// encounter-order binding renumber. Derived from WinNative's lsfg_dxbc.cpp
// (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed, vendored
// under cpp/thirdparty/dxbc.
// encounter-order binding renumber. Ported to WinNative (lsfg_dxbc.cpp) and
// DroidDeck by @maxjivi05 from the work of Camille LaVey / the Eden Emulator
// Project (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed,
// vendored under cpp/thirdparty/dxbc.
#include "lsfg_dxbc.h"
@@ -1,7 +1,8 @@
// See lsfg_engine.h.
//
// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited
// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk.
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by
// @maxjivi05, where it runs on gamescope's output in the compositor.
#include "lsfg_engine.h"
@@ -269,7 +270,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32
// nothing. Copy only when generating, on the one priming frame that
// precedes generation, or while the governor is warming up towards a
// probe - so the ring is populated by the time it is needed.
const bool needHistory = generations > 0 || primeHistory_
const bool needHistory = generations > 0 || primeHistory_ || followEveryFrame()
|| (governorEnabled_ && governor_.wantsHistory());
if (needHistory) {
copyPresentedFrame(cmd, source, chain_->Input(count), VkExtent2D{width, height});
@@ -284,7 +285,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32
// regardless of the game's own settings, and it fed straight back into the
// governor: the chain made the source rate collapse, the governor saw the
// collapse and refused to generate, and refusing did not stop the chain.
if (warm_ && generations > 0) chain_->DispatchShared(cmd, count);
if (warm_ && (generations > 0 || followEveryFrame())) chain_->DispatchShared(cmd, count);
}
void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t targetIndex,
+7 -2
View File
@@ -16,8 +16,9 @@
// unavoidable cost of interpolating rather than extrapolating; no placement of
// this code changes it.
//
// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited
// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk.
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by
// @maxjivi05, where it runs on gamescope's output in the compositor.
// ============================================================================
#include <cstdint>
@@ -109,6 +110,10 @@ private:
// generation last stopped.
bool historyFresh() const { return haveCopied_ && lastCopiedCount_ + 1 == frameCount_; }
// With a target rate the pacer plans 0..3 generations frame by frame, so the ring and the
// shared chain have to follow every frame it keeps warm, not just the ones that generate.
bool followEveryFrame() const { return warm_ && pacer_.Config().target_rate != 0; }
Device device_{};
std::string cachePath_;
std::unique_ptr<LsfgShaders> shaders_;
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#include "lsfg_gamma.hpp"
#include "lsfg_shaders.hpp"
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#pragma once
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#include "lsfg_generate.hpp"
#include "lsfg_dll.h"
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#pragma once
@@ -27,12 +27,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeValidateDll(JNIEnv* env, jclass
return (jint)lsfg::validateDll(toStdString(env, dllPath));
}
extern "C" JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeDllVariant(JNIEnv* env, jclass, jstring dllPath,
jboolean preferFp16) {
return (jint)lsfg::dllVariant(toStdString(env, dllPath), preferFp16 == JNI_TRUE);
}
extern "C" JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass, jstring dllPath,
jstring cachePath, jboolean preferFp16) {
@@ -40,16 +34,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass,
preferFp16 == JNI_TRUE);
}
extern "C" JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheMatchesSource(JNIEnv* env, jclass,
jstring cachePath,
jstring dllPath) {
bool matches = false;
const lsfg::DllStatus status = lsfg::cacheMatchesSource(toStdString(env, cachePath),
toStdString(env, dllPath), matches);
return (jboolean)(status == lsfg::DllStatus::Ok && matches);
}
extern "C" JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheVariant(JNIEnv* env, jclass, jstring cachePath) {
lsfg::Variant variant = lsfg::Variant::None;
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#include "lsfg_mipmaps.hpp"
#include "lsfg_dll.h"
@@ -4,10 +4,9 @@
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#pragma once
@@ -1,10 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#include "lsfg_pacer.hpp"
@@ -22,6 +21,8 @@ constexpr float SOURCE_SMOOTHING = 0.15f;
constexpr float SOURCE_STALE_SECONDS = 0.5f;
constexpr float DISCONTINUITY_SECONDS = 0.25f;
constexpr float HEADROOM_EPSILON = 0.02f;
// A target is aimed just under, so rounding never puts output past the panel's refresh rate.
constexpr float TARGET_MARGIN = 0.1f;
constexpr float CREDIT_EPSILON = 1.0e-4f;
constexpr float SOURCE_ACCUM_FLOOR = 0.01f;
constexpr uint32_t MIN_RATE_SAMPLES = 12;
@@ -117,6 +118,7 @@ LsfgPlan LsfgPacer::Plan(size_t capacity, uint64_t source_frames) {
if (target_rate > 0.0f && config.refresh_rate > 0.0f) {
target_rate = std::min(target_rate, config.refresh_rate);
}
if (target_rate > 0.0f) target_rate -= TARGET_MARGIN;
if (target_rate == 0.0f) {
// No explicit target rate: the multiplier is the multiplier, full stop.
@@ -1,10 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#pragma once
@@ -1,10 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#include "lsfg_shaders.hpp"
#include "lsfg_common.hpp"
@@ -1,10 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
// Vulkan dispatch differs here (see lsfg_vkd.h).
#pragma once
+2 -2
View File
@@ -2,10 +2,10 @@
// ============================================================================
// lsfg_vkd - the Vulkan entry points the LSFG chain calls, as a single global.
//
// Two dispatch styles meet here. Bannerlator's compositor resolves Vulkan
// Two dispatch styles meet here. DroidDeck's compositor resolves Vulkan
// through a per-context VkTable (`vk_`), because an adrenotools-loaded driver
// lives in an isolated linker namespace and shares no global symbols with the
// system loader. The LSFG chain, ported from eden by way of WinNative, calls
// system loader. The LSFG chain, ported from Eden by way of WinNative, calls
// through a file-scope `vkd`. Rather than rewrite ~2,300 lines of proven chain
// code to thread a table through every call, this shim exposes exactly the 34
// entry points the chain uses and is filled from the renderer's own table when
@@ -19,6 +19,7 @@ add_library(bannerwayland SHARED
src/wl_toplevel_icon.c
src/wl_color_mgmt.c
src/hdr_compose.c
src/blend_pass.c
generated/color-management-v1-protocol.c
generated/xdg-shell-protocol.c
generated/linux-dmabuf-v1-protocol.c
@@ -0,0 +1,9 @@
#version 450
// Wayland buffers carry premultiplied alpha; the pipeline blends ONE, ONE_MINUS_SRC_ALPHA.
layout(binding = 0) uniform sampler2D Surface;
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;
void main() {
outColor = texture(Surface, uv);
}
@@ -0,0 +1,10 @@
#version 450
// One surface as a quad: dst = the target rectangle in NDC, src = the buffer rectangle in UV.
layout(push_constant) uniform PC { vec4 dst; vec4 src; } pc;
layout(location = 0) out vec2 uv;
void main() {
vec2 t = vec2(gl_VertexIndex & 1, gl_VertexIndex >> 1);
gl_Position = vec4(mix(pc.dst.xy, pc.dst.zw, t), 0.0, 1.0);
uv = mix(pc.src.xy, pc.src.zw, t);
}
@@ -0,0 +1,22 @@
// 1115.1.0
#pragma once
const uint32_t blend_frag_code[] = {
0x07230203,0x00010000,0x0008000b,0x00000014,0x00000000,0x00020011,0x00000001,0x0006000b,
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x00000009,0x00000011,0x00030010,
0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,
0x00000000,0x00050005,0x00000009,0x4374756f,0x726f6c6f,0x00000000,0x00040005,0x0000000d,
0x66727553,0x00656361,0x00030005,0x00000011,0x00007675,0x00040047,0x00000009,0x0000001e,
0x00000000,0x00040047,0x0000000d,0x00000021,0x00000000,0x00040047,0x0000000d,0x00000022,
0x00000000,0x00040047,0x00000011,0x0000001e,0x00000000,0x00020013,0x00000002,0x00030021,
0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,
0x00000004,0x00040020,0x00000008,0x00000003,0x00000007,0x0004003b,0x00000008,0x00000009,
0x00000003,0x00090019,0x0000000a,0x00000006,0x00000001,0x00000000,0x00000000,0x00000000,
0x00000001,0x00000000,0x0003001b,0x0000000b,0x0000000a,0x00040020,0x0000000c,0x00000000,
0x0000000b,0x0004003b,0x0000000c,0x0000000d,0x00000000,0x00040017,0x0000000f,0x00000006,
0x00000002,0x00040020,0x00000010,0x00000001,0x0000000f,0x0004003b,0x00000010,0x00000011,
0x00000001,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,
0x0004003d,0x0000000b,0x0000000e,0x0000000d,0x0004003d,0x0000000f,0x00000012,0x00000011,
0x00050057,0x00000007,0x00000013,0x0000000e,0x00000012,0x0003003e,0x00000009,0x00000013,
0x000100fd,0x00010038
};
@@ -0,0 +1,315 @@
/* Alpha composition: one surface drawn source-over onto the scene - see blend_pass.h. Compositor thread. */
#define _POSIX_C_SOURCE 200809L
#include "blend_pass.h"
#include "vk_loader.h"
#include "vk_present.h" /* banner_log, vkp_gpu_name */
#include <string.h>
/* SPIR-V, pre-compiled (glslangValidator -V blend.vert --vn blend_vert_code, the same for blend.frag)
* and committed next to the GLSL: the NDK build compiles no shaders. */
#include "blend_vert.h"
#include "blend_frag.h"
struct pc_blend { float dst[4]; float src[4]; };
static VkDevice g_dev;
static int g_ready; /* 0 = not built, 1 = ok, -1 = unavailable (said once) */
static VkSampler g_sampler;
static VkDescriptorSetLayout g_dsl;
static VkPipelineLayout g_pl;
static VkDescriptorPool g_dpool;
static VkShaderModule g_vert, g_frag;
static unsigned g_frame = 1;
/* One render pass + pipeline per target format: the screen swapchain's, the scene image's, the HDR
* composition's. */
#define NFMT 4
static struct { VkFormat fmt; VkRenderPass rp; VkPipeline pipe; } g_out[NFMT];
#define NSRC 32
static struct { VkImage img; VkImageView view; VkDescriptorSet ds; unsigned frame; } g_src[NSRC];
#define NDST 12
static struct { VkImage img; VkFormat fmt; int w, h; VkImageView view; VkFramebuffer fb; unsigned frame; } g_dst[NDST];
void blendp_bind_device(VkDevice dev) { g_dev = dev; }
void blendp_begin_frame(void) { g_frame++; }
static void src_release(int i) {
if (g_src[i].ds) g_vk.FreeDescriptorSets(g_dev, g_dpool, 1, &g_src[i].ds);
if (g_src[i].view) g_vk.DestroyImageView(g_dev, g_src[i].view, NULL);
memset(&g_src[i], 0, sizeof(g_src[i]));
}
static void dst_release(int i) {
if (g_dst[i].fb) g_vk.DestroyFramebuffer(g_dev, g_dst[i].fb, NULL);
if (g_dst[i].view) g_vk.DestroyImageView(g_dev, g_dst[i].view, NULL);
memset(&g_dst[i], 0, sizeof(g_dst[i]));
}
void blendp_forget_image(VkImage img) {
if (!g_dev || !img) return;
for (int i = 0; i < NSRC; i++) if (g_src[i].img == img) src_release(i);
for (int i = 0; i < NDST; i++) if (g_dst[i].img == img) dst_release(i);
}
static VkShaderModule shader(const uint32_t *code, size_t size) {
VkShaderModule m = VK_NULL_HANDLE;
VkShaderModuleCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, .codeSize = size, .pCode = code};
if (g_vk.CreateShaderModule(g_dev, &ci, NULL, &m) != VK_SUCCESS) return VK_NULL_HANDLE;
return m;
}
/* The target keeps what the blits put there (LOAD) and stays in TRANSFER_DST_OPTIMAL for the blits
* that follow. The dependencies order the pass between them. */
static VkRenderPass make_rp(VkFormat fmt) {
VkAttachmentDescription att = {.format = fmt, .samples = VK_SAMPLE_COUNT_1_BIT,
.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD, .storeOp = VK_ATTACHMENT_STORE_OP_STORE,
.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE,
.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
.initialLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
.finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL};
VkAttachmentReference ref = {0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL};
VkSubpassDescription sub = {.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, .colorAttachmentCount = 1,
.pColorAttachments = &ref};
VkSubpassDependency deps[2] = {
{.srcSubpass = VK_SUBPASS_EXTERNAL, .dstSubpass = 0,
.srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT},
{.srcSubpass = 0, .dstSubpass = VK_SUBPASS_EXTERNAL,
.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.dstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT |
VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT}};
VkRenderPassCreateInfo rci = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, .attachmentCount = 1,
.pAttachments = &att, .subpassCount = 1, .pSubpasses = &sub,
.dependencyCount = 2, .pDependencies = deps};
VkRenderPass rp = VK_NULL_HANDLE;
if (g_vk.CreateRenderPass(g_dev, &rci, NULL, &rp) != VK_SUCCESS) return VK_NULL_HANDLE;
return rp;
}
static VkPipeline make_pipe(VkRenderPass rp) {
VkPipelineShaderStageCreateInfo st[2] = {
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_VERTEX_BIT,
.module = g_vert, .pName = "main"},
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_FRAGMENT_BIT,
.module = g_frag, .pName = "main"}};
VkPipelineVertexInputStateCreateInfo vi = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};
VkPipelineInputAssemblyStateCreateInfo ia = {.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,
.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP};
VkDynamicState dyn[2] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
VkPipelineDynamicStateCreateInfo ds = {.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
.dynamicStateCount = 2, .pDynamicStates = dyn};
VkPipelineViewportStateCreateInfo vp = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
.viewportCount = 1, .scissorCount = 1};
VkPipelineRasterizationStateCreateInfo rs = {.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO,
.polygonMode = VK_POLYGON_MODE_FILL, .cullMode = VK_CULL_MODE_NONE,
.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, .lineWidth = 1.0f};
VkPipelineMultisampleStateCreateInfo ms = {.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO,
.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT};
/* The target's alpha is left alone: the screen is opaque whatever is drawn onto it. */
VkPipelineColorBlendAttachmentState ba = {
.blendEnable = VK_TRUE, .srcColorBlendFactor = VK_BLEND_FACTOR_ONE,
.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, .colorBlendOp = VK_BLEND_OP_ADD,
.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE, .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA,
.alphaBlendOp = VK_BLEND_OP_ADD,
.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT};
VkPipelineColorBlendStateCreateInfo cb = {.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
.attachmentCount = 1, .pAttachments = &ba};
VkGraphicsPipelineCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, .stageCount = 2,
.pStages = st, .pVertexInputState = &vi, .pInputAssemblyState = &ia,
.pViewportState = &vp, .pRasterizationState = &rs, .pMultisampleState = &ms,
.pColorBlendState = &cb, .pDynamicState = &ds, .layout = g_pl,
.renderPass = rp, .subpass = 0};
VkPipeline p = VK_NULL_HANDLE;
if (g_vk.CreateGraphicsPipelines(g_dev, VK_NULL_HANDLE, 1, &pi, NULL, &p) != VK_SUCCESS) return VK_NULL_HANDLE;
return p;
}
static void objects_destroy(void) {
if (g_vert) g_vk.DestroyShaderModule(g_dev, g_vert, NULL);
if (g_frag) g_vk.DestroyShaderModule(g_dev, g_frag, NULL);
if (g_dpool) g_vk.DestroyDescriptorPool(g_dev, g_dpool, NULL);
if (g_pl) g_vk.DestroyPipelineLayout(g_dev, g_pl, NULL);
if (g_dsl) g_vk.DestroyDescriptorSetLayout(g_dev, g_dsl, NULL);
if (g_sampler) g_vk.DestroySampler(g_dev, g_sampler, NULL);
g_vert = g_frag = VK_NULL_HANDLE; g_dpool = VK_NULL_HANDLE; g_pl = VK_NULL_HANDLE;
g_dsl = VK_NULL_HANDLE; g_sampler = VK_NULL_HANDLE;
}
static int objects_ensure(void) {
if (g_ready) return g_ready == 1 ? 0 : -1;
g_ready = -1;
if (!g_dev || !g_vk.CreateGraphicsPipelines) goto fail;
VkSamplerCreateInfo sci = {.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, .magFilter = VK_FILTER_LINEAR,
.minFilter = VK_FILTER_LINEAR, .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE};
if (g_vk.CreateSampler(g_dev, &sci, NULL, &g_sampler) != VK_SUCCESS) goto fail;
VkDescriptorSetLayoutBinding b = {.binding = 0, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
.descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT};
VkDescriptorSetLayoutCreateInfo dli = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
.bindingCount = 1, .pBindings = &b};
if (g_vk.CreateDescriptorSetLayout(g_dev, &dli, NULL, &g_dsl) != VK_SUCCESS) goto fail;
VkPushConstantRange pcr = {.stageFlags = VK_SHADER_STAGE_VERTEX_BIT, .offset = 0, .size = sizeof(struct pc_blend)};
VkPipelineLayoutCreateInfo pli = {.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, .setLayoutCount = 1,
.pSetLayouts = &g_dsl, .pushConstantRangeCount = 1, .pPushConstantRanges = &pcr};
if (g_vk.CreatePipelineLayout(g_dev, &pli, NULL, &g_pl) != VK_SUCCESS) goto fail;
VkDescriptorPoolSize ps = {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, NSRC};
VkDescriptorPoolCreateInfo dpi = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, .maxSets = NSRC,
.poolSizeCount = 1, .pPoolSizes = &ps};
if (g_vk.CreateDescriptorPool(g_dev, &dpi, NULL, &g_dpool) != VK_SUCCESS) goto fail;
if (!(g_vert = shader(blend_vert_code, sizeof(blend_vert_code)))) goto fail;
if (!(g_frag = shader(blend_frag_code, sizeof(blend_frag_code)))) goto fail;
g_ready = 1;
banner_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them",
vkp_gpu_name());
return 0;
fail:
objects_destroy();
banner_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces "
"are drawn opaque this session");
return -1;
}
static int out_slot(VkFormat fmt) {
int i;
for (i = 0; i < NFMT && g_out[i].pipe; i++) if (g_out[i].fmt == fmt) return i;
if (i == NFMT) return -1;
VkRenderPass rp = make_rp(fmt);
VkPipeline pipe = rp ? make_pipe(rp) : VK_NULL_HANDLE;
if (!pipe) {
if (rp) g_vk.DestroyRenderPass(g_dev, rp, NULL);
return -1;
}
g_out[i].fmt = fmt; g_out[i].rp = rp; g_out[i].pipe = pipe;
return i;
}
static VkImageView make_view(VkImage img, VkFormat fmt) {
VkImageView v = VK_NULL_HANDLE;
VkImageViewCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, .image = img,
.viewType = VK_IMAGE_VIEW_TYPE_2D, .format = fmt,
.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}};
if (g_vk.CreateImageView(g_dev, &ci, NULL, &v) != VK_SUCCESS) return VK_NULL_HANDLE;
return v;
}
/* The slot to recycle: a free one, else the one unused for longest - never one this command buffer
* already refers to. -1 = every slot is in this frame. */
#define OLDEST(arr, n, out) do { \
unsigned age_ = 0; \
(out) = -1; \
for (int k_ = 0; k_ < (n); k_++) { \
if (!(arr)[k_].img) { (out) = k_; break; } \
if ((arr)[k_].frame != g_frame && g_frame - (arr)[k_].frame > age_) { age_ = g_frame - (arr)[k_].frame; (out) = k_; } \
} \
} while (0)
static VkDescriptorSet src_ensure(VkImage img, VkFormat fmt, VkImageLayout layout) {
int i;
for (i = 0; i < NSRC; i++)
if (g_src[i].img == img) { g_src[i].frame = g_frame; return g_src[i].ds; }
OLDEST(g_src, NSRC, i);
if (i < 0) return VK_NULL_HANDLE;
src_release(i);
if (!(g_src[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
VkDescriptorSetAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
.descriptorPool = g_dpool, .descriptorSetCount = 1, .pSetLayouts = &g_dsl};
if (g_vk.AllocateDescriptorSets(g_dev, &ai, &g_src[i].ds) != VK_SUCCESS) {
g_src[i].ds = VK_NULL_HANDLE;
src_release(i);
return VK_NULL_HANDLE;
}
VkDescriptorImageInfo ii = {.sampler = g_sampler, .imageView = g_src[i].view, .imageLayout = layout};
VkWriteDescriptorSet wr = {.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = g_src[i].ds, .dstBinding = 0,
.descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
.pImageInfo = &ii};
g_vk.UpdateDescriptorSets(g_dev, 1, &wr, 0, NULL);
g_src[i].img = img; g_src[i].frame = g_frame;
return g_src[i].ds;
}
static VkFramebuffer dst_ensure(VkImage img, VkFormat fmt, int w, int h, VkRenderPass rp) {
int i;
for (i = 0; i < NDST; i++) {
if (g_dst[i].img != img) continue;
if (g_dst[i].fmt == fmt && g_dst[i].w == w && g_dst[i].h == h) { g_dst[i].frame = g_frame; return g_dst[i].fb; }
if (g_dst[i].frame == g_frame) return VK_NULL_HANDLE;
break;
}
if (i == NDST) OLDEST(g_dst, NDST, i);
if (i < 0) return VK_NULL_HANDLE;
dst_release(i);
if (!(g_dst[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
VkFramebufferCreateInfo fi = {.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, .renderPass = rp,
.attachmentCount = 1, .pAttachments = &g_dst[i].view, .width = (uint32_t)w,
.height = (uint32_t)h, .layers = 1};
if (g_vk.CreateFramebuffer(g_dev, &fi, NULL, &g_dst[i].fb) != VK_SUCCESS) {
g_dst[i].fb = VK_NULL_HANDLE;
dst_release(i);
return VK_NULL_HANDLE;
}
g_dst[i].img = img; g_dst[i].fmt = fmt; g_dst[i].w = w; g_dst[i].h = h; g_dst[i].frame = g_frame;
return g_dst[i].fb;
}
static void src_layout(VkCommandBuffer cmd, VkImage img, VkImageLayout from, VkImageLayout to) {
int sampling = to == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkImageMemoryBarrier b = {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = from, .newLayout = to,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = img, .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1},
.srcAccessMask = sampling ? VK_ACCESS_TRANSFER_READ_BIT : VK_ACCESS_SHADER_READ_BIT,
.dstAccessMask = sampling ? VK_ACCESS_SHADER_READ_BIT : VK_ACCESS_TRANSFER_READ_BIT};
g_vk.CmdPipelineBarrier(cmd, sampling ? VK_PIPELINE_STAGE_TRANSFER_BIT : VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
sampling ? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT : VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 0, NULL, 0, NULL, 1, &b);
}
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit) {
if (!src || !dst || src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0 || objects_ensure() != 0) return -1;
int slot = out_slot(dst_fmt);
if (slot < 0) return -1;
VkImageLayout sampled = src_general ? VK_IMAGE_LAYOUT_GENERAL : VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkDescriptorSet ds = src_ensure(src, src_fmt, sampled);
if (!ds) return -1;
VkFramebuffer fb = dst_ensure(dst, dst_fmt, dst_w, dst_h, g_out[slot].rp);
if (!fb) return -1;
const VkOffset3D *d = blit->dstOffsets, *s = blit->srcOffsets;
int x0 = d[0].x < d[1].x ? d[0].x : d[1].x, x1 = d[0].x < d[1].x ? d[1].x : d[0].x;
int y0 = d[0].y < d[1].y ? d[0].y : d[1].y, y1 = d[0].y < d[1].y ? d[1].y : d[0].y;
if (x0 < 0) x0 = 0;
if (y0 < 0) y0 = 0;
if (x1 > dst_w) x1 = dst_w;
if (y1 > dst_h) y1 = dst_h;
if (x1 <= x0 || y1 <= y0) return 0;
struct pc_blend pc = {
.dst = {2.0f * d[0].x / dst_w - 1.0f, 2.0f * d[0].y / dst_h - 1.0f,
2.0f * d[1].x / dst_w - 1.0f, 2.0f * d[1].y / dst_h - 1.0f},
.src = {(float)s[0].x / src_w, (float)s[0].y / src_h, (float)s[1].x / src_w, (float)s[1].y / src_h}};
if (!src_general) src_layout(cmd, src, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, sampled);
VkRenderPassBeginInfo rpi = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, .renderPass = g_out[slot].rp,
.framebuffer = fb,
.renderArea = {{x0, y0}, {(uint32_t)(x1 - x0), (uint32_t)(y1 - y0)}}};
VkViewport vp = {0, 0, (float)dst_w, (float)dst_h, 0, 1};
g_vk.CmdBeginRenderPass(cmd, &rpi, VK_SUBPASS_CONTENTS_INLINE);
g_vk.CmdSetViewport(cmd, 0, 1, &vp);
g_vk.CmdSetScissor(cmd, 0, 1, &rpi.renderArea);
g_vk.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_out[slot].pipe);
g_vk.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_pl, 0, 1, &ds, 0, NULL);
g_vk.CmdPushConstants(cmd, g_pl, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc);
g_vk.CmdDraw(cmd, 4, 1, 0, 0);
g_vk.CmdEndRenderPass(cmd);
if (!src_general) src_layout(cmd, src, sampled, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL);
return 0;
}
@@ -0,0 +1,28 @@
#ifndef BLEND_PASS_H
#define BLEND_PASS_H
/*
* Alpha composition. The scene is composed with blits, which replace what is under them; a surface
* with translucent pixels (gamescope's overlay and notification planes over a game, a toolkit's
* shadowed popup) is drawn through this pass instead, source-over with premultiplied alpha.
*
* Compositor thread. Objects are built on the first use; if the driver refuses them the pass is
* unavailable for the session (logged once) and the caller keeps blitting.
*/
#define VK_USE_PLATFORM_ANDROID_KHR
#include <vulkan/vulkan.h>
/* Called from dev_init (device up). */
void blendp_bind_device(VkDevice dev);
/* A source or target image is about to be destroyed: drop what is cached for it. Frames are fenced,
* so nothing submitted still uses it. */
void blendp_forget_image(VkImage img);
/* A new command buffer is being recorded: what the previous one used may be recycled again. */
void blendp_begin_frame(void);
/* Record one surface over `dst`. `dst` is in TRANSFER_DST_OPTIMAL before and after. `src` was created
* with SAMPLED usage and is in GENERAL (src_general) or TRANSFER_SRC_OPTIMAL, and is left there.
* `blit` is the draw as the blit path maps it. 0 = recorded, -1 = unavailable (nothing recorded). */
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit);
#endif
@@ -0,0 +1,55 @@
// 1115.1.0
#pragma once
const uint32_t blend_vert_code[] = {
0x07230203,0x00010000,0x0008000b,0x0000003a,0x00000000,0x00020011,0x00000001,0x0006000b,
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
0x0008000f,0x00000000,0x00000004,0x6e69616d,0x00000000,0x0000000c,0x0000001b,0x00000031,
0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,0x00000000,0x00030005,
0x00000009,0x00000074,0x00060005,0x0000000c,0x565f6c67,0x65747265,0x646e4978,0x00007865,
0x00060005,0x00000019,0x505f6c67,0x65567265,0x78657472,0x00000000,0x00060006,0x00000019,
0x00000000,0x505f6c67,0x7469736f,0x006e6f69,0x00070006,0x00000019,0x00000001,0x505f6c67,
0x746e696f,0x657a6953,0x00000000,0x00070006,0x00000019,0x00000002,0x435f6c67,0x4470696c,
0x61747369,0x0065636e,0x00070006,0x00000019,0x00000003,0x435f6c67,0x446c6c75,0x61747369,
0x0065636e,0x00030005,0x0000001b,0x00000000,0x00030005,0x0000001d,0x00004350,0x00040006,
0x0000001d,0x00000000,0x00747364,0x00040006,0x0000001d,0x00000001,0x00637273,0x00030005,
0x0000001f,0x00006370,0x00030005,0x00000031,0x00007675,0x00040047,0x0000000c,0x0000000b,
0x0000002a,0x00030047,0x00000019,0x00000002,0x00050048,0x00000019,0x00000000,0x0000000b,
0x00000000,0x00050048,0x00000019,0x00000001,0x0000000b,0x00000001,0x00050048,0x00000019,
0x00000002,0x0000000b,0x00000003,0x00050048,0x00000019,0x00000003,0x0000000b,0x00000004,
0x00030047,0x0000001d,0x00000002,0x00050048,0x0000001d,0x00000000,0x00000023,0x00000000,
0x00050048,0x0000001d,0x00000001,0x00000023,0x00000010,0x00040047,0x00000031,0x0000001e,
0x00000000,0x00020013,0x00000002,0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,
0x00000020,0x00040017,0x00000007,0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,
0x00000007,0x00040015,0x0000000a,0x00000020,0x00000001,0x00040020,0x0000000b,0x00000001,
0x0000000a,0x0004003b,0x0000000b,0x0000000c,0x00000001,0x0004002b,0x0000000a,0x0000000e,
0x00000001,0x00040017,0x00000015,0x00000006,0x00000004,0x00040015,0x00000016,0x00000020,
0x00000000,0x0004002b,0x00000016,0x00000017,0x00000001,0x0004001c,0x00000018,0x00000006,
0x00000017,0x0006001e,0x00000019,0x00000015,0x00000006,0x00000018,0x00000018,0x00040020,
0x0000001a,0x00000003,0x00000019,0x0004003b,0x0000001a,0x0000001b,0x00000003,0x0004002b,
0x0000000a,0x0000001c,0x00000000,0x0004001e,0x0000001d,0x00000015,0x00000015,0x00040020,
0x0000001e,0x00000009,0x0000001d,0x0004003b,0x0000001e,0x0000001f,0x00000009,0x00040020,
0x00000020,0x00000009,0x00000015,0x0004002b,0x00000006,0x00000029,0x00000000,0x0004002b,
0x00000006,0x0000002a,0x3f800000,0x00040020,0x0000002e,0x00000003,0x00000015,0x00040020,
0x00000030,0x00000003,0x00000007,0x0004003b,0x00000030,0x00000031,0x00000003,0x00050036,
0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000008,
0x00000009,0x00000007,0x0004003d,0x0000000a,0x0000000d,0x0000000c,0x000500c7,0x0000000a,
0x0000000f,0x0000000d,0x0000000e,0x0004006f,0x00000006,0x00000010,0x0000000f,0x0004003d,
0x0000000a,0x00000011,0x0000000c,0x000500c3,0x0000000a,0x00000012,0x00000011,0x0000000e,
0x0004006f,0x00000006,0x00000013,0x00000012,0x00050050,0x00000007,0x00000014,0x00000010,
0x00000013,0x0003003e,0x00000009,0x00000014,0x00050041,0x00000020,0x00000021,0x0000001f,
0x0000001c,0x0004003d,0x00000015,0x00000022,0x00000021,0x0007004f,0x00000007,0x00000023,
0x00000022,0x00000022,0x00000000,0x00000001,0x00050041,0x00000020,0x00000024,0x0000001f,
0x0000001c,0x0004003d,0x00000015,0x00000025,0x00000024,0x0007004f,0x00000007,0x00000026,
0x00000025,0x00000025,0x00000002,0x00000003,0x0004003d,0x00000007,0x00000027,0x00000009,
0x0008000c,0x00000007,0x00000028,0x00000001,0x0000002e,0x00000023,0x00000026,0x00000027,
0x00050051,0x00000006,0x0000002b,0x00000028,0x00000000,0x00050051,0x00000006,0x0000002c,
0x00000028,0x00000001,0x00070050,0x00000015,0x0000002d,0x0000002b,0x0000002c,0x00000029,
0x0000002a,0x00050041,0x0000002e,0x0000002f,0x0000001b,0x0000001c,0x0003003e,0x0000002f,
0x0000002d,0x00050041,0x00000020,0x00000032,0x0000001f,0x0000000e,0x0004003d,0x00000015,
0x00000033,0x00000032,0x0007004f,0x00000007,0x00000034,0x00000033,0x00000033,0x00000000,
0x00000001,0x00050041,0x00000020,0x00000035,0x0000001f,0x0000000e,0x0004003d,0x00000015,
0x00000036,0x00000035,0x0007004f,0x00000007,0x00000037,0x00000036,0x00000036,0x00000002,
0x00000003,0x0004003d,0x00000007,0x00000038,0x00000009,0x0008000c,0x00000007,0x00000039,
0x00000001,0x0000002e,0x00000034,0x00000037,0x00000038,0x0003003e,0x00000031,0x00000039,
0x000100fd,0x00010038
};
+74 -15
View File
@@ -238,6 +238,8 @@ struct surface {
struct dmabuf_buffer *dmabuf_buf; /* its imported image, kept until replaced or the surface goes */
struct wl_listener dmabuf_destroy;
int buf_w, buf_h, has_content;
int buf_alpha;
int opaque[4], pending_opaque[4], pending_opaque_set; /* x, y, w, h; w = 0: none */
int src_set, dst_set;
float src[4];
int dst[2];
@@ -433,9 +435,16 @@ static int on_release_timer(int fd, uint32_t mask, void *data) {
* delivered (a swapchain rebuild destroys buffers with queued releases) or that a coarser earlier
* limit had spaced out, and the game's first frames after that waited on empty slots. */
static void release_buffer(struct surface *s, struct wl_resource *buffer, int64_t since_ns) {
int limit = g_fps_limit;
if (limit <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
int64_t interval = 1000000000LL / limit, now = now_ns();
const int limit = g_fps_limit;
/* Without a limit of its own a game is paced to the screen. A swapchain that does not wait for
* frame callbacks (MAILBOX, IMMEDIATE) is held back by nothing else here, so handing a replaced
* buffer straight back let it draw as fast as the GPU allowed and everything above the refresh
* rate was thrown away - 178 frames a second for 91 on screen, measured (WinNative, maxjivi05).
* One release per refresh is the back-pressure X11 gives, and the screen shows the same frames
* either way. */
const int64_t interval = limit > 0 ? 1000000000LL / limit : g_refresh_ns;
if (interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
int64_t now = now_ns();
int64_t at = s->next_release_ns + interval;
int64_t latest = now + interval * (int64_t)(s->releases_pending + 1);
if (at < now) at = now;
@@ -532,6 +541,13 @@ static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) {
wl_shm_buffer_end_access(shm);
}
/* The serial alone, without the lock: the app asks on every pointer move, and the snapshot below
* copies up to 256 KB of pixels across JNI and holds the lock the compositor thread takes to
* change the cursor. A torn read is only a missed or an extra snapshot. */
int banner_cursor_serial(void) {
return __atomic_load_n(&g_cursor_serial, __ATOMIC_RELAXED);
}
/* Read by the app (UI thread). out = [serial, hidden, w, h, hotspotX, hotspotY, pixels...].
* Returns the number of ints written, or 0 if out is too small. */
int banner_cursor_snapshot(int *out, int cap) {
@@ -722,6 +738,7 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res
wl_buffer_send_release(buffer);
s->buf_w = w;
s->buf_h = h;
s->buf_alpha = wl_shm_buffer_get_format(shm) == WL_SHM_FORMAT_ARGB8888;
s->has_content = s->shm_img != NULL;
g_stat_shm++;
struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
@@ -773,6 +790,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
}
s->buf_w = b->width;
s->buf_h = b->height;
s->buf_alpha = (b->format & 0xff) == 'A';
s->has_content = b->img != NULL;
g_stat_dmabuf++;
const int announced_now = !s->announced_vulkan;
@@ -944,8 +962,27 @@ static void surface_frame(struct wl_client *c, struct wl_resource *r, uint32_t c
wl_resource_set_implementation(callback, NULL, NULL, frame_callback_destroy);
wl_list_insert(s->pending_frames.prev, wl_resource_get_link(callback));
}
/* The bounding box serves pointer confinement. The exact flag also tells opaque-region handling
* whether the bounding box itself is the whole region. */
struct region { int set, exact; int x, y, w, h; };
/* A wl_surface's opaque region is pending until its next commit. An exact region that covers
* the surface lets the blit path keep handling an otherwise alpha-capable buffer. */
static void surface_set_opaque(struct wl_client *c, struct wl_resource *r,
struct wl_resource *region) {}
struct wl_resource *region) {
struct surface *s = wl_resource_get_user_data(r);
struct region *rg = region ? wl_resource_get_user_data(region) : NULL;
memset(s->pending_opaque, 0, sizeof(s->pending_opaque));
s->pending_opaque_set = 1;
if (rg && rg->set && rg->exact) {
s->pending_opaque[0] = rg->x;
s->pending_opaque[1] = rg->y;
s->pending_opaque[2] = rg->w;
s->pending_opaque[3] = rg->h;
}
struct client_info *ci = client_info_of(c);
if (ci) ci->declares_opaque = 1;
}
static void surface_set_input(struct wl_client *c, struct wl_resource *r,
struct wl_resource *region) {}
@@ -1036,6 +1073,10 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
memcpy(s->dst, s->pending_dst, sizeof(s->dst));
s->pending_dst_set = 0;
}
if (s->pending_opaque_set) {
memcpy(s->opaque, s->pending_opaque, sizeof(s->opaque));
s->pending_opaque_set = 0;
}
if (s->pending_attach) {
struct wl_resource *buffer = s->pending_buffer;
@@ -1174,24 +1215,29 @@ static void surface_resource_destroy(struct wl_resource *r) {
/* ------------------------------------------------------------------ wl_region */
/* A region is kept as the bounding box of its rectangles: enough for pointer confinement,
* where winewayland sends one rectangle (the ClipCursor area). Subtractions are ignored. */
struct region { int set; int x, y, w, h; };
static void region_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static void region_add(struct wl_client *c, struct wl_resource *r,
int32_t x, int32_t y, int32_t w, int32_t h) {
struct region *rg = wl_resource_get_user_data(r);
if (!rg || w <= 0 || h <= 0) return;
if (!rg->set) { rg->x = x; rg->y = y; rg->w = w; rg->h = h; rg->set = 1; return; }
int x2 = rg->x + rg->w > x + w ? rg->x + rg->w : x + w;
int y2 = rg->y + rg->h > y + h ? rg->y + rg->h : y + h;
if (!rg->set) { rg->x = x; rg->y = y; rg->w = w; rg->h = h; rg->set = rg->exact = 1; return; }
long long x2 = (long long)rg->x + rg->w, y2 = (long long)rg->y + rg->h;
long long nx2 = (long long)x + w, ny2 = (long long)y + h;
int inside = x >= rg->x && y >= rg->y && nx2 <= x2 && ny2 <= y2;
int around = x <= rg->x && y <= rg->y && nx2 >= x2 && ny2 >= y2;
if (!inside && !around) rg->exact = 0;
if (nx2 > x2) x2 = nx2;
if (ny2 > y2) y2 = ny2;
if (x < rg->x) rg->x = x;
if (y < rg->y) rg->y = y;
rg->w = x2 - rg->x; rg->h = y2 - rg->y;
rg->w = (int)(x2 - rg->x > INT32_MAX ? INT32_MAX : x2 - rg->x);
rg->h = (int)(y2 - rg->y > INT32_MAX ? INT32_MAX : y2 - rg->y);
}
static void region_subtract(struct wl_client *c, struct wl_resource *r,
int32_t x, int32_t y, int32_t w, int32_t h) {}
int32_t x, int32_t y, int32_t w, int32_t h) {
struct region *rg = wl_resource_get_user_data(r);
if (rg && w > 0 && h > 0) rg->exact = 0;
}
static void region_resource_destroy(struct wl_resource *r) { free(wl_resource_get_user_data(r)); }
static const struct wl_region_interface region_impl = {
.destroy = region_destroy,
@@ -1665,6 +1711,17 @@ static void note_hdr_unimported(const struct draw_list *dl, struct surface *s, i
g_hdr_unimported_below = dl->n;
}
/* A client that describes opaque regions can intentionally leave part of an alpha buffer see-through.
* Gamescope uses this for its full-size Steam notification and overlay planes. */
static int surface_translucent(const struct surface *s, int w, int h) {
if (!s->buf_alpha) return 0;
const struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
if (!ci || !ci->declares_opaque) return 0;
const int *o = s->opaque;
return !(o[2] > 0 && o[0] <= 0 && o[1] <= 0 &&
(long long)o[0] + o[2] >= w && (long long)o[1] + o[3] >= h);
}
static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy) {
struct vkp_image *img = surface_image(s);
float sx = 0, sy = 0, sw = (float)s->buf_w, sh = (float)s->buf_h;
@@ -1684,7 +1741,8 @@ static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy)
dl->d = d;
dl->cap = cap;
}
dl->d[dl->n++] = (struct vkp_draw){img, sx, sy, sw, sh, ox, oy, dw, dh};
dl->d[dl->n++] = (struct vkp_draw){img, sx, sy, sw, sh, ox, oy, dw, dh,
surface_translucent(s, dw, dh)};
s->drawn = 1;
}
@@ -1755,6 +1813,7 @@ static int on_frame_timer(void *data) {
static int layer_candidate(const struct draw_list *dl, int scene_w, int scene_h) {
for (int i = dl->n - 1; i >= 0 && i >= dl->n - 2; i--) {
const struct vkp_draw *d = &dl->d[i];
if (d->blend) continue; /* the translucent plane must not replace the opaque game layer */
if (!vkp_image_is_dmabuf(d->img)) continue;
if (d->dx != 0 || d->dy != 0 || d->dw != scene_w || d->dh != scene_h) continue;
if (d->sx != 0 || d->sy != 0 || (int)d->sw != vkp_image_width(d->img) ||
@@ -2093,7 +2152,7 @@ static void render_scene(void) {
* its own layer, cropped and placed by the display. */
int go[8], ov = 0;
if (over == 1 && li >= 0 && vkp_map_draw(&dl.d[li + 1], go))
ov = sc_layer_present_overlay(dl.d[li + 1].img, go) == 0 ? 1 : -1;
ov = sc_layer_present_overlay(dl.d[li + 1].img, go, dl.d[li + 1].blend) == 0 ? 1 : -1;
if (ov <= 0) sc_layer_hide_overlay();
if (ov < 0) { /* the overlay layer refused it: draw the whole scene the old way */
sc_layer_hide();
@@ -60,6 +60,9 @@ struct client_info {
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
unsigned asked_feedback : 1, shm_gl_said : 1;
/* Clients that describe opaque regions can use alpha outside those regions. Wine windows
* never describe them, and their unused alpha channel must not make them translucent. */
unsigned declares_opaque : 1;
unsigned dmabuf_buffers, shm_frames;
struct client_info *next;
};
@@ -19,6 +19,7 @@ void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(pri
static _Atomic int g_kind = VKP_FG_ENGINE_LSFG;
static _Atomic int g_armed;
static _Atomic int g_mult = 2;
static _Atomic int g_target; /* LSFG adaptive fps, 0 = fixed multiplier */
static _Atomic int g_model = 3;
static _Atomic int g_preset = 2;
static _Atomic int g_cfg_dirty = 1;
@@ -51,6 +52,7 @@ static struct {
/* ---- engine + generation ring (compositor thread) ------------------------------------ */
static int g_engine_kind = -1; /* kind of the running engine, -1 = none */
static int g_engine_cache_gen = -1; /* the LSFG cache it loaded */
static int g_engine_ok;
/* The engine of this kind (with this cache) could not start or build its chain; not retried
* every frame - a new engine selection or a new cache clears it. */
@@ -92,15 +94,20 @@ static int64_t now_ns(void) {
void vkp_framegen_set_engine(int kind) {
if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_WINFG) kind = VKP_FG_ENGINE_LSFG;
atomic_store(&g_kind, kind);
atomic_store(&g_failed_kind, -1);
atomic_store(&g_cfg_dirty, 1);
}
void vkp_framegen_set_armed(int armed, int multiplier) {
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps) {
if (target_fps < 0) target_fps = 0;
/* Adaptive sizes the ring and the swapchain for the most it may ever generate. */
if (target_fps) multiplier = VKP_FG_MAX_GENERATIONS + 1;
if (multiplier < 2) multiplier = 2;
if (multiplier > VKP_FG_MAX_GENERATIONS + 1) multiplier = VKP_FG_MAX_GENERATIONS + 1;
if (atomic_exchange(&g_mult, multiplier) != multiplier) atomic_store(&g_cfg_dirty, 1);
if (atomic_exchange(&g_target, target_fps) != target_fps) atomic_store(&g_cfg_dirty, 1);
atomic_store(&g_armed, armed ? 1 : 0);
LOGD("framegen: set_armed(%d, x%d)", armed, multiplier);
LOGD("framegen: set_armed(%d, x%d, target %d fps)", armed, multiplier, target_fps);
}
void vkp_framegen_set_lsfg_cache_path(const char *path) {
@@ -285,7 +292,9 @@ int vkp_framegen_extra_images(void) {
}
static int ensure_engine(int kind) {
if (g_engine_ok && g_engine_kind == kind) return 1;
const int cache_gen = atomic_load(&g_cache_gen);
if (g_engine_ok && g_engine_kind == kind && (kind != VKP_FG_ENGINE_LSFG || g_engine_cache_gen == cache_gen))
return 1;
if (start_failed_for(kind)) return 0;
if (g_engine_ok) { destroy_ring(); fge_stop(); g_engine_ok = 0; g_engine_kind = -1; }
char *path = NULL;
@@ -293,7 +302,7 @@ static int ensure_engine(int kind) {
if (g_cache_path) path = strdup(g_cache_path);
pthread_mutex_unlock(&g_lock);
if (kind == VKP_FG_ENGINE_LSFG && !path) {
FGLOG("LSFG Native can't start: no shader cache (import Lossless.dll in Settings)");
FGLOG("LSFG Native can't start: no shader cache from Lossless.dll");
mark_failed(kind);
return 0;
}
@@ -305,29 +314,39 @@ static int ensure_engine(int kind) {
mark_failed(kind);
return 0;
}
g_engine_ok = 1; g_engine_kind = kind;
g_engine_ok = 1; g_engine_kind = kind; g_engine_cache_gen = cache_gen;
atomic_store(&g_cfg_dirty, 1);
g_built_w = g_built_h = 0;
FGLOG("%s engine ready (%s)", fge_engine_name(kind), fge_build_info());
return 1;
}
static int g_logged_mult;
/* "x3 (2 interpolated frames per game frame)" or "adaptive to 90 fps (up to 3 ...)". */
static const char *describe_mode(char *buf, size_t n, int mult, int target) {
if (target) snprintf(buf, n, "adaptive to %d fps (up to %d interpolated frames per game frame)", target, mult - 1);
else snprintf(buf, n, "x%d (%d interpolated frame%s per game frame)", mult, mult - 1, mult == 2 ? "" : "s");
return buf;
}
static int g_logged_mult, g_logged_target;
static void log_arm_transition(int armed, int kind, int mult) {
const int target = atomic_load(&g_target);
char mode[96];
if (armed == g_was_armed) {
if (armed && mult != g_logged_mult) {
g_logged_mult = mult;
FGLOG("%s multiplier changed to x%d (%d interpolated frames per game frame)", fge_engine_name(kind), mult, mult - 1);
if (armed && (mult != g_logged_mult || target != g_logged_target)) {
g_logged_mult = mult; g_logged_target = target;
FGLOG("%s now %s", fge_engine_name(kind), describe_mode(mode, sizeof(mode), mult, target));
}
return;
}
g_was_armed = armed;
if (armed) {
g_logged_mult = mult;
g_logged_mult = mult; g_logged_target = target;
float flow, hz;
pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock);
FGLOG("%s x%d armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late "
"with the interpolated frames ahead of them", fge_engine_name(kind), mult, (double)flow, (double)hz);
FGLOG("%s %s armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late "
"with the interpolated frames ahead of them", fge_engine_name(kind),
describe_mode(mode, sizeof(mode), mult, target), (double)flow, (double)hz);
} else {
FGLOG("frame generation off (%llu frames generated this session)", (unsigned long long)g_total_generated);
g_generating_logged = 0;
@@ -348,7 +367,8 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
if (atomic_exchange(&g_cfg_dirty, 0)) {
float flow, hz;
pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock);
fge_configure((uint32_t)mult, flow, hz, atomic_load(&g_model), atomic_load(&g_preset));
fge_configure((uint32_t)mult, (uint32_t)atomic_load(&g_target), flow, hz,
atomic_load(&g_model), atomic_load(&g_preset));
}
if (!fge_prepare((uint32_t)w, (uint32_t)h, fmt)) {
if (fmt != VK_FORMAT_R8G8B8A8_UNORM && fge_unavailable()) {
@@ -426,8 +446,9 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
0, 0, NULL, 0, NULL, nb, bars);
if (!g_generating_logged) {
g_generating_logged = 1;
FGLOG("generating: %s x%d at %dx%d (%u interpolated frame%s per game frame%s)",
fge_engine_name(kind), mult, w, h, n_gen, n_gen == 1 ? "" : "s",
char mode[96];
FGLOG("generating: %s %s at %dx%d%s", fge_engine_name(kind),
describe_mode(mode, sizeof(mode), mult, atomic_load(&g_target)), w, h,
fmt == VK_FORMAT_R16G16B16A16_SFLOAT ? ", FP16: the HDR picture" : "");
}
}
@@ -34,9 +34,10 @@ enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 };
/* ---- app controls (any thread) ------------------------------------------------------- */
void vkp_framegen_set_engine(int kind);
/* multiplier 2..4; armed = 0 stops generating (the ring and the engine stay for a re-arm). */
void vkp_framegen_set_armed(int armed, int multiplier);
/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (LsfgNative.ensureCache). */
/* multiplier 2..4, or target_fps > 0 for LSFG's adaptive mode (up to 4x, as the target needs);
* armed = 0 stops generating (the ring and the engine stay for a re-arm). */
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps);
/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (Lossless.sync); a new path reloads the engine. */
void vkp_framegen_set_lsfg_cache_path(const char *path);
/* Flow scale 0.25..1.0 and the panel's real refresh rate (the pacer never generates above it). */
void vkp_framegen_set_tuning(float flow_scale, float refresh_hz);
@@ -197,11 +197,11 @@ int fge_unavailable(void) {
return g_lsfg && g_lsfg->unavailable() ? 1 : 0;
}
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model,
int perf_preset) {
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz,
int model, int perf_preset) {
if (multiplier < 2) multiplier = 2;
if (g_kind == 1 && g_winfg) g_winfg->configure(multiplier, model, perf_preset, flow_scale);
else if (g_lsfg) g_lsfg->configure(multiplier, 0, flow_scale, refresh_hz);
else if (g_lsfg) g_lsfg->configure(multiplier, target_fps, flow_scale, refresh_hz);
}
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) {
@@ -45,8 +45,9 @@ int fge_start(int kind, const char *cache_path);
void fge_stop(void);
/* The engine came up but later found it cannot generate (chain build failed). */
int fge_unavailable(void);
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model,
int perf_preset);
/* target_fps > 0: LSFG picks 0..3 generations per frame to reach it (Win-FG ignores it). */
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz,
int model, int perf_preset);
/* Build/rebuild the chain for this extent; the guest extent for the flow pyramid is the same
* (the Wayland scene is the game's own size in fullscreen). 1 = chain valid. */
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt);
@@ -17,8 +17,9 @@ int fge_format_ok(VkFormat fmt) { (void)fmt; return 0; }
int fge_start(int kind, const char *cache_path) { (void)kind; (void)cache_path; return -1; }
void fge_stop(void) {}
int fge_unavailable(void) { return 1; }
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, int perf_preset) {
(void)multiplier; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset;
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, int model,
int perf_preset) {
(void)multiplier; (void)target_fps; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset;
}
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { (void)w; (void)h; (void)fmt; return 0; }
uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) {
@@ -14,9 +14,9 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenEngine(JN
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenArmed(JNIEnv *env, jclass clazz,
jboolean armed, jint multiplier) {
jboolean armed, jint multiplier, jint targetFps) {
(void)env; (void)clazz;
vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier);
vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier, (int)targetFps);
}
JNIEXPORT void JNICALL
+10 -9
View File
@@ -85,6 +85,7 @@ static struct {
#define ASC_VISIBILITY_HIDE 0
#define ASC_VISIBILITY_SHOW 1
#define ASC_TRANSPARENCY_TRANSLUCENT 1
#define ASC_TRANSPARENCY_OPAQUE 2
static int load_api(void) {
@@ -766,7 +767,8 @@ static ASurfaceControl *swap_sc_begin(struct layer *l) {
/* The transaction that puts pool slot `idx` of layer `l` on screen at `r`, as `color` (NULL = no
* description). 0 = applied. */
static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_color *color) {
static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_color *color,
int translucent) {
struct slot *s = &l->slots[idx];
ASurfaceTransaction *tx = api.txCreate();
if (!tx) return -1;
@@ -774,9 +776,8 @@ static int present_slot(struct layer *l, int idx, const int r[8], const struct b
if (old) api.setZOrder(tx, l->sc, l->z);
/* The blit was waited for on the CPU, so no acquire fence is needed (-1). */
api.setBuffer(tx, l->sc, s->ahb, -1);
/* The compositor composes with blits, which overwrite: nothing is ever alpha-blended on the
* copy path either, so every layer is opaque and the picture matches it pixel for pixel. */
api.setBufferTransparency(tx, l->sc, ASC_TRANSPARENCY_OPAQUE);
api.setBufferTransparency(tx, l->sc,
translucent ? ASC_TRANSPARENCY_TRANSLUCENT : ASC_TRANSPARENCY_OPAQUE);
apply_geometry(tx, l, r);
apply_frame_rate(tx, l);
apply_colour(tx, l, color);
@@ -883,7 +884,7 @@ int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const stru
int idx = take_free_slot(l, sw, sh, AHB_RGBA8, &wait_fd);
if (idx < 0) { log_drop(l); return 0; }
if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, r, color) != 0) return -1;
if (present_slot(l, idx, r, color, 0) != 0) return -1;
if (color && color->dataspace) banner_color_frame_shown(color, BANNER_HDR_LAYER_COPY, AHB_RGBA8);
if (!l->first_logged) {
l->first_logged = 1;
@@ -908,7 +909,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int
int idx = take_free_slot(l, rw, rh, AHB_RGBA8, &wait_fd);
if (idx < 0) { vkp_pass_abort(); log_drop(l); return 0; }
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, r, NULL) != 0) return -1; /* the effects chain's result is 8-bit sRGB */
if (present_slot(l, idx, r, NULL, 0) != 0) return -1; /* the effects chain's result is 8-bit sRGB */
if (!l->first_logged) {
l->first_logged = 1;
banner_log("layer", "presenting %dx%d frames on their own SurfaceControl layer (%s pool, %d buffers); "
@@ -950,7 +951,7 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct
}
if (idx < 0) { vkp_pass_abort(); log_drop(l); return 0; }
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, r, tm ? NULL : color) != 0) return -1; /* tone-mapped = plain sRGB: no tag */
if (present_slot(l, idx, r, tm ? NULL : color, 0) != 0) return -1; /* tone-mapped = plain sRGB: no tag */
if (color && color->dataspace)
banner_color_frame_shown(color, tm ? BANNER_HDR_TONEMAPPED : BANNER_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt);
static int said = -1;
@@ -1031,7 +1032,7 @@ int sc_layer_overlay_affordable(void) {
return ok;
}
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]) {
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent) {
struct layer *l = layer_of(SC_LAYER_OVERLAY);
if (!src || !geo) return -1;
/* The caller decides with sc_layer_overlay_affordable() BEFORE it commits the game to a layer;
@@ -1046,7 +1047,7 @@ int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]) {
/* The window is copied into the layer buffer 1:1; `geo` crops it and places it, so the layer
* is exactly the window's rectangle on screen and nothing else is blended anywhere. */
if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, geo, NULL) != 0) return -1;
if (present_slot(l, idx, geo, NULL, translucent) != 0) return -1;
if (!l->first_logged) {
l->first_logged = 1;
log_layer_count();
+2 -1
View File
@@ -150,8 +150,9 @@ int sc_layer_overlay_affordable(void);
/* OVERLAY layer: show `src` (one window's imported frame) above the game layer, at the placement
* `geo` = {src x0,y0,x1,y1 in image pixels, dst x0,y0,x1,y1 in output pixels} from vkp_map_draw.
* `translucent` preserves a client's alpha over the game for Steam notifications and overlays.
* 0 = shown or dropped, -1 = unavailable (the caller must fall back to the copy path). */
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]);
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent);
/* The scene is not a single fullscreen window this frame: hide every layer that is up. */
void sc_layer_hide(void);
+65 -23
View File
@@ -7,6 +7,7 @@
#include "effects_chain.h"
#include "framegen_bridge.h"
#include "hdr_compose.h"
#include "blend_pass.h"
#include "banner_color.h"
#include <stdio.h>
#include <stdlib.h>
@@ -32,6 +33,8 @@ struct vkp_image {
void *map; /* shm images: persistently mapped linear memory */
VkDeviceSize offset, row_pitch;
int in_general; /* shm images: moved from PREINITIALIZED to GENERAL */
VkFormat fmt;
int sampled; /* created with SAMPLED usage: the alpha pass can read it */
};
static ANativeWindow *g_window; /* the window frames go to; compositor thread only */
@@ -230,6 +233,7 @@ static void destroy_swapchain(void) {
if (g_dev_state != 1) return;
g_vk.DeviceWaitIdle(g_dev);
reset_sync();
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
g_swapchain = VK_NULL_HANDLE;
g_swap_md_identity = 0; /* a new swapchain gets the metadata again */
@@ -525,6 +529,7 @@ static int dev_init(void) {
vkp_effects_bind_device(g_dev, g_pd, &g_memprops);
hdrc_bind_device(g_dev, &g_memprops);
blendp_bind_device(g_dev);
g_dev_state = 1;
reset_sync();
init_black_image();
@@ -873,9 +878,8 @@ const char *vkp_modifier_name(uint64_t modifier) {
return other;
}
/* Can the driver create the image vkp_image_import_dmabuf() creates for this format+modifier
* (2D, DRM_FORMAT_MODIFIER tiling, TRANSFER_SRC, dma-buf memory) - and import it? */
static int modifier_importable(VkFormat fmt, uint64_t modifier) {
/* Can the driver create and import this format/modifier with the requested image usage? */
static int modifier_importable(VkFormat fmt, uint64_t modifier, VkImageUsageFlags usage) {
if (!g_vk.GetPhysicalDeviceImageFormatProperties2) return 1; /* can't ask; the create will tell */
VkPhysicalDeviceExternalImageFormatInfo ext = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_IMAGE_FORMAT_INFO,
@@ -886,7 +890,7 @@ static int modifier_importable(VkFormat fmt, uint64_t modifier) {
VkPhysicalDeviceImageFormatInfo2 info = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_FORMAT_INFO_2, .pNext = &mod,
.format = fmt, .type = VK_IMAGE_TYPE_2D, .tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT};
.usage = usage};
VkExternalImageFormatProperties extp = {.sType = VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES};
VkImageFormatProperties2 props = {.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2, .pNext = &extp};
if (g_vk.GetPhysicalDeviceImageFormatProperties2(g_pd, &info, &props) != VK_SUCCESS) return 0;
@@ -914,7 +918,7 @@ int vkp_dmabuf_modifiers(uint32_t drm_format, uint64_t *out, int max) {
if (m != VKP_MOD_LINEAR && m != VKP_MOD_QCOM_COMPRESSED) why = "unknown layout";
else if (props[i].drmFormatModifierPlaneCount != 1) why = "not single-plane";
else if (!(props[i].drmFormatModifierTilingFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT)) why = "no blit source";
else if (!modifier_importable(fmt, m)) why = "not importable as a dma-buf";
else if (!modifier_importable(fmt, m, VK_IMAGE_USAGE_TRANSFER_SRC_BIT)) why = "not importable as a dma-buf";
if (why) {
LOGI("dmabuf: %c%c%c%c %s reported by the driver but not advertised: %s",
drm_format & 0xff, (drm_format >> 8) & 0xff, (drm_format >> 16) & 0xff,
@@ -942,6 +946,10 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
struct vkp_image *img = calloc(1, sizeof(*img));
if (!img) return NULL;
img->w = w; img->h = h; img->dmabuf = 1; img->blit_dst = as_blit_dst ? 1 : 0;
img->fmt = drm_to_vk(drm_format);
const VkImageUsageFlags sampled_usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
img->sampled = !as_blit_dst && g_vk.GetPhysicalDeviceImageFormatProperties2 &&
modifier_importable(img->fmt, modifier, sampled_usage);
VkSubresourceLayout plane = {.offset = offset, .rowPitch = stride};
VkImageDrmFormatModifierExplicitCreateInfoEXT modInfo = {
@@ -952,10 +960,11 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT};
VkImageCreateInfo ici = {
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .pNext = &extImg,
.imageType = VK_IMAGE_TYPE_2D, .format = drm_to_vk(drm_format), .extent = {w, h, 1},
.imageType = VK_IMAGE_TYPE_2D, .format = img->fmt, .extent = {w, h, 1},
.mipLevels = 1, .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT,
.tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT
: img->sampled ? sampled_usage : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
VkResult cr = g_vk.CreateImage(g_dev, &ici, NULL, &img->image);
if (cr != VK_SUCCESS) {
@@ -1008,18 +1017,35 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
return img;
}
/* Linear wl_shm images can use the blend pass only if the driver samples them with a linear filter. */
static int shm_sampled(void) {
static int known;
if (!known) {
const VkFormatFeatureFlags need = VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT |
VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT;
VkFormatProperties2 fp = {.sType = VK_STRUCTURE_TYPE_FORMAT_PROPERTIES_2};
if (g_vk.GetPhysicalDeviceFormatProperties2)
g_vk.GetPhysicalDeviceFormatProperties2(g_pd, VK_FORMAT_B8G8R8A8_UNORM, &fp);
known = (fp.formatProperties.linearTilingFeatures & need) == need ? 1 : -1;
}
return known == 1;
}
struct vkp_image *vkp_image_create_shm(int w, int h) {
if (w <= 0 || h <= 0 || dev_init() != 0) return NULL;
struct vkp_image *img = calloc(1, sizeof(*img));
if (!img) return NULL;
img->w = w; img->h = h;
img->fmt = VK_FORMAT_B8G8R8A8_UNORM;
img->sampled = shm_sampled();
VkImageCreateInfo ici = {
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .imageType = VK_IMAGE_TYPE_2D,
.format = VK_FORMAT_B8G8R8A8_UNORM, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
.format = img->fmt, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
.samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_LINEAR,
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT, .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | (img->sampled ? VK_IMAGE_USAGE_SAMPLED_BIT : 0),
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.initialLayout = VK_IMAGE_LAYOUT_PREINITIALIZED};
if (g_vk.CreateImage(g_dev, &ici, NULL, &img->image) != VK_SUCCESS) { free(img); return NULL; }
@@ -1062,6 +1088,7 @@ int vkp_image_height(const struct vkp_image *img) { return img ? img->h : 0; }
void vkp_image_destroy(struct vkp_image *img) {
if (!img) return;
if (g_dev) {
blendp_forget_image(img->image);
if (img->map) g_vk.UnmapMemory(g_dev, img->mem);
if (img->image) g_vk.DestroyImage(g_dev, img->image, NULL);
if (img->mem) g_vk.FreeMemory(g_dev, img->mem, NULL);
@@ -1111,8 +1138,21 @@ static int draw_to_scene_blit(const struct vkp_draw *d, int scene_w, int scene_h
return map_draw(d, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, (float)scene_w, (float)scene_h, blit);
}
/* A translucent draw needs source-over blending; every other draw keeps the existing blit path. */
static void record_draw(VkCommandBuffer cmd, const struct vkp_draw *d, const VkImageBlit *blit, VkImage target,
VkFormat target_fmt, int target_w, int target_h, VkFilter filter) {
const struct vkp_image *im = d->img;
if (d->blend && im->sampled &&
blendp_draw(cmd, im->image, im->fmt, im->w, im->h, !im->dmabuf, target, target_fmt, target_w, target_h,
blit) == 0)
return;
g_vk.CmdBlitImage(cmd, im->image, im->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, blit, filter);
}
static void destroy_scene_image(void) {
if (g_scene.img) hdrc_forget_image(g_scene.img); /* the HDR pass may have drawn into it */
if (g_scene.img) blendp_forget_image(g_scene.img);
if (g_scene.img) g_vk.DestroyImage(g_dev, g_scene.img, NULL);
if (g_scene.mem) g_vk.FreeMemory(g_dev, g_scene.mem, NULL);
memset(&g_scene, 0, sizeof(g_scene));
@@ -1150,6 +1190,7 @@ static int ensure_scene_image(int w, int h) {
static int ensure_img(struct vkp_img_slot *s, int w, int h, VkFormat fmt, VkImageUsageFlags usage, const char *what) {
if (s->img && s->w == w && s->h == h && s->fmt == fmt) return 0;
if (s->img) hdrc_forget_image(s->img);
if (s->img) blendp_forget_image(s->img);
if (s->img) g_vk.DestroyImage(g_dev, s->img, NULL);
if (s->mem) g_vk.FreeMemory(g_dev, s->mem, NULL);
memset(s, 0, sizeof(*s));
@@ -1190,7 +1231,8 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
int scene_w, int scene_h, VkImage out, VkFormat out_fmt, enum hdrc_mode mode) {
if (!hf || !hf->is_hdr || n <= 0) return -1;
if (ensure_img(&g_mixed, scene_w, scene_h, HDR_FMT,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
"HDR composition (mixed)") != 0)
return -1;
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
@@ -1214,9 +1256,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
VkImageBlit blit;
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
/* A blit converts UNORM to UNORM by value: 8-bit sRGB and 10-bit PQ both land unchanged. */
g_vk.CmdBlitImage(cmd, draws[i].img->image,
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
g_mixed.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
record_draw(cmd, &draws[i], &blit, g_mixed.img, HDR_FMT, scene_w, scene_h, VK_FILTER_LINEAR);
if (hf->is_hdr[i] && lowest_hdr < 0) lowest_hdr = i;
}
static int truncated_said;
@@ -1249,6 +1289,7 @@ static void device_lost(const char *where) {
vkp_framegen_device_lost();
banner_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
"restart the session", where);
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
g_swapchain = VK_NULL_HANDLE;
if (g_surface) g_vk.DestroySurfaceKHR(g_inst, g_surface, NULL);
@@ -1547,6 +1588,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
g_vk.BeginCommandBuffer(cmd, &bi);
blendp_begin_frame();
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
/* Source images: take dmabufs from the client's queue family, move shm images to
@@ -1589,8 +1631,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
free(bars);
/* Clear the composition target to black (the letterbox on the direct path, the desktop's
* background on the pass) before the draws land on it. A draw that covers the whole scene
* overwrites every pixel of its rectangle (blits never blend), so then only what lies outside it
* background on the pass) before the draws land on it. An opaque draw that covers the whole scene
* overwrites every pixel of its rectangle, so then only what lies outside it
* needs black: the letterbox bars on the direct path, nothing at all on the pass (the scene image
* is exactly the scene). Anything else - no such draw, the HDR composition - clears the whole
* target as before. */
@@ -1600,7 +1642,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
if (!(hf && pass)) {
for (int i = n - 1; i >= 0 && !covered; i--) {
const struct vkp_draw *d = &draws[i];
if (!d->img || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w || d->dy + d->dh < scene_h) continue;
if (!d->img || d->blend || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w ||
d->dy + d->dh < scene_h) continue;
covered = pass ? draw_to_scene_blit(d, scene_w, scene_h, &cover_blit) : draw_to_blit(d, &cover_blit);
}
}
@@ -1633,10 +1676,9 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
VkImageBlit blit;
if (!draws[i].img) continue;
if (pass ? !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit) : !draw_to_blit(&draws[i], &blit)) continue;
g_vk.CmdBlitImage(cmd, draws[i].img->image,
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit,
pass ? VK_FILTER_LINEAR : blit_filter);
record_draw(cmd, &draws[i], &blit, target, pass ? scene_fmt : g_swap_fmt,
pass ? scene_w : (int)g_extent.width, pass ? scene_h : (int)g_extent.height,
pass ? VK_FILTER_LINEAR : blit_filter);
drawn++;
}
@@ -2073,6 +2115,7 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
g_vk.BeginCommandBuffer(cmd, &bi);
blendp_begin_frame();
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
/* Same acquisition as vkp_render's compositor pass: the sources come from the client's queue
@@ -2132,9 +2175,8 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
for (int i = 0; i < n; i++) {
VkImageBlit blit;
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
g_vk.CmdBlitImage(cmd, draws[i].img->image,
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
g_scene.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
record_draw(cmd, &draws[i], &blit, g_scene.img, SCENE_FMT, scene_w, scene_h,
VK_FILTER_LINEAR);
}
if (vkp_effects_active()) vkp_effects_set_formats(VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM);
}
@@ -79,6 +79,7 @@ struct vkp_draw {
struct vkp_image *img;
float sx, sy, sw, sh;
int dx, dy, dw, dh;
int blend; /* translucent: composed over what is under it (blend_pass.h) */
};
// The GPU the renderer runs on ("Adreno (TM) 750"), empty before the device is up.
@@ -6,6 +6,7 @@
*/
#include <jni.h>
#include <errno.h>
#include <stdbool.h>
#include <stdint.h>
#include <pthread.h>
#include <stdlib.h>
@@ -25,6 +26,7 @@ extern int banner_wayland_run(void);
extern void banner_wayland_send_pointer(int action, int x, int y);
extern void banner_wayland_send_touch(int action, int pointer_id, int x, int y);
extern int banner_cursor_snapshot(int *out, int cap);
extern int banner_cursor_serial(void);
extern void banner_wayland_send_key(int evdev, int state);
extern void banner_wayland_send_scene_input(int type, int a, int b);
extern void banner_wayland_vsync(int64_t frame_time_ns);
@@ -294,6 +296,13 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot(
return n;
}
/* The serial nativeCursorSnapshot would return, without copying anything. */
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSerial(JNIEnv *env, jclass clazz) {
(void)env; (void)clazz;
return banner_cursor_serial();
}
/* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey(
@@ -670,3 +679,29 @@ JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) {
banner_host_text_delete(before, after);
}
/* KGSL's power control, off (the GPU held at its top clock) or back on. The property is the
* device's, not this process's: it outlives the fd and the app, so the caller clears it at stop
* and again at every app start. No root; the kernel's thermal limits still apply. */
extern void adrenotools_set_turbo(bool turbo);
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *env, jclass clazz, jboolean on) {
(void)env; (void)clazz;
adrenotools_set_turbo(on == JNI_TRUE);
}
/* Raises one of our own processes or threads to `nice` with a bare setpriority(), never lowering
* it. android.os.Process.setThreadPriority is not used for this: it also moves the thread between
* scheduling groups by priority, and a guest process must stay where Android put the app. Returns
* the nice value it is left at, or 100 when it could not be read. */
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeRaisePriority(JNIEnv *env, jclass clazz, jint tid, jint nice) {
(void)env; (void)clazz;
errno = 0;
int before = getpriority(PRIO_PROCESS, (id_t)tid);
if (before == -1 && errno != 0) return 100;
if (before > nice) setpriority(PRIO_PROCESS, (id_t)tid, nice);
errno = 0;
int after = getpriority(PRIO_PROCESS, (id_t)tid);
return after == -1 && errno != 0 ? 100 : after;
}
@@ -1,12 +1,23 @@
package com.droiddeck.launcher
import android.app.Application
import com.droiddeck.launcher.runtime.BwrapSpawner
import com.droiddeck.launcher.session.CrashHandler
import com.droiddeck.launcher.session.GpuClockPin
/** Process-wide setup: the crash handler, so a session's folder is finished even when we die. */
/**
* Process-wide setup: the crash handler, so a session's folder is finished even when we die, and
* the GPU clock pin cleared in case a killed process left it set, and the socket Flatpak's
* sandboxes are started through (BwrapSpawner).
*/
class App : Application() {
override fun onCreate() {
super.onCreate()
CrashHandler.install(this)
// Before anything else touches the session prefs: a new install's first read decides it.
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
GpuClockPin.clearLeftover(this)
BwrapSpawner.start(this)
}
}
@@ -16,7 +16,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
var compCatalogAt by mutableStateOf(0L)
var compProton by mutableStateOf<String?>(null)
var compComp by mutableStateOf("dxvk")
var compComp by mutableStateOf(com.droiddeck.launcher.ui.GPU_TAB)
var compChecking by mutableStateOf(false)
var compBusy by mutableStateOf<String?>(null)
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
@@ -10,6 +10,10 @@ import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableIntStateOf
import com.droiddeck.launcher.gpu.DriverBundle
import com.droiddeck.launcher.gpu.DriverPairs
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
import com.droiddeck.launcher.gpu.TurnipDriver
@@ -17,11 +21,16 @@ import com.droiddeck.launcher.gpu.TurnipReleases
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.ui.DriverRow
import com.droiddeck.launcher.ui.GpuDriversState
import com.droiddeck.launcher.ui.PairRow
import com.droiddeck.launcher.wayland.CompositorHost
/**
* The Linux and Android driver menus: the drivers each can pick from, the release downloads the
* last check found, and what each mode is set to. The launcher screen keeps one and hands its
* rows to the mode settings dialog.
* The GPU drivers: what this GPU is, the matched driver pairs the release repos offer for it, and
* - in Auto, the default - keeping the recommended pair installed and set. Under Advanced, the
* Linux (runtime) and Android (display) lists as before, each picked on its own - except a
* bundle's two halves (DriverBundle), which are always picked, and deleted, together. The launcher
* screen keeps one and hands it to the Components page's GPU drivers tab.
*/
internal class DriverMenus(private val activity: Activity, private val ui: Handler) {
var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
@@ -33,17 +42,42 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
var canRestoreBundled by mutableStateOf(false)
/** Asset name -> download percent, while it downloads. */
private val releaseProgress = HashMap<String, Int>()
var linuxSteam by mutableStateOf("")
var linuxDesktop by mutableStateOf("")
var linuxSelected by mutableStateOf("")
val gpu: GpuInfo = GpuInfo.detect()
// Read in refreshDrivers/ensureAuto: the activity has no context yet while its fields are made.
var mode by mutableStateOf(SessionPrefs.GPU_DRIVERS_AUTO)
var pairRows by mutableStateOf<List<PairRow>>(emptyList())
/** The pair being installed, and how far along. */
var pairBusy by mutableStateOf<String?>(null)
var pairPercent by mutableIntStateOf(-1)
/** What Auto last did or found, for the line under the pair in use. */
var autoStatus by mutableStateOf("")
/** The bundle both drivers are set to, as "DD-Turnip 0.1.0", or null when they are not one. */
var activeBundle by mutableStateOf<String?>(null)
private var autoCheckedThisProcess = false
fun state() = GpuDriversState(
gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText,
supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED,
auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent,
autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking,
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
activeBundle = activeBundle,
)
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
var androidSelected by mutableStateOf("")
fun refreshDrivers() {
val lm = LinuxVulkanDriverManager(activity)
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
val bundles = DriverBundle.all(activity)
fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let {
DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE)
}
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
else DriverRow(
else bundleRow(id, linux = true) ?: DriverRow(
id, lm.getDriverName(id),
listOfNotNull(
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
@@ -52,8 +86,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
true, origin(id),
)
}
linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM)
linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP)
linuxSelected = SessionPrefs.linuxDriver(activity)
mode = SessionPrefs.gpuDriverMode(activity)
val td = TurnipDriver(activity)
val auto = td.autoId()
androidRows = buildList {
@@ -63,23 +97,211 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
false,
))
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
for (id in td.enumerateImported()) add(bundleRow(id, linux = false) ?: DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
}
canRestoreBundled = td.hiddenBundled().isNotEmpty()
androidSelected = SessionPrefs.androidDriver(activity)
activeBundle = DriverBundle.active(activity)?.label
refreshReleaseRows()
refreshPairs()
}
/** One line for the settings row that opens this: "Auto · WinNative · Balanced". */
fun summary(): String {
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "")
}
/** The pairs the last check found, with what is installed and in use; this GPU's first. */
fun refreshPairs() {
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
val recommended = DriverPairs.recommendedKey(gpu, pairs)
pairRows = pairs.map { p ->
val bundle = p.bundle?.let { installedBundle(it) }
val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) }
val linuxId = if (p.bundle != null) bundle?.linuxId else p.linux?.let { TurnipReleases.installedId(activity, it, lm::isInstalled) }
val mb = p.assets.sumOf { it.size } / 1_048_576.0
PairRow(
key = p.key, name = p.name, version = p.version,
detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb),
recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete,
installed = displayId != null && linuxId != null,
active = displayId != null && linuxId != null &&
SessionPrefs.androidDriver(activity) == displayId && SessionPrefs.linuxDriver(activity) == linuxId,
)
}.sortedByDescending { it.recommended }
}
fun setMode(auto: Boolean) {
mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL
SessionPrefs.setGpuDriverMode(activity, mode)
autoStatus = ""
if (auto) ensureAuto(force = false)
}
/**
* Auto: make the recommended pair for this GPU the one installed and set. Looks online when
* [force]d (refresh), or when the last check is a day old - Banners-Turnip rebuilds hourly, and
* a new pair a day is plenty - otherwise works from the last check. Once per app start on its
* own; the pair it replaces is removed, anything the user imported or picked is left alone.
*/
fun ensureAuto(force: Boolean) {
mode = SessionPrefs.gpuDriverMode(activity)
if (mode != SessionPrefs.GPU_DRIVERS_AUTO || pairBusy != null || releaseChecking) return
if (!force && autoCheckedThisProcess) return
autoCheckedThisProcess = true
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}"
return
}
releaseChecking = true
autoStatus = "Checking for the latest drivers…"
Thread({
val cached = TurnipReleases.cached(activity)
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
cached.assets.none { it.pair.isNotEmpty() }
val problem = if (force || stale) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
ui.post {
releaseChecking = false
refreshReleaseRows()
refreshPairs()
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
val key = DriverPairs.recommendedKey(gpu, pairs)
val pair = pairs.firstOrNull { it.key == key }
val row = pairRows.firstOrNull { it.key == key }
when {
row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "")
pair == null || !pair.complete ->
autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now"
else -> installPair(pair, auto = true)
}
}
}, "gpu-driver-auto").start()
}
/** Manual: install (if need be) and set both halves of a pair. */
fun selectPair(key: String) {
if (pairBusy != null) return
val pair = DriverPairs.from(TurnipReleases.cached(activity)).firstOrNull { it.key == key && it.complete } ?: return
installPair(pair, auto = false)
}
/**
* Download what is missing of [pair], then set both halves - only once both are in, so a
* failed download never leaves a half-changed pair. Auto's own downloads are recorded, and the
* ones the new pair replaces are removed.
*/
private fun installPair(pair: DriverPairs.DriverPair, auto: Boolean) {
if (!pair.complete) return
pairBusy = pair.key
pairPercent = 0
if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…"
Thread({
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
var downloaded = false
val result = runCatching {
val zips = pair.assets
val ids = zips.mapIndexed { i, asset ->
installedIdOf(asset)
?: installAsset(asset) { pct -> ui.post { pairPercent = (i * 100 + pct) / zips.size } }.also { downloaded = true }
}
if (pair.bundle != null) DriverBundle.get(activity, ids[0])!!.let { listOf(it.androidId, it.linuxId) } else ids
}
ui.post {
pairBusy = null
pairPercent = -1
result.onSuccess { (displayId, linuxId) ->
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
SessionPrefs.setAndroidDriver(activity, displayId)
SessionPrefs.setLinuxDriver(activity, linuxId)
val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
if (auto) {
val previous = SessionPrefs.gpuAutoInstalled(activity)
for (id in previous - setOf(displayId, linuxId)) {
val bundle = DriverBundle.containing(activity, id, lm.isInstalled(id))
when {
bundle != null -> { DriverBundle.remove(activity, bundle); TurnipReleases.forget(activity, bundle.id) }
lm.isInstalled(id) -> lm.removeDriver(id)
else -> td.remove(id)
}
TurnipReleases.forget(activity, id)
}
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart"
} else {
android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show()
}
}.onFailure { e ->
Log.w(TAG, "driver pair ${pair.key}", e)
val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}"
if (auto) autoStatus = why ?: "Download failed"
else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show()
}
refreshDrivers()
}
}, "gpu-driver-pair").start()
}
/** The bundle a release bundle was downloaded as, while both its halves are installed. */
private fun installedBundle(asset: TurnipReleases.Asset): DriverBundle.Bundle? =
TurnipReleases.installedId(activity, asset) { DriverBundle.get(activity, it) != null }?.let { DriverBundle.get(activity, it) }
/** The id a release asset was installed as (a bundle's id for a bundle), or null when it is not installed. */
private fun installedIdOf(asset: TurnipReleases.Asset): String? = when {
asset.bundle -> installedBundle(asset)?.id
asset.linux -> TurnipReleases.installedId(activity, asset, LinuxVulkanDriverManager(activity)::isInstalled)
else -> TurnipReleases.installedId(activity, asset, TurnipDriver(activity)::isInstalled)
}
/**
* One release asset, downloaded, checked and installed through the importer; returns its id,
* a bundle's id for a bundle.
*/
private fun installAsset(asset: TurnipReleases.Asset, progress: (Int) -> Unit): String {
var file: File? = null
try {
file = TurnipReleases.download(activity, asset, progress)
val uri = Uri.fromFile(file)
val id = when {
asset.bundle -> DriverBundle.install(activity, uri).id
asset.linux -> LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
else -> TurnipDriver(activity).installFromZip(uri, asset.name)
}
TurnipReleases.recordDownload(activity, asset, id)
return id
} finally {
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
}
}
/**
* Import off the main thread - a driver zip is a few MB and the glibc check reads the whole
* library - then say what happened. A refusal's message is the user-facing reason.
* library - then say what happened. A refusal's message is the user-facing reason. A bundle,
* from any import, installs both halves and is set as both drivers at once. [linux] null (the
* tab's own Import .zip) takes a single driver to whichever list its libc belongs in.
*/
fun importDriver(uri: Uri, linux: Boolean) {
fun importDriver(uri: Uri, linux: Boolean?) {
val name = activity.displayNameOf(uri)
Thread({
var bundle: DriverBundle.Bundle? = null
val problem = try {
if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name)
else TurnipDriver(activity).installFromZip(uri, name)
when {
DriverBundle.isBundle(activity, uri) -> bundle = DriverBundle.install(activity, uri)
linux == true -> LinuxVulkanDriverManager(activity).installDriver(uri, name)
linux == false -> TurnipDriver(activity).installFromZip(uri, name)
else -> try {
TurnipDriver(activity).installFromZip(uri, name)
} catch (display: IllegalArgumentException) {
try {
LinuxVulkanDriverManager(activity).installDriver(uri, name)
} catch (runtime: IllegalArgumentException) {
throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle")
}
}
}
null
} catch (e: IllegalArgumentException) {
e.message
@@ -88,26 +310,57 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
"Import failed: ${e.message}"
}
ui.post {
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
android.widget.Toast.makeText(
activity, problem ?: "Imported ${name ?: "driver"}",
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
activity, problem ?: done,
if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
).show()
refreshDrivers()
}
}, "import-driver").start()
}
/**
* Pick one driver from a list, which is Manual (Auto would put its pair back). A bundle's half
* sets the bundle's other half in the other list as well.
*/
fun selectDriver(id: String, linux: Boolean) {
val bundle = DriverBundle.containing(activity, id, linux)
when {
bundle != null -> useBundle(bundle)
linux -> SessionPrefs.setLinuxDriver(activity, id)
else -> SessionPrefs.setAndroidDriver(activity, id)
}
setMode(false)
refreshDrivers()
}
/** Set both halves of [bundle] as the drivers in use; returns what to tell the user. */
private fun useBundle(bundle: DriverBundle.Bundle): String {
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
DriverBundle.select(activity, bundle)
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
return "Using ${bundle.label} as both the runtime and display driver." +
if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
}
/**
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
* session never starts on a driver that is gone; a release download is forgotten, so the menu
* offers it again.
*/
fun deleteDriver(id: String, linux: Boolean) {
val bundle = DriverBundle.containing(activity, id, linux)
if (bundle != null) {
DriverBundle.remove(activity, bundle)
TurnipReleases.forget(activity, bundle.id)
android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show()
refreshDrivers()
return
}
if (linux) {
LinuxVulkanDriverManager(activity).removeDriver(id)
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "")
}
if (SessionPrefs.linuxDriver(activity) == id) SessionPrefs.setLinuxDriver(activity, "")
} else {
val td = TurnipDriver(activity)
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
@@ -121,11 +374,9 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
/** The download entries and the refresh line, from what the last check found. */
fun refreshReleaseRows() {
val check = TurnipReleases.cached(activity)
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
fun rows(linux: Boolean) = check?.assets.orEmpty()
.filter { it.linux == linux }
.filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
.filter { it.bundle || it.linux == linux }
.filter { a -> installedIdOf(a) == null }
.map { a ->
val mb = "%.1f MB".format(a.size / 1_048_576.0)
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
@@ -154,7 +405,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
fun checkLatestTurnip() {
if (releaseChecking) return
releaseChecking = true
releaseStatus = "Checking Banners-Turnip and WinNative…"
releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…"
Thread({
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
@@ -162,6 +413,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
ui.post {
releaseChecking = false
refreshReleaseRows()
refreshPairs()
if (problem != null) releaseStatus = "Couldn't check: $problem"
}
}, "turnip-release-check").start()
@@ -174,28 +426,20 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
releaseProgress[assetName] = 0
refreshReleaseRows()
Thread({
var file: java.io.File? = null
val problem = try {
file = TurnipReleases.download(activity, asset) { pct ->
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
}
val uri = Uri.fromFile(file)
val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
else TurnipDriver(activity).installFromZip(uri, asset.name)
TurnipReleases.recordDownload(activity, asset, id)
installAsset(asset) { pct -> ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } }
null
} catch (e: IllegalArgumentException) {
e.message
} catch (e: Exception) {
Log.w(TAG, "release driver download", e)
"Download failed: ${e.message}"
} finally {
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
}
ui.post {
releaseProgress.remove(assetName)
val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu"
android.widget.Toast.makeText(
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
android.widget.Toast.LENGTH_LONG,
).show()
refreshDrivers()
@@ -1,5 +1,6 @@
package com.droiddeck.launcher
import androidx.compose.foundation.layout.fillMaxSize
import android.Manifest
import android.app.ActivityOptions
import android.content.Intent
@@ -27,7 +28,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toArgb
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.gpu.LsfgNative
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
@@ -38,6 +39,7 @@ import com.droiddeck.launcher.session.OfflineMode
import com.droiddeck.launcher.session.ProtonExtras
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.GameSaves
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.ui.ProtonPage
@@ -56,7 +58,6 @@ import com.droiddeck.launcher.ui.ControllerActions
import com.droiddeck.launcher.ui.ControllerMappingPage
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.ControllerEditorActivity
import com.droiddeck.launcher.ui.CreditsDialog
import com.droiddeck.launcher.ui.FrontEndScreen
import com.droiddeck.launcher.ui.FrontEndState
import com.droiddeck.launcher.ui.FrontEndActions
@@ -81,6 +82,7 @@ class MainActivity : ComponentActivity() {
private val drivers = DriverMenus(this, ui)
private val components = ComponentsMenu(this, ui)
private val decky = DeckyMenu(this, ui)
private val updates = UpdatesMenu(this, ui)
private val protons = ProtonMenu(this, ui)
// The screen's state. Compose redraws whatever reads these when they change.
@@ -99,17 +101,22 @@ class MainActivity : ComponentActivity() {
private var percent by mutableIntStateOf(-1)
private var failed by mutableStateOf(false)
private var frameGenLabel by mutableStateOf("Off")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var steamDeckMode by mutableStateOf(false)
private var showCredits by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showProtons by mutableStateOf(false)
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
private var showMapping by mutableStateOf(false)
private var saveBusy: String? = null
/** What to do with the zip or folder the file picker hands back after a game page's Manage saves. */
private var onSavePicked: ((File) -> Unit)? = null
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(null)
private var catalogLoading by mutableStateOf(false)
@@ -126,7 +133,9 @@ class MainActivity : ComponentActivity() {
private var gameCores by mutableStateOf<Set<Int>>(emptySet())
private var tuSysmem by mutableStateOf(false)
private var zinkLazy by mutableStateOf(false)
private var noXalia by mutableStateOf(false)
private var noXalia by mutableStateOf(true)
private var gamescopeRealtime by mutableStateOf(false)
private var gpuClockPin by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
private var phantomWarning by mutableStateOf<String?>(null)
@@ -136,6 +145,8 @@ class MainActivity : ComponentActivity() {
private var clientDirectAudio by mutableStateOf(false)
private var forceFullscreen by mutableStateOf(true)
private var launcherFullscreen by mutableStateOf(true)
private var storeEnabled by mutableStateOf(false)
private var appImagesEnabled by mutableStateOf(false)
private var mic by mutableStateOf(false)
// The app's own picker (files/), once per kind of pick: the two driver lists validate
@@ -149,6 +160,68 @@ class MainActivity : ComponentActivity() {
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
}
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
}
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
}
private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
}
private val pickSaveDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
}
/** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */
private fun syncLossless() {
Thread({
Lossless.sync(this)
val state = Lossless.state(this)
ui.post { lossless = state }
}, "lossless-sync").start()
}
private fun importLossless(dll: File) {
Thread({
val message = Lossless.message(this, Lossless.import(this, dll))
val state = Lossless.state(this)
ui.post {
lossless = state
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "lossless-import").start()
}
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
private fun importSaves(name: String, game: () -> GameSaves.Game) {
if (SessionState.running) {
android.widget.Toast.makeText(this, "Close the Steam session first, so the game can't save over the import", android.widget.Toast.LENGTH_LONG).show()
return
}
onSavePicked = { zip ->
saveAction("Importing into $name") {
val (written, backup) = GameSaves.import(game(), zip)
val kind = GameSaves.layoutOf(zip)?.label ?: "zip"
"Imported $written files from the $kind into $name" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "")
}
}
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for $name", GameSaves.savesDir().parentFile?.parentFile?.path))
}
/** Export [game]'s saves in [layout] to a folder picked in the app's file picker. */
private fun exportSaves(name: String, layout: GameSaves.Layout, game: () -> GameSaves.Game) {
onSavePicked = { dir ->
saveAction("Exporting $name") {
val (zip, count) = GameSaves.export(game(), layout, dir)
"Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}"
}
}
GameSaves.savesDir().mkdirs()
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save $name (${layout.label})", GameSaves.savesDir().path))
}
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path)
@@ -172,6 +245,9 @@ class MainActivity : ComponentActivity() {
private var addedGamesArt by mutableStateOf(true)
@Volatile private var artFetchRunning = false
private var addedGames by mutableStateOf<List<com.droiddeck.launcher.ui.AddedGameRow>>(emptyList())
private val pickAppImage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { com.droiddeck.launcher.store.AppImageState.import(this, it) }
}
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
SessionPrefs.setRomsDir(this, path)
@@ -188,6 +264,7 @@ class MainActivity : ComponentActivity() {
private var theme by mutableStateOf("graphite")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var hdrOn by mutableStateOf(false)
private var fpsLimit by mutableStateOf(0)
private var hdrReason by mutableStateOf<String?>(null)
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
@@ -264,9 +341,13 @@ class MainActivity : ComponentActivity() {
theme = SessionPrefs.theme(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
storeEnabled = SessionPrefs.storeEnabled(this)
appImagesEnabled = SessionPrefs.appImagesEnabled(this)
applyLauncherFullscreen()
updates.start()
setContent {
DroidDeckTheme(theme) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
sm != null -> { { ModeSettingsHost(sm) } }
@@ -285,8 +366,8 @@ class MainActivity : ComponentActivity() {
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
lsfgReady = LsfgNative.isInstalled(this),
frameGen = FrameGen.mode(this),
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
isHomeApp = homeAppSelected,
@@ -304,9 +385,12 @@ class MainActivity : ComponentActivity() {
buildLabel = BuildConfig.BUILD_LABEL,
oscMode = oscMode,
controller = controllerSettings,
updates = updates.state(),
phantomProcessStatus = phantomProcessStatus,
showPhantomGate = showPhantomGate,
launcherFullscreen = launcherFullscreen,
storeEnabled = storeEnabled,
appImagesEnabled = appImagesEnabled,
),
FrontEndActions(
onPlay = { startSteamSession() },
@@ -326,6 +410,25 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP))
},
onEmulator = { e -> launchProgram(e.program) },
// A Flatpak app from the store, full screen under gamescope like an emulator.
onImportAppImage = {
pickAppImage.launch(InAppFilePicker.buildIntent(this, listOf("appimage"), "Choose an AppImage"))
},
// An imported AppImage, full screen under gamescope like an emulator.
onAppImage = { dir, name ->
Library.flatpakNames[dir] = name
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(dir)))
},
onFlatpakApp = { id, name ->
Library.flatpakNames[id] = name
startActivity(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(id)))
},
onRom = { g ->
val e = emulatorList.first { it.id == g.emulatorId }
startActivity(Intent(this, SessionActivity::class.java)
@@ -340,12 +443,18 @@ class MainActivity : ComponentActivity() {
onInstallPackage = { id -> installPackage(id) },
onRemovePackage = { id -> removePackage(id) },
onRuntime = { onRuntimeButton() },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this, engine, multiplier)
onFrameGenPick = { mode ->
FrameGen.set(this, mode)
frameGenLabel = FrameGen.label(this)
},
onImportLossless = {
pickLossless.launch(InAppFilePicker.buildIntent(this, listOf("dll"), getString(R.string.lsfg_pick_title)))
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
onRoms = { showRoms = true },
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
@@ -370,7 +479,6 @@ class MainActivity : ComponentActivity() {
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
offline = OfflineMode.enabled(this)
},
onCredits = { showCredits = true },
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLauncherFullscreen = { on ->
@@ -378,6 +486,8 @@ class MainActivity : ComponentActivity() {
launcherFullscreen = on
applyLauncherFullscreen()
},
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
onAppImagesEnabled = { on -> SessionPrefs.setAppImagesEnabled(this, on); appImagesEnabled = on },
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
HomeApp.setHomeScreenEnabled(this, on)
@@ -388,9 +498,6 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setBackActionsInverted(this, inverted)
backActionsInverted = inverted
},
onCheckLatestBuild = {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/The412Banner/DroidDeck/actions/workflows/build.yml")))
},
onRefreshPhantomStatus = { refreshPhantomStatus() },
onOpenDeveloperOptions = { displayId -> openDeveloperOptions(displayId) },
onWirelessAdbPair = { host, port, code, complete ->
@@ -441,6 +548,7 @@ class MainActivity : ComponentActivity() {
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
},
updates = updates.actions(),
controller = ControllerActions(
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
@@ -482,7 +590,6 @@ class MainActivity : ComponentActivity() {
onConfirm = { Thread({ LinuxRuntimeInstaller.uninstall(this); ui.post { refresh() } }, "uninstall").start() },
onDismiss = { showRemove = false },
)
if (showCredits) CreditsDialog { showCredits = false }
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
returning?.let { r ->
com.droiddeck.launcher.ui.FloodReturn(androidx.compose.ui.graphics.Color(r.color), r.to, onProgress = { floodProgress = it }) {
@@ -493,6 +600,7 @@ class MainActivity : ComponentActivity() {
}
}
}
}
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
@@ -503,8 +611,9 @@ class MainActivity : ComponentActivity() {
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
// The microphone is on by default; ask once, with the storage prompt, so voice chat works
// without a trip to the settings. A refusal is not asked again - the toggle asks when used.
// The microphone is off by default. Someone who turned it on before the permission was
// granted is asked once here, with the storage prompt; a refusal is not asked again - the
// toggle asks when used.
if (SessionPrefs.micEnabled(this) && !SessionPrefs.micAsked(this)
&& checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.RECORD_AUDIO)
@@ -551,11 +660,11 @@ class MainActivity : ComponentActivity() {
}
}
/** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
val all = ComponentsManager.COMPONENTS
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
}
@@ -572,6 +681,11 @@ class MainActivity : ComponentActivity() {
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
}
refreshPhantomStatus()
syncLossless()
// Opening the app and coming back from a session both land here.
updates.onResume()
// Auto keeps this GPU's recommended driver pair installed and set: once per app start.
drivers.ensureAuto(force = false)
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
if (!SessionState.running) Thread({
@@ -602,6 +716,11 @@ class MainActivity : ComponentActivity() {
refreshSecondScreenDisplays()
}
override fun onDestroy() {
updates.unregister()
super.onDestroy()
}
override fun onStop() {
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
@@ -656,6 +775,19 @@ class MainActivity : ComponentActivity() {
}, "catalog-desktop").start()
}
/** Runs a save import or export off the main thread, one at a time, and says how it went. */
private fun saveAction(label: String, work: () -> String) {
if (saveBusy != null) return
saveBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
ui.post {
saveBusy = null
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "game-saves-action").start()
}
private fun openProtons() {
settingsMode = null
showPerformance = false
@@ -680,14 +812,16 @@ class MainActivity : ComponentActivity() {
)
}
private fun openComponents(focusContent: Boolean = true) {
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
focusComponentsContent = focusContent
if (tab != null) components.compComp = tab
settingsMode = null
showPerformance = false
showProtons = false
showMapping = false
showComponents = true
components.refreshComponents(snapshotFirst = true)
drivers.refreshDrivers()
}
private fun importComponent(uri: Uri) {
@@ -722,6 +856,22 @@ class MainActivity : ComponentActivity() {
onRefresh = { components.refreshComponentCatalog() },
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
onBack = { showComponents = false },
gpu = drivers.state(),
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
onAuto = { on -> drivers.setMode(on) },
onPair = { key -> drivers.selectPair(key) },
onRefresh = { if (drivers.mode == SessionPrefs.GPU_DRIVERS_AUTO) drivers.ensureAuto(force = true) else drivers.checkLatestTurnip() },
// Picking one driver on its own is Manual: Auto would put its pair back.
onSelectLinux = { id -> drivers.selectDriver(id, linux = true) },
onSelectAndroid = { id -> drivers.selectDriver(id, linux = false) },
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) },
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
),
)
}
@@ -788,10 +938,8 @@ class MainActivity : ComponentActivity() {
ModeSettingsPage(
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
hdr = hdrOn, hdrReason = hdrReason,
linuxRows = drivers.linuxRows,
linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop,
androidRows = drivers.androidRows, androidSelected = drivers.androidSelected,
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
@@ -805,12 +953,13 @@ class MainActivity : ComponentActivity() {
storageOptions = storageOptions,
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
addedGamesArt = addedGamesArt,
linuxDownloads = drivers.linuxDownloads, androidDownloads = drivers.androidDownloads, releaseStatus = drivers.releaseStatus,
releaseChecking = drivers.releaseChecking, canRestoreBundled = drivers.canRestoreBundled,
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
@@ -821,15 +970,8 @@ class MainActivity : ComponentActivity() {
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() },
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) },
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
onRefreshReleases = { drivers.checkLatestTurnip() },
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); drivers.refreshDrivers() },
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) },
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
@@ -856,6 +998,13 @@ class MainActivity : ComponentActivity() {
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
onSteamDeckMode = { on ->
SessionPrefs.setSteamDeckMode(this, on)
steamDeckMode = on
steamChannel = SessionPrefs.steamChannel(this)
},
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
@@ -886,15 +1035,18 @@ class MainActivity : ComponentActivity() {
PerformancePage(
cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) },
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, steamDeckMode = steamDeckMode, noXalia = noXalia,
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
gamescopeRealtime = gamescopeRealtime,
gpuClockPin = gpuClockPin,
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on },
onGlThread = { on -> SessionPrefs.setGlThread(this, on); glThread = on },
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
onSteamDeckMode = { on -> SessionPrefs.setSteamDeckMode(this, on); steamDeckMode = on },
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onClientCore = { core, on ->
@@ -939,11 +1091,15 @@ class MainActivity : ComponentActivity() {
customResolution = SessionPrefs.customResolution(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
steamChannel = SessionPrefs.steamChannel(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
mangoapp = SessionPrefs.mangoapp(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
refreshAddedGames()
shapeMode = SessionPrefs.shapeMode(this)
hdrOn = SessionPrefs.hdr(this, mode)
fpsLimit = SessionPrefs.fpsLimit(this, mode)
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
touchMode = SessionPrefs.touchMode(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
@@ -988,6 +1144,8 @@ class MainActivity : ComponentActivity() {
tuSysmem = SessionPrefs.tuSysmem(this)
zinkLazy = SessionPrefs.zinkLazy(this)
noXalia = SessionPrefs.noXalia(this)
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
gpuClockPin = SessionPrefs.gpuClockPin(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
guestHostname = SessionPrefs.guestHostname(this)
refreshPhantomStatus()
@@ -1005,7 +1163,8 @@ class MainActivity : ComponentActivity() {
logsEnabled = SessionPrefs.logsEnabled(this)
runningLabel = if (SessionState.running) when (SessionState.mode) {
SessionService.MODE_DESKTOP -> "Desktop"
SessionService.MODE_RUN -> SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program"
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program"
else -> "Steam"
} else null
// The libraries, off the main thread: manifests and a folder scan.
@@ -1013,7 +1172,7 @@ class MainActivity : ComponentActivity() {
val games = if (ready) Library.steamGames(this) + com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art ->
Library.SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId,
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, Library.ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = Library.protonPrefix(this, g.steamAppId?.toLong() ?: g.appId),
)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher
import androidx.compose.foundation.layout.fillMaxSize
import android.hardware.input.InputManager
import android.hardware.display.DisplayManager
import android.content.Intent
@@ -23,6 +24,7 @@ import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -32,9 +34,10 @@ import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.files.InAppFilePicker
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.LsfgNative
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.input.EvdevKeys
import com.droiddeck.launcher.input.KeyboardHost
@@ -47,7 +50,6 @@ import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.input.TouchpadGestures
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.LoadingState
import com.droiddeck.launcher.session.PerfHints
import com.droiddeck.launcher.session.PerfHud
import com.droiddeck.launcher.session.PerfMode
import com.droiddeck.launcher.session.SessionPrefs
@@ -92,6 +94,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var loading: LoadingState
private lateinit var hud: PerfHud
private var padBridge: PadBridge? = null
/** The handheld's gyro and accelerometer, fed to the Deck controller while the session shows. */
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
/** Between onResume and onPause. */
private var resumed = false
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } }
/** Motion is read while the session shows and only if the pad is a Deck controller - which
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
private fun updatePadMotion() {
if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
}
private var onScreenControls: OnScreenControls? = null
private var keyboard: KeyboardHost? = null
private var watching = true
@@ -159,22 +172,43 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var pcKeyboardOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var fillScreen by mutableStateOf(true)
private var frameGenLabel by mutableStateOf("Off")
private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF)
private var frameGenMultiplier by mutableStateOf(2)
private var lossless by mutableStateOf(Lossless.State.NONE)
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
}
private var frameGen by mutableStateOf(FrameGen.Mode.OFF)
private var fexPreset by mutableStateOf("")
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var secondScreenMode by mutableStateOf(SecondScreenMode.NONE)
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var selectedSecondScreenDisplay by mutableStateOf(-1)
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
/** Between onStart and onStop: while stopped the second screen stays closed, its choice kept. */
private var started = false
private lateinit var displayManager: DisplayManager
private var secondScreenPresentation: SecondScreenPresentation? = null
private val displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
override fun onDisplayChanged(displayId: Int) {
followPanelRefresh(displayId)
refreshSecondScreenDisplays()
}
}
/**
* The panel's rate, followed for the rest of the session. It is read once at start, and a
* panel that drops from 120 to 60 Hz later (battery saver, heat, a vendor overriding the mode
* asked for) left the compositor telling gamescope every frame took 8.3 ms, so the client and
* its games paced against a vblank that no longer came. (WinNative 53836ca9.)
*/
private fun followPanelRefresh(displayId: Int) {
if (!CompositorHost.isStarted) return
val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return
if (display.displayId != displayId) return
val hz = display.refreshRate
if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz)
}
private var isHomeApp by mutableStateOf(false)
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
@@ -203,9 +237,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Game-tier power policy for the whole session: a sustained clock floor, the panel's
// fastest mode, and the OS told it is in gameplay. Logged so a slow device says why.
Log.i(TAG, "perf: " + PerfMode.apply(this))
// Game-tier power policy for the whole session: the panel's fastest mode (one the frame cap
// divides, when there is a cap), and the OS told it is in gameplay. Logged so a slow device
// says why. The cap is the session's: an activity re-created mid-session keeps it.
if (!SessionState.running) {
SessionState.fpsLimit = SessionPrefs.fpsLimit(
this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM),
)
}
Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit))
goFullscreen()
// The device's volume keys change the stream the session plays on (the relay and
// PulseAudio are media playback); they are never forwarded to the guest.
@@ -224,6 +264,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
padBridge = bridge
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
@Suppress("DEPRECATION")
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
}
SessionState.deckPadListener = deckPadListener
// A player on the pad or the on-screen controls has no use for the mouse arrow; the next
// touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment.
bridge.setOnPlayerInput {
@@ -298,6 +343,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
isFocusableInTouchMode = true
setContent {
DroidDeckTheme {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty()) HudText(hud.text)
@@ -319,6 +365,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Opening the drawer takes the controller away from the game: release its pad.
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
syncLossless()
padBridge?.releaseAll()
androidx.compose.runtime.withFrameNanos { }
val requested = sessionOverlay.requestFocus()
@@ -342,8 +389,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
@@ -352,11 +399,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onFrameGenPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
onFrameGenPick = { mode ->
FrameGen.set(this@SessionActivity, mode)
readPrefs()
applyFrameGen()
},
onImportLossless = {
pickLossless.launch(InAppFilePicker.buildIntent(
this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title)))
},
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
@@ -428,6 +479,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
}
}
}
private fun refreshHomeApp() {
@@ -460,7 +512,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
performBackAction(double = false)
}
pendingBackAction = pending
uiHandler.postDelayed(pending, BACK_DOUBLE_PRESS_TIMEOUT_MS)
// Android's own double-tap window (300 ms): the single action waits it out, so it is felt.
uiHandler.postDelayed(pending, android.view.ViewConfiguration.getDoubleTapTimeout().toLong())
}
private fun performBackAction(double: Boolean) {
@@ -560,9 +613,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
hudOn = SessionPrefs.hudEnabled(this)
fillScreen = SessionPrefs.forceFullscreen(this)
touchMode = SessionPrefs.touchMode(this)
frameGenLabel = FrameGen.label(this)
frameGenEngine = FrameGen.engine(this)
frameGenMultiplier = FrameGen.multiplier(this)
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
oscMode = SessionPrefs.oscMode(this)
@@ -757,12 +808,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
CompositorHost.startOrAttach(
holder.surface, runtimeDir.path,
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit,
)
if (!SessionState.running) SessionEvents.record("compositor.started")
// ADPF: the compositor thread's frame intervals go to the power HAL against the panel's
// period, so a long frame raises CPU clocks now rather than after the load averages up.
PerfHints.arm(this, refreshHz())
// The service owns everything below the compositor. It is started whenever no session is
// running - NOT only when the compositor was just started: the compositor lives for the
// whole process, so the second Play after a session ended used to re-attach the Surface,
@@ -848,9 +896,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return Pair(width and 1.inv(), height and 1.inv())
}
/**
* The rate of the mode PerfMode asked for, not the one the panel is still in: the switch lands
* some frames after onCreate, and read too early it gave gamescope `-r 60` on a 120 Hz panel -
* the client and every game then paced themselves to 60. (WinNative's requestedPanelRefreshRate.)
*/
private fun refreshHz(): Float {
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val hz = display?.refreshRate ?: 60f
val wanted = window.attributes.preferredDisplayModeId
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
?: display?.refreshRate ?: 60f
return if (hz > 1f) hz else 60f
}
@@ -862,10 +917,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val hz = refreshHz()
Thread({
val problem = FrameGen.apply(this, hz)
if (problem != null) runOnUiThread { Toast.makeText(this, problem, Toast.LENGTH_LONG).show() }
val state = Lossless.state(this)
runOnUiThread {
lossless = state
if (problem != null) Toast.makeText(this, problem, Toast.LENGTH_LONG).show()
}
}, "frame-gen").start()
}
/** A Lossless Scaling installed or updated in Steam during the session is taken when the drawer opens. */
private fun syncLossless() {
Thread({
Lossless.sync(this)
val state = Lossless.state(this)
runOnUiThread { lossless = state }
}, "lossless-sync").start()
}
private fun importLossless(dll: File) {
Thread({
val message = Lossless.message(this, Lossless.import(this, dll))
val state = Lossless.state(this)
runOnUiThread {
lossless = state
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
if (FrameGen.engine(this) == FrameGen.ENGINE_LSFG) applyFrameGen()
}
}, "lossless-import").start()
}
// ── Session state ───────────────────────────────────────────────────────────────────────
/** Keeps the loading overlay current from the session log, and its clock moving. */
@@ -927,8 +1007,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
// It stays until the user leaves: a failure read against a timer is a failure not read.
val what = when (SessionState.mode) {
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program"
SessionService.MODE_DESKTOP -> "The desktop"
else -> "Steam"
}
loading.showEnded(
hint ?: "Steam stopped unexpectedly. Share the logs with a bug report, or try again.",
hint ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
)
focusEndedScreen()
@@ -1229,6 +1314,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
*/
private fun syncClientCursor(): Boolean {
if (!CompositorHost.isStarted) return false
// Asked on every pointer move; the image is copied only when the client has changed it.
if (WaylandCompositor.nativeCursorSerial() == cursorSerial) return false
val n = WaylandCompositor.nativeCursorSnapshot(cursorBuf)
if (n < 6) return false
val serial = cursorBuf[0]
@@ -1270,11 +1357,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1
if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true)
// A choice kept while its display was away (asleep, waking): back as soon as it is.
else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
}
private fun selectSecondScreenDisplay(displayId: Int) {
if (secondScreenDisplays.none { it.id == displayId }) return
selectedSecondScreenDisplay = displayId
SessionState.secondScreenDisplay = displayId
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
}
@@ -1296,8 +1386,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun showSecondScreen(mode: SecondScreenMode) {
val target = displayManager.getDisplay(selectedSecondScreenDisplay)
if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) {
closeSecondScreen(reset = true)
refreshSecondScreenDisplays()
// Not there right now - a panel still waking, say. The choice is kept; the display
// listener shows it again when the display comes back (refreshSecondScreenDisplays).
closeSecondScreen(reset = false)
return
}
var presentation = secondScreenPresentation
@@ -1329,6 +1420,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (!presentation.isShowing) presentation.show()
presentation.showMode(mode)
secondScreenMode = mode
SessionState.secondScreenMode = mode
SessionState.secondScreenDisplay = selectedSecondScreenDisplay
} catch (e: Exception) {
Log.w(TAG, "could not show second-screen controls on ${target.name}", e)
closeSecondScreen(reset = true)
@@ -1348,7 +1441,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun closeSecondScreen(reset: Boolean) {
secondScreenPresentation?.closeControls()
secondScreenPresentation = null
if (reset) secondScreenMode = SecondScreenMode.NONE
if (reset) {
secondScreenMode = SecondScreenMode.NONE
SessionState.secondScreenMode = SecondScreenMode.NONE
}
}
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
@@ -1461,15 +1557,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onStart() {
super.onStart()
started = true
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
refreshSecondScreenDisplays()
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
SessionService.setActivityVisible(this, true)
}
override fun onStop() {
started = false
displayManager.unregisterDisplayListener(displayListener)
closeSecondScreen(reset = true)
// Closed while the session is out of sight (sleep, a closed lid, another app), and kept:
// onStart puts it back.
closeSecondScreen(reset = false)
SessionService.setActivityVisible(this, false)
super.onStop()
}
@@ -1506,6 +1606,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
onScreenControls?.reload()
updateOnScreenControls()
resumed = true
updatePadMotion()
readPrefs()
if (CompositorHost.isStarted) applyFrameGen()
}
@@ -1519,6 +1621,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
releaseDrawerDirection()
// A button held when the app goes away would stay held in the ring for the whole session.
onScreenControls?.releaseAll()
resumed = false
updatePadMotion()
keyboard?.takeIf { it.shown }?.hide()
super.onPause()
}
@@ -1572,6 +1676,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
closeSecondScreen(reset = true)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
padMotion?.stop()
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
if (SessionState.endListener === endListener) SessionState.endListener = null
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
super.onDestroy()
@@ -1598,7 +1704,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
companion object {
private const val TAG = "SessionActivity"
private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION
private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L
private const val CURSOR_PAD_HOLD_MS = 1200L
private const val DRAWER_HAT_THRESHOLD = 0.5f
private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f
@@ -0,0 +1,141 @@
package com.droiddeck.launcher
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.os.Build
import android.os.Handler
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.ui.UpdatesActions
import com.droiddeck.launcher.ui.UpdatesState
import com.droiddeck.launcher.update.AppUpdates
import com.droiddeck.launcher.update.SelfInstaller
/**
* The Updates page's state: the channels' latest builds, the one followed, and an update being
* downloaded or installed. Checked when the launcher comes to the front - opening the app, and
* coming back from a session - at most once a minute.
*/
internal class UpdatesMenu(private val activity: Activity, private val ui: Handler) {
// Read in start(): the activity has no context yet while its fields are made.
var catalog by mutableStateOf<AppUpdates.Catalog?>(null)
var follow by mutableStateOf(AppUpdates.Follow(AppUpdates.Channel.STABLE))
var checking by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
var stage by mutableStateOf<String?>(null)
var percent by mutableIntStateOf(-1)
var askPermission by mutableStateOf(false)
// An install waiting for the user to allow "Install unknown apps"; it goes on when they come back.
private var pending: AppUpdates.Release? = null
private var lastCheck = 0L
private var request = 0
fun state() = UpdatesState(catalog, follow, checking, error, stage, percent, askPermission)
fun actions() = UpdatesActions(
onCheck = { check(force = true) },
onFollow = ::choose,
onInstall = ::install,
onAllowInstalls = {
askPermission = false
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
.onFailure { error = "Couldn't open Android's \"Install unknown apps\" setting"; pending = null }
},
onDismissPermission = { askPermission = false; pending = null },
)
val hasUpdate: Boolean get() = AppUpdates.hasUpdate(catalog, follow)
/** On every return to the launcher: go on with an install the user just allowed, and check if due. */
fun onResume() {
pending?.let { r ->
if (SelfInstaller.canInstall(activity)) { pending = null; install(r) }
}
if (!SessionState.running) check(force = false)
}
fun check(force: Boolean) {
if (checking || stage != null) return
val now = System.currentTimeMillis()
if (!force && now - lastCheck < 60_000) return
lastCheck = now
val id = ++request
checking = true
Thread({
val result = runCatching { AppUpdates.refresh(activity) }
ui.post {
if (id != request) return@post
checking = false
result.onSuccess {
catalog = it
follow = AppUpdates.follow(activity, it)
error = null
}.onFailure { if (force) error = "Couldn't check: ${it.message ?: it.javaClass.simpleName}" }
}
}, "app-updates").start()
}
private fun choose(f: AppUpdates.Follow) {
AppUpdates.setFollow(activity, f)
follow = f
error = null
}
private fun install(r: AppUpdates.Release) {
if (stage != null) return
val apk = r.apk ?: run { error = "This build has no download for this copy of DroidDeck"; return }
if (SessionState.running) { error = "Stop the running session first, then update"; return }
if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
error = null
stage = "Downloading"
percent = 0
Thread({
val result = runCatching {
val file = SelfInstaller.download(activity, apk) { pct -> ui.post { percent = pct } }
ui.post { stage = "Installing"; percent = -1 }
SelfInstaller.install(activity, file)
}
result.onFailure { e -> ui.post { stage = null; error = e.message ?: "The update failed" } }
}, "app-update-install").start()
}
/** PackageInstaller's answer: Android's confirm prompt to show, or why it failed. Success restarts the app. */
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirm = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
if (confirm == null) { stage = null; error = "Android didn't offer to install the update"; return }
stage = "Waiting for you to confirm"
activity.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
PackageInstaller.STATUS_SUCCESS -> stage = null
else -> {
stage = null
error = SelfInstaller.describeFailure(status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
}
}
}
}
/** From onCreate: the last check, the channel followed, and PackageInstaller's answers. */
fun start() {
catalog = AppUpdates.cached(activity)
follow = AppUpdates.follow(activity, catalog)
val filter = IntentFilter(SelfInstaller.ACTION_STATUS)
if (Build.VERSION.SDK_INT >= 33) activity.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
else activity.registerReceiver(receiver, filter)
}
fun unregister() {
runCatching { activity.unregisterReceiver(receiver) }
}
}
@@ -113,19 +113,24 @@ object DeviceReport {
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
h("Drivers")
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "")
k("Driver mode", SessionPrefs.gpuDriverMode(context) +
(com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from(
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)))?.let { " (recommended pair: $it)" } ?: ""))
val turnip = TurnipDriver(context)
val androidChoice = SessionPrefs.androidDriver(context)
k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice)
k(" name / version", "${turnip.displayName(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)} ${turnip.driverVersion(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)}".trim())
k(" imported available", turnip.enumerateImported().ifEmpty { listOf("none") }.joinToString(", "))
val lm = LinuxVulkanDriverManager(context)
for (m in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
val id = SessionPrefs.linuxDriver(context, m)
run {
val id = SessionPrefs.linuxDriver(context)
val label = if (id.isEmpty()) "runtime default (the Turnip built into the runtime)"
else "${lm.getDriverName(id)} ${lm.getDriverVersion(id)}".trim() +
lm.getMinGlibc(id).let { if (it.isEmpty()) "" else " (glibc $it+)" } +
if (lm.isInstalled(id)) "" else " [MISSING - falls back to the runtime's own]"
k("Linux driver ($m)", label)
k("Linux driver", label)
}
k("Runtime's own ICD", LinuxRuntime.vulkanIcd(context)?.path)
@@ -144,8 +149,10 @@ object DeviceReport {
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
k("No GL error checks", SessionPrefs.noGlError(context))
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
k("Steam controller", SessionPrefs.steamController(context))
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
k("Skip xalia", SessionPrefs.noXalia(context))
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
@@ -104,6 +104,7 @@ public final class FileUtils {
public static String sizeToString(long bytes) {
if (bytes >= 1L << 30) return String.format(java.util.Locale.US, "%.1f GB", bytes / (float) (1L << 30));
if (bytes >= 1L << 20) return String.format(java.util.Locale.US, "%.0f MB", bytes / (float) (1L << 20));
if (bytes >= 1L << 10) return String.format(java.util.Locale.US, "%.0f KB", bytes / (float) (1L << 10));
return bytes + " B";
}
}
@@ -64,7 +64,8 @@ object HostProcess {
}
}
private fun pidOf(process: Process): Int = try {
@JvmStatic
fun pidOf(process: Process): Int = try {
val field = process.javaClass.getDeclaredField("pid")
field.isAccessible = true
val pid = field.getInt(process)
@@ -23,6 +23,9 @@ object Library {
val hero: File? = null, val lastPlayed: Long = 0L,
val gameFiles: File? = null, val protonPrefix: File? = null,
)
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
const val ADDED = "added"
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
@@ -163,7 +166,13 @@ object Library {
)
/** The emulator's name for a program path from the rail ("ARMSX2"), or null. */
fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name
fun nameForProgram(program: String?): String? =
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) {
com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') }
} else specs.firstOrNull { it.program == program }?.name
/** Flatpak apps' and AppImages' names by id or directory, as launched: the session only knows that. */
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
/**
* Whether a program from the rail runs at the panel's own resolution rather than the session's
@@ -0,0 +1,196 @@
package com.droiddeck.launcher.gpu
import android.content.Context
import android.net.Uri
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.util.zip.ZipInputStream
/**
* An all-in-one driver package: one zip carrying both halves of a matched pair, the display
* (Android, bionic) driver in one directory and the runtime (Linux, glibc) driver in another,
* indexed by a root `manifest.json` - DroidDeck's own DD-Turnip releases (Droid-Deck/Drivers):
*
* ```
* manifest.json {"package", "version", "drivers": {"android": {"path"}, "linux": {"path"}}}
* android/ meta.json (AdrenoTools) + libvulkan_freedreno.so
* linux/ meta.json + libvulkan_freedreno.so
* ```
*
* Installing one puts each half through its own importer, with that importer's checks, and
* remembers the two as one bundle: picking either half sets both, and deleting either deletes both,
* so a bundle is always used whole. Zips of the older kind, one driver each, never reach this.
*/
object DriverBundle {
private const val TAG = "DriverBundle"
private const val PREFS = "driver_bundles"
private const val MANIFEST = "manifest.json"
/** Refuses a zip that unpacks to more than this: both halves together are about 30 MB. */
private const val MAX_BYTES = 512L shl 20
class Manifest(val name: String, val version: String, val tag: String, val androidPath: String, val linuxPath: String)
/** An installed bundle: [androidId] in TurnipDriver's list, [linuxId] in LinuxVulkanDriverManager's. */
class Bundle(val id: String, val name: String, val version: String, val androidId: String, val linuxId: String) {
/** "DD-Turnip 0.1.0". */
val label: String get() = if (version.isEmpty()) name else "$name $version"
}
/** The manifest of a bundle, or null when [json] is not one (both halves, in two directories). */
fun parseManifest(json: JSONObject): Manifest? {
val drivers = json.optJSONObject("drivers") ?: return null
val android = cleanPath(drivers.optJSONObject("android")?.optString("path").orEmpty()) ?: return null
val linux = cleanPath(drivers.optJSONObject("linux")?.optString("path").orEmpty()) ?: return null
if (android == linux) return null
return Manifest(
json.optString("package").ifEmpty { "Driver bundle" }, json.optString("version"),
json.optString("tag"), android, linux,
)
}
private fun cleanPath(path: String): String? {
val p = path.trim().trim('/')
return p.takeIf { it.isNotEmpty() && it.split('/').none { part -> part.isEmpty() || part == "." || part == ".." } }
}
/** True when the zip at [uri] is a bundle rather than a single driver. */
fun isBundle(context: Context, uri: Uri): Boolean = try {
context.contentResolver.openInputStream(uri)?.use { input ->
ZipInputStream(input).use { zip ->
generateSequence { zip.nextEntry }.firstOrNull { !it.isDirectory && it.name == MANIFEST }
?.let { parseManifest(JSONObject(zip.readBytes().toString(Charsets.UTF_8))) } != null
}
} ?: false
} catch (e: Exception) {
Log.w(TAG, "reading $uri as a bundle", e)
false
}
/**
* Install both halves of the bundle at [uri]. Both or neither: a half that is refused removes
* the one already in. Throws [IllegalArgumentException] with a user-facing reason when the zip
* is not a usable bundle, [IOException] on read failures.
*/
fun install(context: Context, uri: Uri): Bundle {
val work = File(context.cacheDir, "bundle-${System.nanoTime()}")
val td = TurnipDriver(context)
val lm = LinuxVulkanDriverManager(context)
var androidId: String? = null
try {
unzip(context, uri, work)
val manifest = File(work, MANIFEST).takeIf { it.isFile }
?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() }
?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver")
val androidDir = File(work, manifest.androidPath)
val linuxDir = File(work, manifest.linuxPath)
val androidStage = td.newStagingDir()
androidId = try {
androidDir.listFiles()?.filter { it.isFile }?.forEach { it.copyTo(File(androidStage, it.name)) }
td.adopt(androidStage, manifest.name)
} catch (e: Exception) {
FileUtils.delete(androidStage)
throw e
}
val linuxStage = lm.newStagingDir()
val linuxId = try {
val library = linuxDir.listFiles()?.firstOrNull { it.isFile && it.name.startsWith("libvulkan_freedreno") && it.name.endsWith(".so") }
library?.copyTo(File(linuxStage, LinuxVulkanDriverManager.LIB_NAME))
val meta = File(linuxDir, LinuxVulkanDriverManager.META_NAME).takeIf { it.isFile }
?.let { runCatching { JSONObject(FileUtils.readString(it)) }.getOrNull() }
lm.adopt(linuxStage, library?.name, meta, manifest.name)
} catch (e: Exception) {
FileUtils.delete(linuxStage)
throw e
}
val base = LinuxVulkanDriverManager.sanitizeId(manifest.tag.ifEmpty { "${manifest.name}-${manifest.version}" })
val saved = registry(context)
var id = base
var n = 2
while (saved.contains(id)) id = "$base-${n++}"
val bundle = Bundle(id, manifest.name, manifest.version, androidId, linuxId)
saved.edit().putString(id, JSONObject().put("name", bundle.name).put("version", bundle.version)
.put("android", androidId).put("linux", linuxId).toString()).apply()
Log.i(TAG, "installed bundle $id: display $androidId, runtime $linuxId")
return bundle
} catch (e: Exception) {
androidId?.let { td.remove(it) }
throw e
} finally {
FileUtils.delete(work)
}
}
private fun unzip(context: Context, uri: Uri, dest: File) {
val root = dest.canonicalFile
var total = 0L
context.contentResolver.openInputStream(uri)?.use { input ->
ZipInputStream(input).use { zip ->
for (entry in generateSequence { zip.nextEntry }) {
if (entry.isDirectory) continue
val out = File(root, entry.name).canonicalFile
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it")
out.parentFile?.mkdirs()
out.outputStream().use { sink ->
val buf = ByteArray(1 shl 16)
while (true) {
val r = zip.read(buf)
if (r <= 0) break
total += r
if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB")
sink.write(buf, 0, r)
}
}
}
}
} ?: throw IOException("cannot open $uri")
}
/** Every installed bundle whose two halves are both still there. */
fun all(context: Context): List<Bundle> {
val td = TurnipDriver(context)
val lm = LinuxVulkanDriverManager(context)
return registry(context).all.mapNotNull { (id, raw) ->
runCatching {
val j = JSONObject(raw as String)
Bundle(id, j.getString("name"), j.optString("version"), j.getString("android"), j.getString("linux"))
}.getOrNull()
}.filter { td.isInstalled(it.androidId) && lm.isInstalled(it.linuxId) }.sortedBy { it.id }
}
fun get(context: Context, id: String): Bundle? = all(context).firstOrNull { it.id == id }
/** The bundle [driverId] is a half of, in the runtime list when [linux], else the display list. */
fun containing(context: Context, driverId: String, linux: Boolean): Bundle? =
all(context).firstOrNull { (if (linux) it.linuxId else it.androidId) == driverId }
/** The bundle both drivers are set to right now, if they are one. */
fun active(context: Context): Bundle? {
val android = SessionPrefs.androidDriver(context)
val linux = SessionPrefs.linuxDriver(context)
return all(context).firstOrNull { it.androidId == android && it.linuxId == linux }
}
/** Set both halves as the drivers in use. */
fun select(context: Context, bundle: Bundle) {
SessionPrefs.setAndroidDriver(context, bundle.androidId)
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
}
/** Delete both halves; a driver still set to either goes back to its default. */
fun remove(context: Context, bundle: Bundle) {
TurnipDriver(context).remove(bundle.androidId)
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
if (SessionPrefs.linuxDriver(context) == bundle.linuxId) SessionPrefs.setLinuxDriver(context, "")
registry(context).edit().remove(bundle.id).apply()
Log.i(TAG, "removed bundle ${bundle.id}")
}
private fun registry(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -0,0 +1,112 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.gpu.GpuInfo.Family
/**
* The release drivers as matched pairs: a display build (the app's compositor, bionic) and a Linux
* build (the runtime, glibc) that were made to run together. Mixing a runtime driver carrying a
* patch with a display driver that lacks it is where the hard-to-read bugs come from, so the app
* installs and selects both halves of one pair at once.
*
* - Banners-Turnip publishes each variant as a set in every release (`Turnip-<tag><variant>.zip`
* and `...-Linux.zip`), rebuilt from upstream Mesa as it moves: the pair is one variant of the
* newest release that carries both halves.
* - WinNative ships its display (`v1.x`) and Linux (`linux-v0.x`) builds as two release lines with
* their own numbers: the pair is the newest of each, of one flavour (Balanced or Performance).
* - DroidDeck's DD-Turnip ships both halves in one zip per release (DriverBundle): the pair is the
* newest bundle, and it can never be mixed.
*/
object DriverPairs {
const val BANNER = "banner"
const val BANNER_A8XX = "banner-a8xx"
const val BANNER_710 = "banner-710-720-test"
const val BANNER_ONEUI = "banner-8g2-oneui"
const val WN_BALANCED = "wn-b"
const val WN_PERFORMANCE = "wn-p"
const val DD_TURNIP = "dd-turnip"
/** Each DD-Turnip release is its own pair: "dd-turnip:DD-Turnip-v0.1.0". */
fun ddTurnip(tag: String) = "$DD_TURNIP:$tag"
class DriverPair(
val key: String,
/** "Banners-Turnip · Adreno 6xx/7xx", "WinNative · Balanced". */
val name: String,
/** The families the pair is built for; the list only offers it on those unless asked. */
val families: Set<Family>,
val display: TurnipReleases.Asset?,
val linux: TurnipReleases.Asset?,
/** Both halves in one zip, instead of [display] and [linux]. */
val bundle: TurnipReleases.Asset? = null,
) {
val complete: Boolean get() = bundle != null || (display != null && linux != null)
/** The zips to download for the pair: the bundle, or the two halves. */
val assets: List<TurnipReleases.Asset> get() = listOfNotNull(bundle ?: display, if (bundle == null) linux else null)
/** "r4 · 1.19 + 0.1.2": the build of each half. */
val version: String
get() = listOfNotNull(bundle?.tag, display?.tag, linux?.tag).distinct().joinToString(" + ") { it.removePrefix("linux-").removePrefix("DD-Turnip-") }
fun suits(gpu: GpuInfo) = gpu.family in families ||
(gpu.family == Family.ADRENO_UNKNOWN && (Family.A7XX in families || Family.A8XX in families))
}
private val ALL_ADRENO = setOf(Family.A6XX, Family.A7XX, Family.A7XX_LOW, Family.A8XX, Family.ADRENO_UNKNOWN)
private fun describe(key: String): Pair<String, Set<Family>> = when {
key.startsWith(DD_TURNIP) -> "DroidDeck · DD-Turnip" to ALL_ADRENO
else -> describeFixed(key)
}
private fun describeFixed(key: String): Pair<String, Set<Family>> = when (key) {
BANNER -> "Banners-Turnip · Adreno 6xx/7xx" to setOf(Family.A6XX, Family.A7XX)
BANNER_A8XX -> "Banners-Turnip · Adreno 8xx" to setOf(Family.A8XX)
BANNER_710 -> "Banners-Turnip · Adreno 710/720/722 (test)" to setOf(Family.A7XX_LOW)
BANNER_ONEUI -> "Banners-Turnip · 8 Gen 2 on One UI" to setOf(Family.A7XX)
WN_BALANCED -> "WinNative · Balanced" to ALL_ADRENO
WN_PERFORMANCE -> "WinNative · Performance" to ALL_ADRENO
else -> key to ALL_ADRENO
}
/** Every pair the last release check found, in the order the list shows them. */
fun from(check: TurnipReleases.Check?): List<DriverPair> {
val assets = check?.assets.orEmpty().filter { it.pair.isNotEmpty() }
val order = listOf(DD_TURNIP, BANNER, BANNER_A8XX, BANNER_ONEUI, BANNER_710, WN_BALANCED, WN_PERFORMANCE)
return assets.map { it.pair }.distinct()
.sortedBy { key -> order.indexOf(key.substringBefore(':')).let { i -> if (i < 0) order.size else i } }
.map { key ->
val (name, families) = describe(key)
val halves = assets.filter { it.pair == key }
halves.firstOrNull { it.bundle }?.let { return@map DriverPair(key, name, families, null, null, it) }
// The list keeps the newest release of each half; for Banners-Turnip the two halves
// must come from the same release, so a set missing one half in its newest release
// is offered incomplete rather than mixed with an older one.
val display = halves.firstOrNull { !it.linux }
val linux = halves.firstOrNull { it.linux }
val sameRelease = !key.startsWith(BANNER) || display?.tag == linux?.tag
DriverPair(key, name, families, display, if (sameRelease) linux else null)
}
}
/**
* The pair Auto sets for [gpu], among the [pairs] the last release check found: the newest
* official DD-Turnip release on the Adreno 840 (840 and 840v2), WinNative Balanced on the other
* 8xx (Banners' A8xx builds still break on some first boots) and on an 840 while no DD-Turnip
* release is listed, the OneUI build for an 8 Gen 2 under One UI, the patched test builds for
* the 710/720/722, and upstream Banners-Turnip for the rest. Null when nothing fits (not Adreno).
*/
fun recommendedKey(gpu: GpuInfo, pairs: List<DriverPair>): String? = when (gpu.family) {
Family.NOT_ADRENO -> null
Family.A8XX -> if (gpu.model == 840) newestDdTurnip(pairs) ?: WN_BALANCED else WN_BALANCED
Family.A7XX_LOW -> BANNER_710
Family.A7XX -> if (gpu.oneUi8Gen2) BANNER_ONEUI else BANNER
Family.A6XX -> BANNER
// KGSL gave no model: Android 16 arrived with the 8xx, so a device that new is taken as one.
Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER
}
/** The complete DD-Turnip pair with the highest version, or null when none is listed. */
fun newestDdTurnip(pairs: List<DriverPair>): String? = pairs
.filter { it.key.startsWith("$DD_TURNIP:") && it.complete }
.maxByOrNull { p ->
Regex("""(\d+)\.(\d+)\.(\d+)$""").find(p.key)?.destructured
?.let { (major, minor, patch) -> major.toLong() * 1_000_000_000_000 + minor.toLong() * 1_000_000 + patch.toLong() } ?: -1L
}?.key
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.gpu
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.wayland.WaylandCompositor
/**
@@ -9,7 +10,7 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
*
* Both engines generate on gamescope's final output inside our compositor, so they work for any
* game with nothing changed in the runtime. Win-FG is our own and needs nothing; LSFG needs the
* shader chain from the user's copy of Lossless Scaling (see [LsfgNative]). Every compositor
* shader chain from the user's copy of Lossless Scaling (see [Lossless]). Every compositor
* setter is a value store on the native side, so this can be applied before the compositor is
* up and changed live from the main screen while a session runs.
*/
@@ -26,62 +27,77 @@ object FrameGen {
private const val FLOW_SCALE_WINFG = 0.60f
private const val FLOW_SCALE_LSFG = 0.80f
/** LSFG's adaptive targets: it generates as many frames as it takes to reach one. */
val ADAPTIVE_TARGETS = listOf(120, 90, 60)
private const val PREFS = "frame_gen"
fun engine(context: Context): String =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("engine", ENGINE_OFF) ?: ENGINE_OFF
fun multiplier(context: Context): Int =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt("multiplier", 2).coerceIn(2, 4)
fun set(context: Context, engine: String, multiplier: Int) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString("engine", engine).putInt("multiplier", multiplier).apply()
/** A fixed [multiplier] (2..4), or for LSFG a [target] frame rate (0 = fixed). */
data class Mode(val engine: String, val multiplier: Int = 2, val target: Int = 0) {
companion object {
val OFF = Mode(ENGINE_OFF)
}
}
/** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */
fun label(context: Context): String = label(engine(context), multiplier(context))
fun engine(context: Context): String = mode(context).engine
fun label(engine: String, multiplier: Int): String = when (engine) {
ENGINE_WINFG -> "Win-FG $multiplier×"
ENGINE_LSFG -> "LSFG $multiplier×"
else -> "Off"
fun mode(context: Context): Mode {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF
val target = prefs.getInt("target", 0)
if (engine == ENGINE_LSFG && target in ADAPTIVE_TARGETS) return Mode(engine, target = target)
return Mode(engine, prefs.getInt("multiplier", 2).coerceIn(2, 4))
}
fun set(context: Context, mode: Mode) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString("engine", mode.engine).putInt("multiplier", mode.multiplier).putInt("target", mode.target).apply()
}
/** "Off", "Win-FG 2×", "LSFG 3×", "LSFG Adaptive 90". */
fun label(context: Context): String = label(context, mode(context))
fun label(context: Context, mode: Mode): String = when {
mode.engine == ENGINE_WINFG -> context.getString(R.string.frame_gen_winfg, mode.multiplier)
mode.engine == ENGINE_LSFG && mode.target > 0 -> context.getString(R.string.frame_gen_lsfg_adaptive, mode.target)
mode.engine == ENGINE_LSFG -> context.getString(R.string.frame_gen_lsfg, mode.multiplier)
else -> context.getString(R.string.frame_gen_off)
}
/**
* Pushes the saved setting into the compositor. Returns a message for the user when the
* engine cannot run (LSFG without Lossless Scaling), or null.
* engine cannot run (LSFG without Lossless Scaling), or null. Blocks while a new or updated
* Lossless.dll is prepared.
*/
fun apply(context: Context, refreshHz: Float): String? {
val engine = engine(context)
val multiplier = multiplier(context)
var problem: String? = null
when (engine) {
val mode = mode(context)
when (mode.engine) {
ENGINE_LSFG -> {
val status = LsfgNative.ensureCache(context)
if (status != LsfgNative.STATUS_OK) {
problem = if (status == LsfgNative.STATUS_NOT_INSTALLED)
"LSFG needs Lossless Scaling installed from the Steam client"
else "LSFG: " + LsfgNative.statusName(status)
Log.w(TAG, problem)
WaylandCompositor.nativeSetFrameGenArmed(false, 0)
val status = Lossless.sync(context)
val cache = Lossless.cacheFile(context)
if (status != LsfgNative.STATUS_OK || cache == null) {
WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
val problem = context.getString(
if (status == LsfgNative.STATUS_NOT_INSTALLED) R.string.lsfg_problem_missing else R.string.lsfg_problem_failed,
)
Log.w(TAG, "$problem (status $status)")
return problem
}
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_LSFG)
WaylandCompositor.nativeSetLsfgCachePath(LsfgNative.cacheFile(context).absolutePath)
WaylandCompositor.nativeSetLsfgCachePath(cache.absolutePath)
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz)
WaylandCompositor.nativeSetFrameGenArmed(true, multiplier)
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, mode.target)
}
ENGINE_WINFG -> {
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_WINFG)
WaylandCompositor.nativeSetWinFgTuning(WINFG_MODEL, WINFG_PERF_PRESET)
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_WINFG, refreshHz)
WaylandCompositor.nativeSetFrameGenArmed(true, multiplier)
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0)
}
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0)
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
}
Log.i(TAG, "frame generation: $engine x$multiplier at ${refreshHz}Hz")
return problem
Log.i(TAG, "frame generation: $mode at ${refreshHz}Hz")
return null
}
private const val TAG = "FrameGen"
@@ -0,0 +1,80 @@
package com.droiddeck.launcher.gpu
import android.os.Build
import com.droiddeck.launcher.core.FileUtils
import java.io.File
/**
* What GPU this is, in the terms the driver lists are sorted by. KGSL names the model
* ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the
* support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610
* may start, but it is not "supported" just because it is a Qualcomm chip.
*/
data class GpuInfo(
/** "Adreno 740", or the vendor's own name when this is not an Adreno. */
val name: String,
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
val model: Int,
val family: Family,
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
val soc: String,
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
val oneUi8Gen2: Boolean,
) {
enum class Family(val label: String) {
A8XX("Adreno 8xx"),
/** Adreno 710/720/722: gen 7 cores cut down enough to need their own patches. */
A7XX_LOW("Adreno 710/720/722"),
A7XX("Adreno 7xx"),
A6XX("Adreno 6xx"),
/** An Adreno whose model KGSL does not give: treated as the newest family it could be. */
ADRENO_UNKNOWN("Adreno"),
NOT_ADRENO("Not an Adreno GPU"),
}
enum class Support { TESTED, UNTESTED, UNSUPPORTED }
val support: Support
get() = when {
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
family == Family.A8XX -> Support.TESTED
family == Family.A7XX && model >= 725 -> Support.TESTED
else -> Support.UNTESTED
}
/** One line for the device card and the system check. */
val supportText: String
get() = when (support) {
Support.TESTED -> "Supported"
Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds"
else "Below tested hardware (Adreno 725 and newer): it may not run"
Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU"
}
companion object {
fun detect(): GpuInfo {
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
val family = familyOf(adreno, model)
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
return GpuInfo(
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
model = model, family = family, soc = soc,
oneUi8Gen2 = samsung && model == 740,
)
}
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
!adreno -> Family.NOT_ADRENO
model == 0 -> Family.ADRENO_UNKNOWN
model >= 800 -> Family.A8XX
model in listOf(710, 720, 722) -> Family.A7XX_LOW
model >= 700 -> Family.A7XX
model >= 600 -> Family.A6XX
else -> Family.ADRENO_UNKNOWN
}
}
}
@@ -126,9 +126,7 @@ public class LinuxVulkanDriverManager {
* Linux driver; {@link IOException} on read/extract failures.
*/
public String installDriver(Uri zipUri, String displayName) throws IOException {
File tmpDir = new File(rootDir, ".tmp-" + System.currentTimeMillis());
FileUtils.delete(tmpDir);
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
File tmpDir = newStagingDir();
boolean keep = false;
try {
String soName = null;
@@ -156,9 +154,32 @@ public class LinuxVulkanDriverManager {
}
}
// The zip's own freedreno_icd.aarch64.json is dropped on purpose: its
// library_path is relative to itself, and we write an absolute one below.
// library_path is relative to itself, and adopt() writes an absolute one.
}
}
String id = adopt(tmpDir, soName, zipMeta, displayName);
keep = true;
return id;
} finally {
if (!keep) FileUtils.delete(tmpDir);
}
}
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
File newStagingDir() throws IOException {
File tmpDir = new File(rootDir, ".tmp-" + System.nanoTime());
FileUtils.delete(tmpDir);
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
return tmpDir;
}
/**
* Check a staged driver ({@link #LIB_NAME} in {@code tmpDir}, copied from {@code soName} in
* the zip), write its manifest and meta.json, and move it in. Returns the id; the caller
* deletes {@code tmpDir} when this throws.
*/
String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException {
try {
if (soName == null) {
throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android "
+ "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver.");
@@ -212,13 +233,10 @@ public class LinuxVulkanDriverManager {
if (!FileUtils.writeString(new File(tmpDir, META_NAME), meta.toString(2))) throw new IOException("cannot write meta.json");
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
keep = true;
Log.i(TAG, "imported Linux Vulkan driver " + id + " (" + soName + ", minGlibc=" + minGlibc + ") -> " + dir);
return id;
} catch (org.json.JSONException e) {
throw new IOException("manifest write failed: " + e.getMessage());
} finally {
if (!keep) FileUtils.delete(tmpDir);
}
}
@@ -0,0 +1,187 @@
package com.droiddeck.launcher.gpu
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.security.DigestOutputStream
import java.security.MessageDigest
/**
* LSFG's shader chain, from the user's own Lossless Scaling (Steam app 993090).
*
* The copy the Steam client installs, in any library it installs to, is taken by
* itself and taken again whenever the client updates it. Once the session has seen the account
* own the app, a Lossless.dll can also be imported by hand; it stays until the Steam copy changes
* or another is imported. The DLL is copied in and its shaders are built into a cache named after
* its contents, so a different DLL hands the compositor a different path and its engine reloads.
* Everything here blocks: call it off the main thread.
*/
object Lossless {
enum class Source { NONE, STEAM, IMPORT }
enum class ImportResult { UPDATED, UNCHANGED, NOT_OWNED, INVALID, FAILED }
data class State(val owned: Boolean, val ready: Boolean, val source: Source) {
companion object {
val NONE = State(owned = false, ready = false, source = Source.NONE)
}
}
private const val TAG = "Lossless"
private const val PREFS = "lossless"
private const val KEY_OWNED = "owned"
private const val KEY_ID = "id"
private const val KEY_SOURCE = "source"
private const val KEY_STEAM_STAMP = "steamStamp"
private const val STEAM_DLL = "steamapps/common/Lossless Scaling/Lossless.dll"
private const val UNCHANGED = -1
fun state(context: Context): State {
val source = prefs(context).getString(KEY_SOURCE, null)
?.let { name -> Source.entries.firstOrNull { it.name == name } } ?: Source.NONE
return State(owned(context), cacheFile(context)?.isFile == true, source)
}
/** Recorded once: by the session's ownership check (a flag in the guest's home) or a Steam copy. */
fun owned(context: Context): Boolean {
if (prefs(context).getBoolean(KEY_OWNED, false)) return true
if (!ownedFlag(context).exists()) return false
markOwned(context)
return true
}
fun cacheFile(context: Context): File? = prefs(context).getString(KEY_ID, null)?.let { cacheFor(context, it) }
/** Takes a new or updated Steam copy and rebuilds a missing or outdated cache. A [LsfgNative] status. */
@Synchronized
fun sync(context: Context): Int {
val steam = steamDll(context)
if (steam != null) {
markOwned(context)
val stamp = "${steam.length()}:${steam.lastModified()}"
if (stamp != prefs(context).getString(KEY_STEAM_STAMP, null)) {
val status = adopt(context, steam, Source.STEAM)
if (status != LsfgNative.STATUS_OK && status != UNCHANGED) {
Log.w(TAG, "Steam copy $steam not taken: ${statusName(status)}")
}
prefs(context).edit().putString(KEY_STEAM_STAMP, stamp).apply()
}
}
return ensureBuilt(context)
}
@Synchronized
fun import(context: Context, dll: File): ImportResult {
if (!owned(context)) return ImportResult.NOT_OWNED
return when (adopt(context, dll, Source.IMPORT)) {
UNCHANGED -> ImportResult.UNCHANGED
LsfgNative.STATUS_OK -> ImportResult.UPDATED
LsfgNative.STATUS_UNREADABLE_FILE, LsfgNative.STATUS_NOT_PORTABLE_EXECUTABLE,
LsfgNative.STATUS_MISSING_SHADERS -> ImportResult.INVALID
else -> ImportResult.FAILED
}
}
fun message(context: Context, result: ImportResult): String = context.getString(
when (result) {
ImportResult.UPDATED -> R.string.lsfg_imported
ImportResult.UNCHANGED -> R.string.lsfg_import_unchanged
ImportResult.NOT_OWNED -> R.string.lsfg_import_not_owned
ImportResult.INVALID -> R.string.lsfg_import_invalid
ImportResult.FAILED -> R.string.lsfg_import_failed
},
)
private fun ensureBuilt(context: Context): Int {
val id = prefs(context).getString(KEY_ID, null) ?: return LsfgNative.STATUS_NOT_INSTALLED
val cache = cacheFor(context, id)
if (usable(cache)) return LsfgNative.STATUS_OK
val dll = storedDll(context)
if (!dll.isFile) return LsfgNative.STATUS_NOT_INSTALLED
return build(dll, cache)
}
private fun adopt(context: Context, source: File, from: Source): Int {
val staged = File(dir(context).apply { mkdirs() }, "Lossless.dll.staged")
try {
val id = copyHashing(source, staged)
if (id == prefs(context).getString(KEY_ID, null) && usable(cacheFor(context, id))) return UNCHANGED
val cache = cacheFor(context, id)
var status = LsfgNative.nativeValidateDll(staged.path)
if (status == LsfgNative.STATUS_OK) status = build(staged, cache)
if (status != LsfgNative.STATUS_OK) {
cache.delete()
return status
}
if (!staged.renameTo(storedDll(context))) {
cache.delete()
return LsfgNative.STATUS_CACHE_UNUSABLE
}
prefs(context).edit().putString(KEY_ID, id).putString(KEY_SOURCE, from.name).apply()
removeStale(context, id)
Log.i(TAG, "Lossless.dll from $source ($from) ready as $id")
return LsfgNative.STATUS_OK
} catch (e: java.io.IOException) {
Log.w(TAG, "could not copy $source", e)
return LsfgNative.STATUS_UNREADABLE_FILE
} catch (t: Throwable) {
Log.e(TAG, "could not take $source", t)
return LsfgNative.STATUS_CACHE_UNUSABLE
} finally {
staged.delete()
}
}
private fun build(dll: File, cache: File): Int = try {
val status = LsfgNative.nativeBuildCache(dll.path, cache.path, true)
Log.i(TAG, "shader cache ${cache.name}: ${statusName(status)}" +
if (status == LsfgNative.STATUS_OK) " (${LsfgNative.nativeVariantName(LsfgNative.nativeCacheVariant(cache.path))})" else "")
status
} catch (t: Throwable) {
Log.e(TAG, "shader cache ${cache.name}", t)
LsfgNative.STATUS_CACHE_UNUSABLE
}
private fun usable(cache: File): Boolean = cache.isFile &&
runCatching { LsfgNative.nativeCacheVariant(cache.path) != LsfgNative.VARIANT_NONE }.getOrDefault(false)
private fun copyHashing(source: File, target: File): String {
val digest = MessageDigest.getInstance("SHA-256")
source.inputStream().use { input ->
DigestOutputStream(target.outputStream(), digest).use { output -> input.copyTo(output) }
}
return digest.digest().take(8).joinToString("") { "%02x".format(it) }
}
private fun removeStale(context: Context, keep: String) {
File(context.filesDir, "lsfg-native").deleteRecursively()
dir(context).listFiles { f -> f.name.startsWith("shaders-") && f.name != cacheFor(context, keep).name }
?.forEach { it.delete() }
}
/** The newest copy in any library the client installs to: its own, the card's, the Games folders. */
private fun steamDll(context: Context): File? {
val libraries = listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
GameStorage.effective(context)?.path?.let(::File),
) + SessionPrefs.addedGamesDirs(context).map(::File)
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
}
private fun markOwned(context: Context) {
if (!prefs(context).getBoolean(KEY_OWNED, false)) prefs(context).edit().putBoolean(KEY_OWNED, true).apply()
}
private fun statusName(status: Int): String =
runCatching { LsfgNative.nativeStatusName(status) }.getOrDefault("status $status")
private fun ownedFlag(context: Context) = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/lossless-owned")
private fun dir(context: Context) = File(context.filesDir, "lsfg")
private fun storedDll(context: Context) = File(dir(context), "Lossless.dll")
private fun cacheFor(context: Context, id: String) = File(dir(context), "shaders-$id.cache")
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -1,99 +1,30 @@
package com.droiddeck.launcher.gpu;
import android.content.Context;
import android.util.Log;
import com.droiddeck.launcher.runtime.LinuxRuntime;
import java.io.File;
/**
* LSFG frame generation's shader chain: extracted from the user's own copy of Lossless Scaling
* and cached on device.
*
* <p>The 25 compute shaders are not redistributable. They live as resources inside the user's
* paid {@code Lossless.dll} (Steam app 993090), which in this app the user installs from the
* Steam client itself - so the DLL sits in the runtime's own Steam library. It is mapped
* read-only and parsed as data, never loaded or executed. Extraction is slow enough to be worth
* doing once (the DXBC chain is translated to SPIR-V), so the result is cached, keyed on the
* DLL's size and hash. Call {@link #ensureCache} off the main thread.
*
* <p>Ported from Bannerlator's LsfgNative; the native side is the same lsfg_jni.cpp compiled into
* the compositor library.
* The native half of LSFG's shader cache (lsfg_jni.cpp, compiled into the compositor library):
* {@code Lossless.dll} is mapped read-only and parsed as data, never loaded or executed, and its
* DXBC chain translated to SPIR-V. {@link Lossless} decides when; these calls block.
*/
public final class LsfgNative {
private static final String TAG = "LsfgNative";
final class LsfgNative {
static { System.loadLibrary("bannerwayland"); }
private LsfgNative() {}
// Mirrors lsfg::DllStatus.
public static final int STATUS_OK = 0;
public static final int STATUS_NOT_INSTALLED = 1;
public static final int STATUS_UNREADABLE_FILE = 2;
public static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3;
public static final int STATUS_MISSING_SHADERS = 4;
public static final int STATUS_TRANSLATION_FAILED = 5;
public static final int STATUS_CACHE_UNUSABLE = 6;
static final int STATUS_OK = 0;
static final int STATUS_NOT_INSTALLED = 1;
static final int STATUS_UNREADABLE_FILE = 2;
static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3;
static final int STATUS_MISSING_SHADERS = 4;
static final int STATUS_TRANSLATION_FAILED = 5;
static final int STATUS_CACHE_UNUSABLE = 6;
private static native int nativeValidateDll(String dllPath);
private static native int nativeDllVariant(String dllPath, boolean preferFp16);
private static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16);
private static native boolean nativeCacheMatchesSource(String cachePath, String dllPath);
private static native int nativeCacheVariant(String cachePath);
private static native String nativeStatusName(int status);
private static native String nativeVariantName(int variant);
// Mirrors lsfg::Variant.
static final int VARIANT_NONE = 0;
/** Where Lossless Scaling lands when installed from the client inside the runtime. */
public static File losslessDll(Context context) {
return new File(LinuxRuntime.rootDir(context),
"root/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll");
}
/** Where the translated SPIR-V chain is cached. */
public static File cacheFile(Context context) {
return new File(context.getFilesDir(), "lsfg-native/shaders.cache");
}
public static boolean isInstalled(Context context) {
return losslessDll(context).isFile();
}
/**
* Makes sure a usable cache exists for the installed DLL, building it if the DLL is new or
* changed. Returns a {@code STATUS_*} code; {@link #STATUS_OK} means the engine can start.
*/
public static int ensureCache(Context context) {
File dll = losslessDll(context);
if (!dll.isFile()) return STATUS_NOT_INSTALLED;
File cache = cacheFile(context);
try {
if (cache.isFile() && nativeCacheMatchesSource(cache.getPath(), dll.getPath())) {
return STATUS_OK;
}
int status = nativeValidateDll(dll.getPath());
if (status != STATUS_OK) {
Log.w(TAG, "Lossless.dll rejected: " + statusName(status));
return status;
}
//noinspection ResultOfMethodCallIgnored
cache.getParentFile().mkdirs();
status = nativeBuildCache(dll.getPath(), cache.getPath(), true);
Log.i(TAG, "cache build: " + statusName(status)
+ (status == STATUS_OK ? " (" + nativeVariantName(nativeCacheVariant(cache.getPath())) + ")" : ""));
return status;
} catch (Throwable t) {
Log.e(TAG, "ensureCache", t);
return STATUS_CACHE_UNUSABLE;
}
}
public static String statusName(int status) {
try {
return nativeStatusName(status);
} catch (Throwable t) {
return "status " + status;
}
}
static native int nativeValidateDll(String dllPath);
static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16);
static native int nativeCacheVariant(String cachePath);
static native String nativeStatusName(int status);
static native String nativeVariantName(int variant);
}
@@ -179,10 +179,7 @@ public final class TurnipDriver {
* {@link IOException} on read/extract failures.
*/
public String installFromZip(Uri zipUri, String displayName) throws IOException {
if (!contentDir.exists()) contentDir.mkdirs();
File tmpDir = new File(contentDir, ".tmp-" + System.currentTimeMillis());
FileUtils.delete(tmpDir);
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
File tmpDir = newStagingDir();
boolean keep = false;
try {
try (InputStream is = context.getContentResolver().openInputStream(zipUri);
@@ -198,26 +195,44 @@ public final class TurnipDriver {
Files.copy(zis, new File(tmpDir, base).toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
String rejected = rejectionReason(tmpDir);
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
String name = meta != null ? meta.optString("name", "") : "";
if (name.isEmpty()) {
name = displayName != null ? displayName : "adrenotools-driver";
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
}
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
File dir = new File(contentDir, id);
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
String id = adopt(tmpDir, displayName);
keep = true;
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
return id;
} finally {
if (!keep) FileUtils.delete(tmpDir);
}
}
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
File newStagingDir() throws IOException {
if (!contentDir.exists()) contentDir.mkdirs();
File tmpDir = new File(contentDir, ".tmp-" + System.nanoTime());
FileUtils.delete(tmpDir);
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
return tmpDir;
}
/**
* Check a staged driver (meta.json and its library, flat) and move it in as a new driver.
* Returns the id; the caller deletes {@code tmpDir} when this throws.
*/
String adopt(File tmpDir, String displayName) throws IOException {
String rejected = rejectionReason(tmpDir);
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
String name = meta != null ? meta.optString("name", "") : "";
if (name.isEmpty()) {
name = displayName != null ? displayName : "adrenotools-driver";
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
}
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
File dir = new File(contentDir, id);
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
return id;
}
private static JSONObject readMetaFile(File metaFile) {
try {
return new JSONObject(FileUtils.readString(metaFile));
@@ -29,12 +29,20 @@ object TurnipReleases {
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
/** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */
/**
* [pair] names the matched set an asset belongs to (DriverPairs): a display build and a Linux
* build of the same pair are made to run together. "" for a list cached before pairs were kept.
*/
/** [bundle]: one zip holding both halves of the pair (DriverBundle); [linux] is false for it. */
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String,
val sha256: String = "")
val sha256: String = "", val pair: String = "", val bundle: Boolean = false)
/** [latest] = each source's newest release tag, for the refresh line. */
class Check(val assets: List<Asset>, val latest: List<Pair<String, String>>, val failed: List<String>, val checkedAt: Long)
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Pair<Boolean, String>?)
/** What an asset is: a Linux build or not, the menu label, and the pair it belongs to. */
private class Kind(val linux: Boolean, val label: String, val pair: String, val bundle: Boolean = false)
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Kind?)
private val SOURCES = listOf(
// Turnip-<tag>[-variant][-Linux|-Wayland].zip; -Wayland is for a path this app does not have.
@@ -45,12 +53,14 @@ object TurnipReleases {
if (variant.endsWith("-Wayland")) return@Source null
val linux = variant.endsWith("-Linux")
variant = variant.removeSuffix("-Linux")
linux to when {
val label = when {
variant.isEmpty() -> "Adreno 6xx/7xx"
variant == "-A8xx" -> "Adreno 8xx"
variant.startsWith("-710-720") -> "Adreno 710/720" + if (variant.contains("Test")) " (test)" else ""
variant.contains("OneUI", ignoreCase = true) -> "8 Gen 2 on One UI"
else -> variant.trimStart('-')
}
Kind(linux, label, "banner" + variant.lowercase())
},
// WN-Turnip-<ver>-<b|p>_Axxx.zip (Android) and WN-Linux-Turnip-<ver>-<b|p>_Axxx.zip (Linux):
// b = Balanced, p = Performance, Axxx = every Adreno.
@@ -58,7 +68,13 @@ object TurnipReleases {
val m = Regex("""^WN-(Linux-)?Turnip-[^-]+-([a-z]+)_(\w+)\.zip$""").find(name) ?: return@Source null
val flavour = when (m.groupValues[2]) { "b" -> "Balanced"; "p" -> "Performance"; else -> m.groupValues[2] }
val gpus = if (m.groupValues[3] == "Axxx") "all Adreno" else m.groupValues[3]
(m.groupValues[1].isNotEmpty()) to "$gpus · $flavour"
Kind(m.groupValues[1].isNotEmpty(), "$gpus · $flavour", "wn-" + m.groupValues[2])
},
// DD-Turnip-v<ver>.zip: DroidDeck's own all-in-one build, both halves in one zip, published
// to Drivers-CI (releases only). Every release is offered, not just the newest, each its own pair.
Source("DroidDeck", "Droid-Deck/Drivers-CI") { name, tag ->
if (!Regex("""^DD-Turnip-v\d+\.\d+\.\d+\.zip$""").matches(name)) return@Source null
Kind(false, "Android + Linux · all Adreno", DriverPairs.ddTurnip(tag), bundle = true)
},
)
@@ -91,14 +107,19 @@ object TurnipReleases {
for (j in 0 until list.length()) {
val a = list.getJSONObject(j)
val name = a.getString("name")
val (linux, label) = src.classify(name, tag) ?: continue
val kind = src.classify(name, tag) ?: continue
// A bundle is only offered from an official release, never a pre-release.
if (kind.bundle && r.optBoolean("prerelease")) continue
val linux = kind.linux
val label = kind.label
// Only assets GitHub has a sha256 for are offered: the download is checked against it.
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
// Newest first: the first release carrying a variant is the one offered.
if (!seen.add("$linux|$label")) continue
if (!seen.add(if (kind.bundle) kind.pair else "$linux|$label")) continue
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
.put("linux", linux).put("label", label).put("sha256", sha))
.put("linux", linux).put("label", label).put("sha256", sha).put("pair", kind.pair)
.put("bundle", kind.bundle))
}
}
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
@@ -136,7 +157,8 @@ object TurnipReleases {
for (i in 0 until list.length()) {
val a = list.getJSONObject(i)
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", "")))
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", ""), a.optString("pair", ""),
a.optBoolean("bundle", false)))
}
val latest = ArrayList<Pair<String, String>>()
val l = json.optJSONArray("latest") ?: JSONArray()
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.input
/**
* The Steam Deck controller's back grips and trackpads, which a handheld's own pad does not have:
* the second screen's Deck controls (DeckControlsPanel) drive them, and each change goes into the
* pad's ring ([FakeInputWriter.writeDeckControls]) for libfakeinput's Deck report.
*
* Trackpad positions are -1..1 each way with y up, as on a Deck; a pad touched reports its
* position, a pad clicked also reports full pressure.
*/
object DeckControls {
/** Bits of the ring's controls word - MUST match DECK_EXTRA_* in fakeinput_steam.cpp. */
const val L4 = 1
const val R4 = 2
const val L5 = 4
const val R5 = 8
private const val LEFT_TOUCH = 16
private const val RIGHT_TOUCH = 32
private const val LEFT_CLICK = 64
private const val RIGHT_CLICK = 128
/** The pad the Deck controller is made from (PadBridge's slot). */
private const val SLOT = 0
private var controls = 0
private val pads = ShortArray(4)
private val pressure = ShortArray(2)
@Synchronized
fun setGrip(bit: Int, down: Boolean) {
set(bit, down)
publish()
}
/** A finger on (or off) one trackpad at x, y in -1..1, y up. */
@Synchronized
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
val at = if (right) 2 else 0
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
pads[at] = if (touching) axis(x) else 0
pads[at + 1] = if (touching) axis(y) else 0
publish()
}
@Synchronized
fun setClick(right: Boolean, down: Boolean) {
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
publish()
}
/** Everything let go - for when the panel goes away with a finger still on it. */
@Synchronized
fun releaseAll() {
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
controls = 0
pads.fill(0)
pressure.fill(0)
publish()
}
private fun set(bit: Int, on: Boolean) {
controls = if (on) controls or bit else controls and bit.inv()
}
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
}
@@ -67,6 +67,9 @@ public class FakeInputWriter {
/** Bit 10 of the snapshot word. The first ten are PadState's own bit order; the Steam
* button is not, because PadState keeps 10 and 11 for the triggers. */
private static final int SNAPSHOT_IDX_MODE = 10;
/** Bit 11: the Quick Access button. Snapshot only - an Xbox pad's evdev node has no such key;
* libfakeinput's Deck controller reads it from here. */
private static final int SNAPSHOT_IDX_QAM = 11;
private static final int EVENT_SIZE = 24;
static final int MAX_EVENTS_PER_UPDATE = 32;
@@ -92,6 +95,18 @@ public class FakeInputWriter {
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
private static final String RING_DIR_NAME = "fakeinput-rings";
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
private static final int IMU_OFFSET = RING_SIZE;
private static final int IMU_MAGIC = 0x31554D49; // IMU1
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
private static final int RING_FILE_SIZE = RING_SIZE + 64;
private static final Object RING_LOCK = new Object();
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
@@ -110,6 +125,7 @@ public class FakeInputWriter {
private volatile boolean destroyed = false;
private final boolean[] prevButtonStates = new boolean[12];
private boolean prevQam;
private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY;
private int prevTriggerL, prevTriggerR;
private int prevHatX, prevHatY;
@@ -217,30 +233,40 @@ public class FakeInputWriter {
for (int i = 0; i < 8; i++) {
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
}
data.putInt(IMU_OFFSET, 0);
data.putLong(IMU_SEQ_OFFSET, 0L);
for (int i = 0; i < 12; i++) {
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
}
data.putInt(DECK_CONTROLS_OFFSET, 0);
}
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
// re-checks it there: a writer that looked the slot up just before this ran finds it gone.
private static void releaseRingSlotLocked(int slot) {
RingSlot ringSlot = RING_SLOTS[slot];
if (ringSlot == null) {
return;
}
ringSlot.data = null;
if (ringSlot.ringChannel != null) {
try {
ringSlot.ringChannel.close();
} catch (IOException ignored) {
synchronized (ringSlot) {
ringSlot.data = null;
if (ringSlot.ringChannel != null) {
try {
ringSlot.ringChannel.close();
} catch (IOException ignored) {
}
ringSlot.ringChannel = null;
}
ringSlot.ringChannel = null;
}
if (ringSlot.ringRaf != null) {
try {
ringSlot.ringRaf.close();
} catch (IOException ignored) {
if (ringSlot.ringRaf != null) {
try {
ringSlot.ringRaf.close();
} catch (IOException ignored) {
}
ringSlot.ringRaf = null;
}
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
ringSlot.ringFile.delete();
}
ringSlot.ringRaf = null;
}
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
ringSlot.ringFile.delete();
}
RING_SLOTS[slot] = null;
}
@@ -289,9 +315,9 @@ public class FakeInputWriter {
FileChannel channel = null;
try {
raf = new RandomAccessFile(ringFile, "rw");
raf.setLength(RING_SIZE);
raf.setLength(RING_FILE_SIZE);
channel = raf.getChannel();
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_SIZE);
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE);
initializeRingHeader(data);
RingSlot ringSlot = new RingSlot();
@@ -332,6 +358,9 @@ public class FakeInputWriter {
return false;
}
synchronized (ringSlot) {
if (ringSlot.data == null) {
return false;
}
if (!ringSlot.active) {
if (ringSlot.everActivated) {
ringSlot.generation++;
@@ -381,6 +410,9 @@ public class FakeInputWriter {
ByteBuffer source = this.buffer.duplicate();
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) {
return false;
}
long writeSeq = ring.getLong(RING_WRITE_SEQ_OFFSET);
int sourceLimit = source.limit();
while (source.remaining() >= EVENT_SIZE) {
@@ -416,6 +448,9 @@ public class FakeInputWriter {
buttons |= (1 << i);
}
}
if (this.prevQam) {
buttons |= (1 << SNAPSHOT_IDX_QAM);
}
long seq = ring.getLong(RING_SNAPSHOT_SEQ_OFFSET);
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 1); // odd: write in progress
nativeStoreFence();
@@ -434,6 +469,58 @@ public class FakeInputWriter {
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 2); // even: write complete
}
/**
* Publishes the pad's motion for slot {@code slot}: accelerometer and gyro, already in the Deck
* controller's axes and units (see PadMotion). Safe from any thread.
*/
public static void writeMotion(int slot, short[] accel, short[] gyro) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) return;
long seq = ring.getLong(IMU_SEQ_OFFSET);
ring.putLong(IMU_SEQ_OFFSET, seq + 1); // odd: write in progress
nativeStoreFence();
for (int i = 0; i < 3; i++) {
ring.putShort(IMU_ACCEL_OFFSET + i * 2, accel[i]);
ring.putShort(IMU_GYRO_OFFSET + i * 2, gyro[i]);
}
ring.putInt(IMU_OFFSET, IMU_MAGIC);
nativeStoreFence();
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
}
}
/**
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
* {@code pressure} left, right, already in the Deck's units. Safe from any thread.
*/
public static void writeDeckControls(int slot, int controls, short[] pads, short[] pressure) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null) return;
long seq = ring.getLong(IMU_SEQ_OFFSET);
ring.putLong(IMU_SEQ_OFFSET, seq + 1);
nativeStoreFence();
for (int i = 0; i < 4; i++) ring.putShort(DECK_PADS_OFFSET + i * 2, pads[i]);
for (int i = 0; i < 2; i++) ring.putShort(DECK_PRESSURE_OFFSET + i * 2, pressure[i]);
ring.putInt(DECK_CONTROLS_OFFSET, controls);
ring.putInt(IMU_OFFSET, IMU_MAGIC);
nativeStoreFence();
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
}
}
private static short clampShort(int value) {
if (value > Short.MAX_VALUE) {
return Short.MAX_VALUE;
@@ -512,6 +599,11 @@ public class FakeInputWriter {
}
}
if (prevQam) {
prevQam = false;
hasChanges = true;
}
// Zero all axes
if (prevThumbLX != 0) {
prevThumbLX = 0;
@@ -620,6 +712,11 @@ public class FakeInputWriter {
// reports as button 8 - exactly the "guide:b8" in the mapping Steam writes for this pad.
writeButton(SNAPSHOT_IDX_MODE,
state.isDown(PadState.GUIDE));
boolean qam = state.isDown(PadState.QAM);
if (forceResend || qam != prevQam) {
prevQam = qam;
hasChanges = true;
}
// Sticks
int lx = (int) (state.leftX * 32767);
@@ -7,6 +7,8 @@ import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import com.droiddeck.launcher.session.SessionState;
import java.io.File;
/**
@@ -19,7 +21,10 @@ import java.io.File;
* SDL → Big Picture.
*
* <p>The identity the interposer reports is an Xbox 360 pad, which is what makes SDL apply a known
* mapping without the user configuring anything.
* mapping without the user configuring anything. In a Steam session it is a Steam Deck controller
* to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick
* Access button of its own; an Xbox pad has none, so there the client's menu is reached with the
* Guide-then-A chord the client itself answers.
*/
public final class PadBridge {
private static final String TAG = "PadBridge";
@@ -54,6 +59,8 @@ public final class PadBridge {
private boolean systemQamPressed;
private boolean qamChordActive;
private boolean qamSyntheticAPressed;
/** The Deck controller's Quick Access button, held for one tap. */
private boolean qamTapPressed;
private int qamChordGeneration;
/** Told on the main thread when a player uses the pad or the on-screen controls; see [setOnPlayerInput]. */
private volatile Runnable onPlayerInput;
@@ -77,6 +84,7 @@ public final class PadBridge {
systemQamPressed = false;
qamChordActive = false;
qamSyntheticAPressed = false;
qamTapPressed = false;
qamChordGeneration++;
if (open) {
state.clear();
@@ -204,7 +212,8 @@ public final class PadBridge {
boolean qamStarted = qamPressed && !systemQamPressed;
systemGuidePressed = guidePressed;
systemQamPressed = qamPressed;
if (qamStarted) startQamChord();
// A Deck controller's QAM button is held for as long as the touch one is.
if (qamStarted && !SessionState.getDeckPad()) startQamChord();
else publish();
}
@@ -217,9 +226,23 @@ public final class PadBridge {
publish();
}
/** Starts the same Guide-then-A sequence used by the touch QAM button. */
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
public synchronized void triggerQam() {
startQamChord();
if (!SessionState.getDeckPad()) {
startQamChord();
return;
}
if (qamTapPressed) return;
qamTapPressed = true;
int generation = qamChordGeneration;
publish();
chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS);
}
private synchronized void releaseQamTap(int generation) {
if (generation != qamChordGeneration || !qamTapPressed) return;
qamTapPressed = false;
publish();
}
private void startQamChord() {
@@ -258,16 +281,17 @@ public final class PadBridge {
private void publish() {
if (!open && !start()) return;
if (systemGuidePressed || qamChordActive) {
effectiveState.copyFrom(state);
effectiveState.press(PadState.GUIDE, true);
if (qamSyntheticAPressed) {
effectiveState.press(PadState.A, state.isDown(PadState.A) || qamSyntheticAPressed);
}
writer.writePad(effectiveState);
} else {
boolean guide = systemGuidePressed || qamChordActive;
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
if (!guide && !qam) {
writer.writePad(state);
return;
}
effectiveState.copyFrom(state);
if (guide) effectiveState.press(PadState.GUIDE, true);
if (qam) effectiveState.press(PadState.QAM, true);
if (qamSyntheticAPressed) effectiveState.press(PadState.A, true);
writer.writePad(effectiveState);
}
private static float axis(MotionEvent event, int axis) {
@@ -0,0 +1,103 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import android.view.Surface
import kotlin.math.roundToInt
/**
* The handheld's own gyro and accelerometer, as the Steam Deck controller's (SteamDeckPad).
*
* The phone is the controller's body, so its motion sensors are the ones a Deck's would be. Android
* reports them in the device's natural orientation; they are turned to the screen as it is shown
* (x right, y up, z towards the player - SDL's frame for a gamepad), then to the Deck's own axes
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
*
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
*/
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
private val sensors = context.getSystemService(SensorManager::class.java)
private var thread: HandlerThread? = null
/** Readings are taken only while this is set; a callback already queued when [stop] ran drops. */
@Volatile private var active = false
// Touched only on the worker thread.
private val accel = ShortArray(3)
private val gyro = ShortArray(3)
fun start() {
if (thread != null || sensors == null) return
val gyroscope = sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
val accelerometer = sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (gyroscope == null && accelerometer == null) {
Log.i(TAG, "pad motion: this device has no gyro or accelerometer")
return
}
val worker = HandlerThread("pad-motion", android.os.Process.THREAD_PRIORITY_DISPLAY).apply { start() }
thread = worker
val handler = Handler(worker.looper)
active = true
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
}
fun stop() {
val worker = thread ?: return
thread = null
active = false
sensors?.unregisterListener(this)
// A still controller, not one frozen mid-turn - published on the worker, after any reading
// already queued there, so nothing can overwrite it.
Handler(worker.looper).post {
gyro.fill(0)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
}
worker.quitSafely()
}
override fun onSensorChanged(event: SensorEvent) {
if (!active) return
val values = event.values
// Natural orientation to the screen's: Android's display-rotation transform.
val (x, y) = when (rotation()) {
Surface.ROTATION_90 -> -values[1] to values[0]
Surface.ROTATION_180 -> -values[0] to -values[1]
Surface.ROTATION_270 -> values[1] to -values[0]
else -> values[0] to values[1]
}
val z = values[2]
val (target, scale) = when (event.sensor.type) {
Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S
Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2
else -> return
}
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
target[0] = counts(x * scale)
target[1] = counts(-z * scale)
target[2] = counts(y * scale)
FakeInputWriter.writeMotion(SLOT, accel, gyro)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
private fun counts(value: Float): Short = value.roundToInt().coerceIn(-32768, 32767).toShort()
companion object {
private const val TAG = "PadMotion"
/** The pad the Deck controller is made from (PadBridge's slot). */
private const val SLOT = 0
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
private const val SAMPLING_US = 4000
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
}
}
@@ -57,5 +57,7 @@ class PadState {
const val R3 = 9
/** The Steam/Guide button, which the fake evdev node reports as BTN_MODE. */
const val GUIDE = 12
/** The Quick Access button: a Deck controller's (SteamDeckPad) only, since an Xbox pad has none. */
const val QAM = 13
}
}
@@ -8,6 +8,8 @@ enum class SecondScreenMode(val id: String, val label: String) {
NONE("none", "None"),
KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"),
TERMINAL("terminal", "Terminal"),
/** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */
DECK_CONTROLS("deck-controls", "Deck grips + trackpads"),
}
data class SecondScreenDisplay(val id: Int, val label: String)
@@ -0,0 +1,181 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.StatFs
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.io.RandomAccessFile
/**
* AppImages the user brings: picked from storage, checked, and extracted once into the runtime at
* /opt/appimages/user/<id>/app. proot has no FUSE to mount an image, and extracting it at every
* start would cost its whole size in /tmp each time. Each one gets a menu entry on the Linux
* desktop, and the front end starts it under gamescope through bannerlator-appimage-run.
*
* <id>/name holds the name to show, <id>/icon.png the icon when the image has a PNG one.
*/
object AppImageManager {
private const val TAG = "AppImageManager"
const val GUEST_DIR = "/opt/appimages/user"
const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run"
private const val ELF_AARCH64 = 183
private const val ELF_X86_64 = 62
class Item(val id: String, val name: String, val icon: File?, val comment: String?) {
val guestDir: String get() = "$GUEST_DIR/$id"
}
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
fun list(context: Context): List<Item> =
root(context).listFiles()?.filter { File(it, "app/AppRun").exists() || Files.isLink(File(it, "app/AppRun")) }
?.map { dir ->
Item(
dir.name,
FileUtils.readString(File(dir, "name"))?.trim()?.takeIf { it.isNotEmpty() } ?: dir.name,
File(dir, "icon.png").takeIf { it.isFile },
FileUtils.readString(File(dir, "comment"))?.trim()?.takeIf { it.isNotEmpty() },
)
}?.sortedBy { it.name.lowercase() } ?: emptyList()
/**
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
*/
internal fun problem(file: File): String? {
if (!file.isFile) return "The file is gone"
val head = ByteArray(20)
try {
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
} catch (e: Exception) {
return "The file cannot be read: ${e.message}"
}
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
return "This is not an AppImage (it is not a Linux program)"
}
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
return when {
machine == ELF_X86_64 -> "This AppImage is built for x86_64 PCs. DroidDeck runs ARM64 (aarch64) AppImages - look for an \"aarch64\" or \"arm64\" download."
machine != ELF_AARCH64 -> "This AppImage is not built for ARM64 (aarch64)"
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is an ARM64 program but not an AppImage"
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
else -> null
}
}
/** A directory name from the image's file name: "Some_App-1.2-aarch64.AppImage" -> "some-app-1-2-aarch64". */
internal fun idFor(fileName: String, taken: Set<String>): String {
val base = fileName.removeSuffix(".AppImage").removeSuffix(".appimage").lowercase()
.replace(Regex("[^a-z0-9]+"), "-").trim('-').take(48).ifEmpty { "appimage" }
var id = base
var n = 2
while (id in taken) id = "$base-${n++}"
return id
}
/** Imports [file]; null on success, else what went wrong. [onProgress] gets a stage line. */
fun import(context: Context, file: File, onProgress: (String) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
problem(file)?.let { return it }
val base = root(context).apply { mkdirs() }
// Room for the image while it is extracted, and the extracted tree (about the same again).
if (StatFs(base.path).availableBytes < file.length() * 3) {
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
}
val id = idFor(file.name, base.list()?.toSet() ?: emptySet())
val dir = File(base, id)
val image = File(dir, "image.AppImage")
try {
dir.mkdirs()
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
// maps a program executable.
onProgress("Copying ${file.name}")
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
image.setExecutable(true, false)
onProgress("Extracting ${file.name}")
val guestDir = "$GUEST_DIR/$id"
val out = StringBuilder()
val status = GuestCommand.run(context, listOf(
"/bin/bash", "-c",
"cd \"$1\" && ./image.AppImage --appimage-extract >/dev/null && mv squashfs-root app",
"extract", guestDir,
), logName = "appimage-import") { line -> if (out.length < 2000) out.appendLine(line) }
if (status != 0 || !File(dir, "app").isDirectory) {
FileUtils.delete(dir)
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
}
image.delete()
describe(dir, file)
writeMenuEntry(context, dir)
return null
} catch (e: Exception) {
Log.e(TAG, "import ${file.name}", e)
FileUtils.delete(dir)
return e.message ?: "Import failed"
} finally {
image.delete()
}
}
/** Name, comment and icon from the image's own desktop entry; the file name when it has none. */
private fun describe(dir: File, source: File) {
val app = File(dir, "app")
val entry = app.listFiles { f -> f.isFile && f.name.endsWith(".desktop") }?.firstOrNull()
val text = entry?.let { FileUtils.readString(it) }
fun key(k: String) = text?.let { desktopKey(it, k) }
val name = key("Name") ?: source.name.substringBeforeLast('.')
FileUtils.writeString(File(dir, "name"), name)
key("Comment")?.let { FileUtils.writeString(File(dir, "comment"), it) }
key("Categories")?.let { FileUtils.writeString(File(dir, "categories"), it) }
// The icon the entry names, beside it or anywhere in the image's icon theme, else .DirIcon.
val iconName = key("Icon")
val candidates = listOfNotNull(
iconName?.let { File(app, "$it.png") },
iconName?.let { n -> app.walkTopDown().maxDepth(8).filter { it.isFile && it.name == "$n.png" }.maxByOrNull { it.length() } },
File(app, ".DirIcon"),
)
candidates.firstOrNull { it.isFile && isPng(it) }?.copyTo(File(dir, "icon.png"), overwrite = true)
}
private fun isPng(f: File): Boolean = try {
f.inputStream().use { val b = ByteArray(4); it.read(b) == 4 && b[1] == 'P'.code.toByte() && b[2] == 'N'.code.toByte() && b[3] == 'G'.code.toByte() }
} catch (e: Exception) { false }
/** A key of a desktop entry's main group, unlocalised. */
internal fun desktopKey(desktop: String, key: String): String? {
var inMain = false
for (line in desktop.lineSequence()) {
val t = line.trim()
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
}
return null
}
private fun menuEntry(context: Context, id: String) =
// A system directory, not ~/.local/share/applications: the desktop copies game entries from
// here into the user's with a GPU launcher (droiddeck-desktop-gpu) while it runs on pixman.
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-appimage-$id.desktop")
/** The Linux desktop's menu entry: the desktop's own Exec rules (and droiddeck-gpu) apply to it. */
private fun writeMenuEntry(context: Context, dir: File) {
val id = dir.name
val name = FileUtils.readString(File(dir, "name"))?.trim() ?: id
val categories = FileUtils.readString(File(dir, "categories"))?.trim()?.takeIf { it.isNotEmpty() } ?: "Utility;"
val icon = if (File(dir, "icon.png").isFile) "$GUEST_DIR/$id/icon.png" else "application-x-executable"
FileUtils.writeString(menuEntry(context, id),
"[Desktop Entry]\nType=Application\nName=$name\nExec=$LAUNCHER $GUEST_DIR/$id %U\nIcon=$icon\n" +
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
}
fun remove(context: Context, id: String) {
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
FileUtils.delete(File(root(context), id))
menuEntry(context, id).delete()
}
private object Files {
fun isLink(f: File) = java.nio.file.Files.isSymbolicLink(f.toPath())
}
}
@@ -0,0 +1,424 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.net.LocalServerSocket
import android.net.LocalSocket
import android.os.Process
import android.system.Os
import android.util.Base64
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.util.concurrent.atomic.AtomicInteger
/**
* Flatpak's sandboxes, started for the runtime's bwrap stand-in (bannerlator-bwrap).
*
* A Flatpak app runs with its runtime as /usr and itself as /app, which bwrap arranges with mount
* namespaces Android gives no app. proot can show the same picture, and started here - beside the
* session's proot rather than inside it - it traces the app once instead of twice. The stand-in
* sends the sandbox Flatpak asked for (its binds, directories, symlinks and files, in guest paths),
* this builds it under the cache and starts it, then streams the output, the pid and the exit
* status back until one side goes away: the stand-in ending takes the sandbox down with it.
*
* The socket is abstract, so anything on the device could connect: only this app's own uid - the
* uid every program in the runtime already runs as - is served.
*/
object BwrapSpawner {
private const val TAG = "BwrapSpawner"
const val SOCKET = "com.droiddeck.launcher.bwrap"
private val seq = AtomicInteger()
@Volatile private var server: LocalServerSocket? = null
/** Listens for sandboxes for the rest of the process's life; later calls do nothing. */
@Synchronized
fun start(context: Context) {
if (server != null) return
val app = context.applicationContext
val socket = try {
LocalServerSocket(SOCKET)
} catch (e: IOException) {
Log.e(TAG, "cannot listen on $SOCKET", e)
return
}
server = socket
FileUtils.delete(File(app.cacheDir, "bwrap"))
Thread({
while (true) {
val client = try { socket.accept() } catch (e: IOException) { Log.e(TAG, "accept", e); break }
Thread({ serve(app, client) }, "bwrap-${seq.get() + 1}").apply { isDaemon = true; start() }
}
}, "bwrap-server").apply { isDaemon = true; start() }
}
private fun serve(context: Context, client: LocalSocket) {
val id = seq.incrementAndGet()
val dir = File(context.cacheDir, "bwrap/$id")
client.use {
val out = DataOutputStream(client.outputStream)
try {
val peer = client.peerCredentials
if (peer.uid != Process.myUid()) {
Log.w(TAG, "refused a sandbox for uid ${peer.uid}")
return
}
val input = DataInputStream(client.inputStream)
val kind = input.readUnsignedByte()
val body = ByteArray(input.readInt()).also { input.readFully(it) }
if (kind != 'R'.code) return
val request = JSONObject(String(body, Charsets.UTF_8))
val plan = plan(context, request, dir)
run(context, plan, client, out, id)
} catch (e: Exception) {
Log.e(TAG, "sandbox $id", e)
runCatching {
frame(out, 'E', (e.message ?: e.toString()).toByteArray())
frame(out, 'X', "1".toByteArray())
}
} finally {
FileUtils.delete(dir)
}
}
}
/** What proot is started with: the sandbox root, its binds (host to guest), and the program. */
class Plan(val root: File, val binds: List<Bind>, val cwd: String, val argv: List<String>, val env: Map<String, String>)
data class Bind(val host: String, val guest: String) {
val spec: String get() = if (host == guest) host else "$host:$guest"
companion object {
fun parse(spec: String): Bind {
val colon = spec.indexOf(':')
return if (colon < 0) Bind(spec, spec) else Bind(spec.substring(0, colon), spec.substring(colon + 1))
}
}
}
/** True when [path] is [dir] or inside it. */
internal fun under(path: String, dir: String): Boolean =
dir == "/" || path == dir || path.startsWith(if (dir.endsWith("/")) dir else "$dir/")
/**
* Where a path the session's programs see lives on the host: through the session's own binds
* (the most specific one), or else inside the rootfs.
*/
internal fun hostPath(sessionBinds: List<Bind>, rootfs: String, guest: String): String {
val bind = sessionBinds.filter { under(guest, it.guest) }.maxByOrNull { it.guest.length }
?: return rootfs.trimEnd('/') + guest
val rest = guest.substring(bind.guest.length).trimStart('/')
return if (rest.isEmpty()) bind.host else bind.host.trimEnd('/') + "/" + rest
}
/**
* Guest [src] bound at [dest]: the bind itself, then every session bind below [src] moved
* under [dest] - /dev brings the session's /dev/shm and GPU node, /proc its stand-ins for what
* Android keeps apps from reading.
*/
internal fun bindsFor(sessionBinds: List<Bind>, rootfs: String, src: String, dest: String): List<Bind> {
val out = ArrayList<Bind>()
out.add(Bind(hostPath(sessionBinds, rootfs, src), dest))
for (b in sessionBinds.sortedBy { it.guest.length }) {
if (b.guest != src && under(b.guest, src)) {
out.add(Bind(b.host, (dest.trimEnd('/') + "/" + b.guest.substring(src.length).trimStart('/'))))
}
}
return out
}
internal fun plan(context: Context, request: JSONObject, dir: File): Plan {
val rootfs = LinuxRuntime.rootDir(context).path
val session = LinuxRuntime.lastBinds(context).map { Bind.parse(it) }
val root = File(dir, "root").apply { mkdirs() }
val binds = ArrayList<Bind>()
var scratch = 0
fun addBind(b: Bind) {
binds.removeAll { it.guest == b.guest }
binds.add(b)
}
fun coveringBind(dest: String) = binds.filter { under(dest, it.guest) }.maxByOrNull { it.guest.length }
fun newScratch(): File = File(dir, "x${scratch++}")
/**
* Where [dest] can be created on the host: inside the sandbox root, or inside a directory
* of ours bound into it (a --tmpfs under the runtime's /usr, which Flatpak fills with the
* GL extension's links). Null inside anything else bound in - the runtime itself, a host
* directory - which must not be written to.
*/
fun writable(dest: String): File? {
val cover = coveringBind(dest) ?: return inRoot(root, dest)
if (!under(cover.host, dir.path)) return null
return inRoot(File(cover.host), dest.substring(cover.guest.length))
}
val ops = request.getJSONArray("ops")
for (i in 0 until ops.length()) {
val op = ops.getJSONObject(i)
val dest = op.optString("dest")
when (op.getString("t")) {
"bind" -> {
val src = op.getString("src")
val all = bindsFor(session, rootfs, src, dest)
if (!File(all.first().host).exists()) {
if (!op.optBoolean("try")) Log.w(TAG, "sandbox source $src is missing; left out")
continue
}
all.forEach { addBind(it) }
}
"proc" -> bindsFor(session, rootfs, "/proc", dest).forEach { addBind(it) }
"dev" -> bindsFor(session, rootfs, "/dev", dest).forEach { addBind(it) }
"dir" -> {
val perms = op.optInt("perms", 0x1ed)
val here = writable(dest)
when {
op.optBoolean("empty") && coveringBind(dest) != null ->
addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest))
here != null && (here.isDirectory || here.mkdirs()) -> chmod(here, perms)
File(hostPath(binds, root.path, dest)).isDirectory -> {}
else -> addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest))
}
}
"file" -> {
val data = Base64.decode(op.getString("data"), Base64.DEFAULT)
val here = writable(dest)
val f = if (here != null && here.parentFile?.let { it.isDirectory || it.mkdirs() } == true) here
else newScratch().also { addBind(Bind(it.path, dest)) }
f.writeBytes(data)
chmod(f, op.optInt("perms", 0x1b6))
}
"symlink" -> {
val target = op.getString("target")
val link = writable(dest)
if (link != null) {
link.parentFile?.mkdirs()
if (!Files.isSymbolicLink(link.toPath()) && !link.exists()) Files.createSymbolicLink(link.toPath(), java.nio.file.Paths.get(target))
} else {
// A link inside the runtime's own tree cannot be made; what it points at is
// bound in its place.
val abs = if (target.startsWith("/")) target else normalize(dest.substringBeforeLast('/') + "/" + target)
val host = hostPath(binds, root.path, abs)
if (File(host).exists()) addBind(Bind(host, dest)) else Log.w(TAG, "symlink $dest -> $target: nothing there; left out")
}
}
}
}
if (binds.any { it.guest == "/proc" }) genericCpuinfo(context)?.let { addBind(Bind(it.path, "/proc/cpuinfo")) }
val argv = request.getJSONArray("argv").let { a -> (0 until a.length()).map { a.getString(it) } }
val env = LinkedHashMap<String, String>()
request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } }
if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) }
preload(session, rootfs, env).forEach { addBind(it) }
browserSandboxes(env)
return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env)
}
/** Where a sandbox finds the runtime's own GPU driver. */
private const val GPU_DIR = "/run/droiddeck-gpu"
/**
* The GPU, for an app that is given /dev. Flathub's Mesa has Turnip, but built for the DRM
* kernel driver: Adreno on Android is KGSL, so apps fell back to drawing on the CPU. The
* runtime's own Turnip has the KGSL backend and needs nothing the Freedesktop runtime lacks
* but three libraries, which come with it; Mesa's GL then runs on it through Zink. An app's
* own choice of driver (VK_ICD_FILENAMES, MESA_LOADER_DRIVER_OVERRIDE) is left alone.
*/
private fun gpu(rootfs: String, root: File, env: MutableMap<String, String>): List<Bind>? {
val lib = File(rootfs, "usr/lib")
val driver = File(lib, "libvulkan_freedreno.so")
if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null
if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null
// A window on the desktop while labwc composites with pixman gets shared-memory buffers
// only; Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe).
// From the desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
if (env["WLR_RENDERER"] == "pixman" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
val binds = ArrayList<Bind>()
for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) {
val f = File(lib, name)
if (!f.exists()) { Log.w(TAG, "gpu: the runtime has no $name; apps draw on the CPU"); return null }
binds.add(Bind(f.canonicalPath, "$GPU_DIR/$name"))
}
// Beside the sandbox root, in the sandbox's own directory: removed with it.
val icd = File(root.parentFile, "freedreno_icd.json")
icd.writeText("{\"file_format_version\": \"1.0.0\", \"ICD\": {\"library_path\": \"$GPU_DIR/libvulkan_freedreno.so\", \"api_version\": \"1.4.0\"}}\n")
binds.add(Bind(icd.path, "$GPU_DIR/freedreno_icd.json"))
env["VK_DRIVER_FILES"] = "$GPU_DIR/freedreno_icd.json"
env["VK_ICD_FILENAMES"] = "$GPU_DIR/freedreno_icd.json"
if (!env.containsKey("MESA_LOADER_DRIVER_OVERRIDE") && !env.containsKey("GALLIUM_DRIVER")) {
env["MESA_LOADER_DRIVER_OVERRIDE"] = "zink"
env["GALLIUM_DRIVER"] = "zink"
}
// The driver's own libraries, found beside it; nothing else is in that directory.
env["LD_LIBRARY_PATH"] = listOf(env["LD_LIBRARY_PATH"], GPU_DIR).filter { !it.isNullOrEmpty() }.joinToString(":")
return binds
}
/**
* What every program in the session preloads (SessionFiles writes /etc/ld.so.preload): the
* session shim - SysV shared memory for X11, the network calls Android refuses - and the
* controller reader. A sandbox's /etc is the runtime's, so the same libraries go in through
* LD_PRELOAD instead, with the directory the controllers' rings live in; without them an app
* saw no gamepad. Both are built against an older glibc than any Flathub runtime has.
*/
private fun preload(session: List<Bind>, rootfs: String, env: MutableMap<String, String>): List<Bind> {
val libs = FileUtils.readString(File(rootfs, "etc/ld.so.preload"))?.lines()
?.map { it.trim() }?.filter { it.startsWith("/") && File(rootfs + it).isFile } ?: return emptyList()
if (libs.isEmpty()) return emptyList()
val binds = ArrayList<Bind>()
val paths = libs.map { lib -> "$PRELOAD_DIR/${lib.substringAfterLast('/')}".also { binds.add(Bind(rootfs + lib, it)) } }
env["LD_PRELOAD"] = (paths + listOfNotNull(env["LD_PRELOAD"]?.takeIf { it.isNotBlank() })).joinToString(":")
// Not through ZYPAK_LD_PRELOAD to Chromium's children: the session shim's stat hooks then
// hide the setuid bit zypak fakes on chrome-sandbox, and Chromium refuses to start.
// The controllers: FAKE_EVDEV_DIR is <session>/dev/input, the rings beside it in <session>/dev.
env["FAKE_EVDEV_DIR"]?.let { File(it).parent }?.let { dev ->
binds.addAll(bindsFor(session, rootfs, dev, dev))
}
return binds
}
private const val PRELOAD_DIR = "/run/droiddeck-preload"
/**
* Browsers sandbox their own children, and neither way works in here. Firefox wants
* namespaces and seccomp from the kernel; its content processes died without them (the
* desktop sets the same for the runtime's own Firefox). Chromium and Electron apps start
* their zygote through Flatpak's portal (zypak's spawn strategy), and the portal refuses:
* it knows a Flatpak by /proc/<pid>/root/.flatpak-info, and a proot sandbox's root is the
* host's. zypak's older strategy mimics the zygote inside the app's own sandbox instead.
*/
private fun browserSandboxes(env: MutableMap<String, String>) {
env.putIfAbsent("ZYPAK_ZYGOTE_STRATEGY_SPAWN", "0")
// Qt WebEngine is Chromium without zypak: it sets up its own namespace sandbox.
env.putIfAbsent("QTWEBENGINE_DISABLE_SANDBOX", "1")
// Chromium on Wayland asks the render node the compositor names for its DRM version;
// KGSL has none, and the GPU process aborts until Chromium gives up ("GPU process isn't
// usable"). Electron picks Wayland from XDG_SESSION_TYPE; with an X server there (every
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
env.putIfAbsent(name, "1")
}
}
/**
* /proc/cpuinfo without the cores' names. Snapdragon's ARMv9 cores (Cortex-A510/A715/X3) have
* SVE2 by architecture, and LLVM - llvmpipe's JIT in Flathub's Mesa, which is what apps draw
* with here - takes the CPU part to mean it may use it. Qualcomm leaves SVE off, so the first
* shader compiled killed the app with SIGILL. Without a part number LLVM targets generic
* ARMv8 plus the "Features" line, which lists what the kernel really enabled.
*/
private fun genericCpuinfo(context: Context): File? = try {
val out = File(context.cacheDir, "cpuinfo-generic")
val text = File("/proc/cpuinfo").readLines()
.filterNot { it.startsWith("CPU implementer") || it.startsWith("CPU part") || it.startsWith("CPU variant") || it.startsWith("CPU revision") }
.joinToString("\n", postfix = "\n")
// Sandboxes start side by side; one must never read another's half-written copy.
if (!out.isFile || out.readText() != text) {
val staged = File.createTempFile("cpuinfo", null, context.cacheDir)
staged.writeText(text)
if (!staged.renameTo(out)) staged.delete()
}
out
} catch (e: Exception) {
Log.w(TAG, "cpuinfo", e); null
}
/** [path] with "." and ".." resolved lexically. */
internal fun normalize(path: String): String {
val out = ArrayList<String>()
for (part in path.split('/')) when (part) {
"", "." -> {}
".." -> if (out.isNotEmpty()) out.removeAt(out.size - 1)
else -> out.add(part)
}
return "/" + out.joinToString("/")
}
/**
* [guest] inside the sandbox root, or null when the way there crosses a symlink: one of
* Flatpak's (var/run -> ../run) may point anywhere once followed on the host.
*/
private fun inRoot(root: File, guest: String): File? {
var f = root
for (part in guest.split('/').filter { it.isNotEmpty() }) {
if (part == "..") return null
f = File(f, part)
if (Files.isSymbolicLink(f.toPath())) return null
}
return f
}
private fun chmod(f: File, mode: Int) {
try { Os.chmod(f.path, mode) } catch (e: Exception) { /* the default mode serves */ }
}
private fun run(context: Context, plan: Plan, client: LocalSocket, out: DataOutputStream, id: Int) {
val cmd = LinuxRuntime.prootPrefix(context, plan.root, plan.cwd)
// DROIDDECK_PROOT_VERBOSE=N in the app's environment (flatpak run --env=...) has proot
// say what it does, to logcat in full: for a sandbox that dies without a word.
val verbose = plan.env["DROIDDECK_PROOT_VERBOSE"]?.toIntOrNull()
if (verbose != null) { cmd.add(1, "-v"); cmd.add(2, verbose.toString()) }
plan.binds.forEach { cmd.add("-b"); cmd.add(it.spec) }
// proot hands its own environment on; the sandbox gets exactly what Flatpak asked for.
cmd.add("/usr/bin/env")
cmd.add("-i")
plan.env.forEach { (k, v) -> cmd.add("$k=$v") }
cmd.addAll(plan.argv)
val builder = ProcessBuilder(cmd).redirectErrorStream(true).directory(plan.root)
builder.environment().apply {
clear()
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", context.cacheDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
val pid = HostProcess.pidOf(process)
// A sandbox lives exactly as long as its bwrap stand-in, which ends it; a session starting
// meanwhile (OrphanReaper) must not take it - an install's extra-data step, say.
com.droiddeck.launcher.session.OrphanReaper.keep(pid)
Log.i(TAG, "sandbox $id pid $pid: ${plan.argv.joinToString(" ")} (${plan.binds.size} binds)")
synchronized(out) { frame(out, 'P', pid.toString().toByteArray()) }
// The stand-in going away (Flatpak killed, the session over) ends the sandbox.
Thread({
try { while (client.inputStream.read() >= 0) { /* nothing is sent after the request */ } }
catch (e: IOException) { /* closed */ }
if (process.isAlive) {
Log.i(TAG, "sandbox $id: its bwrap is gone; stopping it")
process.destroy()
}
}, "bwrap-$id-watch").apply { isDaemon = true; start() }
val buffer = ByteArray(16384)
// The start of what the sandbox says goes to logcat too: a sandbox that fails at once
// (proot refusing a bind, a missing library) says why there even when nothing reads it.
var logged = 0
process.inputStream.use { stream ->
while (true) {
val n = try { stream.read(buffer) } catch (e: IOException) { -1 }
if (n < 0) break
if (logged < 4096 || verbose != null) {
String(buffer, 0, if (verbose != null) n else minOf(n, 4096 - logged)).lines().filter { it.isNotBlank() }
.forEach { Log.i(TAG, "sandbox $id: $it") }
logged += n
}
try { synchronized(out) { frame(out, 'O', buffer.copyOf(n)) } } catch (e: IOException) { break }
}
}
val status = process.waitFor()
com.droiddeck.launcher.session.OrphanReaper.release(pid)
Log.i(TAG, "sandbox $id exited $status")
runCatching { synchronized(out) { frame(out, 'X', status.toString().toByteArray()) } }
}
private fun frame(out: DataOutputStream, kind: Char, payload: ByteArray) {
out.writeByte(kind.code)
out.writeInt(payload.size)
out.write(payload)
out.flush()
}
}
@@ -0,0 +1,204 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONObject
import java.io.File
/**
* Flatpak in the Linux runtime: putting it there (bannerlator-flatpak-setup), the apps installed
* from Flathub, and installing, removing and updating them (bannerlator-flatpak). One per-user
* installation at /root/.local/share/flatpak, so nothing needs the system helper or polkit.
*
* Every command runs in a proot of its own, outside any session: the store works whether or not
* Steam or the desktop is up, and a session starting meanwhile leaves it running (OrphanReaper).
*/
object FlatpakManager {
private const val TAG = "FlatpakManager"
const val FLATPAK = "/usr/bin/flatpak"
const val BWRAP = "/usr/local/bin/bannerlator-bwrap"
/** The program the front end starts a Flatpak app through, in a session of its own. */
const val LAUNCHER = "/usr/local/bin/bannerlator-flatpak-run"
private const val HELPER = "/usr/local/bin/bannerlator-flatpak"
private const val SETUP = "/usr/local/bin/bannerlator-flatpak-setup"
private const val USER_DIR = "root/.local/share/flatpak"
/** One store operation at a time: Flatpak locks its installation per transaction anyway. */
@Volatile var busy: String? = null
private set
class App(val id: String, val name: String, val icon: File?, val branch: String, val summary: String?)
/** Flatpak is in the runtime, with Flathub to install from. */
fun ready(context: Context): Boolean {
val root = LinuxRuntime.rootDir(context)
return File(root, FLATPAK.substring(1)).isFile &&
File(root, "var/cache/droiddeck-flatpak/installed").isFile &&
(FileUtils.readString(File(root, "$USER_DIR/repo/config"))?.contains("[remote \"flathub\"]") == true)
}
private fun arch(): String = when (android.os.Build.SUPPORTED_ABIS.firstOrNull()) {
"x86_64" -> "x86_64"
else -> "aarch64"
}
/**
* The installed apps, read from the installation itself: each app's deployed files carry the
* desktop entry and icons it exports, so listing them needs no process in the runtime.
*/
fun installedApps(context: Context): List<App> {
val apps = File(LinuxRuntime.rootDir(context), "$USER_DIR/app")
val arch = arch()
return apps.listFiles()?.mapNotNull { dir ->
val id = dir.name
val archDir = File(dir, arch)
val branch = archDir.listFiles()?.firstOrNull { File(it, "active").exists() } ?: return@mapNotNull null
val active = File(branch, "active")
val export = File(active, "export/share")
val desktop = FileUtils.readString(File(export, "applications/$id.desktop"))
val name = desktop?.let { entry(it, "Name") } ?: id.substringAfterLast('.')
val summary = desktop?.let { entry(it, "Comment") }
App(id, name, icon(export, id), branch.name, summary)
}?.sortedBy { it.name.lowercase() } ?: emptyList()
}
fun isInstalled(context: Context, id: String): Boolean =
File(LinuxRuntime.rootDir(context), "$USER_DIR/app/$id/${arch()}").listFiles()?.any { File(it, "active").exists() } == true
/** A key of the desktop entry's main group ([Desktop Entry]), unlocalised. */
private fun entry(desktop: String, key: String): String? {
var inMain = false
for (line in desktop.lineSequence()) {
val t = line.trim()
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
}
return null
}
/** The largest PNG the app exports (the front end draws no SVG). */
private fun icon(export: File, id: String): File? {
val hicolor = File(export, "icons/hicolor")
return hicolor.listFiles()
?.mapNotNull { size -> size.name.substringBefore('x').toIntOrNull()?.let { it to File(size, "apps/$id.png") } }
?.filter { it.second.isFile }
?.maxByOrNull { it.first }?.second
}
/** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-<verb>.log. */
private fun runGuest(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup")
return GuestCommand.run(context, argv, fakeRoot, name, onLine)
}
private inline fun <T> exclusive(what: String, block: () -> T): T? {
synchronized(this) {
if (busy != null) return null
busy = what
}
try { return block() } finally { busy = null }
}
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
return exclusive("setup") {
var failure: String? = null
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
Log.i(TAG, "setup: $line")
when {
line.startsWith("== STEP ") -> onProgress(line.removePrefix("== STEP "), -1)
line.startsWith("== FAIL ") -> failure = line.removePrefix("== FAIL ")
}
}
when {
failure != null -> failure
status != 0 -> "Flatpak setup failed (exit $status)"
!ready(context) -> "Flatpak setup did not finish"
else -> null
}
} ?: "Another store task is running"
}
/**
* One store operation - install, uninstall or update - with its progress as a stage line and
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
*/
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
if (!ready(context)) return "Set up Flatpak first"
return exclusive("$verb:${id ?: "all"}") {
var error: String? = null
// Progress lines carry no ref; they belong to the step the last "op" line started.
var step = "Starting"
val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id)
val status = runGuest(context, argv, fakeRoot = false) { line ->
val o = runCatching { JSONObject(line) }.getOrNull()
if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest }
when (o.optString("e")) {
"op" -> { step = stage(o); onProgress(step, overall(o, 0)) }
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
}
}
error ?: if (status != 0) "Flatpak exited with status $status" else null
} ?: "Another store task is running"
}
private fun stage(o: JSONObject): String {
val ref = o.optString("ref")
val name = refLabel(ref)
val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else ""
return when (o.optString("kind")) {
"uninstall" -> "Removing $name$step"
"update" -> "Updating $name$step"
else -> "Installing $name$step"
}.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it }
}
/** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */
internal fun refLabel(ref: String): String {
val parts = ref.split('/')
val id = parts.getOrNull(1) ?: return ref
if (parts.firstOrNull() == "app") return id.substringAfterLast('.')
val branch = parts.getOrNull(3)?.let { " $it" } ?: ""
return when {
id.endsWith(".Locale") -> "translations"
".GL." in id || ".GL32." in id -> "graphics drivers"
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs"
id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) {
"gnome", "kde" -> vendor.uppercase()
else -> vendor.replaceFirstChar { it.uppercase() }
} + " runtime$branch"
else -> id.substringAfterLast('.')
}
}
/** The whole transaction's percentage: steps done, plus this one's share. */
private fun overall(o: JSONObject, percent: Int): Int {
val n = o.optInt("n").coerceAtLeast(1)
val i = o.optInt("i").coerceIn(1, n)
return (((i - 1) * 100 + percent.coerceIn(0, 100)) / n)
}
fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress)
fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress)
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
/** Installed apps with an update on Flathub, or null when that could not be checked. */
fun updates(context: Context): Set<String>? {
if (!ready(context)) return emptySet()
var apps: Set<String>? = null
exclusive("updates") {
runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line ->
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest
if (o.optString("e") == "updates") {
val a = o.optJSONArray("apps")
apps = (0 until (a?.length() ?: 0)).map { a!!.getString(it) }.toSet()
}
}
}
return apps
}
}
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.Environment
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionFiles
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
/**
* One command in the Linux runtime outside any session - the store's Flatpak work, an AppImage
* import - in a proot of its own. A session starting meanwhile leaves it running (OrphanReaper).
*/
object GuestCommand {
/**
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
* for the package tools, which refuse any uid but 0. With [logName], everything the command
* says also goes to Download/DroidDeck/<logName>.log beside the session logs, so a problem can
* be handed over like a session's.
*/
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
onLine: (String) -> Unit): Int {
val log = logName?.let {
try { File(LinuxRuntime.debugLogDir().apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null }
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
return runLogged(context, argv, fakeRoot) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
// The resolver otherwise names the DNS of whatever network the last session was on.
LinuxNetworkLinkComponent(context, root).publish()
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
.forEach { cmd.add("-b"); cmd.add(it) }
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}",
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
cmd += argv
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
builder.environment().apply {
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
put("PROOT_TMP_DIR", context.cacheDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
val pid = HostProcess.pidOf(process)
OrphanReaper.keep(pid)
try {
process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) }
return process.waitFor()
} finally {
OrphanReaper.release(pid)
}
}
}
@@ -115,6 +115,37 @@ public final class LinuxRuntime {
File externalStorage, List<String> extraBinds,
List<String> guestCommand) {
File root = rootDir(context);
List<String> binds = binds(context, sessionRoot, runtimeDir, externalStorage, extraBinds);
// A session's binds are the view its programs have; a one-off command's are not recorded.
if (sessionRoot != null) lastBinds = binds;
List<String> cmd = prootPrefix(context, root, "/root");
for (String spec : binds) bind(cmd, spec);
cmd.addAll(guestCommand);
return cmd;
}
/**
* The binds of the last command built: the guest's view of the host, which the Flatpak
* sandboxes started beside a session (BwrapSpawner) translate their paths through.
*/
private static volatile List<String> lastBinds;
public static List<String> lastBinds(Context context) {
List<String> binds = lastBinds;
if (binds != null) return binds;
return binds(context, null, null, android.os.Environment.getExternalStorageDirectory(), null);
}
/** proot and its options up to the binds: the rootfs at {@code root}, starting in {@code cwd}. */
public static List<String> prootPrefix(Context context, File root, String cwd) {
return prootPrefix(context, root, cwd, false);
}
/**
* As above; {@code fakeRoot} has the guest see uid 0 (proot -0), for the package tools that
* refuse any other uid. Files it writes still belong to the app.
*/
public static List<String> prootPrefix(Context context, File root, String cwd, boolean fakeRoot) {
List<String> cmd = new ArrayList<>();
cmd.add(prootBinary(context).getPath());
cmd.add("--kill-on-exit");
@@ -125,12 +156,24 @@ public final class LinuxRuntime {
// this the keymap never compiles and Xwayland dies. -i makes proot answer those calls
// itself while still reporting our real ids, so nothing inside sees a different user.
int uid = Process.myUid();
cmd.add("-i");
cmd.add(uid + ":" + uid);
if (fakeRoot) {
cmd.add("-0");
} else {
cmd.add("-i");
cmd.add(uid + ":" + uid);
}
cmd.add("-r");
cmd.add(root.getPath());
cmd.add("-w");
cmd.add("/root");
cmd.add(cwd);
return cmd;
}
/** The {@code host:guest} (or same-path) bind specs of a session; see {@link #command}. */
public static List<String> binds(Context context, File sessionRoot, File runtimeDir,
File externalStorage, List<String> extraBinds) {
File root = rootDir(context);
List<String> cmd = new ArrayList<>();
bind(cmd, "/dev");
bind(cmd, "/proc");
bind(cmd, "/sys");
@@ -142,7 +185,7 @@ public final class LinuxRuntime {
bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux");
bind(cmd, context.getFilesDir().getPath());
bind(cmd, context.getCacheDir().getPath());
bind(cmd, runtimeDir.getPath());
if (runtimeDir != null) bind(cmd, runtimeDir.getPath());
if (sessionRoot != null) bind(cmd, sessionRoot.getPath());
if (externalStorage != null && externalStorage.isDirectory()) {
bind(cmd, externalStorage.getPath());
@@ -193,11 +236,14 @@ public final class LinuxRuntime {
}
}
bindGpuNode(context, cmd);
bindAdrenoStats(cmd);
bindCpuTemps(cmd, root);
if (extraBinds != null) {
for (String spec : extraBinds) bind(cmd, spec);
}
cmd.addAll(guestCommand);
return cmd;
List<String> specs = new ArrayList<>();
for (int i = 0; i + 1 < cmd.size(); i += 2) specs.add(cmd.get(i + 1));
return specs;
}
/**
@@ -244,13 +290,89 @@ public final class LinuxRuntime {
bind(cmd, KGSL_DEVICE + ":/dev/dri/" + node);
}
/**
* Valve's mangoapp (Deck mode's performance overlay) reads an Adreno GPU's load, clock and
* temperatures from where they are on Valve's own hardware; every Adreno under Android keeps
* them in KGSL's sysfs, readable by the app, though which files a kernel has differs between
* Snapdragon generations - so each value takes the first source that exists, the order other
* Android PC emulators (GameNative, Winlator forks) read them in. A value found nowhere is left
* alone.
*/
private static void bindAdrenoStats(List<String> cmd) {
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
String gpuTemp = firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu"));
String[][] stats = {
{firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load"), "/sys/kernel/debug/dri/0/perf_now"},
{firstReadable(kgsl + "devfreq/cur_freq", kgsl + "gpuclk"),
"/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"},
{gpuTemp, "/sys/class/thermal/thermal_zone28/temp"},
{gpuTemp, "/sys/class/thermal/thermal_zone26/temp"},
{thermalZone("ddr"), "/sys/class/thermal/thermal_zone22/temp"},
};
for (String[] stat : stats) {
if (stat[0] != null) bind(cmd, stat[0] + ":" + stat[1]);
}
}
/**
* mangoapp's CPU temperature is the mean of the thermal zones named cpuN-thermal or
* cpuN-top-thermal, as mainline kernels name them; Android kernels name the same sensors
* cpuss-0, cpu-1-0, cpu0-silver-usr, apc1-cpu0-usr and the like, so those zones are shown
* under the mainline name.
*/
private static void bindCpuTemps(List<String> cmd, File root) {
File name = new File(root, "etc/bannerlator/cpu-thermal-type");
try {
if (!name.isFile()) Files.write(name.toPath(), "cpu0-thermal\n".getBytes(StandardCharsets.US_ASCII));
} catch (IOException e) {
return;
}
for (java.util.Map.Entry<String, File> zone : thermalZones().entrySet()) {
String type = zone.getKey();
if (type.contains("cpu") && !type.contains("gpu") && !type.matches("cpu\\d-(top-)?thermal")) {
bind(cmd, name.getPath() + ":" + new File(zone.getValue(), "type").getPath());
}
}
}
private static String firstReadable(String... paths) {
for (String path : paths) {
if (path != null && new File(path).canRead()) return path;
}
return null;
}
/** The temp file of the thermal zone for a sensor: the one named so, else the first whose name has it. */
private static String thermalZone(String sensor) {
java.util.Map<String, File> zones = thermalZones();
File zone = zones.get(sensor);
for (java.util.Map.Entry<String, File> e : new java.util.TreeMap<>(zones).entrySet()) {
if (zone == null && e.getKey().contains(sensor)) zone = e.getValue();
}
return zone != null ? new File(zone, "temp").getPath() : null;
}
/** The device's thermal zones by their sensor's name, in lower case. */
private static java.util.Map<String, File> thermalZones() {
java.util.Map<String, File> byType = new java.util.HashMap<>();
File[] zones = new File("/sys/class/thermal").listFiles((dir, n) -> n.startsWith("thermal_zone"));
if (zones == null) return byType;
for (File zone : zones) {
try (java.io.BufferedReader r = new java.io.BufferedReader(new java.io.FileReader(new File(zone, "type")))) {
byType.putIfAbsent(String.valueOf(r.readLine()).trim().toLowerCase(java.util.Locale.ROOT), zone);
} catch (IOException ignored) {
}
}
return byType;
}
private static void bind(List<String> cmd, String spec) {
cmd.add("-b");
cmd.add(spec);
}
/** PRoot's complex -k format: sysname, nodename, release, version, machine, domain, HWCAP. */
private static String guestUtsname(String hostname) {
static String guestUtsname(String hostname) {
StructUtsname host = Os.uname();
return "\\" + host.sysname + "\\" + hostname + "\\" + host.release
+ "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\";
@@ -0,0 +1,238 @@
package com.droiddeck.launcher.session
import android.os.Environment
import com.droiddeck.launcher.frontend.Library
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
/**
* Game saves in and out of a game's Proton prefix, as zips other apps read: GameHub keeps a prefix's
* drive_c rooted at "/drive_c/users/steamuser/…", Winlator / WinNative / Bannerlator at
* "drive_c/users/xuser/…". Proton here runs as steamuser too, so on the way in any user folder but
* Public becomes steamuser, and on the way out the chosen layout decides the user segment.
*
* Saves belong to the game's prefix (compatdata/<appid>/pfx), whichever Proton it runs with.
* Ported from Bannerlator's GameSaveBackup and SaveLocator.
*/
object GameSaves {
enum class Layout(val label: String, val rootPrefix: String, val user: String) {
GAMEHUB("GameHub zip", "/drive_c/", "steamuser"),
WINLATOR("Winlator zip", "drive_c/", "xuser"),
}
/** A game, its prefix, and the save folders found in it. */
data class Game(val game: Library.SteamGame, val prefix: File?, val saves: List<SaveDir>) {
val launched get() = prefix?.let { File(it, "drive_c").isDirectory } == true
}
/** A folder of saves, relative to the steamuser profile. */
data class SaveDir(val relPath: String, val files: Int, val bytes: Long)
private const val USER = "steamuser"
fun savesDir(): File = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck/Saves")
private fun backupsDir(): File = File(savesDir(), "backups")
/** [game] with its prefix and the saves found there; reads files, so not on the main thread. */
fun game(game: Library.SteamGame): Game = Game(game, game.protonPrefix, game.protonPrefix?.let { locate(it, game) } ?: emptyList())
// ------------------------------------------------------------------ finding saves
/** Roots of the steamuser profile that hold most PC game saves, in the order they are trusted. */
private val SAVE_ROOTS = listOf("Documents/My Games", "Saved Games", "AppData/Roaming", "Documents", "AppData/Local", "AppData/LocalLow")
private const val KEEP_THRESHOLD = 50
private fun profile(prefix: File) = File(prefix, "drive_c/users/$USER")
/**
* Folders under the save roots, one and two deep, whose names match the game's name or its folder's.
* Nested matches keep the outer folder only.
*/
fun locate(prefix: File, game: Library.SteamGame): List<SaveDir> {
val profile = profile(prefix)
if (!profile.isDirectory) return emptyList()
val ids = listOfNotNull(game.name, game.gameFiles?.name).map(::ident).filter { it.norm.isNotEmpty() }
if (ids.isEmpty()) return emptyList()
val hits = LinkedHashMap<String, Int>()
fun consider(dir: File) {
val d = ident(dir.name)
if (d.norm.isEmpty()) return
val s = ids.maxOf { score(it, d) }
if (s < KEEP_THRESHOLD) return
val rel = dir.relativeTo(profile).path.replace(File.separatorChar, '/')
if ((hits[rel] ?: -1) < s) hits[rel] = s
}
for (root in SAVE_ROOTS) {
File(profile, root).listFiles()?.filter { it.isDirectory }?.forEach { d1 ->
consider(d1)
d1.listFiles()?.filter { it.isDirectory }?.forEach(::consider)
}
}
val kept = ArrayList<String>()
for (rel in hits.entries.sortedByDescending { it.value }.map { it.key }) {
if (kept.none { rel == it || rel.startsWith("$it/") }) kept.add(rel)
}
return kept.map { rel ->
var files = 0
var bytes = 0L
File(profile, rel).walkTopDown().filter { it.isFile }.forEach { files++; bytes += it.length() }
SaveDir(rel, files, bytes)
}.filter { it.files > 0 }
}
private data class Ident(val norm: String, val tokens: Set<String>)
private fun ident(raw: String): Ident {
val cleaned = raw.lowercase().replace("®", "").replace("™", "").replace("©", "")
return Ident(cleaned.filter { it.isLetterOrDigit() }, cleaned.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() }.toSet())
}
/** exact 100, one contains the other 70, half the words shared 50, close spelling 40. */
private fun score(a: Ident, b: Ident): Int {
if (a.norm.isEmpty() || b.norm.isEmpty()) return 0
if (a.norm == b.norm) return 100
if (a.norm.contains(b.norm) || b.norm.contains(a.norm)) return 70
if (a.tokens.isNotEmpty() && b.tokens.isNotEmpty() &&
a.tokens.intersect(b.tokens).size.toDouble() / minOf(a.tokens.size, b.tokens.size) >= 0.5) return 50
return if (1.0 - levenshtein(a.norm, b.norm).toDouble() / maxOf(a.norm.length, b.norm.length) >= 0.85) 40 else 0
}
private fun levenshtein(a: String, b: String): Int {
val prev = IntArray(b.length + 1) { it }
val cur = IntArray(b.length + 1)
for (i in 1..a.length) {
cur[0] = i
for (j in 1..b.length) cur[j] = minOf(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + if (a[i - 1] == b[j - 1]) 0 else 1)
System.arraycopy(cur, 0, prev, 0, cur.size)
}
return prev[b.length]
}
// ------------------------------------------------------------------ export
private fun stamp() = SimpleDateFormat("yyyy-MM-dd_HHmm", Locale.US).format(Date())
private fun safe(name: String) = name.replace(Regex("[/\\\\:*?\"<>|]"), "_").trim().ifEmpty { "game" }
/**
* Zips the game's saves into [dir] as "<Game>_<date>.zip" in [layout]. The found save folders when
* there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip.
*/
fun export(game: Game, layout: Layout, dir: File): Pair<File, Int> {
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
dir.mkdirs()
val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip")
val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out)
if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") }
return out to count
}
private fun zipProfile(profile: File, roots: List<String>?, layout: Layout, out: File): Int {
if (!profile.isDirectory) return 0
val scope = roots?.map { File(profile, it) }?.filter { it.exists() } ?: listOf(profile)
var count = 0
ZipOutputStream(BufferedOutputStream(FileOutputStream(out))).use { zos ->
for (top in scope) top.walkTopDown()
.onEnter { d -> d.relativeTo(profile).path.replace(File.separatorChar, '/').let { !it.equals("AppData/Local/Temp", true) && !it.equals("AppData/Local/CrashDumps", true) } }
.filter { it.isFile }
.forEach { f ->
val rel = f.relativeTo(profile).path.replace(File.separatorChar, '/')
zos.putNextEntry(ZipEntry("${layout.rootPrefix}users/${layout.user}/$rel"))
f.inputStream().use { it.copyTo(zos) }
zos.closeEntry()
count++
}
}
return count
}
// ------------------------------------------------------------------ import
/**
* Unzips a GameHub or Winlator save zip into the game's drive_c, after zipping what its steamuser
* profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher
* shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null).
*/
fun import(game: Game, zip: File): Pair<Int, File?> {
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
val driveC = File(prefix, "drive_c")
if (!driveC.isDirectory) error("${game.game.name} has not been launched yet")
if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip")
backupsDir().mkdirs()
val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip")
val backedUp = zipProfile(profile(prefix), null, Layout.GAMEHUB, backup)
if (backedUp == 0) backup.delete()
val base = driveC.canonicalFile
var written = 0
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
var e = zis.nextEntry
while (e != null) {
remap(e.name)?.let { rel ->
val out = File(driveC, rel).canonicalFile
if (out.path.startsWith(base.path + File.separator)) {
if (e.isDirectory) out.mkdirs()
else {
out.parentFile?.mkdirs()
Files.copy(zis, out.toPath(), StandardCopyOption.REPLACE_EXISTING)
written++
}
}
}
zis.closeEntry()
e = zis.nextEntry
}
}
return written to backup.takeIf { it.exists() }
}
/** Which layout a zip was written in, from its first user folder; null when it has none. */
fun layoutOf(zip: File): Layout? = runCatching {
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
var e = zis.nextEntry
while (e != null) {
val segs = driveCSegments(e.name)
if (segs != null && segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true))
return@use if (segs[1].equals("xuser", true)) Layout.WINLATOR else Layout.GAMEHUB
e = zis.nextEntry
}
null
}
}.getOrNull()
private fun looksLikeSaveZip(zip: File): Boolean = runCatching {
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
var e = zis.nextEntry
while (e != null) { if (driveCSegments(e.name) != null) return@use true; e = zis.nextEntry }
false
}
}.getOrDefault(false)
/** An entry's path below drive_c, with any user folder but Public renamed to steamuser. */
private fun remap(raw: String): String? {
val segs = driveCSegments(raw)?.takeIf { it.isNotEmpty() } ?: return null
if (segs.any { it == ".." }) return null
// GameHub also packs its own launcher shortcuts; they are never saves.
if (segs[0].equals("proton_shortcuts", true)) return null
val last = segs.last().lowercase()
if ((last.endsWith(".lnk") || last.endsWith(".desktop") || last.endsWith(".url")) && segs.any { it.equals("Desktop", true) }) return null
if (segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true)) segs[1] = USER
return segs.joinToString("/")
}
private fun driveCSegments(raw: String): MutableList<String>? {
val segs = raw.replace('\\', '/').trimStart('/').split("/").filter { it.isNotEmpty() && it != "." }
if (segs.isEmpty() || !segs[0].equals("drive_c", true)) return null
return segs.drop(1).toMutableList()
}
}
@@ -0,0 +1,70 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
/**
* The GPU held at its top clock for a session, without root: KGSL's power-control property turned
* off on /dev/kgsl-3d0, the node the driver already opens (adrenotools' "turbo"). Left to itself the
* governor lets the GPU sit low between bursts, and a frame that lands on a low clock is a hitch;
* Bannerlator measured the clock going from 231 to 1000 MHz with this set. It costs power and heat,
* so it is opt-in, and the kernel's thermal limits still apply on top of it.
*
* The property belongs to the device, not to this process, and a killed app cannot unwind it: it
* is cleared when a session stops, and at the next app start when a marker file says this app set
* it and never cleared it - only then, so another app's own setting is not undone.
* (Bannerlator, PerfGpuTurbo.)
*/
object GpuClockPin {
private const val TAG = "GpuClockPin"
private const val KGSL_NODE = "/dev/kgsl-3d0"
private const val MARKER = "gpu-clock-pinned"
@Volatile
private var pinned = false
private val supported: Boolean by lazy { File(KGSL_NODE).exists() }
/** At a session's start: pins the clock when the user asked for it. The line is for the log. */
@Synchronized
fun start(context: Context): String {
if (!SessionPrefs.gpuClockPin(context)) return "gpu clock: governed"
if (!supported) return "gpu clock: no $KGSL_NODE, left governed"
return try {
File(context.filesDir, MARKER).createNewFile()
WaylandCompositor.nativeSetGpuTurbo(true)
pinned = true
"gpu clock: held at its top"
} catch (t: Throwable) {
Log.w(TAG, "pin", t)
"gpu clock: pin failed"
}
}
/** At a session's end. */
@Synchronized
fun stop(context: Context) {
if (pinned) clear(context)
}
/** At the app's start: a process killed with the clock pinned left only the marker behind. */
@Synchronized
fun clearLeftover(context: Context) {
if (File(context.filesDir, MARKER).exists()) clear(context)
}
private fun clear(context: Context) {
if (supported) {
try {
WaylandCompositor.nativeSetGpuTurbo(false)
} catch (t: Throwable) {
Log.w(TAG, "clear", t)
}
}
pinned = false
File(context.filesDir, MARKER).delete()
}
}
@@ -13,17 +13,28 @@ import java.io.File
* force-stop the app before every launch. Everything of ours that should be alive between
* sessions is inside the app process, so at session start anything else under our uid is a
* leftover, and this sweeps it. Each pid is checked by uid at kill time, never by name.
*
* The one exception is work the app started outside any session and is still waiting on - a
* Flatpak install from the store - registered with [keep]: that process and everything under it
* are left to finish.
*/
object OrphanReaper {
private const val TAG = "OrphanReaper"
private val kept = java.util.concurrent.ConcurrentHashMap.newKeySet<Int>()
/** Spares [pid] and its descendants until [release]. */
fun keep(pid: Int) { if (pid > 0) kept.add(pid) }
fun release(pid: Int) { kept.remove(pid) }
fun reap(reason: String): Int {
val me = android.os.Process.myPid()
val uid = android.os.Process.myUid()
val spared = descendants(kept)
var killed = 0
File("/proc").listFiles()?.forEach { entry ->
val pid = entry.name.toIntOrNull() ?: return@forEach
if (pid == me || pid <= 1) return@forEach
if (pid == me || pid <= 1 || pid in spared) return@forEach
if (uidOf(pid) != uid) return@forEach
val name = try { File(entry, "cmdline").readText().substringBefore('\u0000') } catch (e: Exception) { "?" }
try {
@@ -38,6 +49,25 @@ object OrphanReaper {
return killed
}
/** [roots] and every process below them, by each process's parent in /proc/<pid>/stat. */
private fun descendants(roots: Set<Int>): Set<Int> {
if (roots.isEmpty()) return emptySet()
val parent = HashMap<Int, Int>()
File("/proc").listFiles()?.forEach { entry ->
val pid = entry.name.toIntOrNull() ?: return@forEach
val stat = try { File(entry, "stat").readText() } catch (e: Exception) { return@forEach }
// "pid (comm) state ppid ...": comm may hold spaces and parentheses; the last ')' ends it.
stat.substringAfterLast(") ").split(' ').getOrNull(1)?.toIntOrNull()?.let { parent[pid] = it }
}
val out = HashSet(roots)
var grew = true
while (grew) {
grew = false
for ((pid, ppid) in parent) if (ppid in out && out.add(pid)) grew = true
}
return out
}
private fun uidOf(pid: Int): Int {
return try {
File("/proc/$pid/status").useLines { lines ->
@@ -1,87 +0,0 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PerformanceHintManager
import android.util.Log
import androidx.annotation.RequiresApi
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.util.concurrent.Executors
/**
* ADPF (Android 12+): a PerformanceHintManager session over the compositor thread, told the
* panel's frame period as its target and each presented frame's interval as the actual. When a
* frame runs long the power HAL raises CPU clocks for the next one at once, instead of the
* governor waiting for the load average to climb - the difference between a menu that stutters
* on the first scroll and one that does not.
*
* The session may only carry this process's threads, so the guest (gamescope, the Steam client
* under FEX) cannot join it; they are covered by the app-wide game category and sustained mode.
* Below API 31 nothing here runs; on a power HAL without hint sessions createHintSession returns
* null and the log says so.
*/
object PerfHints {
private const val TAG = "PerfHints"
@Volatile private var session: PerformanceHintManager.Session? = null
@Volatile private var target = 0L
/** Compositor thread only. */
private var last = 0L
private val main = Handler(Looper.getMainLooper())
/** Reports leave the compositor thread: each one is a binder call to the power HAL. */
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "adpf-report").apply { isDaemon = true } }
/** Opens the session once the compositor thread exists (it is started just before this). */
fun arm(context: Context, refreshHz: Float) {
if (Build.VERSION.SDK_INT < 31) { Log.i(TAG, "adpf: needs Android 12; this is API ${Build.VERSION.SDK_INT}"); return }
val wanted = (1_000_000_000.0 / refreshHz.coerceAtLeast(30f)).toLong()
val s = session
if (s != null) {
if (wanted != target) { target = wanted; try { s.updateTargetWorkDuration(wanted) } catch (t: Throwable) { Log.w(TAG, "adpf: retarget", t) } }
return
}
target = wanted
val app = context.applicationContext
var tries = 0
main.post(object : Runnable {
override fun run() {
if (session != null) return
val tid = try { WaylandCompositor.nativeCompositorTid() } catch (t: Throwable) { 0 }
if (tid == 0) {
if (++tries < 30) main.postDelayed(this, 100) else Log.w(TAG, "adpf: the compositor thread never reported its tid")
return
}
open(app, tid)
}
})
}
@RequiresApi(31)
private fun open(app: Context, tid: Int) {
val pm = app.getSystemService(PerformanceHintManager::class.java)
if (pm == null) { Log.i(TAG, "adpf: no PerformanceHintManager on this device"); return }
val s = try { pm.createHintSession(intArrayOf(tid), target) } catch (t: Throwable) { Log.w(TAG, "adpf: createHintSession", t); null }
if (s == null) { Log.i(TAG, "adpf: unsupported here (the power HAL has no hint sessions)"); return }
last = 0L
session = s
Log.i(TAG, "adpf: session on compositor thread $tid · target ${target / 1000} µs · HAL wants updates every ${pm.preferredUpdateRateNanos / 1000} µs")
}
/** Compositor thread, once per presented game frame; the interval since the last is the actual. */
@JvmStatic
fun onFrame() {
val s = session ?: return
val now = System.nanoTime()
val prev = last
last = now
if (prev == 0L) return
val dt = now - prev
// Under half a millisecond is a double present; over half a second is a pause, not a frame.
if (dt < 500_000L || dt > 500_000_000L) return
worker.execute {
try { s.reportActualWorkDuration(dt) } catch (t: Throwable) { Log.w(TAG, "adpf: report failed, session dropped", t); session = null }
}
}
}
@@ -8,7 +8,9 @@ import android.os.SystemClock
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.R
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.util.Locale
@@ -86,20 +88,22 @@ class PerfHud(context: Context) {
}
private fun line(base: Float): String {
val engine = FrameGen.engine(context)
if (engine == FrameGen.ENGINE_OFF) return fps(base)
val name = if (engine == FrameGen.ENGINE_LSFG) "LSFG" else "Win-FG"
val multiplier = FrameGen.multiplier(context)
val mode = FrameGen.mode(context)
if (mode.engine == FrameGen.ENGINE_OFF) return fps(base)
val lsfg = mode.engine == FrameGen.ENGINE_LSFG
val name = if (lsfg) "LSFG" else "Win-FG"
val label = FrameGen.label(context, mode)
if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name)
val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null }
val problem = try { WaylandCompositor.nativeFrameGenProblem() } catch (t: Throwable) { -1 }
return when {
problem == 1 -> "$name: unsupported"
problem == 2 -> "$name: engine failed"
problem == 1 -> context.getString(R.string.hud_fg_unsupported, name)
problem == 2 -> context.getString(R.string.hud_fg_failed, name)
stats != null && stats[3] > 1f -> {
val source = if (stats[2] > 1f) stats[2] else base
String.format(Locale.US, "%s %d× %.0f → %.0f fps", name, multiplier, source, stats[3])
String.format(Locale.US, "%s %.0f → %.0f fps", label, source, stats[3])
}
else -> String.format(Locale.US, "%s %d× %s · FG starting", name, multiplier, fps(base))
else -> context.getString(R.string.hud_fg_starting, label, fps(base))
}
}
@@ -4,7 +4,6 @@ import android.app.Activity
import android.app.GameManager
import android.app.GameState
import android.os.Build
import android.os.PowerManager
import android.util.Log
/**
@@ -13,10 +12,13 @@ import android.util.Log
* The manifest already makes the app a game (appCategory + game_mode_config); this is the
* per-window half, applied when the session activity is created:
*
* - sustained performance mode (Android 7+): a clock floor that does not throttle away after
* two minutes, which suits an hour in Big Picture better than a boost that fades;
* - NOT sustained performance mode: on Pixel and Qualcomm power HALs it caps the CPU and GPU at
* a level the device can hold indefinitely rather than holding a floor, which cost games and
* Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to
* the game;
* - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to
* 120 Hz for the menu, not only when a game votes for it;
* 120 Hz for the menu, not only when a game votes for it. With a frame cap, the fastest mode
* the cap divides evenly, so every capped frame is shown for the same number of refreshes;
* - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases
* its boost on loading screens or menus keeps it up.
*
@@ -26,26 +28,20 @@ import android.util.Log
object PerfMode {
private const val TAG = "PerfMode"
fun apply(a: Activity): String {
val parts = ArrayList<String>(3)
// Sustained performance mode.
parts += if (Build.VERSION.SDK_INT >= 24) {
val pm = a.getSystemService(PowerManager::class.java)
if (pm?.isSustainedPerformanceModeSupported == true) {
try { a.window.setSustainedPerformanceMode(true); "sustained mode on" } catch (t: Throwable) { Log.w(TAG, "sustained mode", t); "sustained mode refused" }
} else "sustained mode unsupported"
} else "sustained mode needs Android 7"
fun apply(a: Activity, fpsLimit: Int = 0): String {
val parts = ArrayList<String>(2)
// The fastest display mode at the panel's current size.
parts += try {
val display = if (Build.VERSION.SDK_INT >= 30) a.display else @Suppress("DEPRECATION") a.windowManager.defaultDisplay
if (display == null || Build.VERSION.SDK_INT < 23) "display mode unchanged" else {
val cur = display.mode
val best = display.supportedModes
val sameSize = display.supportedModes
.filter { it.physicalWidth == cur.physicalWidth && it.physicalHeight == cur.physicalHeight }
.maxByOrNull { it.refreshRate }
if (best != null && best.modeId != cur.modeId && best.refreshRate > cur.refreshRate + 0.5f) {
val best = sameSize.filter { cadenceFits(it.refreshRate, fpsLimit) }.maxByOrNull { it.refreshRate }
?: sameSize.maxByOrNull { it.refreshRate }
if (best != null && best.modeId != cur.modeId &&
(best.refreshRate > cur.refreshRate + 0.5f || !cadenceFits(cur.refreshRate, fpsLimit))) {
a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId }
"display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})"
} else "display ${cur.refreshRate.toInt()} Hz"
@@ -71,4 +67,11 @@ object PerfMode {
return parts.joinToString(" · ")
}
/** A panel rate that shows every capped frame for a whole number of refreshes (no cap: any). */
fun cadenceFits(hz: Float, fpsLimit: Int): Boolean {
if (fpsLimit <= 0) return true
val ratio = hz / fpsLimit
return ratio >= 0.98f && Math.abs(ratio - Math.round(ratio)) < 0.02f
}
}
@@ -42,6 +42,13 @@ object SessionFiles {
"usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts",
"usr/local/bin/bannerlator-steam-games" to "usr/local/bin/bannerlator-steam-games",
"usr/local/bin/bannerlator-pad-defaults" to "usr/local/bin/bannerlator-pad-defaults",
// Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher.
"usr/local/bin/bannerlator-bwrap" to "usr/local/bin/bannerlator-bwrap",
"usr/local/bin/bannerlator-flatpak" to "usr/local/bin/bannerlator-flatpak",
"usr/local/bin/bannerlator-flatpak-setup" to "usr/local/bin/bannerlator-flatpak-setup",
"usr/local/bin/bannerlator-flatpak-run" to "usr/local/bin/bannerlator-flatpak-run",
// The user's own AppImages, extracted at import (AppImageManager).
"usr/local/bin/bannerlator-appimage-run" to "usr/local/bin/bannerlator-appimage-run",
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
"usr/bin/steamos-update" to "usr/bin/steamos-update",
@@ -8,6 +8,8 @@ object SessionPrefs {
const val SUSPEND_MANUAL = "manual"
const val SUSPEND_NEVER = "never"
const val CONTROLLER_DECK = "deck"
const val CONTROLLER_XBOX360 = "xbox360"
const val OSC_AUTO = "auto"
const val OSC_ALWAYS = "always"
const val OSC_STEAM_QAM = "steam-qam"
@@ -28,12 +30,37 @@ object SessionPrefs {
prefs(context).edit().putBoolean("launcherFullscreen", on).apply()
}
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true)
/** The Flathub Store (beta): its rail item and the Store's apps on the Desktop page. Off by default. */
fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false)
fun setStoreEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("storeEnabled", on).apply()
}
/** AppImage import (beta): the AppImages section on the Desktop page. Off by default. */
fun appImagesEnabled(context: Context): Boolean = prefs(context).getBoolean("appImagesEnabled", false)
fun setAppImagesEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("appImagesEnabled", on).apply()
}
/**
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
* in one - a choice kept apart from every other session's, where it stays on by default.
*/
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), !mangoappSession(context))
fun setHudEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("hud", on).apply()
prefs(context).edit().putBoolean(hudKey(context), on).apply()
}
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
/** A Steam session that runs Deck mode with its performance overlay (mangoapp). */
private fun mangoappSession(context: Context) =
SessionState.mode == SessionService.MODE_STEAM && steamDeckMode(context) && mangoapp(context)
/** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
@@ -85,15 +112,52 @@ object SessionPrefs {
}
/**
* The imported glibc Turnip a mode draws with inside the runtime, keyed by
* SessionService.MODE_STEAM / MODE_DESKTOP so Steam and the desktop can differ; "" = the
* driver built into the runtime. Resolved by LinuxVulkanDriver at session start.
* The imported glibc Turnip every session draws with inside the runtime - Steam, its games and
* the desktop alike, as they run on the same GPU; "" = the driver built into the runtime.
* Resolved by LinuxVulkanDriver at session start. It was once chosen per mode: the Steam
* session's choice, the one nearly everyone set, carries over.
*/
fun linuxDriver(context: Context, mode: String): String =
prefs(context).getString("linuxDriver.$mode", "") ?: ""
fun linuxDriver(context: Context): String =
prefs(context).getString("linuxDriver", null)
?: prefs(context).getString("linuxDriver.steam", null)
?: prefs(context).getString("linuxDriver.desktop", "") ?: ""
fun setLinuxDriver(context: Context, mode: String, id: String) {
prefs(context).edit().putString("linuxDriver.$mode", id).apply()
fun setLinuxDriver(context: Context, id: String) {
prefs(context).edit().putString("linuxDriver", id).apply()
}
const val GPU_DRIVERS_AUTO = "auto"
const val GPU_DRIVERS_MANUAL = "manual"
/**
* Who picks the GPU drivers: [GPU_DRIVERS_AUTO] (the app, the matched pair recommended for
* this GPU, kept current - DriverPairs) or [GPU_DRIVERS_MANUAL] (the user). Auto for new
* installs; see settleGpuDriverMode.
*/
fun gpuDriverMode(context: Context): String =
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
fun setGpuDriverMode(context: Context, mode: String) {
prefs(context).edit().putString("gpuDriverMode", mode).apply()
}
/**
* Auto arrived after people had picked drivers by hand: an install that chose either driver
* keeps its choice (Manual), everyone else is Auto. Decided once, at process start.
*/
fun settleGpuDriverMode(context: Context) {
val p = prefs(context)
if (p.contains("gpuDriverMode")) return
val chosen = androidDriver(context).isNotEmpty() || linuxDriver(context).isNotEmpty()
p.edit().putString("gpuDriverMode", if (chosen) GPU_DRIVERS_MANUAL else GPU_DRIVERS_AUTO).apply()
}
/** Drivers Auto downloaded: the only ones it removes when a newer pair replaces them. */
fun gpuAutoInstalled(context: Context): Set<String> =
prefs(context).getStringSet("gpuAutoInstalled", emptySet()).orEmpty()
fun setGpuAutoInstalled(context: Context, ids: Set<String>) {
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
}
/**
@@ -143,8 +207,8 @@ object SessionPrefs {
prefs(context).edit().putBoolean("directAudio", on).apply()
}
/** The microphone for voice chat, on unless turned off; used only once RECORD_AUDIO is granted. */
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", true)
/** The microphone for voice chat, off until the user turns it on (which asks for RECORD_AUDIO). */
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", false)
/** Whether the app has already asked for the microphone once at start-up. */
fun micAsked(context: Context): Boolean = prefs(context).getBoolean("micAsked", false)
@@ -188,14 +252,28 @@ object SessionPrefs {
* xalia is an x86 Windows program Proton launches to give Windows programs gamepad navigation.
* Under FEX it cannot load the session's aarch64 preload shim, so its socket() and memfd calls
* reach the vendor's seccomp filter raw; where that answers ENOSYS - a Galaxy Fold, measured -
* it storms, and the session dies seconds after Big Picture appears.
* it storms, and the session dies seconds after Big Picture appears. On by default: where it
* does run, it sits beside every game under FEX for nothing a controller-first session needs
* (about 10% of a core beside Once Upon a KATAMARI on an SD 8 Gen 2).
*/
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", false)
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", true)
fun setNoXalia(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("noXalia", on).apply()
}
/**
* Whether gamescope asks for realtime-priority Vulkan queues (GAMESCOPE_FORCE_VULKAN_REALTIME=1,
* which the app's gamescope build honours without CAP_SYS_NICE). Off by default, as in
* Bannerlator's session: the compositor's queue preempting the game's buys nothing on a device
* whose GPU is waiting on the CPU.
*/
fun gamescopeRealtime(context: Context): Boolean = prefs(context).getBoolean("gamescopeRealtime", false)
fun setGamescopeRealtime(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gamescopeRealtime", on).apply()
}
/**
* Whether proot runs without its seccomp acceleration (PROOT_NO_SECCOMP=1).
*
@@ -205,6 +283,13 @@ object SessionPrefs {
* plainly exist is the signature - and the fallback is to trace everything instead: slower,
* but correct. Max's advice for devices whose kernels "don't work well with it".
*/
/** Hold the GPU at its top clock during a session (GpuClockPin). Off by default: power and heat. */
fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false)
fun setGpuClockPin(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
}
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
fun setProotNoSeccomp(context: Context, on: Boolean) {
@@ -227,8 +312,13 @@ object SessionPrefs {
return trimmed.takeIf { Regex("[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?").matches(it) }
}
/** Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. */
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", false)
/**
* Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling.
* On by default, as WinNative runs every Linux session: Chromium -> ANGLE -> Zink draws the
* client's interface as many small render passes, each paying GMEM's load/store and binning,
* and WinNative's A/B on an Adreno 840 put a game ahead with it too (Palworld 42.8 against 41.3).
*/
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", true)
fun setTuSysmem(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("tuSysmem", on).apply()
@@ -248,11 +338,43 @@ object SessionPrefs {
fun noGlError(context: Context): Boolean = prefs(context).getBoolean("noGlError", true)
fun setNoGlError(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noGlError", on).apply() }
/** `steam -steamdeck -steamos3`: the client as SteamOS runs it. Expects Deck hardware; untested. */
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false)
/**
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
* button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain
* Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
*/
fun steamController(context: Context): String =
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
/** Runs the SteamOS gamepad client with its Quick Access performance controls. On for new installs (settleDeckModeDefault). */
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", true)
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
/** Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) for the client's GL-on-Vulkan UI. On by default. */
/**
* Deck mode became the default for new installs; an install from before keeps what it ran with
* (off), so an update never changes its interface or restarts the client on its own. Run once at
* process start, before anything reads or writes these prefs: a new install has neither prefs
* nor a runtime yet. The answer is written down, so it is decided once.
*/
fun settleDeckModeDefault(context: Context) {
val p = prefs(context)
if (p.contains("steamDeckMode")) return
val existing = p.all.isNotEmpty() || java.io.File(context.filesDir, "linuxfs").exists()
p.edit().putBoolean("steamDeckMode", !existing).apply()
}
/**
* Deck mode's performance overlay (mangoapp, beside gamescope): the QAM's Overlay Level draws
* through it. Off is the way out where Valve's mangoapp crashes (one Turnip build did).
*/
fun mangoapp(context: Context): Boolean = prefs(context).getBoolean("mangoapp", true)
fun setMangoapp(context: Context, on: Boolean) { prefs(context).edit().putBoolean("mangoapp", on).apply() }
/**
* Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) with its compact set layout
* (ZINK_DEBUG=compact) for the client's GL-on-Vulkan UI, as WinNative runs it. On by default.
*/
fun zinkLazy(context: Context): Boolean = prefs(context).getBoolean("zinkLazy", true)
fun setZinkLazy(context: Context, on: Boolean) {
@@ -309,8 +431,15 @@ object SessionPrefs {
fun resolutionChosen(context: Context, mode: String): Boolean =
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
/** The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. */
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "") ?: ""
/**
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
* Performance + TSO unless chosen, WinNative's default: FEX's own defaults keep half-barrier TSO
* and full-precision x87, which cost every x86 game time; a game that needs them can still be
* given another preset.
*/
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", DEFAULT_FEX_PRESET) ?: DEFAULT_FEX_PRESET
private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO"
fun setFexPreset(context: Context, id: String) {
prefs(context).edit().putString("fexPreset", id).apply()
@@ -318,14 +447,18 @@ object SessionPrefs {
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
/** The Steam client branch forced on the command line: "publicbeta" (every session so far) or "steamdeck_publicbeta" (Armada's). */
/**
* The Steam client branch forced on the command line: "publicbeta" (every session so far) or
* "steamdeck_publicbeta" (Armada's). Deck mode always takes the Deck branch, whatever was chosen:
* with -steamos3 the client picks its own branch as SteamOS does, and on publicbeta it settled
* on steamdeck_stable - an older client it then offered as a "Software Update" in every session,
* which applying turns into the exit-42 restart loop (seen on device 2026-09-30). Earlier, Deck
* mode on publicbeta also reinstalled the same client at every start (2026-09-23). On
* steamdeck_publicbeta the client finds no update. The choice applies with Deck mode off.
*/
fun steamChannel(context: Context): String =
prefs(context).getString("steamChannel", null)
// Deck mode on the publicbeta channel reinstalls the same client at every start (the
// client reports "installed version 0" against that manifest and exits 42 to apply it,
// losing the launch URL each time); on steamdeck_publicbeta the second launch comes up
// clean. Seen on device 2026-09-23. So Deck mode takes the Deck channel unless chosen.
?: if (steamDeckMode(context)) "steamdeck_publicbeta" else "publicbeta"
if (steamDeckMode(context)) "steamdeck_publicbeta"
else prefs(context).getString("steamChannel", null) ?: "publicbeta"
fun setSteamChannel(context: Context, id: String) {
prefs(context).edit().putString("steamChannel", id).apply()
@@ -430,6 +563,22 @@ object SessionPrefs {
prefs(context).edit().putBoolean("hdr.$mode", on).apply()
}
/**
* The session's frame cap, 0 for none. One number used everywhere a frame is paced: gamescope's
* -r (what the client and its games see as the display's rate), the compositor's buffer release
* pacer, the rate the display layer votes for, and the panel mode picked, which is the fastest
* one the cap divides evenly (40 on a 120 Hz panel, not on a 144 Hz one). A 60 fps cap on a
* 144 Hz panel with nothing else changed judders; this is what WinNative's per-shortcut limit
* does. Applies next session.
*/
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0)
fun setFpsLimit(context: Context, mode: String, fps: Int) {
prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply()
}
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
/**
* The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a
* fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices.
@@ -36,6 +36,7 @@ import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.input.FakeInputWriter
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
@@ -57,6 +58,8 @@ import java.util.Locale
*/
class SessionService : Service() {
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
/** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */
private var deckBinds: List<String> = emptyList()
private val stopLock = Any()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
@@ -267,6 +270,8 @@ class SessionService : Service() {
val sessionDir = openSessionFolder()
val sessionLog = File(sessionDir, "session.log")
Log.i(TAG, GpuClockPin.start(this))
val size = SessionState.outputSize
val guest = ArrayList<String>()
// The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the
@@ -285,13 +290,15 @@ class SessionService : Service() {
guest.add("DXVK_HDR=1")
Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1")
}
guest.add("BL_FPS=0")
guest.add("BL_FPS=" + SessionState.fpsLimit)
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
guest.add("BL_LOG=" + sessionLog.path)
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
SessionState.deckPad = false
deckBinds = emptyList()
if (controllersOn) addControllerEnvironment(guest, fakeInputDir)
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
@@ -310,6 +317,7 @@ class SessionService : Service() {
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
guest.add("BL_ADDED_GAMES=" + listing.path)
if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1")
if (com.droiddeck.launcher.gpu.Lossless.owned(this)) guest.add("BL_LOSSLESS_OWNED=1")
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
}
// Where the guest leaves a request for another session (the desktop's Steam launchers).
@@ -431,6 +439,7 @@ class SessionService : Service() {
}
sessionPid = pid
if (pid > 1) {
raiseTracer(pid, gen)
SessionEvents.guestStarted(pid)
} else {
SessionEvents.record("guest.start_failed", mapOf("pid" to pid))
@@ -494,15 +503,12 @@ class SessionService : Service() {
guest.add("GALLIUM_DRIVER=zink")
guest.add("LIBGL_KOPPER_DRI2=true")
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
// An imported glibc Turnip for this mode, when the user chose one: the session script checks
// the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES,
// so the runtime's own driver above stays untouched and is what a bad import falls back to.
// A program from the rail is one of the desktop's emulators, so it draws with the desktop's
// Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver
// build, and with two drivers every emulator compiled its shaders twice - once per way of
// starting it (RPCS3's 6650 interpreter variants on each first boot).
val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode)
val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode)
// An imported glibc Turnip, when one is set (by the user, or by Auto): the session script
// checks the manifest and its library from inside and points the loader at it with
// VK_DRIVER_FILES, so the runtime's own driver above stays untouched and is what a bad
// import falls back to. One driver for every session: the driver's shader cache is keyed on
// its build, and with one per mode every emulator compiled its shaders twice.
val linuxDriverId = SessionPrefs.linuxDriver(this)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
// Turnip's own debug switches, for the runtime's driver and everything on it. The file in
@@ -513,22 +519,36 @@ class SessionService : Service() {
// Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is
// the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships
// by default for its Zink path; a switch here because on one Fold the menus run at 14 fps.
if (SessionPrefs.zinkLazy(this)) guest.add("ZINK_DESCRIPTORS=lazy")
// compact packs Zink's descriptor sets into fewer, so a draw binds less (WinNative's default).
if (SessionPrefs.zinkLazy(this)) {
guest.add("ZINK_DESCRIPTORS=lazy")
guest.add("ZINK_DEBUG=compact")
}
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
// BL_STEAMDECK; it is the one that builds the command line).
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
// Mesa's shader cache as one database instead of a file per entry. Every lookup in the
// file cache is an open and every store an open and a rename, each a proot stop (a rename
// two), for every pipeline DXVK, vkd3d-proton and Zink compile or find; the database is
// opened once and read and written with pread/pwrite, which proot never sees. Mesa removes
// the old folder itself once it has gone a week untouched.
guest.add("MESA_DISK_CACHE_DATABASE=1")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
SessionPrefs.writeForceFullscreenFlag(this)
}
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
// XALIA_SUPPORTED_ONLY itself otherwise). Skipped by default: under FEX it costs every game
// a slice of a core for gamepad navigation the session already has.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// gamescope's realtime Vulkan queues (the session script turns this into
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
@@ -598,6 +618,12 @@ class SessionService : Service() {
/** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File) {
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
// Virtual pads a client made last session (event16 and up, and their hidden rings) are
// not there any more; a client that crashed never took its own down.
fakeInputDir.listFiles()?.forEach { file ->
val node = Regex("event(\\d+)").matchEntire(file.name)?.groupValues?.get(1)?.toIntOrNull()
if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD)) file.delete()
}
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
@@ -605,14 +631,34 @@ class SessionService : Service() {
// identity gets the standard layout without the user configuring the pad by hand.
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
// Steam Input's virtual-gamepad identity is for games the client starts, which are
// meant to see that pad. Everywhere else (the desktop, a program from the rail) it
// hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so
// every SDL emulator came up with no controller. There it is a plain Xbox 360 pad.
if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists()
// In Steam, the pad is what a Deck's is: a Steam Deck controller the client reads over
// hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read.
// Decided only once its sysfs is in place: without it the client would find no Deck, and
// with the Deck asked for the pad's own nodes are withdrawn - no controller at all.
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
SessionState.deckPad = deckBinds.isNotEmpty()
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
if (uinput) {
// /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a
// game becomes a node the game reads, carrying the player's layout, as on a Deck.
guest.add("FAKE_EVDEV_UINPUT=1")
if (SessionState.deckPad) guest.add("FAKE_EVDEV_DECK=1")
} else if (SessionState.mode == MODE_STEAM) {
// Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity -
// for games the client starts only. Everywhere else (the desktop, a program from the
// rail) that identity hides the pad: SDL ignores a Steam virtual gamepad unless it
// runs under Steam, so every SDL emulator came up with no controller.
guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
}
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
guest.add("SDL_JOYSTICK_HIDAPI=0")
// The client reads a Deck controller through SDL's HIDAPI; a hint in the environment
// outranks the client's own. Nothing else is shown the Deck (libfakeinput).
if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0")
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
guest.add("FAKE_EVDEV_LOG=1")
}
@@ -623,6 +669,7 @@ class SessionService : Service() {
private fun sessionBinds(controllersOn: Boolean, fakeInputDir: File): ArrayList<String> {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
if (controllersOn) binds.addAll(deckBinds)
// The client's battery readout (the Quick Access Menu, the top bar) reads
// /sys/class/power_supply/BAT<n>/..., a laptop's or a Deck's naming; Android's supply is
// called "battery" and its files differ, so the client sees no battery at all. A directory
@@ -928,6 +975,31 @@ class SessionService : Service() {
}
}
/**
* proot is one thread, and every syscall it traps anywhere in the session - a path lookup in
* Proton's python, Wine's file opens, the Steam client's /proc scans - waits for that thread to
* be scheduled, at nice 0 beside a game that keeps the big cores busy. It is raised to nice -6,
* under the compositor's -8. After a moment, not at once: the first guest process is forked by
* proot itself and would inherit the value, and with it everything the session starts.
*/
private fun raiseTracer(pid: Int, gen: Int) {
Thread({
try {
Thread.sleep(2000)
} catch (e: InterruptedException) {
return@Thread
}
if (gen != sessionGen || sessionPid != pid) return@Thread
val nice = try {
WaylandCompositor.nativeRaisePriority(pid, TRACER_NICE)
} catch (t: Throwable) {
Log.w(TAG, "tracer priority", t)
return@Thread
}
Log.i(TAG, "proot tracer $pid: nice $nice (asked for $TRACER_NICE)")
}, "tracer-priority").start()
}
private fun stopSession(status: Int) {
synchronized(stopLock) {
if (!SessionState.running) return
@@ -936,6 +1008,7 @@ class SessionService : Service() {
val stoppedGen = sessionGen
SessionState.stopRequested = false
SessionEvents.record("guest.exited", mapOf("status" to status))
GpuClockPin.stop(this)
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
suspendOperationPending = false
launchWatcher?.stopWatching()
@@ -1112,6 +1185,8 @@ class SessionService : Service() {
companion object {
private const val TAG = "SessionService"
/** proot's tracer: above everything in the guest, under the compositor thread's -8. */
private const val TRACER_NICE = -6
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
/** Downloads file of KEY=VALUE lines added to the session environment verbatim. */
@@ -1138,6 +1213,10 @@ class SessionService : Service() {
private const val STEAM_EXIT_MS = 10_000L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
private const val FIRST_VIRTUAL_PAD = 16
const val EXTRA_MODE = "mode"
const val MODE_STEAM = "steam"
@@ -63,6 +63,27 @@ object SessionState {
* gets DXVK_HDR=1 and gamescope --hdr-enabled. Decided by the activity before the compositor starts. */
@JvmStatic var hdr = false
/** The pad is presented to the Steam client as a Steam Deck controller this session
* (SteamDeckPad): its Quick Access button is a real button, not the Guide+A chord. Decided by
* the service as the session starts, which can be after the activity has resumed - hence
* [deckPadListener]. */
@Volatile private var deckPadValue = false
@JvmStatic var deckPad: Boolean
get() = deckPadValue
set(value) {
if (deckPadValue == value) return
deckPadValue = value
deckPadListener?.invoke()
}
/** Told (on the thread that changed it) whenever [deckPad] changes. */
@Volatile var deckPadListener: (() -> Unit)? = null
/** What the second screen shows, and on which display: kept for the session, so a screen
* turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
@Volatile var secondScreenDisplay = -1
/** Which session this is: SessionService.MODE_STEAM or MODE_DESKTOP. */
@Volatile
var mode = "steam"
@@ -74,6 +95,10 @@ object SessionState {
@Volatile
var refreshHz: Float = 60f
/** The session's frame cap (SessionPrefs.fpsLimit), fixed when it starts; 0 = none. */
@Volatile
var fpsLimit: Int = 0
/** The compositor has presented a frame, so the loading panel is behind us for this session. */
@Volatile
var firstFrameSeen = false
@@ -0,0 +1,105 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.system.Os
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* The session's pad, described to the Steam client as a Steam Deck controller.
*
* On a Deck - and on the handhelds InputPlumber serves as one - the client finds the controller
* through libudev as a `hidraw` device under a `hid` device under a USB interface and reads it
* itself. libfakeinput serves the device node (`/dev/hidraw16`, built from the pad's input ring);
* what the client also needs is the sysfs it walks to find and identify that node, which a sandbox
* shows nothing of. This writes that sysfs as the kernel lays out a Deck's controller interface and
* returns the binds that put it in place:
*
* - `/sys/devices/droiddeck/usb1` - the USB device (Valve, 28de:1205), its interface 2, the HID
* device on it (`HID_ID`, `report_descriptor`) and that device's `hidraw/hidraw16`;
* - `/sys/class/hidraw/hidraw16` - what an enumeration of the `hidraw` subsystem lists;
* - `/sys/dev/char/240:16` - how the node's device number is looked up (in the directory the
* runtime already binds there for the GPU);
* - `/run/udev/data/c240:16` - the udev database entry that marks the device initialised, without
* which libudev's enumeration leaves a device node out.
*
* Symlinks are absolute guest paths; proot resolves them inside the session.
*/
object SteamDeckPad {
private const val TAG = "SessionService"
/** Must match libfakeinput (fakeinput_steam.cpp: DECK_HIDRAW_*). */
private const val MAJOR = 240
private const val MINOR = 16
private const val NODE = "hidraw$MINOR"
private const val GUEST_DEVICES = "/sys/devices/droiddeck"
private const val USB = "$GUEST_DEVICES/usb1"
private const val INTERFACE = "$USB/1-1:1.2"
private const val HID = "$INTERFACE/0003:28DE:1205.0001"
private const val HIDRAW = "$HID/hidraw/$NODE"
private const val SERIAL = "DROIDDECK0001"
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
private val REPORT_DESCRIPTOR = intArrayOf(
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07,
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
fun prepare(context: Context, sessionRoot: File): List<String> {
val base = File(sessionRoot, "sys/deck")
val devices = File(base, "devices")
val hidrawClass = File(base, "class-hidraw")
return try {
FileUtils.clear(base)
fun dir(guest: String) = File(devices, guest.removePrefix("$GUEST_DEVICES/")).apply { mkdirs() }
fun write(guest: String, name: String, text: String) = File(dir(guest), name).writeText(text)
fun link(file: File, target: String) {
file.parentFile?.mkdirs()
file.delete()
Os.symlink(target, file.path)
}
write(USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1205/100\nTYPE=0/0/0\nBUSNUM=001\nDEVNUM=002\n")
write(USB, "idVendor", "28de\n")
write(USB, "idProduct", "1205\n")
write(USB, "bcdDevice", "0100\n")
write(USB, "manufacturer", "Valve Software\n")
write(USB, "product", "Steam Deck Controller\n")
write(USB, "serial", "$SERIAL\n")
link(File(dir(USB), "subsystem"), "/sys/bus/usb")
write(INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1205/100\nINTERFACE=3/0/0\n")
write(INTERFACE, "bInterfaceNumber", "02\n")
write(INTERFACE, "bInterfaceClass", "03\n")
link(File(dir(INTERFACE), "subsystem"), "/sys/bus/usb")
write(HID, "uevent", "DRIVER=hid-steam\nHID_ID=0003:000028DE:00001205\n" +
"HID_NAME=Valve Software Steam Deck Controller\nHID_PHYS=usb-droiddeck-1/input2\n" +
"HID_UNIQ=$SERIAL\nMODALIAS=hid:b0003g0001v000028DEp00001205\n")
File(dir(HID), "report_descriptor").writeBytes(REPORT_DESCRIPTOR)
link(File(dir(HID), "subsystem"), "/sys/bus/hid")
write(HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$MINOR\nDEVNAME=$NODE\n")
write(HIDRAW, "dev", "$MAJOR:$MINOR\n")
link(File(dir(HIDRAW), "subsystem"), "/sys/class/hidraw")
link(File(dir(HIDRAW), "device"), HID)
hidrawClass.mkdirs()
link(File(hidrawClass, NODE), HIDRAW)
// The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode).
link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW)
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
} catch (e: Exception) {
Log.w(TAG, "deck pad: could not describe the pad as a Deck controller: $e")
emptyList()
}
}
}
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.store
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.AppImageManager
import java.io.File
/** The imported AppImages and the import in progress, for the Desktop page. */
object AppImageState {
private val main = Handler(Looper.getMainLooper())
var items by mutableStateOf<List<AppImageManager.Item>>(emptyList())
private set
/** The file being imported and what the import is doing; null when idle. */
var importing by mutableStateOf<String?>(null)
private set
var stage by mutableStateOf<String?>(null)
private set
/** Why the last import was refused, shown on the page until the next one. */
var lastError by mutableStateOf<String?>(null)
private set
fun refresh(context: Context) {
val app = context.applicationContext
Thread({
val list = AppImageManager.list(app)
main.post { items = list }
}, "appimage-refresh").start()
}
fun import(context: Context, file: File) {
if (importing != null) return
val app = context.applicationContext
importing = file.name; stage = "Starting…"; lastError = null
Thread({
val problem = try {
AppImageManager.import(app, file) { s -> main.post { stage = s } }
} catch (e: Exception) {
e.message ?: e.toString()
}
val list = AppImageManager.list(app)
main.post {
importing = null; stage = null; items = list
lastError = problem?.let { "${file.name}: $it" }
if (problem == null) Toast.makeText(app, "${file.name} imported", Toast.LENGTH_SHORT).show()
}
}, "appimage-import").start()
}
fun remove(context: Context, id: String) {
val app = context.applicationContext
Thread({
AppImageManager.remove(app, id)
val list = AppImageManager.list(app)
main.post { items = list }
}, "appimage-remove").start()
}
}
@@ -0,0 +1,158 @@
package com.droiddeck.launcher.store
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* Flathub's public API (flathub.org/api/v2): the collections its front page shows, search, and an
* app's AppStream data. Only what this device can install is kept - apps built for aarch64.
*/
object FlathubApi {
private const val TAG = "FlathubApi"
private const val BASE = "https://flathub.org/api/v2"
private const val ARCH = "aarch64"
private const val TIMEOUT_MS = 20_000
class AppSummary(
val id: String, val name: String, val summary: String, val icon: String?,
val developer: String?, val verified: Boolean, val installs: Int,
)
class Screenshot(val thumb: String, val full: String)
class AppDetails(
val id: String, val name: String, val summary: String, val description: String,
val developer: String?, val icon: String?, val version: String?, val license: String?,
val homepage: String?, val screenshots: List<Screenshot>, val categories: List<String>,
val runtime: String?, val downloadSize: Long, val installedSize: Long, val arches: List<String>,
)
/** A category as Flathub's search filters it, with the name shown for it. */
class Category(val id: String, val label: String)
val categories = listOf(
Category("Game", "Games"), Category("AudioVideo", "Audio & Video"), Category("Graphics", "Graphics"),
Category("Network", "Internet"), Category("Office", "Office"), Category("Development", "Developer"),
Category("Education", "Education"), Category("Science", "Science"), Category("System", "System"),
Category("Utility", "Utilities"),
)
/** Front-page collections: popular, trending, recently-updated, recently-added. */
fun collection(name: String, count: Int = 30): List<AppSummary>? {
// The collections are not filtered by architecture; ask for more and keep what fits.
val body = get("$BASE/collection/$name?page=1&per_page=${count * 2}") ?: return null
return hits(body)?.take(count)
}
/** Search, optionally within one category (Flathub's main_categories facet). */
fun search(query: String, category: String? = null, count: Int = 48): List<AppSummary>? {
val filters = JSONArray().put(JSONObject().put("filterType", "arches").put("value", ARCH))
if (category != null) filters.put(JSONObject().put("filterType", "main_categories").put("value", category))
val payload = JSONObject().put("query", query).put("filters", filters)
.put("hits_per_page", count).put("page", 1)
val body = post("$BASE/search", payload.toString()) ?: return null
return hits(body)
}
/** Flathub's own copy of an app's icon: for apps that export only an SVG, which is not drawn here. */
fun iconUrl(id: String) = "https://dl.flathub.org/media/icons/128x128/$id.png"
fun details(id: String): AppDetails? {
val app = get("$BASE/appstream/${enc(id)}")?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
val summary = get("$BASE/summary/${enc(id)}?arch=$ARCH")?.let { runCatching { JSONObject(it) }.getOrNull() }
val shots = app.optJSONArray("screenshots")?.let { arr ->
(0 until arr.length()).mapNotNull { i ->
val sizes = arr.optJSONObject(i)?.optJSONArray("sizes") ?: return@mapNotNull null
val all = (0 until sizes.length()).mapNotNull { sizes.optJSONObject(it) }
.map { (it.optString("width").toIntOrNull() ?: 0) to it.optString("src") }
.filter { it.second.startsWith("http") }.sortedBy { it.first }
if (all.isEmpty()) null
else Screenshot(all.firstOrNull { it.first >= 600 }?.second ?: all.last().second,
all.firstOrNull { it.first >= 1200 }?.second ?: all.last().second)
}
} ?: emptyList()
val release = app.optJSONArray("releases")?.optJSONObject(0)
val cats = app.optJSONArray("categories")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList()
val arches = summary?.optJSONArray("arches")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList()
return AppDetails(
id = app.optString("id", id),
name = app.optString("name", id),
summary = app.optString("summary"),
description = plainText(app.optString("description")),
developer = app.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" },
icon = app.optString("icon").takeIf { it.startsWith("http") },
version = release?.optString("version")?.takeIf { it.isNotEmpty() && it != "null" },
license = app.optString("project_license").takeIf { it.isNotEmpty() && it != "null" },
homepage = app.optJSONObject("urls")?.optString("homepage")?.takeIf { it.startsWith("http") },
screenshots = shots,
categories = cats,
runtime = summary?.optJSONObject("metadata")?.optString("runtime")?.takeIf { it.isNotEmpty() },
downloadSize = summary?.optLong("download_size") ?: 0L,
installedSize = summary?.optLong("installed_size") ?: 0L,
arches = arches,
)
}
private fun hits(body: String): List<AppSummary>? = try {
val arr = JSONObject(body).getJSONArray("hits")
(0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val arches = o.optJSONArray("arches")
if (arches != null && (0 until arches.length()).none { arches.optString(it) == ARCH }) return@mapNotNull null
if (o.optString("type", "desktop-application") != "desktop-application") return@mapNotNull null
AppSummary(
id = o.optString("app_id"),
name = o.optString("name"),
summary = o.optString("summary"),
icon = o.optString("icon").takeIf { it.startsWith("http") },
developer = o.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" },
verified = o.optBoolean("verification_verified"),
installs = o.optInt("installs_last_month"),
)
}.filter { it.id.isNotEmpty() }
} catch (e: Exception) {
Log.w(TAG, "hits: $e"); null
}
/** AppStream descriptions are a small HTML subset: paragraphs and lists become lines. */
internal fun plainText(html: String): String = html
.replace(Regex("(?i)<li>\\s*"), "• ")
.replace(Regex("(?i)</(p|li|ul|ol)>"), "\n")
.replace(Regex("<[^>]+>"), "")
.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#39;", "'")
.lines().map { it.trim().replace(Regex("\\s+"), " ") }
.joinToString("\n").replace(Regex("\n{3,}"), "\n\n").trim()
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
private fun get(url: String): String? = request(url, null)
private fun post(url: String, json: String): String? = request(url, json)
private fun request(url: String, json: String?): String? {
var c: HttpURLConnection? = null
return try {
c = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
setRequestProperty("Accept", "application/json")
if (json != null) {
requestMethod = "POST"
doOutput = true
setRequestProperty("Content-Type", "application/json")
outputStream.use { it.write(json.toByteArray()) }
}
}
if (c.responseCode / 100 != 2) { Log.w(TAG, "$url -> HTTP ${c.responseCode}"); null }
else c.inputStream.bufferedReader().use { it.readText() }
} catch (e: Exception) {
Log.w(TAG, "$url: $e"); null
} finally {
c?.disconnect()
}
}
}
@@ -0,0 +1,142 @@
package com.droiddeck.launcher.store
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.FlatpakManager
/**
* The store's state for the life of the process, so an install carries on - and its progress
* shows - while the user moves around the launcher or plays something meanwhile.
*/
object StoreState {
private val main = Handler(Looper.getMainLooper())
var ready by mutableStateOf(false)
private set
var installed by mutableStateOf<List<FlatpakManager.App>>(emptyList())
private set
var updates by mutableStateOf<Set<String>>(emptySet())
private set
var checkingUpdates by mutableStateOf(false)
private set
/** Flathub has been asked this session, so an empty [updates] means up to date. */
var updatesChecked by mutableStateOf(false)
private set
/** What is running: "setup", an app id, or "update-all"; null when idle. */
var busy by mutableStateOf<String?>(null)
private set
var stage by mutableStateOf<String?>(null)
private set
var percent by mutableStateOf(-1)
private set
/** The front page's sections, by collection name; absent until loaded, empty list on failure. */
val sections = mutableStateMapOf<String, List<FlathubApi.AppSummary>>()
var sectionsFailed by mutableStateOf(false)
private set
val details = mutableStateMapOf<String, FlathubApi.AppDetails>()
val detailsFailed = mutableStateMapOf<String, Boolean>()
var searchResults by mutableStateOf<List<FlathubApi.AppSummary>?>(null)
private set
var searching by mutableStateOf(false)
private set
private var searchSeq = 0
val SECTIONS = listOf("popular" to "Popular", "trending" to "Trending", "recently-added" to "New", "recently-updated" to "Updated")
fun refresh(context: Context) {
val app = context.applicationContext
Thread({
val r = FlatpakManager.ready(app)
val list = if (r) FlatpakManager.installedApps(app) else emptyList()
main.post { ready = r; installed = list; updates = updates.filter { id -> list.any { it.id == id } }.toSet() }
}, "store-refresh").start()
}
fun loadSections(force: Boolean = false) {
if (!force && sections.isNotEmpty()) return
sectionsFailed = false
Thread({
var failed = false
for ((key, _) in SECTIONS) {
val apps = FlathubApi.collection(key, 18)
if (apps == null) failed = true
main.post { sections[key] = apps ?: emptyList() }
}
main.post { sectionsFailed = failed }
}, "store-sections").start()
}
fun loadDetails(id: String) {
if (details.containsKey(id)) return
detailsFailed.remove(id)
Thread({
val d = FlathubApi.details(id)
main.post { if (d != null) details[id] = d else detailsFailed[id] = true }
}, "store-details").start()
}
fun search(query: String, category: String?) {
val seq = ++searchSeq
searching = true
Thread({
val r = FlathubApi.search(query, category)
main.post { if (seq == searchSeq) { searchResults = r ?: emptyList(); searching = false } }
}, "store-search").start()
}
fun clearSearch() { searchSeq++; searchResults = null; searching = false }
private fun run(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) {
if (busy != null) return
val app = context.applicationContext
busy = what; stage = "Starting…"; percent = -1
Thread({
val problem = try {
work(app) { s, p -> main.post { stage = s; percent = p } }
} catch (e: Exception) {
e.message ?: e.toString()
}
val r = FlatpakManager.ready(app)
val list = if (r) FlatpakManager.installedApps(app) else emptyList()
main.post {
busy = null; stage = null; percent = -1
ready = r; installed = list
if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show()
}
}, "store-$what").start()
}
fun setup(context: Context) = run(context, "setup", "Flatpak setup") { c, p -> FlatpakManager.setup(c, p) }
fun install(context: Context, id: String, name: String) =
run(context, id, name) { c, p -> FlatpakManager.install(c, id, p) }
fun uninstall(context: Context, id: String, name: String) =
run(context, id, name) { c, p -> FlatpakManager.uninstall(c, id, p).also { if (it == null) main.post { updates = updates - id } } }
fun update(context: Context, id: String?, name: String) =
run(context, id ?: "update-all", name) { c, p ->
FlatpakManager.update(c, id, p).also { if (it == null) main.post { updates = if (id == null) emptySet() else updates - id } }
}
fun checkUpdates(context: Context) {
if (checkingUpdates || busy != null) return
val app = context.applicationContext
checkingUpdates = true
Thread({
val u = FlatpakManager.updates(app)
main.post {
checkingUpdates = false
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, "Could not check Flathub for updates", Toast.LENGTH_SHORT).show()
}
}, "store-updates").start()
}
}
@@ -102,6 +102,9 @@ fun ComponentsPage(
onImport: () -> Unit,
onBack: () -> Unit,
requestInitialFocus: Boolean = true,
/** The GPU drivers tab ([GPU_TAB]): what it shows and does. */
gpu: GpuDriversState = GpuDriversState(),
gpuActions: GpuDriversActions = GpuDriversActions(),
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -112,23 +115,55 @@ fun ComponentsPage(
var about by remember { mutableStateOf(false) }
var protonMenu by remember { mutableStateOf(false) }
fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action }
// The GPU drivers tab's Advanced pages: one driver list on its own, full page.
var driverPage by remember { mutableStateOf<String?>(null) }
val gpuTab = comp == GPU_TAB
when (driverPage) {
"rt" -> {
DriverPage(
title = "Runtime driver", hint = "Steam, its games and the desktop draw with it. Applies next session.",
rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads,
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import Turnip zip…", canRestore = false,
onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh,
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null },
)
return
}
"panel" -> {
DriverPage(
title = "Display driver", hint = "Puts frames on the screen in both modes. Restart the app to apply.",
rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads,
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import an AdrenoTools zip…",
canRestore = gpu.canRestoreBundled,
onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh,
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled,
onBack = { driverPage = null },
)
return
}
}
val views = snapshot?.protons ?: emptyList()
val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull()
val label = ComponentsManager.LABEL[comp] ?: comp
val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never"
val nightlies = if (busy != null) "$busy…" else "Nightlies checked $checked"
val nightlies = if (gpuTab) gpu.releaseStatus else if (busy != null) "$busy…" else "Nightlies checked $checked"
Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
// ---- title, and the one thing that goes online -----------------------------------------
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
Text(
"Components", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
maxLines = 1, modifier = Modifier.weight(1f),
maxLines = 1,
)
if (!narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
// The status gives way to the title, not the other way round.
if (!narrow) Text(
nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
) else Spacer(Modifier.weight(1f))
if (!gpuTab) ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
if (gpuTab) ToolIcon(Icons.Outlined.Refresh, "Check for new drivers", busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh)
else ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
}
if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -138,7 +173,7 @@ fun ComponentsPage(
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
if (view != null) Box {
if (view != null && !gpuTab) Box {
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu }
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton",
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first ->
@@ -152,11 +187,12 @@ fun ComponentsPage(
}
}
}
TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) })
val tabs = listOf(GPU_TAB) + comps
TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: "GPU drivers" }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) })
}
// ---- what is in use -----------------------------------------------------------------------
if (view != null) {
if (view != null && !gpuTab) {
val st = view.components.getValue(comp)
val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else ""
Row(
@@ -179,6 +215,12 @@ fun ComponentsPage(
// ---- the lists ------------------------------------------------------------------------------
when {
gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) {
GpuDriversPanel(gpu, gpuActions) { driverPage = it }
Row(modifier = Modifier.padding(top = 14.dp)) {
SmallButton("Import .zip", onClick = gpuActions.onImportZip)
}
}
snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(12.dp))
@@ -295,7 +337,7 @@ fun ComponentsPage(
}
@Composable
private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
@@ -304,7 +346,7 @@ private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fal
contentAlignment = Alignment.Center,
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp))
.background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.dp))
.glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick),
) {
@@ -327,7 +369,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean =
contentAlignment = Alignment.Center,
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp))
.background(if (hot) pal.signal else colors.surfaceVariant)
.border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp))
.glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp)
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
.controllerConfirm(enabled = enabled, onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
@@ -369,7 +411,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier)
.border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE)
.glideBorder(hot, ROW_SHAPE, pal.signal)
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
.controllerConfirm(onClick = onSelect)
.padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
@@ -398,7 +440,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on
contentAlignment = Alignment.Center,
modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE)
.background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent)
.border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE)
.glideBorder(delHot, ROW_SHAPE, colors.error)
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete)
.controllerConfirm(onClick = onDelete),
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) }
@@ -432,3 +474,6 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
/** The Components page's tab for the GPU drivers, before the Proton components' own. */
const val GPU_TAB = "gpu"
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more