Compare commits

..
Author SHA1 Message Date
Resul Övür a74ff3d67a fix: cancelling a paste stops the copy and keeps a move's source (#1029) 2026-10-20 20:05:05 -04:00
Juan Martí 92444946ea fix: open game Components on the game's profile (#1146) 2026-10-20 19:31:17 -04:00
Resul Övür 79889755fd fix: paste bar ignores taps while a paste is running (#1025) 2026-10-20 19:22:33 -04:00
Resul Övür ae1f90aaf0 fix: ignore uinput-* system virtual input devices in controller detection (#954) 2026-10-20 18:39:35 -04:00
Resul Övür e8595638fc fix: empty App ID no longer crashes the game environment editor (#1014) 2026-10-20 18:31:27 -04:00
Resul Övür 0a92e0abf2 fix: a slow folder listing no longer replaces the folder being shown (#1028) 2026-10-20 18:31:21 -04:00
Resul Övür 13cb2068f0 fix: file manager selection actions respect the search filter (#1021) 2026-10-20 18:31:14 -04:00
MaxsTechReview c44c12488e fix: Mono P/Invoke readv calls reach the controller hook too (#1227) 2026-10-20 18:18:10 -04:00
Resul Övür e5b44f95f6 fix: moving a stick no longer releases a D-pad or trigger held as a key (#1060) 2026-10-20 18:17:37 -04:00
maxjivi05 cbc113ea3e fix: Mono P/Invoke readv calls reach the controller hook too 2026-10-20 18:02:29 -04:00
maxjivi05 69054b1962 Merge branch 'main' into fix/readv-follow-up 2026-10-20 18:02:29 -04:00
Resul Övür 276bfd7fa2 fix: file provider rejects document ids with . or .. segments (#1016) 2026-10-20 17:58:36 -04:00
Charan Rathore 5f9acf51d4 fix: treat Unreal bootstrap launcher PE name as junk (#1020) 2026-10-20 17:52:47 -04:00
Delete228 975d6e260a fix: restore Factorio ARM64 controller input through readv (#1195) 2026-10-20 17:52:40 -04:00
Resul Övür 606ba5a9db fix: shared session logs are always scrubbed, whatever their size (#1065) 2026-10-20 17:16:36 -04:00
MaxsTechReview 06a6fd96db fix: Rewired games see DroidDeck's pads (udev listing, dlsym-resolved libc calls) (#1222) 2026-10-20 16:53:37 -04:00
Resul Övür 9139524015 fix: added-games scan keeps games like Reach, Breach, Crashday and Observer (#1023) 2026-10-20 16:53:29 -04:00
Resul Övür 019a3700ff fix: Steam Touch labels no longer corrupt the controller config (#1017) 2026-10-20 16:53:22 -04:00
Kurt HimebauchandDetinator10 782bff4e87 fix: let the device sleep while a session is suspended (#1220)
* fix: stop the DirectAudio output while the sink is suspended

* fix: verify every session thread before reporting suspension

* fix: freeze PRoot first and stop each guest thread on suspend

---------

Co-authored-by: Detinator10 <[email protected]>
2026-10-20 16:43:09 -04:00
MaxsTechReview 6a0671d5bd fix: leave the PC keyboard's pad handling to #956 and fix the red keyboard test (#1221) 2026-10-20 16:30:51 -04:00
Resul Övür ae6e4a3f80 fix: preserve Electric contrast for filled and text buttons (#1149) 2026-10-20 15:45:08 -04:00
kingokksa 3ecae47966 feat: uninstall or remove a game from the Games page (#1190) 2026-10-20 15:37:21 -04:00
Resul Övür 4969f31574 fix: hide on-screen controls while the PC keyboard is open (#956) 2026-10-20 15:06:38 -04:00
Kurt Himebauch 49446c7f01 feat: launch Easy Anti-Cheat games offline when they support it (#1039) 2026-10-20 18:58:56 +00:00
MaxsTechReview 1e33eee3f2 fix: native Linux games under FEX reach FEXServer, and a refused compat app no longer loops the sync (#1213)
FEX launched through a script (Valve's _v2-entry-point) was judged 32-bit, so the
fast path stayed off in the FEX client while FEXServer bound its socket through it.
The fast path now follows #! lines before judging the program.

The live compat sync drops apps the client refuses GetCompatTools for instead of
reattaching forever.
2026-10-20 14:58:30 -04:00
MaxsTechReview 1f71cbd117 fix: the on-screen pad steps aside while Steam's or DroidDeck's keyboard is open (#1217) 2026-10-20 14:29:50 -04:00
MaxsTechReview 55d5e2191c fix: a store game's first launch creates its prefix before the cloud save download (#1215) 2026-10-20 14:11:46 -04:00
MaxsTechReview 11892c7ad1 fix: a session on an external display stays awake while the phone screen is off (#1211) 2026-10-20 14:11:42 -04:00
MaxsTechReview 0f1b82aa26 Work around Android's denial of executable stacks (#1208) 2026-10-20 12:44:49 -04:00
Kurt Himebauch 8b279001a5 fix: prefetch Comet and Xbox packs for existing users (#1204) 2026-10-20 16:29:08 +00:00
Kurt Himebauch 61254786e6 feat: ship proot, DirectAudio, Xbox, Comet and hand tracking as CI-built add-on packs (#1198)
* perf: download hand tracking models and native libs on first use

* feat: build and publish add-on packs in CI, add Xbox and Comet packs

* feat: ship proot and DirectAudio drivers as CI-built packs

* test: hand tracking readiness requires the tracking pack

* ci: attach PR test-build packs to the PR release instead of Components
2026-10-20 11:18:44 -04:00
Kurt Himebauch ece1df0da8 Fix Game Pass library sync when subscription plan rows are omitted (#1193) 2026-10-20 11:14:06 -04:00
kingokksa 49d1311808 feat: arrange and hide/show store tabs from stores settings (#1121) 2026-10-20 11:12:27 -04:00
Delete228 197d31c2ed test: cover controller destination faults and partial reads
Exercise keyframe and ring reads with unmapped, PROT_NONE, read-only and page-boundary destinations. Verify complete-event progress within one vector and across vectors, including a NULL destination. The Linux fakeinput suite passes 11 tests with 3 i386 skips; both native ARM64 probes pass on the Thor.
2026-10-20 17:55:25 +03:00
Delete228 c0359d1c8c fix: safely copy controller events to caller buffers
Stage ring and keyframe events before copying them with process_vm_writev. Return EFAULT for inaccessible destinations and advance the reader only for complete events delivered, preserving partial readv progress. Retested Factorio on the Thor with the exact ARM64 library.
2026-10-20 17:53:40 +03:00
MaxsTechReview a38514afa0 fix: RetroArch fills the screen from the emulator rail (#1194) 2026-10-20 10:38:52 -04:00
Delete228 a17a40db02 test: reject inaccessible readv descriptor arrays
Cover unmapped, PROT_NONE, and partially accessible iovec arrays. The probe crashes with SIGSEGV before the descriptor-copy fix and returns EFAULT afterward. The Linux fakeinput suite and native ARM64 probe pass; Factorio was manually retested on the Thor with the updated library.
2026-10-20 17:34:14 +03:00
Delete228 c4298e73b4 fix: safely copy readv descriptors before validation
Copy the caller's iovec array through process_vm_readv before inspecting it, so unmapped, protected, or partially accessible arrays return EFAULT instead of crashing the process. Verified valid and invalid copies in the Android guest.
2026-10-20 17:29:42 +03:00
Delete228 02e19b2fb8 build: require the controller readv export
Check that packaged libfakeinput libraries export readv alongside the existing read hook.
2026-10-20 16:43:35 +03:00
Delete228 f817af9607 test: cover vectored controller reads
Cover readv on original and virtual controller rings, an overlay-style read-to-readv fallback, event-aligned vectors, empty vectors, nonblocking errors, and ordinary descriptor passthrough.
2026-10-20 16:42:24 +03:00
MaxsTechReview 31a6c3d033 fix: open Flatpak apps in renamed app variants (#1192)
Flatpak starts bwrap with an empty environment, so the bwrap shim lost DROIDDECK_BWRAP_SOCKET and dialled the default package's socket. The session now writes the socket name to /etc/droiddeck/bwrap-socket and the shim reads it when the variable is missing.

Fixes #1092
2026-10-20 09:38:14 -04:00
Delete228 003f4ecd3b fix: restore Factorio ARM64 controller input through readv
Steam's ARM64 overlay can fall back from read() to readv(). Route registered controller reads through the existing private ring reader so the game receives input events instead of backing-file bytes. Preserve ordinary descriptor passthrough and evdev per-vector reads.
2026-10-20 16:38:14 +03:00
FX1922 99ab6dd64e feat: Better boot movies system; Added steamdeckrepo support (#1036) 2026-10-20 09:36:25 -04:00
MaxsTechReview 9b5df0d224 perf: tile rendering for games, faster session start (#1188)
Games launched from Steam get Turnip's own per-render-pass choice between
tile memory and system memory again; TU_DEBUG=sysmem now stays with the
Steam client, whose interface it was set for. A per-game TU_DEBUG, the
debug override file and the Adreno 710/720 driver still win. On the
REDMAGIC tablet (Adreno 840) PEAK runs 46.8 -> 52.3 fps over 7 alternating
pairs and Valheim's 3D menu needs 12.6 % less GPU time per frame; light
games are unchanged.

droiddeck-seed-redists remembers the identity (inode, size, mtime) of each
prefix registry it has verified and skips re-reading it while it is
unchanged. Steam started after it on every session, ~1.8 s later.

SessionFiles keeps a ledger of the files it staged for the installed APK
and copies only the ones that changed, ~180 ms less per session start.

Session start to Steam ready: 5.57 -> 3.57 s.
2026-10-20 09:19:46 -04:00
MaxsTechReview aeb0fa57e3 fix: RPCS3 opens from the emulator rail again (#1191)
RPCS3's AppImage could not start: its own runtime unpacks the image with
a dwarfsextract it runs from a memfd, which Android refuses (EACCES), and
it has no other way to run it. Emulator AppImages for arm64 now start
through the shipped uruntime, which falls back to a file in /tmp.

RPCS3 then stopped with "Null object" in utils::shm (errno 13): it reads
/proc/sys/vm/overcommit_memory to pick memfd for guest memory, and apps
cannot read it. The guest now gets the kernel's default policy (0) there
when the real file is unreadable, like file-max.
2026-10-20 08:52:31 -04:00
MaxsTechReview ba582dea65 fix: add a picked exe the session cannot see by binding its folder (#1173)
Adding a game from a single exe outside internal storage and the game
library, such as a microSD card, only showed "This location is not
available in the session" and added nothing. #1153 made "Add all games in
this folder" bind such a folder under /root/Games, but a picked exe still
had no guest path.

addExe now binds the folder the same way when the session cannot see the
exe (the game it belongs to, or else the exe's own folder), then adds the
game. The bind is mounted from the next session start.
A folder the session already sees is not bound again, and a folder the app
cannot read is still refused.
2026-10-20 00:45:56 -04:00
MaxsTechReview 8074ec44e8 fix: route ALSA to PulseAudio for x86 Linux games under FEX (no game audio) (#1166)
fix: route ALSA to PulseAudio for x86 Linux games under FEX
2026-10-20 00:44:56 -04:00
MaxsTechReview fdab3cea82 fix: GOG games miss the registry entries their install script sets (#1159)
A GOG download only places the files, so games that look up their own registry keys (Fallout: New Vegas reads HKLM\Software\Bethesda Softworks\FalloutNV "Installed Path") said they were not installed. Every GOG launch now reads the setRegistry actions of the game's goggame-<id>.script and imports them into the prefix's 32-bit view through Proton's reg.exe, once per change of the script, so games already downloaded are fixed on their next launch.
2026-10-20 00:38:08 -04:00
MaxsTechReview b26b43fbd0 fix: find and save Steam touch layouts of non-Steam shortcuts (#1171)
The touch layout editor opened "App -<n> Layout" for a non-Steam shortcut
(an added game, the Epic Games Launcher) with LB, LT, RT, RB, both sticks
and LS/RS greyed out, so they could not be placed. Steam keys a shortcut's
controller config by the shortcut's lowercase name and logs its app id
unsigned, but the editor looked both up by the signed app id, found
neither, and fell back to a blank layout with nothing bound.

SteamTouchConfig now reads the shortcut's name from shortcuts.vdf, loads
and saves its layout under that name the way Steam does (autosave folder
and configset entry), and matches Steam's log line by the unsigned id. The
editor is titled with the game's name, and the app id in the panel is
shown unsigned. A name that is not a plain folder name (a
path separator, "." or "..", a quote, a backslash or a control character)
keeps the numeric app id. Existing configset entries are matched
without regard to case, as Steam reads them.
2026-10-20 00:34:01 -04:00
MaxsTechReview 4ba3e442a8 fix: let Wine write hooks into code FEX has translated (Ubisoft "Error at hooking API") (#1163)
fix: let Wine write hooks into code FEX has translated

Wine 11's wineserver writes another process's memory with process_vm_writev,
which the kernel refuses on a page that is not writable. FEX's WoW64 layer
keeps the pages it has translated read-only to catch self-modifying code and
re-protects them right after a program unprotects them, so WriteProcessMemory
and NtWriteVirtualMemory into ntdll failed with a partial copy and Ubisoft
games stopped at "Error at hooking API NtProtectVirtualMemory". Wine only
falls back to /proc/<pid>/mem on ENOSYS or EPERM.

libblsession now completes a single-buffer write that wineserver was refused
with EFAULT through /proc/<pid>/mem, the way Wine wrote before Wine 11. Other
programs and every other error keep the kernel's answer. The iovecs are read
with process_vm_readv, so a bad pointer still returns EFAULT, and offsets are
64-bit in the i386 build.
2026-10-20 00:31:03 -04:00
MaxsTechReview 57799bef5a fix: add games picked inside a folder game as their own games (#1155)
Picking an exe anywhere under an added folder treated it as that game, so a
folder added with "Add all games in this folder" (or a removed one) swallowed
every game below it: picking another game's exe switched the folder game's
target, or brought the removed folder game back under its old name and art.

Only treat a pick as the same game when it sits in the folder root, in the
same top-level subfolder as the game's current exe, or is another build of
that exe, and prefer the deepest matching folder.
2026-10-20 00:29:21 -04:00
MaxsTechReview cee84fd12a fix: show the app's cursor in sessions started from the front end (#1154)
gamescope makes its cursor surfaces by attaching an shm buffer, committing,
destroying the buffer and only then calling wl_pointer.set_cursor. The
compositor kept that buffer only as a reference, so set_cursor found nothing
to publish: apps run from the front end (store apps, Flatpaks, emulators)
showed DroidDeck's fallback arrow, or no cursor at all after a desktop
session had hidden its own.

Copy a role-less surface's pixels when its held buffer is destroyed and
publish that copy when the surface becomes the cursor, for as long as the
surface lives.
2026-10-20 00:28:14 -04:00
Resul Övür 48daa064c9 fix: confirm before bulk-removing unused components in Storage (#1140) 2026-10-19 23:51:11 -04:00
Resul Övür a42ad82bcb fix: keep held Steam Touch controls pressed across action-set and layer changes (#1143) 2026-10-19 23:50:29 -04:00
Kurt Himebauch 06e819f864 fix: an interrupted package install can no longer break the Linux runtime (#1078) 2026-10-19 23:40:30 -04:00
Grimarius 4c81c98f12 feat: add configurable touchscreen gestures (#940) 2026-10-20 03:22:41 +00:00
MaxsTechReview 3a8d6aaabb fix: Steam no longer sleeps again right after a paused session resumes (#1162) 2026-10-19 23:06:48 -04:00
Resul Övürandovurrsl 64b966af49 fix: keep ModeHeader search from stealing focus when recomposed (#1141)
Co-authored-by: ovurrsl <[email protected]>
2026-10-19 21:28:22 -04:00
Resul Övürandovurrsl 583c6805fd fix: keep touchpad two-finger scroll from jumping the cursor or starting a drag (#1142)
Co-authored-by: ovurrsl <[email protected]>
2026-10-19 21:28:06 -04:00
Resul Övür e8ee11612f fix: uppercase section headings in the app language (#1148)
* fix: uppercase section headings in the app language

* fix: localize store section headings
2026-10-19 21:27:50 -04:00
MaxsTechReview 7855bec6a1 fix: give x86 programs under FEX the runtime's OpenSSL 3 (libcrypto.so.3 not found) (#1167)
fix: give x86 programs under FEX the runtime's OpenSSL 3
2026-10-19 21:27:25 -04:00
MaxsTechReview 2a423a729a fix: let x86 apps under FEX use the device's fonts (Epic launcher CJK text shows boxes) (#1168)
fix: let x86 apps under FEX use the device's fonts for CJK text
2026-10-19 21:27:18 -04:00
MaxsTechReview 01f41e0553 fix: user apps open at the Desktop's resolution (#1172)
A user app (script, AppImage or Flatpak) started from its shortcut runs in
a Run session, which took the Steam resolution (720p by default). It
ignored the resolution chosen under Open Desktop and was letterboxed at
1280x720 on screens that are not 16:9.

User apps now use the Desktop's resolution once one has been chosen. Until
then, and for every other Run program, nothing changes.
2026-10-19 21:27:06 -04:00
Kurt Himebauch 3dd0045874 Restore Xbox launches from Steam (#1170) 2026-10-20 00:46:35 +00:00
Kurt Himebauch 6bbcf3ad8b Restrict Xbox credential IPC and address launch review findings (#1164)
* Restrict Xbox credentials to host backends and lease game launches

* Verify mapped PRoot backend images and retry lease heartbeat timeouts

* Bound Xbox backend shutdown and isolate service socket lifetimes

* Keep unique Xbox service sockets within Unix address limits

* Own licensed launch directories and recover abandoned views safely

* Serialize Xbox account revocation with sign-in commits and launch authorization
2026-10-19 20:10:54 -04:00
Kurt Himebauch f5bba17f53 Add experimental Xbox and PC Game Pass store support (#1156) 2026-10-19 18:44:10 -04:00
The412Banner af57286c98 fix: adding a games folder on an SD card found nothing (#1153)
Adding a folder on an SD card outside the Steam library added nothing and said
"Nothing new in this folder": every game's exe had no guest path, so each one
was skipped. Since #542 nothing created the game-folder binds that let older
builds use such folders; only migrated ones remained.

addFolder now binds the picked folder under /root/Games (named as
GamesFolderMigration names them, with a suffix when the name is taken) when the
session cannot see it, then adds its games. The bind is mounted from the next
session start.
2026-10-19 19:43:03 +00:00
Kurt Himebauch 446452a97f Fix intermittent CI package updates and escaped test worker (#1151)
Fix escaped cloud-save test worker and CI package updates
2026-10-19 14:52:30 -04:00
Kurt Himebauch 868f54e425 fix: skip DirectAudio for Steam install scripts (#1046) 2026-10-19 14:19:10 -04:00
Kurt Himebauch c3bc3e6fbd fix: run x86 Proton under FEX in the Steam runtime it requires (#1034) 2026-10-19 14:18:52 -04:00
Kurt Himebauch 1197be90d9 Fix direct-touch right-click and Steam Touch mode restoration (#677) 2026-10-19 14:18:26 -04:00
Kurt Himebauch 6f29018864 fix: register SteamVR only during a DroidDeck VR session (#1137) 2026-10-19 12:31:23 -04:00
Kurt Himebauch ac25283f04 fix: DD-FG 3x/4x flicker and rebrand Win-FG to DD-FG (#1132) 2026-10-19 11:21:32 -04:00
Kurt Himebauch 2ecfe18f76 fix: keep the TP UBWC hint on for Samsung One UI 8 Gen 2 (#1128) 2026-10-19 09:58:24 -04:00
kingokksa 7515bb9898 Ship the desktop's translations as per-language packs (#942) 2026-10-19 09:54:19 -04:00
Kurt Himebauch 4555fa266d feat: capsule view with DVD-case store art across all stores (#1117) 2026-10-19 08:45:58 -04:00
Kurt Himebauch 60f40913a2 feat: play button on installed store game pages (#1125) 2026-10-19 12:23:02 +00:00
Kurt Himebauch facaa18a3c feat: library and store tabs per store with installed games listed first (#1116) 2026-10-19 02:06:38 -04:00
kingokksaandxXJSONDeruloXx 8838cf28ed feat: itch.io store, library and installs for DRM-free Windows builds (#1064)
Co-authored-by: xXJSONDeruloXx <[email protected]>
2026-10-19 05:25:00 +00:00
Kurt Himebauch 65f44e2b80 feat: add light launcher icon option (#1104) 2026-10-19 02:54:50 +00:00
MaxsTechReview e321f5025d Fill missing plural forms and translate English left in the app strings (#1102)
Nine storage plurals lacked the forms Arabic, French, Spanish, Italian
and Portuguese use (zero/two/few/many), and 71 strings across 16
languages were still the English text.
2026-10-18 21:28:36 -04:00
MaxsTechReview a105e4e49c Steam x86-64 client: menus come up, sign-in stays put, games listed once (#1096)
* Steam x86-64 client: menus come up, sign-in stays put, games listed once

Switching the Steam client from arm64 to x86-64 left the client on a blank
screen behind "Downloading the Steam client update 100%", and each switch
signed one of the clients out.

- The x86-64 client is Valve's steamrt64 platform, but it was installed and
  synced from the ubuntu12 manifest, which lags it (1792108788 against
  1792192922). The client was therefore never the arm64 client's build, the
  arm64 web helper was never used, and the client then updated itself in
  place after the helper had been chosen. Install, sync and the build check
  now read the steamrt64 manifest.
- The x86 web helper's GPU process loads libpci, which exits when
  /sys/bus/pci/devices lists a device whose files the app may not read (a
  RedMagic's PCIe root ports). Every GPU process exited with status 1, even
  in software, until Chromium gave up and the menus stayed blank. An empty
  folder is bound over /sys/bus/pci/devices on such devices.
- Steam refuses one client's login token from the other ("Invalid
  Password" for a token the arm64 client signed in with minutes before), so
  copying the newer token at every start signed the other client out. Each
  client now keeps its own token; a client with no accounts only gets the
  other's account list.
- Games could be listed twice: an added game whose folder a Steam import
  already covers is now shown as that Steam game only, a client switch
  drops the old client's imported entries, an import stays one when its
  ownership cannot be confirmed instead of gaining a shortcut beside it,
  and only the client's own library folders count as an install.

* Steam x86-64 client: share the arm64 sign-in again

The x86-64 client refused the arm64 client's login token because it did
not know the account's SteamID: it takes it from Accounts in
config/config.vdf, which this script creates without one. It logged on as
[U:1:0] with a valid token, Steam answered "Invalid Password", and the
client dropped the token and AutoLoginUser, so every switch asked for the
password.

The newer sign-in is copied between the clients again, each start adds
the accounts either client knows to the other's config.vdf, and an
AutoLoginUser or RememberPassword one client lacks is filled in. Device:
the x86 client signs in with the arm64 token on a fresh install and on an
install left signed out, and the arm64 client stays signed in.
2026-10-18 20:38:48 -04:00
MaxsTechReview 2016a363f2 Boot Linux games under the FEX compatibility tool again (#1100)
Valve's FEX opens rootfs paths with openat2(RESOLVE_IN_ROOT), which the
preload emulates under proot. droiddeck-fex's rootfs links to the Steam
runtimes with absolute paths outside the rootfs, so every lookup missed
and FEX fell back to the arm64 host files: x86 bash, and with it Stardew
Valley's launcher, failed on libdl.so.2. An absolute link whose target
is missing in the root but present on the host now finishes there.
2026-10-18 20:38:08 -04:00
MaxsTechReview 1873a9aee7 fix: Steam client responds sooner and its sounds play on time (#1094)
- Pace the session at the rate the panel actually runs. When the system
  keeps a lower rate than the mode DroidDeck asked for (a vendor game
  assistant holding 120 Hz while 185 Hz was requested), gamescope,
  Xwayland and the compositor's presentation feedback were told 185 Hz,
  and Steam's interface drew every 10.8 ms out of step with the panel.
- Build the DirectAudio PulseAudio sink from source (LGPL-2.1-or-later,
  The412Banner/directaudio v1.1.0) with its max request kept to its ring
  target. Advertising 100 ms made PulseAudio grow every client's buffer
  to about 112 ms, so Steam's interface sounds played that late.
2026-10-18 19:26:26 -04:00
MaxsTechReview d5628f7b70 fix(ci): give unit tests a 2 GB heap (#1091)
Robolectric keeps one sandbox per SDK level the tests ask for, and the
suite now asks for seven (26, 28, 29, 31, 33, 34 and the default). They
no longer fit in Gradle's default 512 MB test heap, so testReleaseUnitTest
runs out of memory part way through (SteamHomeHeadsetTest,
GlassesMenuCaptureTest) on main and on every open PR.
2026-10-18 19:24:09 -04:00
Kurt Himebauch e10fc86a76 fix: keep hidden and private Steam games off the Steam home art wall (#1097) 2026-10-18 19:11:30 -04:00
384 changed files with 27663 additions and 1570 deletions

No files matched your search

+5 -1
View File
@@ -64,6 +64,7 @@ jobs:
KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out
cp out/kwin/desktop-kde.kwin.txt out/
echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)"
echo "language packs $(ls out/desktop-kde-i18n-*.tar.zst | wc -l)"
- uses: actions/upload-artifact@v4
with:
name: desktop-kde
@@ -74,6 +75,8 @@ jobs:
out/desktop-kde.packages.txt
out/desktop-kde.kwin.txt
out/desktop-kde.sha256
out/desktop-kde.i18n.txt
out/desktop-kde-i18n-*.tar.zst
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
@@ -88,5 +91,6 @@ jobs:
fi
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
--notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt \
out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst
gh release edit "$TAG" --latest=false --prerelease=false
+106 -12
View File
@@ -29,7 +29,64 @@ env:
PRELOAD_CACHE_VERSION: v6
jobs:
xbox:
name: Xbox ARM64 backend
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
outputs:
base-sha: ${{ steps.merge.outputs.base-sha }}
tree-sha: ${{ steps.source.outputs.tree-sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Pin and merge the APK source
id: merge
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
git config user.name "ci"
git config user.email "[email protected]"
git merge --no-ff --no-edit origin/main
- name: Record backend source tree
id: source
run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
id: xbox-cache
with:
path: |
app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
app/build/xbox/xodus-droiddeck-source.tar.xz
app/build/xbox/runtime/*-source.tar.xz
key: xbox-arm64-${{ hashFiles('tools/xbox/**') }}
- name: Build Xbox backend
if: steps.xbox-cache.outputs.cache-hit != 'true'
run: |
rustup toolchain install 1.99.0 --profile minimal
sudo apt-get update -qq
sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils
bash tools/xbox/build.sh
- uses: actions/upload-artifact@v4
with:
name: xbox-arm64
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
retention-days: 14
- uses: actions/upload-artifact@v4
with:
name: xbox-corresponding-source
path: |
app/build/xbox/xodus-droiddeck-source.tar.xz
app/build/xbox/runtime/*-source.tar.xz
if-no-files-found: error
retention-days: 14
build:
needs: xbox
name: Build APK
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
@@ -41,6 +98,7 @@ jobs:
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Check co-authors
if: github.event_name == 'pull_request'
@@ -86,14 +144,17 @@ jobs:
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }}
run: |
set -uo pipefail
git config user.name "ci"
git config user.email "[email protected]"
git fetch --no-tags origin main
BASE=$(git rev-parse --short origin/main)
git fetch --no-tags origin "$BUILD_BASE_SHA"
BASE=$(git rev-parse --short FETCH_HEAD)
HEAD=$(git rev-parse --short HEAD)
if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then
if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then
{
echo "### Mergeable"
echo
@@ -140,6 +201,11 @@ jobs:
echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')"
exit 1
- name: Verify backend and APK source match
env:
XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
- uses: actions/setup-java@v4
with:
distribution: temurin
@@ -184,15 +250,14 @@ jobs:
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/xbox/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \
-o Dir::Etc::sourceparts=- -o Acquire::Retries=3 update -qq
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null
D=app/src/main/assets/linuxfs
mkdir -p "$D"
@@ -225,7 +290,7 @@ jobs:
done
if [ -f tools/gamescope/release.env ]; then
. tools/gamescope/release.env
gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst
curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst"
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
test -f "$D/usr/local/bin/gamescope"
@@ -328,18 +393,17 @@ jobs:
ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
- name: Bundle the droiddeck-esync packs
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ -f tools/droiddeck-esync/release.env ]; then
. tools/droiddeck-esync/release.env
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p "$SYNC_BUNDLE_ASSET" -O sync-bundle.tzst
release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"
curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"
echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
mkdir -p app/src/main/assets/droiddeck-esync
tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
rm sync-bundle.tzst
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p index.json -O sync-index.json
curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"
revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
for id in $revoked; do
if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
@@ -463,6 +527,11 @@ jobs:
restore-keys: |
cxx-${{ env.NDK_VERSION }}-
- uses: actions/download-artifact@v4
with:
name: xbox-arm64
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
- name: Build GOG Galaxy companion
if: steps.preloads.outputs.cache-hit != 'true'
run: |
@@ -470,6 +539,9 @@ jobs:
rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
- name: Build the add-on packs
run: bash tools/packs/build.sh app/build/packs
- name: Build
id: build
env:
@@ -508,6 +580,11 @@ jobs:
path: app/src/main/rust/stores/Cargo.lock
if-no-files-found: ignore
- name: Install Python test dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq python3-yaml
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
@@ -568,6 +645,14 @@ jobs:
retention-days: 30
compression-level: 0
- uses: actions/upload-artifact@v4
with:
name: droiddeck-packs
path: app/build/packs/*.tar.zst
if-no-files-found: error
retention-days: 30
compression-level: 0
- name: Report on the pull request
if: always() && github.event_name == 'pull_request'
continue-on-error: true
@@ -670,8 +755,17 @@ jobs:
variants: ${{ needs.build.outputs.variants }}
secrets: inherit
packs:
needs: build
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
actions: read
uses: ./.github/workflows/publish-packs.yml
secrets: inherit
announce:
needs: [build, sign]
needs: [build, sign, packs]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
+22
View File
@@ -176,6 +176,27 @@ jobs:
pattern: droiddeck-pr-signed-*
merge-multiple: true
path: signed
- name: The add-on packs main has not published
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ needs.authorize.outputs.run_id }}
run: |
set -euo pipefail
gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
mkdir -p pr-packs
for file in packs/*.tar.zst; do
name=$(basename "$file" .tar.zst)
sha=$(sha256sum "$file" | cut -d' ' -f1)
tag="pack-$name-${sha:0:16}"
digest=$({ gh api "repos/Droid-Deck/DroidDeck-Components/releases/tags/$tag" 2>/dev/null || echo '{}'; } |
jq -r --arg n "$name.tar.zst" '.assets // [] | .[] | select(.name == $n) | .digest // empty')
if [ "$digest" = "sha256:$sha" ]; then
echo "$name: served from DroidDeck-Components $tag"
else
cp "$file" "pr-packs/$tag.tar.zst"
echo "$name: attached to the PR release as $tag.tar.zst"
fi
done
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
@@ -233,6 +254,7 @@ jobs:
first=true
fi
echo "first=$first" >> "$GITHUB_OUTPUT"
cp pr-packs/* signed/ 2>/dev/null || true
gh release upload "$tag" signed/* -R "$repo" --clobber
gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
keep=$(cd signed && ls | jq -R . | jq -sc .)
+83
View File
@@ -0,0 +1,83 @@
name: Publish add-on packs
# The add-on packs (tools/packs/build.sh) a Build APK run made, published to DroidDeck-Components under
# content-addressed tags (pack-<name>-<first 16 hex of sha256>). The APK from that run pins each pack's
# SHA-256 and downloads it from that tag. A tag is created once and never replaced.
# Only main and release builds publish here; a PR test build attaches the packs main has not published
# to its pr-N release on DroidDeck-CI (pr-release-publish.yml), which is deleted when the PR closes.
on:
workflow_call:
inputs:
run_id:
description: The Build APK run whose packs to publish; empty for the calling run's own
type: string
default: ''
permissions:
contents: read
actions: read
jobs:
publish:
name: publish add-on packs
runs-on: ubuntu-24.04
timeout-minutes: 15
concurrency:
group: publish-packs
cancel-in-progress: false
steps:
- name: The packs (this run's build)
if: inputs.run_id == ''
uses: actions/download-artifact@v4
with:
name: droiddeck-packs
path: packs
- name: The packs (another Build APK run)
if: inputs.run_id != ''
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ inputs.run_id }}
run: gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
- uses: actions/create-github-app-token@v2
id: components-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-Components
- name: Publish
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
REPO: Droid-Deck/DroidDeck-Components
BUILD_RUN: ${{ inputs.run_id || github.run_id }}
run: |
set -euo pipefail
published_digest() {
{ gh api "repos/$REPO/releases/tags/$1" 2>/dev/null || echo '{}'; } |
jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty'
}
commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA")
for name in proot directaudio tracking xbox comet; do
file="packs/$name.tar.zst"
[ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; }
sha=$(sha256sum "$file" | cut -d' ' -f1)
tag="pack-$name-${sha:0:16}"
digest=$(published_digest "$tag" "$name.tar.zst")
if [ -z "$digest" ]; then
notes="DroidDeck add-on pack \`$name\`, built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$commit\` with tools/packs/build.sh and published by [run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID).
Size: $(stat -c %s "$file") bytes. SHA-256: $sha. APKs built in the same run pin this digest."
gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" --latest=false "$file" \
|| echo "::warning::creating $tag failed; checking whether another run published it"
digest=$(published_digest "$tag" "$name.tar.zst")
fi
if [ "$digest" != "sha256:$sha" ]; then
echo "::error::$tag holds ${digest:-nothing} for $name.tar.zst, not sha256:$sha"
exit 1
fi
echo "- \`$name\`: [$tag]($GITHUB_SERVER_URL/$REPO/releases/tag/$tag) \`$sha\`" >> "$GITHUB_STEP_SUMMARY"
done
+11 -1
View File
@@ -63,9 +63,19 @@ jobs:
artifact: droiddeck-release
secrets: inherit
packs:
needs: find
permissions:
contents: read
actions: read
uses: ./.github/workflows/publish-packs.yml
with:
run_id: ${{ needs.find.outputs.run_id }}
secrets: inherit
publish:
name: publish the release
needs: [find, sign]
needs: [find, sign, packs]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
+3 -1
View File
@@ -47,10 +47,12 @@ 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). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. 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. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). 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). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. 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. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo.
Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md).
The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment.
+33 -16
View File
@@ -84,6 +84,23 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name ->
def pack = file("build/packs/${name}.tar.zst")
if (!pack.isFile()) {
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
return [(name): [sha256: '', size: 0L]]
}
def digest = java.security.MessageDigest.getInstance('SHA-256')
pack.withInputStream { input ->
byte[] buffer = new byte[1 << 20]
int n
while ((n = input.read(buffer)) > 0) digest.update(buffer, 0, n)
}
[(name): [sha256: digest.digest().encodeHex().toString(), size: pack.length()]]
}
def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json'))
if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) {
throw new GradleException('Desktop removal inventories do not match the pinned archive')
@@ -119,6 +136,10 @@ android {
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
addonPacks.each { name, pack ->
buildConfigField "String", "PACK_${name.toUpperCase()}_SHA256", "\"${pack.sha256}\""
buildConfigField "long", "PACK_${name.toUpperCase()}_SIZE", "${pack.size}L"
}
ndk {
abiFilters 'arm64-v8a'
@@ -172,6 +193,11 @@ android {
disable 'ExpiredTargetSdkVersion'
}
androidResources {
// aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs).
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~:<dir>droiddeck-xbox:<dir>droiddeck-comet:<dir>directaudio'
}
packagingOptions {
resources {
excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
@@ -179,6 +205,8 @@ android {
jniLibs {
useLegacyPackaging = true
pickFirsts += ['**/libtermux.so']
// Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
}
}
@@ -192,6 +220,8 @@ android {
unitTests.returnDefaultValues = true
unitTests.includeAndroidResources = true
unitTests.all {
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
maxHeapSize = '2g'
def logTarget = providers.systemProperty('robolectric.logging').orNull
if (logTarget != null) systemProperty 'robolectric.logging', logTarget
}
@@ -282,22 +312,8 @@ def buildRustStores = tasks.register('buildRustStores', Exec) {
if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
def verifyHandModel = tasks.register('verifyHandModel') {
def models = ['hand_landmarker.task': 'fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1',
'hand_landmarker_lite.task': '173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8']
.collectEntries { name, hash -> [(file("src/main/assets/models/$name")): hash] }
inputs.files(models.keySet())
doLast {
models.each { model, expected ->
def actual = java.security.MessageDigest.getInstance('SHA-256').digest(model.bytes).encodeHex().toString()
if (actual != expected) throw new GradleException("Hand Landmarker model checksum mismatch: ${model.name}")
}
}
}
tasks.named('preBuild').configure { dependsOn(verifyHandModel) }
dependencies {
implementation 'org.opencv:opencv:4.13.0'
implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}"
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
implementation 'org.apache.commons:commons-compress:1.27.1'
@@ -316,7 +332,8 @@ dependencies {
implementation 'com.termux.termux-app:terminal-view:0.118.0'
implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation('com.google.mediapipe:tasks-vision:1.0.0') {
implementation 'androidx.webkit:webkit:1.12.1'
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value'
}
+29 -5
View File
@@ -65,11 +65,6 @@
android:exported="true"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -88,6 +83,31 @@
</intent-filter>
</activity>
<activity-alias
android:name=".LauncherDark"
android:targetActivity=".MainActivity"
android:icon="@mipmap/ic_launcher"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".LauncherLight"
android:targetActivity=".MainActivity"
android:icon="@mipmap/ic_launcher_light"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
</intent-filter>
</activity-alias>
<activity
android:name=".HomeActivity"
android:enabled="false"
@@ -127,6 +147,10 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity android:name=".stores.xbox.XboxLoginActivity" android:exported="false"
android:process=":xboxlogin" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity
android:name=".stores.StoreLoginActivity"
android:exported="false"
Binary file not shown.
+14 -14
View File
@@ -11,23 +11,23 @@ add_subdirectory(adrenotools)
# DXVK's DXBC->SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc).
add_subdirectory(thirdparty/dxbc)
set(WINFG_UPSTREAM "v0.3.0")
set(WINFG_CHAIN_SRC
framegen/winfg/framegen.cpp
framegen/winfg/framegen.hpp
framegen/winfg/record_impl.inc
framegen/winfg/config.hpp
framegen/winfg/embedded_shaders.hpp
framegen/winfg/winfg_engine.cpp
set(DDFG_UPSTREAM "v0.3.0")
set(DDFG_CHAIN_SRC
framegen/ddfg/framegen.cpp
framegen/ddfg/framegen.hpp
framegen/ddfg/record_impl.inc
framegen/ddfg/config.hpp
framegen/ddfg/embedded_shaders.hpp
framegen/ddfg/ddfg_engine.cpp
)
set(WINFG_HASH_INPUT "")
foreach(f ${WINFG_CHAIN_SRC})
set(DDFG_HASH_INPUT "")
foreach(f ${DDFG_CHAIN_SRC})
file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/${f}" _h)
string(APPEND WINFG_HASH_INPUT "${_h}")
string(APPEND DDFG_HASH_INPUT "${_h}")
endforeach()
string(SHA256 WINFG_CHAIN_HASH_FULL "${WINFG_HASH_INPUT}")
string(SUBSTRING "${WINFG_CHAIN_HASH_FULL}" 0 12 WINFG_CHAIN_HASH)
message(STATUS "win-fg chain: ${WINFG_UPSTREAM} (src ${WINFG_CHAIN_HASH})")
string(SHA256 DDFG_CHAIN_HASH_FULL "${DDFG_HASH_INPUT}")
string(SUBSTRING "${DDFG_CHAIN_HASH_FULL}" 0 12 DDFG_CHAIN_HASH)
message(STATUS "dd-fg chain: win-fg ${DDFG_UPSTREAM} (src ${DDFG_CHAIN_HASH})")
add_subdirectory(waylandcomp)
add_subdirectory(questxr)
+163 -10
View File
@@ -21,6 +21,9 @@
#include <dirent.h>
#include <dlfcn.h>
#ifdef __GLIBC__
#include <gnu/lib-names.h>
#endif
#include <fcntl.h>
#include <linux/input.h>
#include <linux/joystick.h>
@@ -3577,7 +3580,21 @@ EXPORT int close(int fd) {
static constexpr int kMaxRingWaitMs = 2;
static constexpr int kLowLatencyRingWaitUs = 250;
EXPORT ssize_t read(int fd, void *buf, size_t count) {
static ssize_t copy_input_events(void *buf, const struct input_event *events, size_t count) {
size_t bytes = count * sizeof(struct input_event);
struct iovec local = {const_cast<struct input_event *>(events), bytes};
struct iovec remote = {buf, bytes};
ssize_t copied = syscall(SYS_process_vm_writev, getpid(), &local, 1, &remote, 1, 0);
if (copied < 0) return -1;
if (copied != static_cast<ssize_t>(bytes)) {
errno = EFAULT;
copied -= copied % sizeof(struct input_event);
if (!copied) return -1;
}
return copied;
}
static ssize_t read_input(int fd, void *buf, size_t count) {
if (!watched(fd)) return syscall(SYS_read, fd, buf, count);
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
auto controller = controller_map().find(fd);
@@ -3622,42 +3639,44 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
if (events == 0) break;
const uint8_t *ring_events = reinterpret_cast<const uint8_t *>(fake.ring) +
FAKE_INPUT_RING_HEADER_SIZE;
std::vector<struct input_event> input_events(events);
for (size_t i = 0; i < events; ++i) {
size_t index = (fake.read_seq + i) % FAKE_INPUT_RING_CAPACITY;
RingEvent stored;
memcpy(&stored, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE);
struct input_event ev = {};
struct input_event &ev = input_events[i];
ev.time.tv_sec = stored.sec;
ev.time.tv_usec = stored.usec;
ev.type = stored.type;
ev.code = stored.code;
ev.value = stored.value;
if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code);
memcpy(static_cast<uint8_t *>(buf) + i * sizeof(ev), &ev, sizeof(ev));
}
__atomic_thread_fence(__ATOMIC_ACQUIRE);
if (snap.sequence != __atomic_load_n(&fake.ring->snapshot_seq, __ATOMIC_RELAXED))
continue;
fake.read_seq += events;
return static_cast<ssize_t>(events * sizeof(struct input_event));
ssize_t copied = copy_input_events(buf, input_events.data(), events);
if (copied > 0) fake.read_seq += copied / sizeof(struct input_event);
return copied;
}
}
size_t events = std::min(requested, fake.keyframe_remaining);
struct input_event input_events[kNeutralEventCount] = {};
struct timeval now = {};
gettimeofday(&now, nullptr);
for (size_t i = 0; i < events; ++i) {
size_t index = kNeutralEventCount - fake.keyframe_remaining;
struct input_event ev = {};
size_t index = kNeutralEventCount - fake.keyframe_remaining + i;
struct input_event &ev = input_events[i];
ev.time = now;
ev.type = kNeutralEvents[index].type;
ev.code = kNeutralEvents[index].code;
ev.value = keyframe_value(fake, ev.type, ev.code);
if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code);
memcpy(static_cast<uint8_t *>(buf) + i * sizeof(ev), &ev, sizeof(ev));
--fake.keyframe_remaining;
}
return static_cast<ssize_t>(events * sizeof(struct input_event));
ssize_t copied = copy_input_events(buf, input_events, events);
if (copied > 0) fake.keyframe_remaining -= copied / sizeof(struct input_event);
return copied;
}
if (nonblock) {
errno = EAGAIN;
@@ -3673,6 +3692,52 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
}
}
EXPORT ssize_t read(int fd, void *buf, size_t count) {
return read_input(fd, buf, count);
}
EXPORT ssize_t readv(int fd, const struct iovec *iov, int iovcnt) {
if (!is_fake_input_fd(fd)) return syscall(SYS_readv, fd, iov, iovcnt);
if (iovcnt < 0 || iovcnt > IOV_MAX) {
errno = EINVAL;
return -1;
}
if (iovcnt && !iov) {
errno = EFAULT;
return -1;
}
std::vector<struct iovec> copied_iov(static_cast<size_t>(iovcnt));
if (iovcnt) {
size_t bytes = copied_iov.size() * sizeof(struct iovec);
struct iovec local = {copied_iov.data(), bytes};
struct iovec remote = {const_cast<struct iovec *>(iov), bytes};
ssize_t copied = syscall(SYS_process_vm_readv, getpid(), &local, 1, &remote, 1, 0);
if (copied != static_cast<ssize_t>(bytes)) {
errno = EFAULT;
return -1;
}
}
iov = copied_iov.data();
size_t size = 0;
for (int i = 0; i < iovcnt; ++i) {
if (iov[i].iov_len > static_cast<size_t>(SSIZE_MAX) - size) {
errno = EINVAL;
return -1;
}
size += iov[i].iov_len;
}
ssize_t total = 0;
for (int i = 0; i < iovcnt; ++i) {
if (!iov[i].iov_len) continue;
ssize_t got = read_input(fd, iov[i].iov_base, iov[i].iov_len);
if (got < 0) return total ? total : -1;
total += got;
if (static_cast<size_t>(got) != iov[i].iov_len) break;
}
return total;
}
EXPORT ssize_t write(int fd, const void *buf, size_t count) {
static auto my_write = reinterpret_cast<decltype(&::write)>(dlsym(RTLD_NEXT, "write"));
@@ -4103,4 +4168,92 @@ EXPORT int __fxstat64(int version, int fd, struct stat64 *buf) {
if (!real) return fstat64(fd, buf);
return fake_stat_fd(fd, buf, [version](int f, struct stat64 *b) { return real(version, f, b); });
}
#if defined(__x86_64__) || defined(__i386__)
using dlsym_fn = void *(*)(void *, const char *);
static const char *const interposed_names[] = {
"open", "open64", "openat", "openat64", "close", "read", "readv", "write", "writev", "ioctl",
"poll", "ppoll", "select", "stat", "stat64", "fstat", "fstat64", "__xstat", "__xstat64",
"__fxstat", "__fxstat64", "statx", "statfs", "statfs64", "fstatfs", "fstatfs64", "access",
"faccessat", "opendir", "scandir", "inotify_add_watch",
};
static dlsym_fn next_dlsym() {
static const dlsym_fn fn = [] {
#ifdef __x86_64__
const char *base = "GLIBC_2.2.5";
#else
const char *base = "GLIBC_2.0";
#endif
for (const char *version : {base, "GLIBC_2.34"}) {
if (auto found = reinterpret_cast<dlsym_fn>(dlvsym(RTLD_NEXT, "dlsym", version))) return found;
}
return dlsym_fn{};
}();
return fn;
}
static void *global_dlsym(void *, const char *name) { return next_dlsym()(RTLD_DEFAULT, name); }
static void *missing_dlsym(void *, const char *) { return nullptr; }
static bool interposed(const char *name) {
for (const char *interposed_name : interposed_names) {
if (strcmp(interposed_name, name) == 0) return true;
}
return false;
}
extern "C" __attribute__((visibility("hidden"), used)) void *fakeinput_dlsym_target(void *handle, const char *name) {
dlsym_fn next = next_dlsym();
if (!next) return reinterpret_cast<void *>(missing_dlsym);
if (handle == RTLD_DEFAULT || handle == RTLD_NEXT || name == nullptr) return reinterpret_cast<void *>(next);
static void *const libc = dlopen(LIBC_SO, RTLD_LAZY | RTLD_NOLOAD);
if (handle != libc || !interposed(name)) return reinterpret_cast<void *>(next);
return reinterpret_cast<void *>(global_dlsym);
}
#ifdef __x86_64__
__asm__(".text\n"
".globl dlsym\n"
".type dlsym, @function\n"
"dlsym:\n"
".cfi_startproc\n"
"pushq %rdi\n"
".cfi_adjust_cfa_offset 8\n"
"pushq %rsi\n"
".cfi_adjust_cfa_offset 8\n"
"subq $8, %rsp\n"
".cfi_adjust_cfa_offset 8\n"
"call fakeinput_dlsym_target\n"
"addq $8, %rsp\n"
".cfi_adjust_cfa_offset -8\n"
"popq %rsi\n"
".cfi_adjust_cfa_offset -8\n"
"popq %rdi\n"
".cfi_adjust_cfa_offset -8\n"
"jmp *%rax\n"
".cfi_endproc\n"
".size dlsym, .-dlsym\n");
#else
__asm__(".text\n"
".globl dlsym\n"
".type dlsym, @function\n"
"dlsym:\n"
".cfi_startproc\n"
"subl $12, %esp\n"
".cfi_adjust_cfa_offset 12\n"
"movl 20(%esp), %eax\n"
"movl %eax, 4(%esp)\n"
"movl 16(%esp), %eax\n"
"movl %eax, (%esp)\n"
"call fakeinput_dlsym_target\n"
"addl $12, %esp\n"
".cfi_adjust_cfa_offset -12\n"
"jmp *%eax\n"
".cfi_endproc\n"
".size dlsym, .-dlsym\n");
#endif
#endif
#endif
@@ -131,7 +131,7 @@ struct VkTable {
#include <string>
namespace lsfg { class Engine; }
namespace winfg { class Engine; }
namespace ddfg { class Engine; }
static constexpr uint32_t MAX_FRAMES_IN_FLIGHT = 2;
@@ -280,7 +280,7 @@ public:
static constexpr int32_t kFgCaptureGame = -1;
void setFrameGenTuning(float flowScale, float refreshHz, int32_t captureHeight = 0);
void setFrameGenEngine(int kind);
void setWinFgTuning(int model, int perfPreset);
void setDdFgTuning(int model, int perfPreset);
private:
struct WinTex {
@@ -468,12 +468,12 @@ private:
uint64_t fgSourceFrames_ = 0;
std::string lsfgCachePath_;
bool lsfgEngineTried_ = false;
std::unique_ptr<winfg::Engine> winfgEngine_;
bool winfgEngineTried_ = false;
std::unique_ptr<ddfg::Engine> ddfgEngine_;
bool ddfgEngineTried_ = false;
std::atomic<int> fgEngineKind_{0};
std::atomic<int> fgModel_{4};
std::atomic<int> fgPerfPreset_{1};
bool ensureWinFgEngine();
bool ensureDdFgEngine();
bool fgCapsOk() const;
uint32_t syncSlot(uint32_t k) const { return currentFrame * kMaxPresentsPerFrame + k; }
@@ -6,7 +6,7 @@
#include <fstream>
#include <sstream>
namespace winfg {
namespace ddfg {
struct Config {
bool enabled = false;
@@ -213,48 +213,48 @@ static inline void apply_toml(Config& c, const std::string& path) {
}
static inline std::string conf_path() {
if (const char* p = std::getenv("WIN_FG_CONF")) return p;
if (const char* p = std::getenv("DD_FG_CONF")) return p;
const char* home = std::getenv("HOME");
return home ? std::string(home) + "/.config/win-fg/conf.toml" : std::string();
return home ? std::string(home) + "/.config/dd-fg/conf.toml" : std::string();
}
static inline std::string capture_root(const Config& c) {
if (!c.capDir.empty()) return c.capDir;
if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/winfg-capture";
return std::string("/tmp/winfg-capture");
if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/ddfg-capture";
return std::string("/tmp/ddfg-capture");
}
static inline Config load_config() {
Config c;
c.enabled = envi("WIN_FG_ENABLE", 0) != 0;
if (const char* d = std::getenv("WIN_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
if (const char* pc = std::getenv("WIN_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
c.model = envi("WIN_FG_MODEL", c.model);
c.multiplier = envi("WIN_FG_MULT", c.multiplier);
c.extraImages = envi("WIN_FG_EXTRA_IMAGES", c.extraImages);
c.perfPreset = envi("WIN_FG_PERF_PRESET", c.perfPreset);
if (const char* g = std::getenv("WIN_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
if (const char* r = std::getenv("WIN_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
c.frIters = envi("WIN_FG_FR_ITERS", c.frIters);
c.frLambda = envf("WIN_FG_FR_LAMBDA", c.frLambda);
c.frDt = envf("WIN_FG_FR_DT", c.frDt);
c.frEdge = envf("WIN_FG_FR_EDGE", c.frEdge);
c.frEps = envf("WIN_FG_FR_EPS", c.frEps);
c.flowScale = envf("WIN_FG_FLOWSCALE", c.flowScale);
c.beta = envf("WIN_FG_BETA", c.beta);
c.lambda = envf("WIN_FG_LAMBDA", c.lambda);
c.epsilon = envf("WIN_FG_EPSILON", c.epsilon);
c.photoScale = envf("WIN_FG_PHOTOSCALE", c.photoScale);
if (const char* cc = std::getenv("WIN_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
c.captureW = envi("WIN_FG_CAPTURE_W", c.captureW);
c.captureH = envi("WIN_FG_CAPTURE_H", c.captureH);
if (const char* cm = std::getenv("WIN_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
c.capPatches = envi("WIN_FG_CAPTURE_PATCHES", c.capPatches);
c.capPatchSize = envi("WIN_FG_CAPTURE_PATCH", c.capPatchSize);
c.capMotion = envf("WIN_FG_CAPTURE_MOTION", c.capMotion);
c.capShardMB = envi("WIN_FG_CAPTURE_SHARD_MB", c.capShardMB);
if (const char* cd = std::getenv("WIN_FG_CAPTURE_DIR")) c.capDir = cd;
if (const char* h = std::getenv("WIN_FG_HUD_RECT")) {
c.enabled = envi("DD_FG_ENABLE", 0) != 0;
if (const char* d = std::getenv("DD_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
if (const char* pc = std::getenv("DD_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
c.model = envi("DD_FG_MODEL", c.model);
c.multiplier = envi("DD_FG_MULT", c.multiplier);
c.extraImages = envi("DD_FG_EXTRA_IMAGES", c.extraImages);
c.perfPreset = envi("DD_FG_PERF_PRESET", c.perfPreset);
if (const char* g = std::getenv("DD_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
if (const char* r = std::getenv("DD_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
c.frIters = envi("DD_FG_FR_ITERS", c.frIters);
c.frLambda = envf("DD_FG_FR_LAMBDA", c.frLambda);
c.frDt = envf("DD_FG_FR_DT", c.frDt);
c.frEdge = envf("DD_FG_FR_EDGE", c.frEdge);
c.frEps = envf("DD_FG_FR_EPS", c.frEps);
c.flowScale = envf("DD_FG_FLOWSCALE", c.flowScale);
c.beta = envf("DD_FG_BETA", c.beta);
c.lambda = envf("DD_FG_LAMBDA", c.lambda);
c.epsilon = envf("DD_FG_EPSILON", c.epsilon);
c.photoScale = envf("DD_FG_PHOTOSCALE", c.photoScale);
if (const char* cc = std::getenv("DD_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
c.captureW = envi("DD_FG_CAPTURE_W", c.captureW);
c.captureH = envi("DD_FG_CAPTURE_H", c.captureH);
if (const char* cm = std::getenv("DD_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
c.capPatches = envi("DD_FG_CAPTURE_PATCHES", c.capPatches);
c.capPatchSize = envi("DD_FG_CAPTURE_PATCH", c.capPatchSize);
c.capMotion = envf("DD_FG_CAPTURE_MOTION", c.capMotion);
c.capShardMB = envi("DD_FG_CAPTURE_SHARD_MB", c.capShardMB);
if (const char* cd = std::getenv("DD_FG_CAPTURE_DIR")) c.capDir = cd;
if (const char* h = std::getenv("DD_FG_HUD_RECT")) {
float r[4] = {0,0,0,0}; int n = 0;
std::string s = h; size_t start = 0;
for (; n < 4; ++n) {
@@ -266,8 +266,8 @@ static inline Config load_config() {
c.hudX0 = r[0]; c.hudY0 = r[1]; c.hudX1 = r[2]; c.hudY1 = r[3];
}
const char* home = std::getenv("HOME");
if (home) apply_toml(c, std::string(home) + "/.config/win-fg/conf.toml");
if (const char* p = std::getenv("WIN_FG_CONF")) apply_toml(c, p);
if (home) apply_toml(c, std::string(home) + "/.config/dd-fg/conf.toml");
if (const char* p = std::getenv("DD_FG_CONF")) apply_toml(c, p);
c.sanitize();
return c;
}
@@ -1,10 +1,10 @@
#include "winfg_engine.h"
#include "winfg_vkd.h"
#include "ddfg_engine.h"
#include "ddfg_vkd.h"
#include "log.hpp"
#include <algorithm>
#include <cstring>
namespace winfg {
namespace ddfg {
namespace {
float alphaFor(uint32_t k, uint32_t n) {
@@ -31,18 +31,18 @@ Engine::~Engine() { destroyRing(); fg_.destroy(); }
bool Engine::init(const VkTable& table, VkPhysicalDevice phys, VkDevice dev,
uint32_t queueFamily, VkQueue queue) {
if (!winfgVkdInit(table, dd_, id_)) {
WFG_LOGE("native: dispatch incomplete; frame generation unavailable");
if (!ddfgVkdInit(table, dd_, id_)) {
DDFG_LOGE("native: dispatch incomplete; frame generation unavailable");
return false;
}
phys_ = phys; dev_ = dev; qf_ = queueFamily; queue_ = queue;
id_.GetPhysicalDeviceMemoryProperties(phys_, &memProps_);
if (!fg_.init(&dd_, &id_, phys_, dev_, qf_, queue_)) {
WFG_LOGE("native: FrameGen::init failed");
DDFG_LOGE("native: FrameGen::init failed");
unavailable_ = true;
return false;
}
WFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
DDFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
return true;
}
@@ -61,7 +61,7 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
if (!fg_.onResize(VkExtent2D{w, h}, fmt, false)) {
WFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
DDFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
unavailable_ = true;
return false;
}
@@ -71,11 +71,13 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
}
builtW_ = w; builtH_ = h; builtFmt_ = fmt;
history_ = 0; curr_ = 0; frames_ = 0;
WFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
DDFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
w, h, (int)fmt, cfg_.model, cfg_.perfPreset, (double)cfg_.flowScale);
return true;
}
static_assert(kMaxGenerations <= (uint32_t)FrameGen::kSynthSlots, "each generation needs its own synth slot");
uint32_t Engine::plan(uint32_t capacity) const {
if (!valid() || history_ < 2) return 0;
const uint32_t want = (uint32_t)std::max(cfg_.multiplier - 1, 0);
@@ -131,7 +133,8 @@ void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t cou
dd_.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &toWrite);
fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_);
fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_,
generation, generation > 0);
if (generation + 1 == count) gmSlot_ = (gmSlot_ + 1) % (uint32_t)FrameGen::kGmSlots;
(void)w; (void)h;
@@ -6,7 +6,7 @@
struct VkTable;
namespace winfg {
namespace ddfg {
constexpr uint32_t kMaxGenerations = 3;
@@ -1,14 +1,14 @@
#include "winfg_vkd.h"
#include "ddfg_vkd.h"
#include "../VulkanRendererContext.h"
#include <android/log.h>
bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id) {
bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id) {
bool ok = true;
#define COPY(dst, fn) \
do { \
dst.fn = table.fn; \
if (!dst.fn) { \
__android_log_print(ANDROID_LOG_ERROR, "WinFgVkd", \
__android_log_print(ANDROID_LOG_ERROR, "DdFgVkd", \
"entry point vk" #fn " did not resolve"); \
ok = false; \
} \
@@ -0,0 +1,4 @@
#pragma once
#include "vk_dispatch.hpp"
struct VkTable;
bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id);
@@ -1,7 +1,7 @@
#pragma once
#include <cstdint>
#include <cstddef>
namespace winfg { namespace embedded {
namespace ddfg { namespace embedded {
static const uint32_t OF3_LUMA_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000054u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00030005u,0x00000030u,0x00007675u,0x00030005u,0x0000003bu,0x00626772u,0x00040005u,0x0000003fu,0x63725375u,0x00000000u,0x00030005u,0x00000047u,0x00005966u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x0000003fu,0x00000021u,0x00000020u,0x00040047u,0x0000003fu,0x00000022u,0x00000000u,0x00040047u,0x00000053u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x0004002bu,0x00000013u,0x00000033u,0x3f000000u,0x00040017u,0x00000039u,0x00000013u,0x00000003u,0x00040020u,0x0000003au,0x00000007u,0x00000039u,0x00090019u,0x0000003cu,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000003du,0x0000003cu,0x00040020u,0x0000003eu,0x00000000u,0x0000003du,0x0004003bu,0x0000003eu,0x0000003fu,0x00000000u,0x0004002bu,0x00000013u,0x00000042u,0x00000000u,0x00040017u,0x00000043u,0x00000013u,0x00000004u,0x00040020u,0x00000046u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000049u,0x3e59b3d0u,0x0004002bu,0x00000013u,0x0000004au,0x3f371759u,0x0004002bu,0x00000013u,0x0000004bu,0x3d93dd98u,0x0006002cu,0x00000039u,0x0000004cu,0x00000049u,0x0000004au,0x0000004bu,0x0004002bu,0x0000000au,0x00000052u,0x00000010u,0x0006002cu,0x0000000bu,0x00000053u,0x00000052u,0x00000052u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000003au,0x0000003bu,0x00000007u,0x0004003bu,0x00000046u,0x00000047u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000007u,0x00000031u,0x00000009u,0x0004006fu,0x0000002eu,0x00000032u,0x00000031u,0x00050050u,0x0000002eu,0x00000034u,0x00000033u,0x00000033u,0x00050081u,0x0000002eu,0x00000035u,0x00000032u,0x00000034u,0x0004003du,0x00000007u,0x00000036u,0x00000012u,0x0004006fu,0x0000002eu,0x00000037u,0x00000036u,0x00050088u,0x0000002eu,0x00000038u,0x0Line truncated
static const size_t OF3_LUMA_LEN = 481;
static const uint32_t OF3_DOWNSAMPLE_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x0000008au,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00040005u,0x00000030u,0x44637273u,0x00006d69u,0x00040005u,0x00000034u,0x63725375u,0x00000000u,0x00040005u,0x0000003au,0x65736162u,0x00000000u,0x00030005u,0x00000044u,0x00000068u,0x00030005u,0x0000004au,0x00000061u,0x00030005u,0x00000059u,0x00000062u,0x00030005u,0x00000065u,0x00000063u,0x00030005u,0x00000071u,0x00000064u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x00000034u,0x00000021u,0x00000020u,0x00040047u,0x00000034u,0x00000022u,0x00000000u,0x00040047u,0x00000089u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x00090019u,0x00000031u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000032u,0x00000031u,0x00040020u,0x00000033u,0x00000000u,0x00000032u,0x0004003bu,0x00000033u,0x00000034u,0x00000000u,0x0004002bu,0x00000006u,0x00000036u,0x00000000u,0x0004002bu,0x00000013u,0x0000003du,0x40000000u,0x0004002bu,0x00000013u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000013u,0x00000045u,0x3f000000u,0x00040020u,0x00000049u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000055u,0x00000000u,0x00040017u,0x00000056u,0x00000013u,0x00000004u,0x0004002bu,0x00000013u,0x00000085u,0x3e800000u,0x0004002bu,0x0000000au,0x00000088u,0x00000010u,0x0006002cu,0x0000000bu,0x00000089u,0x00000088u,0x00000088u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000002fu,0x0000003au,0x00000007u,0x0004003bu,0x0000002fu,0x00000044u,0x00000007u,0x0004003bu,0x00000049u,0x0000004au,0x00000007u,0x0004003bu,0x00000049u,0x00000059u,0x00000007u,0x0004003bu,0x00000049u,0x00000065u,0x00000007u,0x0004003bu,0x00000049u,0x00000071u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000032u,0x00000035u,0x00000034u,0x00040064u,0x00000031u,0x00000037u,0x00000035u,0x0005006Line truncated
@@ -20,10 +20,10 @@ static const uint32_t OF3_GM_PREWARP_SPV[] = {0x07230203u,0x00010300u,0x0008000b
static const size_t OF3_GM_PREWARP_LEN = 706;
static const uint32_t OF3_FLOWREG_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000144u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x00000037u,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00060005u,0x0000000eu,0x63746566u,0x76285568u,0x763b3269u,0x003b3269u,0x00030005u,0x0000000cu,0x00000070u,0x00030005u,0x0000000du,0x006d6964u,0x00060005u,0x00000013u,0x63746566u,0x76284c68u,0x763b3269u,0x003b3269u,0x00030005u,0x00000011u,0x00000070u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000018u,0x6f6c4675u,0x006e4977u,0x00040005u,0x00000026u,0x6d754c75u,0x00000061u,0x00030005u,0x00000034u,0x00646967u,0x00080005u,0x00000037u,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x0000003cu,0x006d6964u,0x00030005u,0x00000055u,0x00007464u,0x00030005u,0x00000056u,0x00000055u,0x00050006u,0x00000056u,0x00000000u,0x61726170u,0x0000736du,0x00030005u,0x00000058u,0x00000000u,0x00040005u,0x0000005cu,0x626d616cu,0x00006164u,0x00050005u,0x0000005fu,0x65676465u,0x68706c41u,0x00000061u,0x00040005u,0x00000063u,0x54737065u,0x00000056u,0x00030005u,0x00000068u,0x00004375u,0x00040005u,0x00000069u,0x61726170u,0x0000006du,0x00040005u,0x0000006bu,0x61726170u,0x0000006du,0x00030005u,0x0000006eu,0x00004c75u,0x00040005u,0x00000073u,0x61726170u,0x0000006du,0x00040005u,0x00000074u,0x61726170u,0x0000006du,0x00030005u,0x00000077u,0x00005275u,0x00040005u,0x0000007bu,0x61726170u,0x0000006du,0x00040005u,0x0000007cu,0x61726170u,0x0000006du,0x00030005u,0x0000007fu,0x00005575u,0x00040005u,0x00000083u,0x61726170u,0x0000006du,0x00040005u,0x00000084u,0x61726170u,0x0000006du,0x00030005u,0x00000087u,0x00004475u,0x00040005u,0x0000008bu,0x61726170u,0x0000006du,0x00040005u,0x0000008cu,0x61726170u,0x0000006du,0x00040005u,0x0000008fu,0x6d69646cu,0x00000000u,0x00030005u,0x00000093u,0x00004c6cu,0x00040005u,0x00000096u,0x61726170u,0x0000006du,0x00040005u,0x00000097u,0x61726170u,0x0000006du,0x00030005u,0x0000009au,0x0000526cu,0x00040005u,0x0000009du,0x61726170u,0x0000006du,0x00040005u,0x0000009eu,0x61726170u,0x0000006du,0x00030005u,0x000000a1u,0x0000556cu,0x00040005u,0x000000a4u,0x61726170u,0x0000006du,0x00040005u,0x000000a5u,0x61726170u,0x0000006du,0x00030005u,0x000000a8u,0x0000446cu,0x00040005u,0x000000abu,0x61726170u,0x0000006du,0x00040005u,0x000000acu,0x61726170u,0x0000006du,0x00040005u,0x000000afu,0x67616d67u,0x00000000u,0x00030005u,0x000000bbu,0x00000067u,0x00030005u,0x000000c4u,0x00004c64u,0x00030005u,0x000000c8u,0x00005264u,0x00030005u,0x000000ccu,0x00005564u,0x00030005u,0x000000d0u,0x00004464u,0x00030005u,0x000000d4u,0x00003265u,0x00030005u,0x000000d8u,0x00004c77u,0x00030005u,0x000000e1u,0x00005277u,0x00030005u,0x000000eau,0x00005577u,0x00030005u,0x000000f3u,0x00004477u,0x00040005u,0x000000fcu,0x576d7573u,0x00000000u,0x00040005u,0x00000104u,0x576d7573u,0x00000055u,0x00040005u,0x00000114u,0x66694475u,0x00000066u,0x00030005u,0x00000120u,0x00003066u,0x00050005u,0x00000121u,0x6f6c4675u,0x73624f77u,0x00000000u,0x00040005u,0x0000012au,0x74656874u,0x00000061u,0x00030005u,0x0000012eu,0x00000072u,0x00040005u,0x00000132u,0x77654e75u,0x00000000u,0x00050005u,0x0000013eu,0x6f6c4675u,0x74754f77u,0x00000000u,0x00040047u,0x00000018u,0x00000021u,0x00000020u,0x00040047u,0x00000018u,0x00000022u,0x00000000u,0x00040047u,0x00000026u,0x00000021u,0x00000022u,0x00040047u,0x00000026u,0x00000022u,0x00000000u,0x00040047u,0x00000037u,0x0000000bu,0x0000001cu,0x00030047u,0x00000056u,0x00000002u,0x00050048u,0x00000056u,0x00000000u,0x00000023u,0x00000000u,0x00040047u,0x00000058u,0x00000021u,0x00000000u,0x00040047u,0x00000058u,0x00000022u,0x00000000u,0x00040047u,0x00000121u,0x00000021u,0x00000021u,0x00040047u,0x00000121u,0x00000022u,0x00000000u,0x00030047u,0x0000013eu,0x00000019u,0x00040047u,0x0000013eu,0x00000021u,0x00000030u,0x00040047u,0x0000013eu,0x00000022u,0x00000000u,0x00040047u,0x00000143u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00030016u,0x00000009u,0x00000020u,0x00040017u,0x0000000au,0x00000009u,0x00000004u,0x00050021u,0x0000000bu,0x0000000au,0x00000008u,0x00000008u,0x00050021u,0x00000010u,0x00000009u,0x00000008u,0x00000008u,0x00090019u,0x00000015u,0x00000009u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000016u,0x00000015u,0x00040020u,0x00000017u,0x00000000u,0x00000016u,0x0004003bu,0x00000017u,0x00000018u,0x00000000u,0x0004002bu,0x00000006u,0x0000001bu,0x00000000u,0x0005002cu,0x00000007u,0x0000001cu,0x0000001bu,0x0000001bu,0x0004002bu,0x00000006u,0x0000001eu,0x00000001u,0x0004003bu,0x00000017u,0x00000026u,0x00000000u,Line truncated
static const size_t OF3_FLOWREG_LEN = 1936;
static const uint32_t WFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0xLine truncated
static const size_t WFG_SYNTH_LEN = 3082;
static const uint32_t DDFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0Line truncated
static const size_t DDFG_SYNTH_LEN = 3082;
struct Blob { const uint32_t* code; size_t words; };
enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, WFG_SYNTH, SHADER_COUNT };
enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, DDFG_SYNTH, SHADER_COUNT };
static inline Blob blob(Shader s) {
switch (s) {
case OF3_LUMA: return { OF3_LUMA_SPV, OF3_LUMA_LEN };
@@ -35,7 +35,7 @@ static inline Blob blob(Shader s) {
case OF3_GM_REDUCE: return { OF3_GM_REDUCE_SPV, OF3_GM_REDUCE_LEN };
case OF3_GM_PREWARP: return { OF3_GM_PREWARP_SPV, OF3_GM_PREWARP_LEN };
case OF3_FLOWREG: return { OF3_FLOWREG_SPV, OF3_FLOWREG_LEN };
case WFG_SYNTH: return { WFG_SYNTH_SPV, WFG_SYNTH_LEN };
case DDFG_SYNTH: return { DDFG_SYNTH_SPV, DDFG_SYNTH_LEN };
default: return { nullptr, 0 };
}
}
@@ -4,9 +4,9 @@
#include <cmath>
#include <cstdio>
namespace winfg {
namespace ddfg {
#define VKOK(x) do { if ((x) != VK_SUCCESS) { WFG_LOGE("vk fail: %s", #x); return false; } } while (0)
#define VKOK(x) do { if ((x) != VK_SUCCESS) { DDFG_LOGE("vk fail: %s", #x); return false; } } while (0)
uint32_t FrameGen::findMemType(uint32_t bits, VkMemoryPropertyFlags props) const {
for (uint32_t i = 0; i < memProps_.memoryTypeCount; ++i)
@@ -153,9 +153,9 @@ bool FrameGen::init(const DeviceDispatch* dd, const InstanceDispatch* id,
if (!makePipe(pGmReduce_, embedded::OF3_GM_REDUCE, unused)) return false;
if (!makePipe(pGmPrewarp_, embedded::OF3_GM_PREWARP, unused)) return false;
if (!makePipe(pFlowReg_, embedded::OF3_FLOWREG, unused)) return false;
if (!makePipe(pSynth_, embedded::WFG_SYNTH, unused)) return false;
if (!makeGmBuffers()) { WFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
WFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
if (!makePipe(pSynth_, embedded::DDFG_SYNTH, unused)) return false;
if (!makeGmBuffers()) { DDFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
DDFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
queueFamily_, cfg_.model, cfg_.gmMode, cfg_.frMode, cfg_.frIters);
return true;
}
@@ -194,7 +194,7 @@ bool FrameGen::onResize(VkExtent2D extent, VkFormat , bool force) {
if (!makeImage(flowRegA_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
if (!makeImage(flowRegB_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
ready_ = true;
WFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
DDFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
extent.width, extent.height, kLevels, flowFinest_, cfg_.perfPreset);
return true;
}
@@ -3,7 +3,7 @@
#include "config.hpp"
#include <vector>
namespace winfg {
namespace ddfg {
class FrameGen {
public:
@@ -12,7 +12,8 @@ public:
void configure(const Config& c);
bool onResize(VkExtent2D extent, VkFormat colorFormat, bool force = false);
void record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
VkImageView outView, float alpha, uint32_t gmSlot = 0);
VkImageView outView, float alpha, uint32_t gmSlot = 0,
uint32_t synthSlot = 0, bool reuseFlow = false);
void destroy();
bool valid() const { return device_ != VK_NULL_HANDLE && ready_; }
const Config& config() const { return cfg_; }
@@ -25,6 +26,7 @@ public:
static constexpr int kGmSlots = 3;
static constexpr int kSynthSlots = 3;
static constexpr int kGmLevel = 3;
static constexpr int kGmThreads = 512;
static constexpr int kGmStride = 32;
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#if defined(__ANDROID__)
#include <android/log.h>
#define DDFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "dd-fg", __VA_ARGS__)
#define DDFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "dd-fg", __VA_ARGS__)
#else
#include <cstdio>
#define DDFG_LOGI(...) do { std::fprintf(stderr, "[dd-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#define DDFG_LOGE(...) do { std::fprintf(stderr, "[dd-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#endif
#define DDFG_LOGD(cond, ...) do { if (cond) DDFG_LOGI(__VA_ARGS__); } while(0)
@@ -1,31 +1,34 @@
namespace {
struct ScratchState {
VkBuffer synthUbo = VK_NULL_HANDLE; VkDeviceMemory synthUboMem = VK_NULL_HANDLE; void* synthUboPtr = nullptr;
VkBuffer synthUbo[ddfg::FrameGen::kSynthSlots]{};
VkDeviceMemory synthUboMem[ddfg::FrameGen::kSynthSlots]{};
void* synthUboPtr[ddfg::FrameGen::kSynthSlots]{};
std::vector<VkBuffer> flowUbo; std::vector<VkDeviceMemory> flowUboMem; std::vector<void*> flowUboPtr;
VkBuffer expandUbo = VK_NULL_HANDLE; VkDeviceMemory expandUboMem = VK_NULL_HANDLE; void* expandUboPtr = nullptr;
VkBuffer flowRegUbo = VK_NULL_HANDLE; VkDeviceMemory flowRegUboMem = VK_NULL_HANDLE; void* flowRegUboPtr = nullptr;
VkBuffer gmUbo[winfg::FrameGen::kGmSlots]{};
VkDeviceMemory gmUboMem[winfg::FrameGen::kGmSlots]{};
void* gmUboPtr[winfg::FrameGen::kGmSlots]{};
VkBuffer gmUbo[ddfg::FrameGen::kGmSlots]{};
VkDeviceMemory gmUboMem[ddfg::FrameGen::kGmSlots]{};
void* gmUboPtr[ddfg::FrameGen::kGmSlots]{};
VkDescriptorSet sLumaCurr = VK_NULL_HANDLE, sLumaPrev = VK_NULL_HANDLE;
std::vector<VkDescriptorSet> sDownA, sDownB, sFlow;
VkDescriptorSet sExpand[winfg::FrameGen::kGmSlots]{};
VkDescriptorSet sExpand[ddfg::FrameGen::kGmSlots]{};
VkDescriptorSet sFlowReg0 = VK_NULL_HANDLE, sFlowRegAB = VK_NULL_HANDLE, sFlowRegBA = VK_NULL_HANDLE;
VkDescriptorSet sSynth = VK_NULL_HANDLE;
VkDescriptorSet sGmReduce[winfg::FrameGen::kGmSlots]{};
std::vector<VkDescriptorSet> sGmPrewarp[winfg::FrameGen::kGmSlots];
VkDescriptorSet sSynth[ddfg::FrameGen::kSynthSlots]{};
VkDescriptorSet sGmReduce[ddfg::FrameGen::kGmSlots]{};
std::vector<VkDescriptorSet> sGmPrewarp[ddfg::FrameGen::kGmSlots];
VkExtent2D builtFor{0,0};
bool built = false;
bool flowInited = false;
bool flowReady = false;
};
ScratchState g_scratch;
}
static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
static bool makeUbo(const ddfg::DeviceDispatch* dd, VkDevice dev,
const VkPhysicalDeviceMemoryProperties& mp,
VkDeviceSize size, VkBuffer& buf, VkDeviceMemory& mem, void*& ptr) {
VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
@@ -41,21 +44,21 @@ static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
return dd->MapMemory(dev, mem, 0, size, 0, &ptr) == VK_SUCCESS;
}
static VkDescriptorSet allocSet(const winfg::DeviceDispatch* dd, VkDevice dev,
static VkDescriptorSet allocSet(const ddfg::DeviceDispatch* dd, VkDevice dev,
VkDescriptorPool pool, VkDescriptorSetLayout layout) {
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = pool; ai.descriptorSetCount = 1; ai.pSetLayouts = &layout;
VkDescriptorSet s = VK_NULL_HANDLE; dd->AllocateDescriptorSets(dev, &ai, &s); return s;
}
static void wImg(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
static void wImg(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkDescriptorType type, VkImageView view, VkSampler samp, VkImageLayout layout) {
VkDescriptorImageInfo ii{}; ii.imageView = view; ii.sampler = samp; ii.imageLayout = layout;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
w.dstSet = set; w.dstBinding = binding; w.descriptorCount = 1; w.descriptorType = type; w.pImageInfo = &ii;
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
}
static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
static void wUbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkBuffer buf, VkDeviceSize range) {
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
@@ -63,7 +66,7 @@ static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet
w.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; w.pBufferInfo = &bi;
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
}
static void wSsbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
static void wSsbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkBuffer buf, VkDeviceSize range) {
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
@@ -117,7 +120,7 @@ static void mulAff(const double A[3][3], const double B[3][3], double out[3][3])
out[2][0]=0.0; out[2][1]=0.0; out[2][2]=1.0;
}
namespace winfg {
namespace ddfg {
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
const VkPhysicalDeviceMemoryProperties& mp, VkDescriptorPool pool,
@@ -140,7 +143,7 @@ static void destroyScratch(const DeviceDispatch* dd, VkDevice dev, VkDescriptorP
b = VK_NULL_HANDLE; m = VK_NULL_HANDLE; p = nullptr;
};
auto& S = g_scratch;
freeUbo(S.synthUbo, S.synthUboMem, S.synthUboPtr);
for (int s = 0; s < FrameGen::kSynthSlots; ++s) freeUbo(S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
freeUbo(S.expandUbo, S.expandUboMem, S.expandUboPtr);
freeUbo(S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
for (size_t i = 0; i < S.flowUbo.size(); ++i) freeUbo(S.flowUbo[i], S.flowUboMem[i], S.flowUboPtr[i]);
@@ -154,7 +157,7 @@ void FrameGen::configure(const Config& c) {
int want = flowFinestForPreset(cfg_.perfPreset);
const int maxFinest = kLevels - 2;
if (want > maxFinest) {
WFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
DDFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
"(need >=2 solved) - clamping to %d", cfg_.perfPreset, want, kLevels, maxFinest);
want = maxFinest;
}
@@ -163,7 +166,7 @@ void FrameGen::configure(const Config& c) {
const int prevFinest = flowFinest_;
flowFinest_ = want;
const bool live = (flowFinest_ != prevFinest) && ready_;
WFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
DDFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
cfg_.perfPreset, flowFinest_, prevFinest, live ? 1 : 0, ready_ ? 1 : 0);
if (!live) return;
@@ -174,12 +177,12 @@ void FrameGen::configure(const Config& c) {
dd_->DeviceWaitIdle(device_);
destroyScratch(dd_, device_, descPool_);
if (!onResize(extent_, VK_FORMAT_UNDEFINED, true))
WFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
DDFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
extent_.width, extent_.height);
for (int i = 0; i < 6; ++i) gmRun_[i] = 0.0;
for (int s = 0; s < kGmSlots; ++s) { gmSlotValid_[s] = false; for (int i = 0; i < 6; ++i) gmLinAt_[s][i] = 0.0; }
gmHaveEst_ = false; gmEngaged_ = false; gmStableCount_ = 0; gmLastTransPx_ = 0.0f;
WFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
DDFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
}
bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
@@ -249,9 +252,10 @@ bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
}
void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
VkImageView outView, float alpha, uint32_t gmSlot) {
VkImageView outView, float alpha, uint32_t gmSlot, uint32_t synthSlot, bool reuseFlow) {
if (!valid()) return;
if (gmSlot >= (uint32_t)kGmSlots) gmSlot = 0;
if (synthSlot >= (uint32_t)kSynthSlots) synthSlot = 0;
const bool m4 = (cfg_.model == 4);
const Pipe& flowP = m4 ? pFlowM4_ : pFlow_;
const Pipe& expandP = m4 ? pExpandM4_ : pExpand_;
@@ -263,35 +267,42 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_);
}
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
if (g_scratch.gmUboPtr[gmSlot]) {
GMUBO g{};
g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
if (gmEngaged) {
g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
// Every generation of one real frame is recorded into the same command buffer, so the flow
// between prev and curr is computed once and each generation gets its own synth UBO and set.
if (reuseFlow && !g_scratch.flowReady) return;
if (!reuseFlow) {
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
if (g_scratch.gmUboPtr[gmSlot]) {
GMUBO g{};
g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
if (gmEngaged) {
g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
}
std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
}
std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
}
{
static int lastEng = -1;
if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
WFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
lastEng = gmEngaged ? 1 : 0;
{
static int lastEng = -1;
if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
DDFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
lastEng = gmEngaged ? 1 : 0;
}
}
wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
}
SynthUBO su{cfg_.flowScale, alpha, cfg_.beta, cfg_.lambda, cfg_.epsilon, cfg_.photoScale, 0, 0,
cfg_.hudX0, cfg_.hudY0, cfg_.hudX1, cfg_.hudY1};
if (g_scratch.synthUboPtr) std::memcpy(g_scratch.synthUboPtr, &su, sizeof(su));
if (g_scratch.synthUboPtr[synthSlot]) std::memcpy(g_scratch.synthUboPtr[synthSlot], &su, sizeof(su));
wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sSynth, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sSynth, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sSynth, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
const VkDescriptorSet synthSet = g_scratch.sSynth[synthSlot];
wImg(dd_, device_, synthSet, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, synthSet, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, synthSet, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
auto disp = [&](const Pipe& p, VkDescriptorSet set, VkExtent2D e) {
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, p.pipeline);
@@ -300,106 +311,109 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
};
auto bar = [&](FrameGen::Img& i){ barrier(cmd, i.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL); };
for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
{
VkClearColorValue zero{};
VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
if (!reuseFlow) {
for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
{
VkClearColorValue zero{};
VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
for (int l = flowFinest_; l < kLevels; ++l) {
VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
b.image = flowLvl_[l].image; b.subresourceRange = rng;
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
}
}
disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
bar(pyrA_[0]); bar(pyrB_[0]);
for (int l = 1; l < kLevels; ++l) {
disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
bar(pyrA_[l]); bar(pyrB_[l]);
}
if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
}
for (int l = flowFinest_; l < kLevels; ++l) {
VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
b.image = flowLvl_[l].image; b.subresourceRange = rng;
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
bar(pyrAs_[l]);
}
for (int l = kLevels - 1; l >= flowFinest_; --l) {
disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
bar(flowLvl_[l]);
}
}
disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
bar(pyrA_[0]); bar(pyrB_[0]);
for (int l = 1; l < kLevels; ++l) {
disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
bar(pyrA_[l]); bar(pyrB_[l]);
}
if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
}
for (int l = flowFinest_; l < kLevels; ++l) {
disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
bar(pyrAs_[l]);
}
for (int l = kLevels - 1; l >= flowFinest_; --l) {
disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
bar(flowLvl_[l]);
}
const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
if (frEngaged) {
const int F = flowFinest_;
const VkExtent2D fe = levelExtent(extent_, F);
if (g_scratch.flowRegUboPtr) {
FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
}
FrameGen::Img* last = nullptr;
for (int it = 0; it < cfg_.frIters; ++it) {
VkDescriptorSet set; FrameGen::Img* outImg;
if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
bar(*outImg);
last = outImg;
}
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
VkImageCopy region{};
region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.extent = {fe.width, fe.height, 1};
dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, &region);
VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, 1, &mb2, 0, nullptr, 0, nullptr);
}
{
static int lastFr = -1;
int fe = frEngaged ? 1 : 0;
if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
DDFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
lastFr = fe;
}
}
const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
if (frEngaged) {
const int F = flowFinest_;
const VkExtent2D fe = levelExtent(extent_, F);
if (g_scratch.flowRegUboPtr) {
FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
}
FrameGen::Img* last = nullptr;
for (int it = 0; it < cfg_.frIters; ++it) {
VkDescriptorSet set; FrameGen::Img* outImg;
if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
bar(*outImg);
last = outImg;
}
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
VkImageCopy region{};
region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.extent = {fe.width, fe.height, 1};
dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, &region);
VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, 1, &mb2, 0, nullptr, 0, nullptr);
disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
bar(flowExpA_); bar(flowExpB_);
g_scratch.flowReady = true;
}
{
static int lastFr = -1;
int fe = frEngaged ? 1 : 0;
if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
WFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
lastFr = fe;
}
}
disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
bar(flowExpA_); bar(flowExpB_);
disp(pSynth_, g_scratch.sSynth, levelExtent(extent_, 0));
disp(pSynth_, synthSet, levelExtent(extent_, 0));
}
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
@@ -418,7 +432,8 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
S = ScratchState{};
const auto GEN = VK_IMAGE_LAYOUT_GENERAL;
makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo, S.synthUboMem, S.synthUboPtr);
for (int s = 0; s < FrameGen::kSynthSlots; ++s)
makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
makeUbo(dd, dev, mp, sizeof(FlowUBO), S.expandUbo, S.expandUboMem, S.expandUboPtr);
if (S.expandUboPtr) { FlowUBO u{cfg.flowScale, 0, 0.6f, 1.6f}; std::memcpy(S.expandUboPtr, &u, sizeof(u)); }
makeUbo(dd, dev, mp, sizeof(FlowRegUBO), S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
@@ -509,10 +524,12 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
wImg(dd, dev, S.sFlowRegBA, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, flowRegA.view, VK_NULL_HANDLE, GEN);
}
S.sSynth = allocSet(dd, dev, pool, pSynth.setLayout);
wUbo(dd, dev, S.sSynth, 0, S.synthUbo, sizeof(SynthUBO));
wImg(dd, dev, S.sSynth, 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
wImg(dd, dev, S.sSynth, 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
for (int s = 0; s < FrameGen::kSynthSlots; ++s) {
S.sSynth[s] = allocSet(dd, dev, pool, pSynth.setLayout);
wUbo(dd, dev, S.sSynth[s], 0, S.synthUbo[s], sizeof(SynthUBO));
wImg(dd, dev, S.sSynth[s], 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
wImg(dd, dev, S.sSynth[s], 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
}
S.builtFor = ext; S.built = true;
return true;
@@ -3,7 +3,7 @@
#include <vulkan/vk_layer.h>
#include <cstring>
namespace winfg {
namespace ddfg {
static inline void* dispatch_key(void* handle) { return *reinterpret_cast<void**>(handle); }
-12
View File
@@ -1,12 +0,0 @@
#pragma once
#if defined(__ANDROID__)
#include <android/log.h>
#define WFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "win-fg", __VA_ARGS__)
#define WFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "win-fg", __VA_ARGS__)
#else
#include <cstdio>
#define WFG_LOGI(...) do { std::fprintf(stderr, "[win-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#define WFG_LOGE(...) do { std::fprintf(stderr, "[win-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#endif
#define WFG_LOGD(cond, ...) do { if (cond) WFG_LOGI(__VA_ARGS__); } while(0)
@@ -1,4 +0,0 @@
#pragma once
#include "vk_dispatch.hpp"
struct VkTable;
bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id);
+10
View File
@@ -1,5 +1,8 @@
#include <errno.h>
#include <jni.h>
#include <stdatomic.h>
#include <sys/syscall.h>
#include <unistd.h>
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env, jclass clazz) {
@@ -7,3 +10,10 @@ Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env,
(void)clazz;
atomic_thread_fence(memory_order_release);
}
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_session_ThreadSignals_tgkill(JNIEnv *env, jclass clazz, jint tgid, jint tid, jint signal) {
(void)env;
(void)clazz;
return syscall(SYS_tgkill, tgid, tid, signal) == 0 ? 0 : errno;
}
+8 -8
View File
@@ -43,10 +43,10 @@ target_include_directories(droiddeckwayland PRIVATE
)
set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
set(DROIDDECK_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg)
set(DROIDDECK_FRAMEGEN_DDFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/ddfg)
if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
AND EXISTS ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp)
message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, Win-FG Native)")
AND EXISTS ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp)
message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, DD-FG Native)")
target_sources(droiddeckwayland PRIVATE
src/framegen_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp
@@ -66,9 +66,9 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp
${DROIDDECK_FRAMEGEN_WINFG_DIR}/framegen.cpp
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp
${DROIDDECK_FRAMEGEN_DDFG_DIR}/framegen.cpp
${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_vkd.cpp
${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp
)
target_include_directories(droiddeckwayland PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/..
@@ -77,8 +77,8 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
target_compile_options(droiddeckwayland PRIVATE
$<$<COMPILE_LANGUAGE:CXX>:-std=c++17 -fvisibility=hidden>)
target_compile_definitions(droiddeckwayland PRIVATE
WINFG_UPSTREAM="${WINFG_UPSTREAM}"
WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}"
DDFG_UPSTREAM="${DDFG_UPSTREAM}"
DDFG_CHAIN_HASH="${DDFG_CHAIN_HASH}"
)
target_link_libraries(droiddeckwayland dxbc atomic)
else()
+34 -1
View File
@@ -218,6 +218,8 @@ struct surface {
enum surface_role role;
struct wl_resource *idle_buffer;
struct wl_listener idle_buffer_destroy;
uint32_t *idle_px;
int idle_w, idle_h;
struct wl_resource *xdg_surface, *xdg_toplevel;
struct wl_resource *viewport;
@@ -899,8 +901,30 @@ static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer)
g_cursor_shown = s;
}
static void surface_forget_idle_px(struct surface *s) {
free(s->idle_px);
s->idle_px = NULL;
s->idle_w = s->idle_h = 0;
}
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
struct wl_shm_buffer *shm = wl_shm_buffer_get(s->idle_buffer);
if (shm) {
int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm);
int32_t stride = wl_shm_buffer_get_stride(shm);
uint32_t *px = w > 0 && h > 0 && (int64_t)w * h <= CURSOR_MAX_PX && (int64_t)stride >= (int64_t)w * 4
? realloc(s->idle_px, (size_t)w * h * 4) : NULL;
if (px) {
wl_shm_buffer_begin_access(shm);
const uint8_t *src = wl_shm_buffer_get_data(shm);
for (int y = 0; y < h; y++) memcpy(&px[(size_t)y * w], src + (size_t)y * stride, (size_t)w * 4);
wl_shm_buffer_end_access(shm);
s->idle_px = px;
s->idle_w = w;
s->idle_h = h;
}
}
wl_list_remove(&s->idle_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
s->idle_buffer = NULL;
@@ -927,6 +951,7 @@ static struct wl_resource *surface_take_idle(struct surface *s) {
}
static void surface_drop_idle(struct surface *s) {
surface_forget_idle_px(s);
struct wl_resource *buffer = surface_take_idle(s);
if (buffer) wl_buffer_send_release(buffer);
}
@@ -1055,6 +1080,7 @@ static void surface_resource_destroy(struct wl_resource *r) {
if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); }
if (g_cursor_shown == s) g_cursor_shown = NULL;
surface_take_idle(s);
surface_forget_idle_px(s);
if (g_grab == s) g_grab = NULL;
if (g_key_target == s) g_key_target = NULL;
if (g_ime_click == s) g_ime_click = NULL;
@@ -2042,6 +2068,9 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
wl_buffer_send_release(buffer);
} else if (g_cursor_shown == s) {
cursor_publish_shown();
} else if (s->idle_px) {
cursor_publish_pixels((const uint8_t *)s->idle_px, s->idle_w, s->idle_h, (size_t)s->idle_w * 4, hx, hy);
g_cursor_shown = s;
}
}
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
@@ -2593,7 +2622,11 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
uint32_t t = now_ms();
pointer_focus(target->resource, fx, fy);
if (!(k && k->is_lock))
// A button-only input must not replay a cached absolute position. A game
// may have warped its cursor since the last motion (before taking lock,
// or while releasing it), so that replay can become an unintended pan.
// Absolute touch events still carry their position, including on clicks.
if (!(k && k->is_lock) && (!relative || dx != 0 || dy != 0))
for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy);
send_relative_motion(client, dx, dy);
for_each_pointer_of(client, sp) {
@@ -86,7 +86,7 @@ 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;
if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_DDFG) kind = VKP_FG_ENGINE_LSFG;
atomic_store(&g_kind, kind);
atomic_store(&g_failed_kind, -1);
atomic_store(&g_cfg_dirty, 1);
@@ -125,7 +125,7 @@ void vkp_framegen_set_tuning(float flow_scale, float refresh_hz) {
atomic_store(&g_cfg_dirty, 1);
}
void vkp_framegen_set_winfg_tuning(int model, int perf_preset) {
void vkp_framegen_set_ddfg_tuning(int model, int perf_preset) {
atomic_store(&g_model, model == 3 ? 3 : 4);
atomic_store(&g_preset, perf_preset < 0 ? 0 : perf_preset > 2 ? 2 : perf_preset);
atomic_store(&g_cfg_dirty, 1);
@@ -180,9 +180,9 @@ void vkp_framegen_device_ready(VkDevice dev, VkQueue queue, uint32_t qfam, int f
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
fge_device_ready(g_gipa, g_inst, g_pd, dev, queue, qfam, features_enabled);
g_dev_ready = vk.CreateImage && vk.CreateImageView && vk.CmdPipelineBarrier;
FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), Win-FG Native %s",
FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), DD-FG Native %s",
fge_caps_ok(VKP_FG_ENGINE_LSFG) ? "available" : "unavailable", fge_caps_reason(),
fge_caps_ok(VKP_FG_ENGINE_WINFG) ? "available" : "unavailable");
fge_caps_ok(VKP_FG_ENGINE_DDFG) ? "available" : "unavailable");
}
void vkp_framegen_device_lost(void) { g_dev_lost = 1; }
@@ -6,14 +6,14 @@
#endif
#include <vulkan/vulkan.h>
enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 };
enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_DDFG = 1 };
#define VKP_FG_MAX_GENERATIONS 3
void vkp_framegen_set_engine(int kind);
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps);
void vkp_framegen_set_lsfg_cache_path(const char *path);
void vkp_framegen_set_tuning(float flow_scale, float refresh_hz);
void vkp_framegen_set_winfg_tuning(int model, int perf_preset);
void vkp_framegen_set_ddfg_tuning(int model, int perf_preset);
int vkp_framegen_problem(void);
const char *vkp_framegen_caps_reason(void);
void vkp_framegen_stats(float out[6]);
@@ -12,7 +12,7 @@
#include "lsfg/lsfg_engine.h"
#include "lsfg/lsfg_probe.h"
#include "lsfg/lsfg_vkd.h"
#include "winfg/winfg_engine.h"
#include "ddfg/ddfg_engine.h"
#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", "framegen: " __VA_ARGS__)
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__)
@@ -30,7 +30,7 @@ uint32_t g_qfam = 0;
bool g_probed = false, g_ready = false;
std::unique_ptr<lsfg::Engine> g_lsfg;
std::unique_ptr<winfg::Engine> g_winfg;
std::unique_ptr<ddfg::Engine> g_ddfg;
int g_kind = -1;
char g_build[96] = "";
@@ -143,15 +143,15 @@ int fge_start(int kind, const char *cache_path) {
fge_stop();
if (!g_ready) return -1;
if (kind == 1) {
auto e = std::make_unique<winfg::Engine>();
auto e = std::make_unique<ddfg::Engine>();
if (!e->init(g_table, g_pd, g_dev, g_qfam, g_queue)) {
ELOGE("winfg-native: engine init failed");
ELOGE("ddfg-native: engine init failed");
return -1;
}
g_winfg = std::move(e);
g_ddfg = std::move(e);
g_kind = 1;
#if defined(WINFG_UPSTREAM) && defined(WINFG_CHAIN_HASH)
std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", WINFG_UPSTREAM, WINFG_CHAIN_HASH);
#if defined(DDFG_UPSTREAM) && defined(DDFG_CHAIN_HASH)
std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", DDFG_UPSTREAM, DDFG_CHAIN_HASH);
#else
std::snprintf(g_build, sizeof(g_build), "embedded chain");
#endif
@@ -176,46 +176,46 @@ int fge_start(int kind, const char *cache_path) {
}
void fge_stop(void) {
if ((g_lsfg || g_winfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
if ((g_lsfg || g_ddfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
g_lsfg.reset();
g_winfg.reset();
g_ddfg.reset();
g_kind = -1;
}
int fge_unavailable(void) {
if (g_kind == 1) return g_winfg && g_winfg->unavailable() ? 1 : 0;
if (g_kind == 1) return g_ddfg && g_ddfg->unavailable() ? 1 : 0;
return g_lsfg && g_lsfg->unavailable() ? 1 : 0;
}
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);
if (g_kind == 1 && g_ddfg) g_ddfg->configure(multiplier, model, perf_preset, flow_scale);
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) {
if (g_kind == 1) return g_winfg && g_winfg->prepare(w, h, fmt) ? 1 : 0;
if (g_kind == 1) return g_ddfg && g_ddfg->prepare(w, h, fmt) ? 1 : 0;
if (!g_lsfg) return 0;
g_lsfg->setGuestExtent(w, h);
return g_lsfg->prepare(w, h, fmt) ? 1 : 0;
}
uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) {
if (g_kind == 1) return g_winfg ? g_winfg->plan(capacity) : 0;
if (g_kind == 1) return g_ddfg ? g_ddfg->plan(capacity) : 0;
if (!g_lsfg) return 0;
g_lsfg->setPresentedRate(presented_rate);
return g_lsfg->plan(capacity, source_frames);
}
void fge_process(VkCommandBuffer cmd, VkImage source, uint32_t w, uint32_t h, uint32_t gens) {
if (g_kind == 1) { if (g_winfg) g_winfg->process(cmd, source, w, h, gens); return; }
if (g_kind == 1) { if (g_ddfg) g_ddfg->process(cmd, source, w, h, gens); return; }
if (g_lsfg) g_lsfg->process(cmd, source, w, h, gens);
}
void fge_generate(VkCommandBuffer cmd, uint32_t g, uint32_t count, VkImage img, VkImageView view,
uint32_t w, uint32_t h) {
if (g_kind == 1) { if (g_winfg) g_winfg->generateInto(cmd, g, count, img, view, w, h); return; }
if (g_kind == 1) { if (g_ddfg) g_ddfg->generateInto(cmd, g, count, img, view, w, h); return; }
if (g_lsfg) g_lsfg->generateInto(cmd, g, g, img, view, w, h);
}
@@ -232,7 +232,7 @@ void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
}
}
const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
const char *fge_build_info(void) { return g_build; }
}
@@ -34,5 +34,5 @@ void fge_forget_targets(void) {}
void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
*accepted = 0.0f; *source_rate = 0.0f; *thermal = -1.0f;
}
const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
const char *fge_build_info(void) { return "stub"; }
@@ -31,10 +31,10 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenTuning(JN
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetWinFgTuning(JNIEnv *env, jclass clazz,
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetDdFgTuning(JNIEnv *env, jclass clazz,
jint model, jint perfPreset) {
(void)env; (void)clazz;
vkp_framegen_set_winfg_tuning((int)model, (int)perfPreset);
vkp_framegen_set_ddfg_tuning((int)model, (int)perfPreset);
}
JNIEXPORT jint JNICALL
@@ -20,12 +20,17 @@ class App : Application() {
val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName()
else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
.runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName
if (process == "$packageName:xboxlogin") {
if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xbox")
return
}
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
CrashHandler.install(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
com.droiddeck.launcher.core.DownloadSource.init(this)
prefetchAddonPacks()
GpuClockPin.clearLeftover(this)
com.droiddeck.launcher.glasses.Glasses.init(this)
com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this)
@@ -35,5 +40,24 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
}
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
private fun prefetchAddonPacks() {
val app = this
Thread({
runCatching {
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app).isDirectory) {
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(app)
}
if (com.droiddeck.launcher.stores.gog.GogComet.enabled(app)) com.droiddeck.launcher.core.AddonPack.COMET.ensure(app)
if (com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, com.droiddeck.launcher.stores.Store.XBOX) != null)
com.droiddeck.launcher.core.AddonPack.XBOX.ensure(app)
com.droiddeck.launcher.handtracking.HandTracking.load(app)
}.onFailure { android.util.Log.w("App", "add-on pack prefetch failed", it) }
}, "addon-packs").start()
}
}
@@ -24,10 +24,10 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
deckyChecking = true
Thread({
val channels = runCatching { DeckyManager.releaseChannels(activity) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList(), emptyList()) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
ui.post {
if (request == deckyReleaseRequest) {
deckyReleases = channels.newest
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
deckyChecking = false
}
}
@@ -0,0 +1,42 @@
package com.droiddeck.launcher
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
object LauncherIcon {
const val DARK = "dark"
const val LIGHT = "light"
private val aliases = linkedMapOf(DARK to "com.droiddeck.launcher.LauncherDark", LIGHT to "com.droiddeck.launcher.LauncherLight")
val options: List<Pair<String, Int>> = listOf(
DARK to R.string.setup_launcher_icon_dark,
LIGHT to R.string.setup_launcher_icon_light,
)
private fun component(context: Context, style: String) =
ComponentName(context, aliases.getValue(style))
private fun enabled(context: Context, style: String): Boolean =
when (context.packageManager.getComponentEnabledSetting(component(context, style))) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> style == DARK
else -> false
}
fun current(context: Context): String = aliases.keys.firstOrNull { enabled(context, it) } ?: DARK
fun activity(context: Context): ComponentName = component(context, current(context))
fun set(context: Context, style: String) {
if (style !in aliases || style == current(context)) return
val pm = context.packageManager
pm.setComponentEnabledSetting(component(context, style),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
aliases.keys.filter { it != style }.forEach {
pm.setComponentEnabledSetting(component(context, it),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
}
}
}
@@ -109,6 +109,7 @@ class MainActivity : ComponentActivity() {
private var frameGenLabel by mutableStateOf("")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showRuntimeActions by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var pendingSwitch by mutableStateOf<Intent?>(null)
private var switchingTo: Intent? = null
private var switchPoll: Runnable? = null
@@ -123,6 +124,7 @@ class MainActivity : ComponentActivity() {
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
private var componentsGameKey by mutableStateOf<String?>(null)
private var showMapping by mutableStateOf(false)
private var saveBusy: String? = null
private var onSavePicked: ((File) -> Unit)? = null
@@ -164,6 +166,7 @@ class MainActivity : ComponentActivity() {
private var downloadMirror by mutableStateOf("")
private var downloadPrefix by mutableStateOf("")
private var animationsEnabled by mutableStateOf(true)
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
private var gameStoresEnabled by mutableStateOf(true)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
@@ -484,6 +487,20 @@ class MainActivity : ComponentActivity() {
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
}
private fun switchLauncherIcon(style: String): Boolean {
val before = LauncherIcon.current(this)
LauncherIcon.set(this, style)
launcherIcon = LauncherIcon.current(this)
if (launcherIcon == before) return false
// Android finishes activities started through the alias that was just disabled.
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(LauncherIcon.activity(this))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "setup:launcher"))
finishAndRemoveTask()
return true
}
private fun readGameIntent(request: Intent) {
request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) }
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
@@ -739,6 +756,7 @@ class MainActivity : ComponentActivity() {
downloadPrefix = DownloadSource.custom(this)
DownloadSource.probe()
animationsEnabled = SessionPrefs.animationsEnabled(this)
launcherIcon = LauncherIcon.current(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesOpenTab = SessionPrefs.storesOpenTab(this)
@@ -799,6 +817,7 @@ class MainActivity : ComponentActivity() {
downloadMirror = downloadMirror,
downloadPrefix = downloadPrefix,
animationsEnabled = animationsEnabled,
launcherIcon = launcherIcon,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
@@ -842,6 +861,7 @@ class MainActivity : ComponentActivity() {
},
onSteamGameArtChanged = { appId -> refreshSteamGameArt(appId) },
onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) },
onGameRemoved = { g -> removeGame(g) },
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
onSgdbKey = { key ->
Thread({
@@ -903,6 +923,7 @@ class MainActivity : ComponentActivity() {
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
onGameComponents = { game -> openComponents(gameKey = game.profileKey) },
onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) },
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
@@ -952,6 +973,7 @@ class MainActivity : ComponentActivity() {
onPageBack = { showPerformance = false; showComponents = false; showMapping = false },
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLauncherIcon = { style -> switchLauncherIcon(style) },
onLanguage = { tag ->
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag)
@@ -1106,6 +1128,13 @@ class MainActivity : ComponentActivity() {
onDismiss = { pendingSwitch = null },
)
}
showRuntimeActions?.let { release ->
com.droiddeck.launcher.ui.RuntimeActionsDialog(
onRepair = { showRuntimeActions = null; if (!runtimeChangesBlocked()) install(release) },
onRemove = { showRuntimeActions = null; showRemove = true },
onDismiss = { showRuntimeActions = null },
)
}
if (showRemove) ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
@@ -1262,6 +1291,7 @@ class MainActivity : ComponentActivity() {
syncLossless()
updates.onResume()
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates()
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
@@ -1406,8 +1436,9 @@ class MainActivity : ComponentActivity() {
)
}
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
focusComponentsContent = focusContent
componentsGameKey = gameKey
if (tab != null) components.compComp = tab
showPerformance = false
showMapping = false
@@ -1456,6 +1487,7 @@ class MainActivity : ComponentActivity() {
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
games = steamGames,
initialGameKey = componentsGameKey,
generalFexPreset = fexPreset,
onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
gpu = drivers.state(),
@@ -1515,6 +1547,27 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_PROGRAM, path))
}
private fun resumeInterruptedPackages() {
if (runtimeChangesBlocked()) return
val first = DesktopCatalog.interrupted(this).firstOrNull()
if (first == null) {
Thread({ DesktopCatalog.pruneDownloads(this) }, "prune-pkg").start()
return
}
pkgId = first.id; pkgStage = getString(R.string.deskpkg_installing, first.name); pkgPercent = -1
Thread({
val problem = DesktopCatalog.resumeInterrupted(this) { stage, percent ->
ui.post { pkgStage = stage; pkgPercent = percent }
}
ui.post {
pkgStage = null; pkgId = null
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, first.name, problem), android.widget.Toast.LENGTH_LONG).show()
refreshPackages()
refresh()
}
}, "resume-pkg").start()
}
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
@@ -1668,12 +1721,12 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
},
onBootMovie = { id -> SessionPrefs.setBootMovie(this, id); loadBootMovie() },
onBootMovie = { id -> com.droiddeck.launcher.session.BootMovie.select(this, id); loadBootMovie() },
onBootMovieSound = { on -> SessionPrefs.setBootMovieSound(this, on); bootMovieSound = on },
onImportBootMovie = {
pickBootMovie.launch(InAppFilePicker.buildIntent(this, com.droiddeck.launcher.session.BootMovie.PICKER_EXTENSIONS, getString(R.string.boot_movie_pick)))
},
onRemoveBootMovie = { com.droiddeck.launcher.session.BootMovie.removeCustom(this); loadBootMovie() },
onBootMovieChanged = { loadBootMovie() },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
@@ -1758,6 +1811,30 @@ class MainActivity : ComponentActivity() {
}, "steam-art-update").start()
}
private fun removeGame(g: Library.SteamGame) {
val folder = g.gameFiles ?: return
when (com.droiddeck.launcher.frontend.GameRemoval.kind(g)) {
com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER -> Unit
com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT -> removeAddedGame(folder.path, g.gameId, g.name)
com.droiddeck.launcher.frontend.GameRemoval.Kind.STORE -> {
val installed = com.droiddeck.launcher.frontend.GameRemoval.storeGame(g)
if (installed != null) {
com.droiddeck.launcher.stores.StoresState.uninstall(this, installed) { refreshAddedGames(); refresh() }
return
}
if (g.library == Library.ADDED) {
removeAddedGame(folder.path, g.gameId, g.name)
return
}
val target = com.droiddeck.launcher.frontend.GameRemoval.steamFiles(g) ?: return
Thread({
com.droiddeck.launcher.frontend.GameRemoval.delete(target)
ui.post { refreshAddedGames(); refresh() }
}, "game-remove").start()
}
}
}
private fun removeAddedGame(folder: String, appId: Long?, name: String) {
steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder }
Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start()
@@ -1882,7 +1959,7 @@ class MainActivity : ComponentActivity() {
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
if (listX64 != steamGamesX64) {
steamGamesX64 = listX64
steamGames = steamGames.filterNot(Library::fromSteamClient)
steamGames = steamGames.filterNot { Library.fromSteamClient(it) || (it.library == Library.ADDED && it.gameId in 1..0xFFFFFFFFL) }
}
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
if (showComponents) drivers.refreshDrivers()
@@ -1916,12 +1993,13 @@ class MainActivity : ComponentActivity() {
ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus }
if (ready) {
val quick = runCatching { Library.steamGames(this) }.getOrNull()
val claimed = quick?.let { runCatching { Library.claimedFolders(it) }.getOrDefault(emptySet()) }.orEmpty()
if (quick != null) ui.post {
if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) {
val before = steamGames.map { it.gameId }.toSet()
val fresh = quick.filter { it.gameId !in before }
if (fresh.isNotEmpty()) {
steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId }
steamGames = (quick + steamGames.filter { it.library == Library.ADDED && it.gameFiles?.absolutePath.let { path -> path == null || path !in claimed } }).distinctBy { it.gameId }
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
}
}
@@ -1960,10 +2038,14 @@ class MainActivity : ComponentActivity() {
private fun onRuntimeButton() {
if (runtimeChangesBlocked()) return
val release = available
if (removalPending || installed != null && release?.version == installed) {
if (removalPending) {
showRemove = true
return
}
if (installed != null && release?.version == installed) {
showRuntimeActions = release
return
}
if (release == null) {
Thread({ checkCatalog() }, "catalog").start()
return
@@ -2044,7 +2126,7 @@ class MainActivity : ComponentActivity() {
com.droiddeck.launcher.core.AppLanguage.setChosen(this, com.droiddeck.launcher.core.AppLanguage.SYSTEM)
if (!ok) android.widget.Toast.makeText(this, R.string.setup_reset_settings_partial, android.widget.Toast.LENGTH_LONG).show()
com.droiddeck.launcher.ui.Motion.refresh(this)
recreate()
if (!switchLauncherIcon(LauncherIcon.DARK)) recreate()
}
}, "reset-settings").start()
}
@@ -75,11 +75,13 @@ import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.PadMouse
import com.droiddeck.launcher.input.PadState
import com.droiddeck.launcher.input.SteamControllerBle
import com.droiddeck.launcher.input.DirectTouchHold
import com.droiddeck.launcher.input.PointerGestures
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.input.TouchpadGestures
import com.droiddeck.launcher.input.TouchscreenGestures
import com.droiddeck.launcher.input.ScrollTouchGestures
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.LoadingState
@@ -188,7 +190,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private var watching = true
private lateinit var touchpad: TouchpadGestures
private lateinit var directTouchHold: DirectTouchHold
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
private lateinit var touchscreenGestures: TouchscreenGestures
private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig()
private var gestureGame: String? = null
private var gestureSettingsOpen by mutableStateOf(false)
private var recognizedGesture by mutableStateOf<TouchscreenGestures.Gesture?>(null)
private var gesturePointerX = 0f
private var gesturePointerY = 0f
private var gesturePointerReady = false
private val clearRecognizedGesture = Runnable { recognizedGesture = null }
private val gestureKeysDown = mutableSetOf<Int>()
private lateinit var scrollTouch: ScrollTouchGestures
private lateinit var padMouse: PadMouse
@Volatile private var padMouseOn = false
@@ -209,6 +222,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
uiHandler.post {
if (pointerLocked == locked) return@post
gesturePointerReady = false
pointerLocked = locked
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
padMouseCheckedAt = 0L
@@ -483,7 +497,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
keyboard = KeyboardHost(this).also { root.addView(it) }
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener) {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
}
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
touchscreenGestures = TouchscreenGestures(
ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput,
)
refreshGestureConfig()
// A real touch target lets FrameLayout split fingers between the
// game surface and on-screen buttons, in either finger-down order.
root.isMotionEventSplittingEnabled = true
surfaceView.setOnTouchListener { _, event ->
routedTouchMode == SessionPrefs.TOUCH_GESTURES && onTouchEvent(event)
}
padMouse = PadMouse(padMouseListener)
bridge.setTouchRoute(object : PadBridge.TouchRoute {
override fun active(): Boolean = if (Looper.myLooper() == Looper.getMainLooper()) padMouseMode() else padMouseOn
@@ -500,12 +527,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
if (!SessionState.running) {
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val repair = intent.getBooleanExtra(EXTRA_REPAIR_RUNTIME, false)
intent.removeExtra(EXTRA_REPAIR_RUNTIME)
val needRuntime = repair || com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val needResume = !needRuntime && com.droiddeck.launcher.runtime.DesktopCatalog.interrupted(this).isNotEmpty()
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
(repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this))
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton)
if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume)
}
hud = PerfHud(this)
hud.onPresentingWindowChanged = {
@@ -593,6 +623,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onDispose { com.droiddeck.launcher.glasses.GlassesMenuMirror.setSessionMenuVisible(this@SessionActivity, false) }
}
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() }
androidx.compose.runtime.LaunchedEffect(AgentGuest.steamKeyboard) { updateOnScreenControls() }
androidx.compose.runtime.LaunchedEffect(picturePosition, SessionState.running, pipUi, cardboard.enabled) { applyGameAlignment() }
DroidDeckTheme {
if (pipUi) {
@@ -635,6 +666,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onCancel = { SessionService.stop(this@SessionActivity); finish() },
endedDetail = loading.endedDetail,
onRetry = { retrySession() },
onRepair = if (repairOffered) ({ repairRuntime() }) else null,
onShareLogs = { shareCurrentSessionLogs() },
onClose = { finish() },
flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) },
@@ -643,6 +675,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
},
)
LaunchedEffect(AgentGuest.focusRevision) {
val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" }
else {
val snapshot = AgentGuest.focus(this@SessionActivity)
val focused = snapshot?.optLong("focusedApp")?.takeIf { it > 0 && it != 769L }
val windows = snapshot?.optJSONArray("focusableApps")
focused?.toString() ?: windows?.let { values ->
(0 until values.length()).map { values.optLong(it) }
.filter { it > 0 && it != 769L }.distinct().singleOrNull()?.toString()
}
}
}
if (nextGame != gestureGame) { gestureGame = nextGame; refreshGestureConfig() }
}
LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) {
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) {
cancelTouchGestures()
}
}
com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture)
if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog(
game = gestureGame,
onDismiss = { gestureSettingsOpen = false },
onSaved = { gestureSettingsOpen = false; refreshGestureConfig() },
)
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
syncLossless()
@@ -678,7 +736,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
achievementsInFront = found
if (found == null) achievementsOpen = false
}
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads() }
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads(); updateOnScreenControls() }
LaunchedEffect(pcKeyboardOpen, drawerOpen, achievementsOpen) {
if (pcKeyboardOpen || drawerOpen || achievementsOpen) padMouse.reset()
}
@@ -777,6 +835,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionPrefs.setSteamController(this@SessionActivity, id)
com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id))
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id))
com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id))
padType = id
},
backActionsInverted = backActionsInverted,
@@ -818,6 +877,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
@@ -1296,6 +1356,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val nextTouchMode = SessionPrefs.touchMode(this)
if (touchMode != nextTouchMode) {
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
if (CompositorHost.isStarted) {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
@@ -1320,17 +1381,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
@Volatile private var installingRuntime = false
private var repairOffered by mutableStateOf(false)
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) {
if (SessionState.stopRequested) return
installingRuntime = true
SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton"
SessionEvents.transition(
SessionPhase.INSTALLING_RUNTIME,
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
loading.say(getString(first.downloading), first.topic, readable = true)
loading.percent = -1
Thread({
@@ -1340,6 +1402,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
problem = installRuntime()
if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime"
}
if (problem == null && resume && !SessionState.stopRequested) {
SessionState.installing = "package"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "package.resuming", mapOf("component" to "package"))
problem = com.droiddeck.launcher.runtime.DesktopCatalog.resumeInterrupted(this, progressAsIs())
?.let { getString(R.string.session_package_resume_failed, it) }
if (problem == null) SessionEvents.record("package.ready") else failedComponent = "package"
}
if (problem == null && desktop && !SessionState.stopRequested) {
SessionState.installing = "desktop"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop"))
@@ -1364,7 +1433,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return@post
}
if (problem != null) {
val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED"
val code = when (failedComponent) {
"runtime" -> "RUNTIME_INSTALL_FAILED"
"package" -> "PACKAGE_RESUME_FAILED"
else -> "DESKTOP_INSTALL_FAILED"
}
SessionEvents.fail(code, problem)
collectStartArtifacts("session start failed")
loading.showEnded(problem)
@@ -1412,9 +1485,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun progressAsIs() = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, percent ->
uiHandler.post {
loading.percent = percent
loading.say(stage, LoadingState.Topic.RUNTIME, readable = true)
}
}
private fun installRuntime(): String? {
val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
?: return getString(R.string.session_runtime_catalog_unreachable)
runCatching { com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release) }
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
progressFor(RUNTIME_LINES, release.size / 1_000_000))
return if (ok) null else getString(R.string.session_runtime_install_failed)
@@ -1522,6 +1603,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
intent.getStringExtra(SessionService.EXTRA_STEAM_UI),
intent.getStringExtra(SessionService.EXTRA_STEAM_URL),
intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS),
shownOnDisplayId,
)
}
applyFrameGen()
@@ -1537,6 +1619,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
surfaceW = width
surfaceH = height
if (resized) {
cancelTouchGestures()
Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor")
CompositorHost.resize(holder.surface) { applyFrameGen() }
followWindow(width, height)
@@ -1555,7 +1638,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun followChoice(): Boolean {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
}
@@ -1571,6 +1654,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
if (started) SessionService.setActivityDisplay(this, shownOnDisplayId)
cancelTouchGestures()
applyFollowOrientation(newConfig)
applyUnfoldedControls()
}
@@ -1579,12 +1664,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Hiding the phone surface after a handoff delivers a late destruction
// callback. It must not reset the new output's size or attachment.
if (holder !== outputHolder()) return
cancelTouchGestures()
surfaceW = 0
surfaceH = 0
glassesOutputActive = false
CompositorHost.detach(holder.surface)
}
private fun cancelTouchGestures() {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
scrollTouchGesture = false
gesturePointerReady = false
}
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
windowManager.maximumWindowMetrics.bounds
} else {
@@ -1594,8 +1688,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
}
private fun displayMode(): String = SessionPrefs.displayMode(this,
intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM, intent.getStringExtra(SessionService.EXTRA_PROGRAM))
private fun outputSize(): Pair<Int, Int> {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
@@ -1609,8 +1706,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
glassesScreen?.hz?.takeIf { it > 1f }?.let { return it }
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val wanted = window.attributes.preferredDisplayModeId
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
?: display?.refreshRate ?: 60f
val requested = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
val running = display?.refreshRate?.takeIf { it > 1f }
val hz = listOfNotNull(requested, running).minOrNull() ?: 60f
val mirrorCap = mirrorRefreshLimit().takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY
val capped = minOf(hz, PerfMode.peakRefreshCap(this), mirrorCap)
return if (capped > 1f) capped else 60f
@@ -1714,7 +1812,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
Thread({
val hint = sessionEndHint(SessionState.logFile)
val hint = runtimeDamageHint() ?: sessionEndHint(SessionState.logFile)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
showEnded(status, hint)
@@ -1733,6 +1831,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop)
else -> getString(R.string.session_ended_named, "Steam")
}
repairOffered = hint?.repair == true
loading.showEnded(
hint?.text ?: stopped,
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
@@ -1751,6 +1850,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
uiHandler.post { sessionOverlay.requestFocus() }
}
private fun repairRuntime() {
val again = Intent(intent).putExtra(EXTRA_REPAIR_RUNTIME, true)
closeAtOnce = true
finish()
startActivity(again)
}
private fun retrySession() {
val again = Intent(intent)
closeAtOnce = true
@@ -1758,7 +1864,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
startActivity(again)
}
private class EndHint(val text: String, val evenOnSuccess: Boolean = false)
private class EndHint(val text: String, val evenOnSuccess: Boolean = false, val repair: Boolean = false)
private fun runtimeDamageHint(): EndHint? {
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(this) }.getOrDefault(emptyList())
if (broken.isEmpty()) return null
Log.w(TAG, "runtime has ${broken.size} broken library link(s): ${broken.take(8).joinToString()}")
return EndHint(resources.getQuantityString(R.plurals.session_end_runtime_damaged, broken.size, broken.size, broken.first()), repair = true)
}
private fun sessionEndHint(log: File?): EndHint? {
if (log == null || !log.isFile) return null
@@ -1982,6 +2095,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.dispatchKeyEvent(event)
}
private fun refreshGestureConfig() {
gestureConfig = com.droiddeck.launcher.input.GesturePrefs.read(this, gestureGame)
if (::touchscreenGestures.isInitialized) touchscreenGestures.configure(gestureConfig)
gesturePointerReady = false
recognizedGesture = null
}
private val gestureOutput = object : TouchscreenGestures.Output {
override fun moveTo(x: Float, y: Float) {
val rect = drawnRect() ?: return
if (pointerLocked) {
if (gesturePointerReady) moveBy(x - gesturePointerX, y - gesturePointerY, true)
} else movePointer(x, y)
gesturePointerX = if (pointerLocked) x else x.coerceIn(rect.left, rect.right)
gesturePointerY = if (pointerLocked) y else y.coerceIn(rect.top, rect.bottom)
gesturePointerReady = true
cursorVisible = false
}
override fun moveBy(dx: Float, dy: Float, relative: Boolean) {
val rect = drawnRect() ?: return
if (relative || pointerLocked) {
val scale = SessionState.outputSize.first / rect.width()
WaylandCompositor.sendPointerDelta(dx * scale, dy * scale)
} else moveTo(gesturePointerX + dx, gesturePointerY + dy)
}
override fun button(code: Int, down: Boolean) {
WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0)
}
override fun prepareRelativePan() {
if (pointerLocked) return
val rect = drawnRect() ?: return
// Gamescope retains the last absolute position while a game can warp
// its own cursor to the center. Its first unlocked motion must start
// from that same origin, before the game switches to pointer lock.
moveTo(rect.centerX(), rect.centerY())
}
override fun key(code: Int, down: Boolean) {
if (down) gestureKeysDown.add(code) else gestureKeysDown.remove(code)
if (code !in guestKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
override fun wheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
override fun showKeyboard() { uiHandler.post { touchscreenGestures.cancel(); togglePcKeyboard() } }
override fun gesture(gesture: TouchscreenGestures.Gesture) {
recognizedGesture = gesture
uiHandler.removeCallbacks(clearRecognizedGesture)
uiHandler.postDelayed(clearRecognizedGesture, 1200)
}
}
private val pointerListener = object : PointerGestures.Listener {
override fun onMove(x: Float, y: Float) {
if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y)
@@ -2116,9 +2278,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
private val shownOnDisplayId: Int get() =
if (Build.VERSION.SDK_INT >= 30) display?.displayId ?: windowManager.defaultDisplay.displayId
else windowManager.defaultDisplay.displayId
private fun refreshSecondScreenDisplays() {
if (!::displayManager.isInitialized) return
val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId
val primaryId = shownOnDisplayId
val candidates = SecondScreenDisplays.available(displayManager, primaryId)
.filter { glassesScreen == null || it.id != glasses.displayId }
val oldIds = secondScreenDisplays.map { it.id }.toSet()
@@ -2272,6 +2438,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this)
return when {
options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF
SessionPrefs.touchMode(this) == SessionPrefs.TOUCH_GESTURES -> SessionPrefs.TOUCH_GESTURES
options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD
else -> SessionPrefs.TOUCH_DIRECT
}
@@ -2284,7 +2451,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
touchpad.sensitivity = 1.15f * speed
}
if (next == routedTouchMode) return
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
gesturePointerReady = false
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
@@ -2319,6 +2489,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) return true
if (routedTouchMode == SessionPrefs.TOUCH_GESTURES) {
if (event.actionMasked == MotionEvent.ACTION_DOWN) gesturePointerReady = false
return touchscreenGestures.onTouch(event)
}
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
scrollTouchGesture = scrollTouchMode()
scrollRestX = 0f
@@ -2352,6 +2527,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) ignoreGameTouch = false
return true
}
if (directTouchHold.onTouch(event)) return true
fun sendTouch(action: Int, index: Int) {
val x = event.getX(index).coerceIn(rect.left, rect.right)
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
@@ -2495,12 +2671,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
if (pipUi || unfoldedPanel != null) {
if (pipUi || unfoldedPanel != null || pcKeyboardOpen) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
applyTouchRouting()
return
}
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
@@ -2513,6 +2690,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (steam == null) {
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
applyTouchRouting()
}
controls.setButtonsOnly(buttonsOnly)
val show = when (forced) {
@@ -2525,29 +2703,33 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
val drawn = show && !keyboardCoversPad()
if (steam != null) {
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
if (!show) steam.releaseAll()
steam.visibility = if (show) View.VISIBLE else View.GONE
if (!drawn) steam.releaseAll()
steam.visibility = if (drawn) View.VISIBLE else View.GONE
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
applyTouchRouting()
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden")
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (drawn) "shown" else "hidden")
return
}
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
controls.visibility = if (show) View.VISIBLE else View.GONE
if (drawn == (controls.visibility == View.VISIBLE)) return
if (!drawn) controls.releaseAll()
controls.visibility = if (drawn) View.VISIBLE else View.GONE
Log.i(TAG, "on-screen controls " + when {
!show -> "hidden"
!drawn -> "hidden"
buttonsOnly -> "Steam + QAM"
else -> "full pad"
})
}
private fun keyboardCoversPad() =
SessionState.mode == SessionService.MODE_STEAM && AgentGuest.steamKeyboard
private fun sendGuestKey(code: Int, down: Boolean) {
if (down && pipUi) return
if (down) guestKeysDown.add(code) else guestKeysDown.remove(code)
WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
private fun pipBounds(): android.graphics.Rect {
@@ -2575,6 +2757,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
releasePads()
touchpad.cancel()
scrollTouch.cancel()
directTouchHold.cancel()
guestKeysDown.toList().forEach { sendGuestKey(it, false) }
keyboard?.hide()
sessionClipboard.stop()
@@ -2628,7 +2811,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
refreshSecondScreenDisplays()
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
startedScreens++
SessionService.setActivityVisible(this, true)
SessionService.setActivityVisible(this, true, shownOnDisplayId)
Glasses.rescan()
updateGlassesScreen()
updateHandCamera()
@@ -2701,6 +2884,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onPause() {
if (runtimeRemovalBlocked) { super.onPause(); return }
sessionClipboard.stop()
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
uiHandler.removeCallbacks(clearRecognizedGesture)
gestureSettingsOpen = false
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
@@ -2950,7 +3138,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun glassesOutputSize(): Pair<Int, Int>? {
if (glassesScreen == null || glasses.width <= 0 || glasses.height <= 0) return null
val panel = (if (glasses.sbs) glasses.width / 2 else glasses.width) to glasses.height
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
val display = com.droiddeck.launcher.session.SessionDisplay
val choice = SessionPrefs.resolutionChoice(this, mode, panel)
val size = display.resolveChoice(panel, choice)
@@ -3076,6 +3264,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onWindowFocusChanged(hasFocus)
if (!hasFocus && ::padMouse.isInitialized) padMouse.reset()
if (runtimeRemovalBlocked) return
if (!hasFocus) {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
}
if (hasFocus && !pipUi) {
goFullscreen()
refreshClipboard()
@@ -3186,6 +3378,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
companion object {
const val EXTRA_RETURN_HOME = "returnHome"
const val EXTRA_STEAMVR = "steamVr"
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TAG = "SessionActivity"
private var startedScreens = 0
@@ -5,6 +5,7 @@ import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import android.os.FileObserver
import android.util.Log
@@ -19,6 +20,8 @@ object AgentGuest {
private val main by lazy { Handler(Looper.getMainLooper()) }
var focusRevision by mutableIntStateOf(0)
private set
var steamKeyboard by mutableStateOf(false)
private set
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
private val lock = Any()
@@ -32,6 +35,7 @@ object AgentGuest {
observer?.stopWatching()
observer = null
lastFocusKey = null
main.post { steamKeyboard = false }
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
@@ -43,6 +47,7 @@ object AgentGuest {
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
}
}
}.also { it.startWatching() }
@@ -53,9 +58,19 @@ object AgentGuest {
synchronized(lock) {
observer?.stopWatching()
observer = null
main.post { steamKeyboard = false }
}
}
private fun recordKeyboard(source: FileObserver, snapshot: JSONObject) {
val showing = snapshot.optBoolean("steamKeyboard")
synchronized(lock) {
if (source !== observer) return
main.post { steamKeyboard = showing }
}
SessionEvents.record("keyboard.changed", mapOf("steamKeyboard" to showing, "seq" to snapshot.optInt("seq")))
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
@@ -0,0 +1,137 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileInputStream
/**
* A pack built by CI alongside this APK (tools/packs). Main and release builds publish it to
* DroidDeck-Components under a content-addressed tag; a PR test build attaches packs that are not there
* yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build
* was made with install, whichever host serves them.
*/
internal class AddonPack(val name: String, private val sha256: String, val size: Long, private val required: List<String>) {
sealed interface State {
data object Missing : State
data class Downloading(val progress: Float) : State
data object Failed : State
data object Ready : State
}
private val mutableState = MutableStateFlow<State>(State.Missing)
val state: StateFlow<State> = mutableState.asStateFlow()
private val lock = Any()
val available: Boolean get() = sha256.isNotEmpty()
private val tag get() = "pack-$name-${sha256.take(16)}"
private val urls get() = listOfNotNull(
"https://github.com/$REPO/releases/download/$tag/$name.tar.zst",
BuildConfig.PR_NUMBER.takeIf { it > 0 }?.let { "https://github.com/$PR_REPO/releases/download/pr-$it/$tag.tar.zst" },
)
private fun root(context: Context) = File(context.filesDir, "addons/$name")
/** App-private and written only after the archive matched the pinned digest. */
fun dir(context: Context) = File(root(context), sha256)
fun file(context: Context, relative: String) = File(dir(context), relative)
fun installed(context: Context): Boolean = available && File(dir(context), COMPLETE).isFile
fun refresh(context: Context): State {
val now = mutableState.value
if (now is State.Downloading) return now
return (if (installed(context)) State.Ready else if (now == State.Failed) State.Failed else State.Missing)
.also { mutableState.value = it }
}
/** Starts the download in the background unless the pack is already present or on its way. */
fun ensure(context: Context, onReady: (Context) -> Unit = {}) {
val app = context.applicationContext
synchronized(lock) {
if (installed(app)) { mutableState.value = State.Ready; return }
if (mutableState.value is State.Downloading) return
mutableState.value = State.Downloading(0f)
}
scope.launch {
val ok = runCatching { install(app) }.onFailure { Log.w(TAG, "$name: install failed", it) }.getOrDefault(false)
mutableState.value = if (ok) State.Ready else State.Failed
if (ok) onReady(app)
}
}
/** Blocks a worker thread until the pack is installed, starting the download if needed. */
@JvmOverloads
fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean {
if (installed(context)) return true
ensure(context)
while (running()) {
val now = state.value
onState(now)
when (now) {
State.Ready -> return installed(context)
State.Failed -> return false
else -> try { Thread.sleep(250) } catch (e: InterruptedException) { return false }
}
}
return false
}
private fun install(context: Context): Boolean {
if (!available) {
Log.w(TAG, "$name: this build has no pack")
return false
}
val root = root(context).apply { mkdirs() }
val archive = File(root, "$sha256.tar.zst")
val downloaded = urls.any { url ->
Downloader.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) }
.also { if (!it) archive.delete() }
}
if (!downloaded) return false
val staging = File(root, "$sha256.partial")
staging.deleteRecursively()
try {
if (!FileInputStream(archive).use { TarZst.extract(it, staging) }) return false
check(required.all { File(staging, it).isFile }) { "$name pack is incomplete" }
File(staging, COMPLETE).writeText(sha256)
val target = dir(context)
target.deleteRecursively()
check(staging.renameTo(target)) { "could not move the $name pack into place" }
} finally {
staging.deleteRecursively()
archive.delete()
}
root.listFiles().orEmpty().filter { it.name != sha256 }.forEach { it.deleteRecursively() }
return true
}
companion object {
private const val TAG = "AddonPack"
private const val REPO = "Droid-Deck/DroidDeck-Components"
private const val PR_REPO = "Droid-Deck/DroidDeck-CI"
const val COMPLETE = ".complete"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@JvmField
val PROOT = AddonPack("proot", BuildConfig.PACK_PROOT_SHA256, BuildConfig.PACK_PROOT_SIZE,
listOf("libproot.so", "libproot-loader.so"))
@JvmField
val DIRECTAUDIO = AddonPack("directaudio", BuildConfig.PACK_DIRECTAUDIO_SHA256, BuildConfig.PACK_DIRECTAUDIO_SIZE,
listOf("linux-wine11/version.txt", "linux-wine11-systhread/version.txt"))
val TRACKING = AddonPack("tracking", BuildConfig.PACK_TRACKING_SHA256, BuildConfig.PACK_TRACKING_SIZE, listOf(
"hand_landmarker.task", "hand_landmarker_lite.task", "pose_landmarker_lite.task",
"libopencv_java4.so", "libmediapipe_tasks_jni.so"))
val XBOX = AddonPack("xbox", BuildConfig.PACK_XBOX_SHA256, BuildConfig.PACK_XBOX_SIZE,
listOf("version", "xodus-cli", "xodus-service"))
val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE,
listOf("version", "comet"))
}
}
@@ -200,6 +200,7 @@ object DeviceReport {
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 "")
if (gpu.model == 740) k("TP UBWC hint", com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() ?: "driver default")
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)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context),
@@ -224,6 +225,9 @@ object DeviceReport {
k("Installed version", LinuxRuntimeInstaller.installedVersion(context))
k("Runtime ready", LinuxRuntime.isInstalled(context))
k("Desktop installed", DesktopCatalog.desktopInstalled(context))
k("Interrupted installs", DesktopCatalog.interrupted(context).joinToString { "${it.id} ${it.version}" }.ifEmpty { "none" })
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(context) }.getOrDefault(emptyList())
k("Broken library links", if (broken.isEmpty()) "none" else "${broken.size}: ${broken.take(8).joinToString()}")
k("Runtime root", LinuxRuntime.rootDir(context).path)
h("Settings in effect")
@@ -29,6 +29,7 @@ object GameEnvironment {
fun defaults(preset: String, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"DROIDDECK_ANTICHEAT_OFFLINE" to "1",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
"DOTNET_GCRegionRange" to "800000000",
@@ -32,6 +32,7 @@ object GameEnvironmentOptions {
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
@@ -146,16 +146,21 @@ object LogRedactor {
return out
}
fun isText(file: java.io.File): Boolean = try {
file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input ->
/** The at-rest rewrite reads a file into memory twice; larger logs are only scrubbed on the way out. */
const val IN_MEMORY_SCRUB_MAX = 64L * 1024 * 1024
fun isBinary(file: java.io.File): Boolean = try {
file.inputStream().use { input ->
val buf = ByteArray(4096)
val n = input.read(buf)
n <= 0 || (0 until n).none { buf[it].toInt() == 0 }
n > 0 && (0 until n).any { buf[it].toInt() == 0 }
}
} catch (e: Exception) {
false
true
}
fun isText(file: java.io.File): Boolean = file.isFile && file.length() < IN_MEMORY_SCRUB_MAX && !isBinary(file)
const val RULES_VERSION = 3
private val LAN_IPV4 = Regex(
@@ -176,8 +181,62 @@ object LogRedactor {
}
}
private const val SHARE_LINE_MAX = 64 * 1024
fun scrubForShare(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") }
src.inputStream().buffered().use { input ->
input.mark(2)
val first = input.read()
val second = input.read()
input.reset()
if ((first == 0xff && second == 0xfe) || (first == 0xfe && second == 0xff)) {
out.write("<unsupported encoding; file withheld>\n")
return
}
val decoder = Charsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
java.io.InputStreamReader(input, decoder).use { reader ->
val buffer = CharArray(8192)
val line = StringBuilder()
var withheld = false
var afterCr = false
fun emit() {
out.write(if (withheld) "<unsupported or oversized line withheld>" else redactForShare(line.toString()))
out.write("\n")
line.setLength(0)
withheld = false
}
try {
while (true) {
val size = reader.read(buffer)
if (size < 0) break
for (i in 0 until size) {
val ch = buffer[i]
if (afterCr) {
afterCr = false
if (ch == '\n') continue
}
if (ch == '\r' || ch == '\n') {
emit()
afterCr = ch == '\r'
} else if (!withheld) {
// Splitting a line can separate a credential from its label.
if (ch == '\u0000' || line.length == SHARE_LINE_MAX) {
withheld = true
line.setLength(0)
} else {
line.append(ch)
}
}
}
}
if (withheld || line.isNotEmpty()) emit()
} catch (_: java.nio.charset.CharacterCodingException) {
out.write("<unsupported encoding; file withheld>\n")
}
}
}
}
private fun quoted(value: String): String {
@@ -85,7 +85,7 @@ class AppFilesProvider : DocumentsProvider() {
override fun deleteDocument(documentId: String) {
val file = fileFor(documentId)
if (file.absolutePath == base.absolutePath) throw FileNotFoundException("the app folder itself is not deleted")
if (file.canonicalPath == baseCanonical) throw FileNotFoundException("the app folder itself is not deleted")
try {
deleteTree(file.toPath())
} catch (e: IOException) {
@@ -154,7 +154,7 @@ class AppFilesProvider : DocumentsProvider() {
private fun fileFor(documentId: String): File {
val f = File(documentId)
if (!f.isAbsolute || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
if (!f.isAbsolute || f.normalize().path != documentId || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
if (!f.exists()) throw FileNotFoundException("$documentId not found")
return f
}
@@ -90,6 +90,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@@ -151,8 +152,11 @@ fun FileManagerScreen(
var conflictChoice by remember { mutableStateOf<ConflictChoice?>(null) }
var conflictApplyToAll by remember { mutableStateOf(false) }
var operationJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var cancelledJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var pendingBulkDelete by remember { mutableStateOf<List<File>>(emptyList()) }
var searchQuery by remember { mutableStateOf("") }
val visibleEntries = if (searchQuery.isBlank()) entries
else entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
var showSearch by remember { mutableStateOf(false) }
var sortBy by remember { mutableStateOf(browsePrefs.getString("fmSortBy", "name") ?: "name") }
var sortDesc by remember { mutableStateOf(browsePrefs.getBoolean("fmSortDesc", false)) }
@@ -174,16 +178,20 @@ fun FileManagerScreen(
var showFavorites by remember { mutableStateOf(false) }
var favTick by remember { mutableIntStateOf(0) }
var listingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
fun loadDirectory(dir: File, resetScroll: Boolean = true) {
currentDir = dir
if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply()
scope.launch {
listingJob?.cancel()
listingJob = scope.launch {
val list = withContext(Dispatchers.IO) {
dir.listFiles()?.toList()
?.filter { if (pickDirMode) it.isDirectory else !pickMode || it.isDirectory || matchesPickExt(it) }
?.filter { showHidden || !it.name.startsWith(".") }
?.sortedWith(comparatorFor(sortBy, sortDesc)) ?: emptyList()
}
if (currentDir != dir) return@launch
entries = list
if (resetScroll) listState.scrollToItem(0)
}
@@ -252,7 +260,7 @@ fun FileManagerScreen(
fun performPaste() {
val sources = clipboardFiles
if (sources.isEmpty()) return
if (sources.isEmpty() || isOperationRunning || operationJob?.isActive == true || cancelledJob?.isCompleted == false) return
val dstDir = currentDir
val cut = isCutOperation
@@ -261,6 +269,7 @@ fun FileManagerScreen(
operationDeterminate = true
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
isOperationRunning = true
val job = coroutineContext[kotlinx.coroutines.Job]
var applyToAll: ConflictChoice? = null
var failed = 0
@@ -296,6 +305,7 @@ fun FileManagerScreen(
}
var lastPct = -1
val onProgress = FileOps.ProgressCallback { copied, total ->
job?.ensureActive()
val pct = if (total > 0) ((copied * 100) / total).toInt() else 100
if (pct != lastPct) {
lastPct = pct
@@ -304,8 +314,8 @@ fun FileManagerScreen(
}
val target = dst
val ok = withContext(Dispatchers.IO) {
if (cut) FileOps.moveWithProgress(src, target, onProgress)
else FileOps.copyWithProgress(src, target, onProgress)
if (cut) FileOps.moveWithProgress(src, target, onProgress) { job?.isActive == false }
else FileOps.copyWithProgress(src, target, onProgress) { job?.isActive == false }
}
if (ok) done++ else failed++
}
@@ -791,19 +801,21 @@ fun FileManagerScreen(
fontSize = 13.sp,
modifier = Modifier.weight(1f),
)
val allVisibleSelected = visibleEntries.isNotEmpty() && visibleEntries.all { it.absolutePath in selectedPaths }
val anyVisibleSelected = visibleEntries.any { it.absolutePath in selectedPaths }
val selBarPadding = PaddingValues(horizontal = 10.dp, vertical = 2.dp)
OutlinedButton(
onClick = {
selectedPaths = if (selectedPaths.size == entries.size) emptySet()
else entries.map { it.absolutePath }.toSet()
val shown = visibleEntries.map { it.absolutePath }
selectedPaths = if (allVisibleSelected) selectedPaths - shown.toSet() else selectedPaths + shown
},
contentPadding = selBarPadding,
) { Text(if (selectedPaths.size == entries.size) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
) { Text(if (allVisibleSelected) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
enabled = anyVisibleSelected,
onClick = {
clipboardFiles = entries.filter { it.absolutePath in selectedPaths }
clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths }
isCutOperation = false
selectionMode = false
selectedPaths = emptySet()
@@ -812,9 +824,9 @@ fun FileManagerScreen(
) { Text(stringResource(R.string.fm_copy), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
enabled = anyVisibleSelected,
onClick = {
clipboardFiles = entries.filter { it.absolutePath in selectedPaths }
clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths }
isCutOperation = true
selectionMode = false
selectedPaths = emptySet()
@@ -823,8 +835,8 @@ fun FileManagerScreen(
) { Text(stringResource(R.string.fm_cut), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
onClick = { pendingBulkDelete = entries.filter { it.absolutePath in selectedPaths } },
enabled = anyVisibleSelected,
onClick = { pendingBulkDelete = visibleEntries.filter { it.absolutePath in selectedPaths } },
contentPadding = selBarPadding,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
@@ -842,7 +854,7 @@ fun FileManagerScreen(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.1f))
.clickable { performPaste() }
.clickable(enabled = !isOperationRunning) { performPaste() }
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(Icons.Filled.ContentPaste, stringResource(R.string.fm_paste), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
@@ -878,6 +890,7 @@ fun FileManagerScreen(
)
if (operationJob != null) {
TextButton(onClick = {
cancelledJob = operationJob
operationJob?.cancel()
operationJob = null
isOperationRunning = false
@@ -964,8 +977,7 @@ fun FileManagerScreen(
.fillMaxSize()
.nestedScroll(pullState.nestedScrollConnection),
) {
val shownEntries = if (searchQuery.isBlank()) entries
else entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
val shownEntries = visibleEntries
if (showGrid && entries.isNotEmpty()) {
LazyVerticalGrid(
@@ -45,22 +45,25 @@ object FileOps {
return f.length()
}
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
if (sameFile(src, dst)) return true
if (isCancelled()) return false
if (!dst.exists() && src.renameTo(dst)) {
progress?.onProgress(1, 1)
return true
}
if (!copyWithProgress(src, dst, progress)) return false
if (!copyWithProgress(src, dst, progress, isCancelled)) return false
if (isCancelled()) return false
return delete(src)
}
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
val total = maxOf(totalSize(src), 1L)
return copyWithProgress(src, dst, longArrayOf(0), total, progress)
return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled)
}
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?): Boolean {
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?, isCancelled: () -> Boolean): Boolean {
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
if (isSymlink(src)) return true
if (sameFile(src, dst)) return true
if (src.isDirectory && isWithin(dst, src)) {
@@ -71,7 +74,7 @@ object FileOps {
if (!dst.exists() && !dst.mkdirs()) return false
var allOk = true
src.list()?.forEach { name ->
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress)) {
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
Log.e(TAG, "Failed to copy: ${File(src, name)}")
allOk = false
}
@@ -81,13 +84,41 @@ object FileOps {
val parent = dst.parentFile
if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false
val chunk = 4L * 1024 * 1024
var staging: File? = null
var created = false
var output: java.nio.channels.FileChannel? = null
try {
output = try {
java.nio.channels.FileChannel.open(dst.toPath(), java.nio.file.StandardOpenOption.WRITE, java.nio.file.StandardOpenOption.CREATE_NEW)
.also { created = true }
} catch (e: java.nio.file.FileAlreadyExistsException) {
null
}
val target = if (!created && isSymlink(dst)) dst.canonicalFile else dst
var permissions: Set<java.nio.file.attribute.PosixFilePermission>? = null
if (output == null) {
if (isSymlink(target) || target.parentFile?.isDirectory != true) {
throw IOException("Destination symlink target is not a writable directory entry: $dst")
}
if (target.isDirectory) throw IOException("Destination is a directory: $target")
permissions = try {
Files.getPosixFilePermissions(target.toPath())
} catch (e: java.nio.file.NoSuchFileException) {
null
} catch (e: UnsupportedOperationException) {
null
}
if (target.exists() && !target.canWrite()) throw IOException("Destination is not writable: $target")
val directory = Files.createTempDirectory(checkNotNull(target.absoluteFile.parentFile).toPath(), ".droiddeck-copy-")
staging = directory.toFile()
output = FileOutputStream(File(staging, "file")).channel
}
FileInputStream(src).channel.use { input ->
FileOutputStream(dst).channel.use { output ->
checkNotNull(output).use { channel ->
val size = input.size()
var position = 0L
while (position < size) {
val transferred = input.transferTo(position, minOf(chunk, size - position), output)
val transferred = input.transferTo(position, minOf(chunk, size - position), channel)
if (transferred <= 0) break
position += transferred
done[0] += transferred
@@ -96,11 +127,28 @@ object FileOps {
if (position < size) throw IOException("Incomplete copy: $position/$size bytes")
}
}
staging?.let { directory ->
val copied = File(directory, "file").toPath()
if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) {
Files.setPosixFilePermissions(copied, permissions)
}
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
}
return true
} catch (e: java.util.concurrent.CancellationException) {
if (created) dst.delete()
throw e
} catch (e: IOException) {
Log.e(TAG, "Failed to copy file: $src to $dst", e)
dst.delete()
if (created) dst.delete()
return false
} finally {
output?.close()
staging?.let { directory ->
File(directory, "file").delete()
directory.delete()
}
}
}
@@ -41,10 +41,20 @@ object AddedGames {
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
}
private val SKIP = Regex(
"(?i)^(unins.*|setup.*|.*redist.*|vcredist.*|dxsetup.*|dxwebsetup.*|.*crash.*|.*report.*|dotnet.*|directx.*|.*prereq.*" +
"|.*installer.*|.*uninstall.*|.*updater?.*|.*config(ur.*)?|.*settings.*|.*editor.*|.*server.*|.*benchmark.*|.*helper.*|.*eac.*|.*easyanticheat.*|.*battleye.*)\\.exe$",
private val SKIP_PREFIX = Regex("(?i)^(unins|setup|vc_?redist|dxsetup|dxwebsetup|dotnet|directx|easyanticheat|battleye).*")
private val SKIP_PART = Regex("(?i)redist|prereq|installer|uninstall|crashreport|crashhandler|crashpad|crashsender|launchhelper")
private val SKIP_WORDS = setOf(
"eac", "report", "reporter", "editor", "server", "benchmark", "helper",
"update", "updater", "config", "configure", "configurator", "configuration", "settings",
)
private val WORD = Regex("[A-Z]?[a-z]+|[A-Z]+(?![a-z])|[0-9]+")
internal fun skipExe(name: String): Boolean {
if (!name.endsWith(".exe", ignoreCase = true)) return false
val base = name.dropLast(4)
if (SKIP_PREFIX.matches(base) || SKIP_PART.containsMatchIn(base)) return true
return WORD.findAll(base).any { it.value.lowercase() in SKIP_WORDS }
}
const val GUEST_DIR = "/root/Games"
@@ -75,7 +85,7 @@ object AddedGames {
val key = "${folder.path}|${folder.lastModified()}"
val now = System.currentTimeMillis()
ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second }
val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) }
val guessed = GameExePicker.rank(folder) { !skipExe(it.name) }
val declared = declaredExes(folder)
val preferred = declared.map { it.path }.toSet()
val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking(
@@ -128,7 +138,7 @@ object AddedGames {
val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList()
return names.asSequence()
.map { File(folder, it.trim().replace('\\', '/').trimStart('/')) }
.filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile }
.filter { it.name.endsWith(".exe", ignoreCase = true) && !skipExe(it.name) && it.isFile }
.filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) }
.distinctBy { it.path }
.toList()
@@ -204,8 +214,10 @@ object AddedGames {
GamesFolderMigration.run(context)
val path = canonical(exe)
fun holds(f: File) = inside(path, canonical(f))
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
val entries = AddedExes.list(context)
fun owns(f: File) = holds(f) && sameGame(f, currentExe(context, entries, f), exe)
entries.map { File(it.folder) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
@@ -220,6 +232,24 @@ object AddedGames {
return null
}
private fun currentExe(context: Context, entries: List<AddedExes.Entry>, folder: File): File? =
SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let(::File)
?: entries.firstOrNull { it.folder == folder.path }?.let { File(it.exe) }
?: rank(folder).exes.firstOrNull()
private fun sameGame(folder: File, current: File?, pick: File): Boolean {
val base = canonical(folder)
if (current == null || pick.parentFile?.let(::canonical) == base) return true
val known = canonical(current)
if (!inside(known, base) || known.substringBeforeLast('/') == base) return true
val picked = canonical(pick)
fun top(p: String) = p.removePrefix("$base/").substringBefore('/')
fun stem(p: String) = p.substringAfterLast('/').lowercase().removeSuffix(".exe").replace(EXE_BUILD, "").filter { it.isLetterOrDigit() }
return top(known) == top(picked) || stem(known).let { it.isNotEmpty() && it == stem(picked) }
}
private val EXE_BUILD = Regex("([-_. ](x64|x86|win64|win32|64|32|shipping))+$")
fun addExe(context: Context, exe: File): AddResult {
val app = context.applicationContext
if (!exe.isFile) return AddResult.NotFound
@@ -228,7 +258,10 @@ object AddedGames {
if (owner != null && owner.path !in SessionPrefs.removedAddedGames(app)) {
single(app, owner.path)?.takeIf { canonical(it.exe) == path }?.let { return AddResult.Existing(it) }
}
if (owner == null && guestPath(app, exe) == null) return AddResult.Unreachable
if (guestPath(app, exe) == null) {
(owner ?: exe.parentFile)?.takeIf { guestPath(app, it) == null }?.let { bindFolder(app, it) }
if (guestPath(app, exe) == null) return AddResult.Unreachable
}
val folder = owner ?: exe.parentFile ?: return AddResult.NotFound
SessionPrefs.setAddedGameRemoved(app, folder.path, false)
SessionPrefs.setAddedGameExe(app, folder.path, exe.path)
@@ -243,6 +276,8 @@ object AddedGames {
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
val app = context.applicationContext
// Binds are mounted at session start, so a new one reaches the session from the next start.
if (guestPath(app, dir) == null) bindFolder(app, dir)
val removed = SessionPrefs.removedAddedGames(app).toSet()
val listed = listedFolders(app, removed)
var already = 0
@@ -266,6 +301,20 @@ object AddedGames {
return FolderResult(added, already)
}
@Synchronized
internal fun bindFolder(context: Context, dir: File) {
if (!dir.isDirectory || !dir.canRead()) return
val binds = SessionPrefs.gameFolderBinds(context)
if (binds.any { it.first == dir.path }) return
val taken = binds.map { it.second }.toSet()
val base = GamesFolderMigration.legacyRoots(binds.map { it.first } + dir.path).last().guest
var guest = base
var n = 2
while (guest in taken) guest = "$base-${n++}"
SessionPrefs.setGameFolderBinds(context, binds + (dir.path to guest))
Log.i(TAG, "games: bound $dir at $guest")
}
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
val out = ArrayList<String>()
GamesFolderMigration.run(context)
@@ -185,7 +185,7 @@ object GameIdentifier {
}
private val JUNK_NAME_RE = Regex(
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*)$"""
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*|bootstrappackagedgame)$"""
)
fun isJunkPeName(name: String): Boolean {
@@ -0,0 +1,52 @@
package com.droiddeck.launcher.frontend
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoresState
import java.io.File
object GameRemoval {
enum class Kind { STORE, SHORTCUT, LAUNCHER }
fun kind(game: Library.SteamGame): Kind = when {
game.library == Library.EPIC_LAUNCHER -> Kind.LAUNCHER
game.library == Library.ADDED && game.source == Library.ADDED -> Kind.SHORTCUT
else -> Kind.STORE
}
class SteamFiles(val dir: File, val manifest: File, val partial: File?)
fun steamFiles(game: Library.SteamGame): SteamFiles? {
val dir = game.gameFiles ?: return null
val common = dir.parentFile ?: return null
if (common.name != "common") return null
val steamapps = common.parentFile ?: return null
if (steamapps.name != "steamapps") return null
val manifest = File(steamapps, "appmanifest_${game.appId}.acf")
if (!manifest.isFile) return null
if (!declares(manifest, game.appId)) return null
return SteamFiles(dir, manifest, File(steamapps, "downloading/${game.appId}"))
}
fun delete(target: SteamFiles): Boolean {
StoreInstalls.deleteTree(target.dir)
if (target.dir.exists()) return false
target.partial?.let { StoreInstalls.deleteTree(it) }
target.manifest.delete()
return !target.manifest.exists()
}
fun storeGame(game: Library.SteamGame): InstalledStoreGame? {
val folder = game.gameFiles ?: return null
val store = Store.byId(game.source)
val id = game.storeId
if (store != null && id != null) StoresState.installedGame(store, id)?.let { return it }
val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled } ?: return null
return InstalledStoreGame(sidecar, folder)
}
private fun declares(manifest: File, appId: Int): Boolean =
runCatching { manifest.readText() }.getOrNull()?.let { Regex("\"appid\"\\s+\"$appId\"").containsMatchIn(it) } == true
}
@@ -10,6 +10,7 @@ import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.net.Uri
import android.widget.Toast
import com.droiddeck.launcher.LauncherIcon
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import java.io.File
@@ -23,7 +24,7 @@ object GameShortcuts {
val name = game.name.ifBlank { context.getString(R.string.app_name) }
val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
.setActivity(LauncherIcon.activity(context))
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
.setIntent(launchIntent(context, game.gameIdString)).build()
}
@@ -72,6 +72,7 @@ object Library {
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val PRIVATE_APPS = Regex("\"PrivateApps_\\d+\"\\s*\"\\[([^\\]]*)\\]\"")
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List<Pair<File, String>> {
@@ -137,13 +138,35 @@ object Library {
}
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
val cache = File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache")
val root = SessionPrefs.activeSteamRoot(context)
val cache = File(root, "appcache/librarycache")
val concealed = concealedSteamApps(root)
return cache.listFiles().orEmpty().mapNotNull { dir ->
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES } ?: return@mapNotNull null
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES && it.toLong() !in concealed } ?: return@mapNotNull null
steamCacheImage(cache, appId, STEAM_CAPSULES)?.let { appId to it }
}
}
/** Apps the user hid (the "hidden" collection) or marked private in Steam. */
internal fun concealedSteamApps(root: File): Set<Long> {
val config = AddedGames.accountConfig(root) ?: return emptySet()
val hidden = runCatching {
val entries = org.json.JSONArray(File(config, "cloudstorage/cloud-storage-namespace-1.json").readText())
val entry = (0 until entries.length()).map { entries.getJSONArray(it) }
.firstOrNull { it.optString(0) == "user-collections.hidden" }?.optJSONObject(1)
if (entry == null || entry.optBoolean("is_deleted")) emptySet() else {
val collection = org.json.JSONObject(entry.optString("value"))
fun ids(key: String) = collection.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optLong(it) } }.orEmpty()
ids("added").toSet() - ids("removed").toSet()
}
}.getOrDefault(emptySet())
val private = runCatching {
PRIVATE_APPS.find(File(config, "localconfig.vdf").readText())?.groupValues?.get(1)
?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet()
}.getOrNull().orEmpty()
return hidden + private
}
fun fromSteamClient(g: SteamGame): Boolean = g.library != ADDED && g.source == SOURCE_STEAM
fun launchableGames(
@@ -155,9 +178,23 @@ object Library {
var steam = steamGames(context, strictRead, arch)
val targets = steam.filter(::fromSteamClient).map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) }
if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead, arch)
return (steam + list.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
val claimed = claimedFolders(steam)
val own = list.filter { g ->
g.steamAppId != null || claimed.isEmpty() ||
(runCatching { g.folder.canonicalPath }.getOrDefault(g.folder.absolutePath) !in claimed &&
AddedGames.guestPath(context, g.folder).let { it == null || it !in claimed })
}
return (steam + own.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
}
fun claimedFolders(steam: List<SteamGame>): Set<String> = steam.filter(::fromSteamClient).flatMap { g ->
val dir = g.gameFiles ?: return@flatMap emptyList()
listOfNotNull(
runCatching { dir.canonicalPath }.getOrNull(),
runCatching { java.nio.file.Files.readSymbolicLink(dir.toPath()).toString() }.getOrNull(),
)
}.toSet()
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
@@ -153,7 +153,7 @@ object SteamAppInfo {
return String(bytes, Charsets.UTF_8)
}
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
internal fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
val out = LinkedHashMap<String, Any>()
while (b.hasRemaining()) {
val type = b.get().toInt() and 0xFF
@@ -1,8 +1,10 @@
package com.droiddeck.launcher.glasses
internal enum class HandModel(val asset: String, val sha256: String) {
FULL("models/hand_landmarker.task", "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE("models/hand_landmarker_lite.task", "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
import com.droiddeck.launcher.handtracking.TrackingPack
internal enum class HandModel(val file: String, val sha256: String) {
FULL(TrackingPack.HAND, "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE(TrackingPack.HAND_LITE, "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
companion object {
val DEFAULT = FULL
@@ -1,7 +1,9 @@
package com.droiddeck.launcher.glasses
import com.droiddeck.launcher.core.AddonPack
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.handtracking.TrackingPack
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
@@ -11,7 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.core.Delegate
import com.google.mediapipe.tasks.vision.core.RunningMode
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
import org.opencv.android.OpenCVLoader
import org.opencv.android.Utils
import org.opencv.core.*
import org.opencv.imgproc.Imgproc
@@ -170,11 +171,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
} finally { calibrationProgress = calibrationProgress.copy(solving = false) }
} }
private fun detector(selected: HandModel=model, useGpu: Boolean=gpu, maxHands: Int=2): HandLandmarker {
val bytes=context.assets.open(selected.asset).use { it.readBytes() }
val bytes=TrackingPack.file(context,selected.file).readBytes()
val hash=MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(hash==selected.sha256) { context.getString(R.string.spatial_model_checksum_error) }
return HandLandmarker.createFromOptions(context,HandLandmarker.HandLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetPath(selected.asset)
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(ByteBuffer.allocateDirect(bytes.size).put(bytes).apply { rewind() })
.setDelegate(if(useGpu) Delegate.GPU else Delegate.CPU).build())
.setRunningMode(RunningMode.VIDEO).setNumHands(maxHands).setMinHandDetectionConfidence(0.6f)
.setMinHandPresenceConfidence(0.6f).setMinTrackingConfidence(0.6f).build())
@@ -325,7 +326,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
SpatialTracking.session.setHands(generation,SpatialHand(),SpatialHand())
}
private fun loop() {
try { check(OpenCVLoader.initLocal()) } catch(e: Throwable) { message=context.getString(R.string.spatial_opencv_error, e.message.orEmpty());return }
val loaded=TrackingPack.awaitNative(context,{ running }) { state ->
if(state is AddonPack.State.Downloading)
message=context.getString(R.string.tracking_pack_downloading,(state.progress*100).toInt())
}
if(!loaded) { if(running) message=context.getString(R.string.tracking_pack_failed);return }
var grayL=Mat();var grayR=Mat();val rgba=Mat()
var bmpL: Bitmap?=null;var bmpR: Bitmap?=null
var leftGray=ByteArray(0);var rightGray=ByteArray(0)
@@ -336,7 +341,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
var lastSource=0L;var lastStamp=0L;var captureOn=false;var captureRate=1000;var lastInputSeq=0L
var rate=0f
try {
check(OpenCVLoader.initLocal()) { context.getString(R.string.spatial_opencv_load_error) }
check(TrackingPack.loadNative(context)) { context.getString(R.string.spatial_opencv_load_error) }
joyConFlow=StereoJoyConFlow()
while(running) {
while(true) {
@@ -7,12 +7,13 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
object FrameGen {
const val ENGINE_OFF = "off"
const val ENGINE_WINFG = "winfg"
const val ENGINE_DDFG = "ddfg"
private const val LEGACY_ENGINE_WINFG = "winfg"
const val ENGINE_LSFG = "lsfg"
private const val WINFG_PERF_PRESET = 2
private const val WINFG_MODEL = 4
private const val FLOW_SCALE_WINFG = 0.60f
private const val DDFG_PERF_PRESET = 2
private const val DDFG_MODEL = 4
private const val FLOW_SCALE_DDFG = 0.60f
private const val FLOW_SCALE_LSFG = 0.80f
val ADAPTIVE_TARGETS = listOf(120, 90, 60)
@@ -29,7 +30,7 @@ object FrameGen {
fun mode(context: Context): Mode {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF
val engine = prefs.getString("engine", ENGINE_OFF).let { if (it == LEGACY_ENGINE_WINFG) ENGINE_DDFG else it ?: 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))
@@ -43,7 +44,7 @@ object FrameGen {
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_DDFG -> context.getString(R.string.frame_gen_ddfg, 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)
@@ -68,10 +69,10 @@ object FrameGen {
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz)
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)
ENGINE_DDFG -> {
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_DDFG)
WaylandCompositor.nativeSetDdFgTuning(DDFG_MODEL, DDFG_PERF_PRESET)
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_DDFG, refreshHz)
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0)
}
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
@@ -11,8 +11,10 @@ object TpUbwcHint {
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
// match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI.
internal fun features(model: Int, systemDriverVersion: String?): String? {
// Samsung's One UI firmware sets it whatever blob version it reports.
internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? {
if (model != 740) return null
if (samsung) return "enable_tp_ubwc_flag_hint=1"
val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null
return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0"
}
@@ -20,11 +22,12 @@ object TpUbwcHint {
@Synchronized
fun forThisDevice(): String? {
if (resolved) return cached
val model = GpuInfo.detect().model
val gpu = GpuInfo.detect()
val model = gpu.model
val version = if (model == 740) VulkanInfo.query()["driver_version"] else null
cached = features(model, version)
cached = features(model, version, gpu.oneUi8Gen2)
resolved = true
Log.i(TAG, "Adreno $model, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
return cached
}
@@ -39,8 +39,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.Executors
import kotlin.math.abs
@@ -188,9 +186,8 @@ internal class HandCamera(
private fun prepare() {
if (stopped) return
runCatching {
val bytes = context.assets.open(MODEL).use { it.readBytes() }
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
model.rewind()
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
val model = TrackingPack.model(context, TrackingPack.POSE)
val options = PoseLandmarker.PoseLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(RunningMode.VIDEO)
@@ -305,7 +302,6 @@ internal class HandCamera(
companion object {
private const val TAG = "HandCamera"
private const val MODEL = "handtracking/pose_landmarker_lite.task"
private const val ANALYSIS_WIDTH = 960
private const val ANALYSIS_HEIGHT = 720
private const val MIN_CONFIDENCE = 0.5f
@@ -13,8 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.core.Delegate
import com.google.mediapipe.tasks.vision.core.RunningMode
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.hypot
import kotlin.math.max
@@ -134,7 +132,6 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
hypot(fix.x - marks[WRISTS[side]].x * k.width, fix.y - marks[WRISTS[side]].y * k.height)
companion object {
private const val MODEL = "handtracking/hand_landmarker.task"
private val WRISTS = intArrayOf(BodyPose.LEFT_WRIST, BodyPose.RIGHT_WRIST)
private val PINKIES = intArrayOf(BodyPose.LEFT_PINKY, BodyPose.RIGHT_PINKY)
private val INDEXES = intArrayOf(BodyPose.LEFT_INDEX, BodyPose.RIGHT_INDEX)
@@ -153,10 +150,9 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
private const val MIN_CONFIDENCE = 0.4f
fun open(context: Context): HandModel {
val bytes = context.assets.open(MODEL).use { it.readBytes() }
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
fun landmarker(mode: RunningMode, hands: Int): HandLandmarker {
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
model.rewind()
val model = TrackingPack.model(context, TrackingPack.HAND)
return HandLandmarker.createFromOptions(context, HandLandmarker.HandLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(mode)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.handtracking
import com.droiddeck.launcher.core.AddonPack
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
@@ -16,9 +17,10 @@ internal object HandTracking {
val side: String? = null,
val permitted: Boolean = false,
val sides: Set<String> = emptySet(),
val downloaded: Boolean = false,
) {
val usable: Boolean get() = sides.isNotEmpty()
val ready: Boolean get() = enabled && permitted && usable
val ready: Boolean get() = enabled && permitted && usable && downloaded
}
private val state = MutableStateFlow(Settings())
@@ -38,8 +40,11 @@ internal object HandTracking {
side = prefs.getString(SIDE, null),
permitted = permitted(context),
sides = if (supported) TrackingCameras.sides(found) else emptySet(),
downloaded = TrackingPack.installed(context),
)
state.value = next
if (next.enabled && next.usable && !next.downloaded && TrackingPack.refresh(context) == AddonPack.State.Missing)
TrackingPack.ensure(context)
return next
}
@@ -0,0 +1,56 @@
package com.droiddeck.launcher.handtracking
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.AddonPack
import java.io.File
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */
internal object TrackingPack {
const val HAND = "hand_landmarker.task"
const val HAND_LITE = "hand_landmarker_lite.task"
const val POSE = "pose_landmarker_lite.task"
private const val OPENCV = "libopencv_java4.so"
private const val MEDIAPIPE = "libmediapipe_tasks_jni.so"
private const val TAG = "TrackingPack"
private val pack = AddonPack.TRACKING
val state get() = pack.state
val size get() = pack.size
private val lock = Any()
@Volatile private var nativeLoaded = false
fun installed(context: Context) = pack.installed(context)
fun refresh(context: Context) = pack.refresh(context)
fun ensure(context: Context) = pack.ensure(context) { HandTracking.load(it) }
fun file(context: Context, name: String): File = pack.file(context, name)
fun model(context: Context, name: String): ByteBuffer =
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
/**
* Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
* already-loaded library by soname, so this must run before any MediaPipe task class initializes.
*/
fun loadNative(context: Context): Boolean {
if (nativeLoaded) return true
synchronized(lock) {
if (nativeLoaded) return true
if (!pack.installed(context)) return false
return runCatching {
System.load(file(context, OPENCV).absolutePath)
System.load(file(context, MEDIAPIPE).absolutePath)
nativeLoaded = true
true
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
}
}
/** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
pack.await(context, running, onState) && loadNative(context)
}
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.input
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import kotlin.math.abs
class DirectTouchHold(
private val slopPx: Float,
private val listener: PointerGestures.Listener,
private val cancelTouch: () -> Unit,
) {
private val handler = Handler(Looper.getMainLooper())
private var x = 0f
private var y = 0f
private var consumed = false
private var pressed = false
private val release = Runnable {
if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
pressed = false
}
private val hold = Runnable {
cancelTouch()
consumed = true
listener.onLongPress()
listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
pressed = true
handler.postDelayed(release, 50L)
}
fun cancel() {
handler.removeCallbacks(hold)
handler.removeCallbacks(release)
release.run()
consumed = false
}
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
cancel()
x = event.x; y = event.y
handler.postDelayed(hold, 500L)
}
MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 ||
abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold)
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
MotionEvent.ACTION_UP -> handler.removeCallbacks(hold)
MotionEvent.ACTION_CANCEL -> cancel()
}
return consumed
}
}
@@ -0,0 +1,111 @@
/*
* SPDX-License-Identifier: GPL-3.0
* Modified for DroidDeck on 2026-10-15.
*/
package com.droiddeck.launcher.input
object GestureKeys {
val codes: Map<String, Int> = linkedMapOf(
"ESC" to 1,
"1" to 2,
"2" to 3,
"3" to 4,
"4" to 5,
"5" to 6,
"6" to 7,
"7" to 8,
"8" to 9,
"9" to 10,
"0" to 11,
"MINUS" to 12,
"EQUAL" to 13,
"BKSP" to 14,
"TAB" to 15,
"Q" to 16,
"W" to 17,
"E" to 18,
"R" to 19,
"T" to 20,
"Y" to 21,
"U" to 22,
"I" to 23,
"O" to 24,
"P" to 25,
"BRACKET_LEFT" to 26,
"BRACKET_RIGHT" to 27,
"ENTER" to 28,
"CTRL_L" to 29,
"A" to 30,
"S" to 31,
"D" to 32,
"F" to 33,
"G" to 34,
"H" to 35,
"J" to 36,
"K" to 37,
"L" to 38,
"SEMICOLON" to 39,
"APOSTROPHE" to 40,
"GRAVE" to 41,
"SHIFT_L" to 42,
"BACKSLASH" to 43,
"Z" to 44,
"X" to 45,
"C" to 46,
"V" to 47,
"B" to 48,
"N" to 49,
"M" to 50,
"COMMA" to 51,
"PERIOD" to 52,
"SLASH" to 53,
"SHIFT_R" to 54,
"KP_MULTIPLY" to 55,
"ALT_L" to 56,
"SPACE" to 57,
"CAPS_LOCK" to 58,
"F1" to 59,
"F2" to 60,
"F3" to 61,
"F4" to 62,
"F5" to 63,
"F6" to 64,
"F7" to 65,
"F8" to 66,
"F9" to 67,
"F10" to 68,
"NUM_LOCK" to 69,
"SCROLL_LOCK" to 70,
"KP_7" to 71,
"KP_8" to 72,
"KP_9" to 73,
"KP_SUBTRACT" to 74,
"KP_4" to 75,
"KP_5" to 76,
"KP_6" to 77,
"KP_ADD" to 78,
"KP_1" to 79,
"KP_2" to 80,
"KP_3" to 81,
"KP_0" to 82,
"KP_DEL" to 83,
"F11" to 87,
"F12" to 88,
"KP_ENTER" to 96,
"CTRL_R" to 97,
"KP_DIVIDE" to 98,
"PRTSCN" to 99,
"ALT_R" to 100,
"HOME" to 102,
"UP" to 103,
"PRIOR" to 104,
"LEFT" to 105,
"RIGHT" to 106,
"END" to 107,
"DOWN" to 108,
"NEXT" to 109,
"INSERT" to 110,
"DEL" to 111,
)
@JvmStatic fun code(name: String): Int? = codes[name]
}
@@ -0,0 +1,24 @@
package com.droiddeck.launcher.input
import android.content.Context
/** Global defaults with optional overrides for the currently focused game. */
object GesturePrefs {
private fun prefs(context: Context) = context.getSharedPreferences("touch_gestures", Context.MODE_PRIVATE)
fun hasOverride(context: Context, game: String): Boolean = prefs(context).contains("game.$game")
fun read(context: Context, game: String? = null): TouchGestureConfig {
val p = prefs(context)
val json = game?.let { p.getString("game.$it", null) } ?: p.getString("default", null)
return if (json == null) TouchGestureConfig() else TouchGestureConfig.fromJson(json)
}
fun save(context: Context, game: String?, config: TouchGestureConfig) {
prefs(context).edit().putString(game?.let { "game.$it" } ?: "default", config.toJson()).apply()
}
fun clear(context: Context, game: String?) {
prefs(context).edit().remove(game?.let { "game.$it" } ?: "default").apply()
}
}
@@ -146,9 +146,15 @@ public final class PadBridge {
}
}
static boolean isSystemVirtualInputName(String name) {
if (name == null) return false;
String lower = name.trim().toLowerCase(java.util.Locale.ROOT);
return lower.startsWith("uinput-") || lower.startsWith("uinput_");
}
public static boolean isFromController(InputDevice device) {
if (device == null) return false;
if (device.isVirtual()) return false;
if (device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
int sources = device.getSources();
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) return true;
if ((sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false;
@@ -165,7 +171,7 @@ public final class PadBridge {
public static boolean isControllerKeyEvent(KeyEvent event) {
InputDevice device = event.getDevice();
if (device == null || device.isVirtual()) return false;
if (device == null || device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK);
@@ -190,6 +196,7 @@ public final class PadBridge {
public static boolean isControllerMotionEvent(MotionEvent event) {
InputDevice device = event.getDevice();
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
&& !isSystemVirtualInputName(device.getName())
&& (device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK));
}
@@ -319,16 +326,27 @@ public final class PadBridge {
boolean wired = wiredXboxLayout(event);
pad.rightX = rightX(event, wired);
pad.rightY = rightY(event, wired);
float lt = leftTrigger(event, wired);
float rt = rightTrigger(event, wired);
pad.leftTrigger = lt;
pad.rightTrigger = rt;
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
pad.up = hatY < -0.5f;
pad.right = hatX > 0.5f;
pad.down = hatY > 0.5f;
pad.left = hatX < -0.5f;
int source = event.getSource();
float lt = 0f;
float rt = 0f;
if (wired || hasAxis(device, source, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)) {
lt = leftTrigger(event, wired);
pad.leftTrigger = lt;
}
if (wired || hasAxis(device, source, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)) {
rt = rightTrigger(event, wired);
pad.rightTrigger = rt;
}
if (hasAxis(device, source, MotionEvent.AXIS_HAT_X)) {
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
pad.right = hatX > 0.5f;
pad.left = hatX < -0.5f;
}
if (hasAxis(device, source, MotionEvent.AXIS_HAT_Y)) {
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
pad.up = hatY < -0.5f;
pad.down = hatY > 0.5f;
}
if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f
|| lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) {
notePlayerInput();
@@ -337,6 +355,12 @@ public final class PadBridge {
return true;
}
static boolean hasAxis(InputDevice device, int source, int... axes) {
if (device == null) return false;
for (int axis : axes) if (device.getMotionRange(axis, source) != null) return true;
return false;
}
public interface TouchRoute {
boolean active();
@@ -16,10 +16,13 @@ object SteamTouchBindings {
const val DEFAULT_FOREGROUND = "#232323"
const val DEFAULT_BACKGROUND = "#E4E4E4"
fun cleanLabel(raw: String): String =
raw.replace(Regex("""["\\,\p{Cntrl}]"""), " ").replace(Regex("""\s+"""), " ").trim().let { it.take(it.offsetByCodePoints(0, minOf(32, it.codePointCount(0, it.length)))) }
data class Binding(val action: String, val label: String, val icon: String, val foreground: String, val background: String) {
fun compose(): String =
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, $label, "
else "$action, $label, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, ${cleanLabel(label)}, "
else "$action, ${cleanLabel(label)}, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
val hasIcon get() = icon.isNotEmpty()
}
@@ -2,10 +2,14 @@ package com.droiddeck.launcher.input
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.frontend.SteamAppInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Locale
object SteamTouchConfig {
private const val TAG = "SteamTouch"
@@ -125,17 +129,37 @@ object SteamTouchConfig {
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
fun steamId(appId: Int): String = Integer.toUnsignedString(appId)
fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet
private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1
fun gameName(context: Context, appId: Int): String? = try {
fun gameName(context: Context, appId: Int): String? = if (appId < 0) shortcutName(context, appId) else try {
val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText())
(kv.child("AppState") ?: kv).string("name")
} catch (_: Exception) {
null
}
fun steamKey(context: Context, appId: Int): String =
if (appId < 0) shortcutName(context, appId)?.lowercase(Locale.ROOT)
?.takeIf { name -> name != "." && name != ".." && name.none { it == '"' || it == '\\' || it == '/' || it < ' ' } } ?: steamId(appId)
else steamId(appId)
private fun shortcutName(context: Context, appId: Int): String? {
val account = accountId(context) ?: return null
return try {
val bytes = File(steamRoot(context), "userdata/$account/config/shortcuts.vdf").readBytes()
val root = SteamAppInfo.readMap(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN), null)
(root["shortcuts"] as? Map<*, *>)?.values?.filterIsInstance<Map<*, *>>()
?.firstOrNull { (it["appid"] as? String)?.toIntOrNull() == appId }
?.let { (it["AppName"] ?: it["appname"]) as? String }?.takeIf { it.isNotEmpty() }
} catch (_: Exception) {
null
}
}
fun steamRoot(context: Context) = com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context)
fun configDir(context: Context): File? {
@@ -158,10 +182,11 @@ object SteamTouchConfig {
fun load(context: Context, appId: Int): Config {
val dir = configDir(context)
val file = dir?.let { deviceConfig(context, it, appId) }
?: dir?.let { autosaveConfig(it, appId) }
val key = steamKey(context, appId)
val file = dir?.let { deviceConfig(context, it, key) }
?: dir?.let { autosaveConfig(it, key) }
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
?: dir?.let { configsetConfig(context, it, appId) }
?: dir?.let { configsetConfig(context, it, key) }
if (file == null) {
Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout")
return Config(null, null, null, emptyMap())
@@ -193,7 +218,7 @@ object SteamTouchConfig {
}
val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1)
if (touchIndex == null) null else {
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""")
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID ${steamId(appId)}, Controller $touchIndex: ([^\n]+)""")
.findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim()
loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) }
}
@@ -201,22 +226,22 @@ object SteamTouchConfig {
null
}
private fun autosaveConfig(dir: File, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null
private fun autosaveConfig(dir: File, key: String): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(key)
if (set?.string("autosave") == "1") File(dir, "$key/controller_mobile_touch.vdf").takeIf { it.isFile } else null
} catch (_: Exception) {
null
}
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, CONFIGSET, appId)
private fun configsetConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, CONFIGSET, key)
private fun deviceConfig(context: Context, dir: File, appId: Int): File? = entryConfig(context, dir, DEVICE_CONFIGSET, appId)
private fun deviceConfig(context: Context, dir: File, key: String): File? = entryConfig(context, dir, DEVICE_CONFIGSET, key)
private fun entryConfig(context: Context, dir: File, configset: String, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(appId.toString())
private fun entryConfig(context: Context, dir: File, configset: String, key: String): File? = try {
val set = KeyValues.parse(File(dir, configset).readText()).child("controller_config")?.child(key)
when {
set == null -> null
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
set.string("autosave") == "1" -> File(dir, "$key/controller_mobile_touch.vdf")
set.string("workshop") != null -> workshopConfig(context, set.string("workshop")!!)
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
else -> null
@@ -333,6 +358,11 @@ object SteamTouchConfig {
}.sortedBy { it.type }
}
fun remapHeld(held: Map<Int, Int?>, next: List<Element>): Map<Int, Element> {
val byType = next.associateBy { it.type }
return held.mapNotNull { (id, type) -> byType[type]?.let { id to it } }.toMap()
}
fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color
@@ -359,7 +389,8 @@ object SteamTouchConfig {
val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) ->
SteamTouchBindings.rewrite(text, ref, change) ?: text
}
val target = File(dir, "$appId/controller_mobile_touch.vdf")
val key = steamKey(context, appId)
val target = File(dir, "$key/controller_mobile_touch.vdf")
val old = config.layouts
val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty()
val written = sets.map { (id, elements) ->
@@ -377,27 +408,27 @@ object SteamTouchConfig {
val tmp = File(target.path + ".tmp")
tmp.writeText(text)
if (!tmp.renameTo(target)) return null
selectAutosave(dir, appId)
dropDeviceEntry(dir, appId)
selectAutosave(dir, key)
dropDeviceEntry(dir, key)
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
return target
}
private fun dropDeviceEntry(dir: File, appId: Int) {
private fun dropDeviceEntry(dir: File, key: String) {
val file = File(dir, DEVICE_CONFIGSET)
if (!file.isFile) return
val text = file.readText()
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE)
if (!block.containsMatchIn(text)) return
File(file.path + ".tmp").apply { writeText(text.replace(block, "")) }.renameTo(file)
}
private fun selectAutosave(dir: File, appId: Int) {
private fun selectAutosave(dir: File, key: String) {
val file = File(dir, CONFIGSET)
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry")
val entry = "\t\"$key\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
val block = Regex("""\n\s*"${Regex.escape(key)}"\s*\{[^}]*\}""", RegexOption.IGNORE_CASE)
val next = if (block.containsMatchIn(text)) text.replace(block) { "\n$entry" }
else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n"
File(file.path + ".tmp").apply { writeText(next) }.renameTo(file)
}
@@ -154,7 +154,7 @@ class SteamTouchControls(
actionSet = state.actionSet
layers = state.layers
appId = app
rebuild()
rebuild(keepHeld = true)
}
}
@@ -175,7 +175,7 @@ class SteamTouchControls(
refreshState()
}
private fun rebuild() {
private fun rebuild(keepHeld: Boolean = false) {
elements = SteamTouchConfig.elementsFor(config, actionSet, layers)
val nextOptions = pendingOptions ?: config.layouts?.options ?: SteamTouchConfig.Options()
if (nextOptions != options) { options = nextOptions; onOptions(options) }
@@ -183,7 +183,7 @@ class SteamTouchControls(
rebuildVisuals()
if (!editing) {
Log.i(TAG, "steam touch: app $appId, action set $actionSet, layers $layers: ${elements.size} controls")
releaseAll()
if (keepHeld) keepHeldFingers() else releaseAll()
}
invalidate()
}
@@ -590,6 +590,13 @@ class SteamTouchControls(
invalidate()
}
private fun keepHeldFingers() {
val moved = SteamTouchConfig.remapHeld(fingers.mapValues { it.value.element?.type }, live())
fingers.keys.retainAll(moved.keys)
fingers.replaceAll { id, f -> Finger(moved.getValue(id), f.x, f.y, f.toolbar) }
publish()
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
@@ -958,7 +965,7 @@ class SteamTouchControls(
BIG_PICTURE -> "Steam"
else -> gameNames[appId] ?: run {
val app = appId
gameNames[app] = context.getString(R.string.touch_app_id, app)
gameNames[app] = context.getString(R.string.touch_app_id, Integer.toUnsignedLong(app))
loader.execute {
val name = SteamTouchConfig.gameName(context, app) ?: return@execute
handler.post { gameNames[app] = name; invalidate() }
@@ -84,11 +84,11 @@ class SteamTouchIconPicker(
updatePreview(preview)
showPanel(host, title, root, listOf(
context.getString(R.string.touch_no_icon) to {
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = "", foreground = "", background = ""))
onPicked(current.copy(label = SteamTouchBindings.cleanLabel(label.text.toString()), icon = "", foreground = "", background = ""))
},
context.getString(R.string.common_cancel) to {},
context.getString(R.string.common_use) to {
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = icon,
onPicked(current.copy(label = SteamTouchBindings.cleanLabel(label.text.toString()), icon = icon,
foreground = if (icon.isEmpty()) "" else foreground, background = if (icon.isEmpty()) "" else background))
},
)) { loader.shutdown() }
@@ -0,0 +1,226 @@
/*
* SPDX-License-Identifier: GPL-3.0
* Modified for DroidDeck on 2026-10-15.
*/
package com.droiddeck.launcher.input
import org.json.JSONObject
data class TouchGestureConfig(
val tapEnabled: Boolean = true,
val tapAction: String = ACTION_LEFT_CLICK,
val dragEnabled: Boolean = true,
val dragAction: String = PAN_LEFT_CLICK_DRAG,
val longPressEnabled: Boolean = false,
val longPressAction: String = ACTION_RIGHT_CLICK,
val longPressMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val longPressDelay: Int = DEFAULT_DELAY_MS,
val doubleTapEnabled: Boolean = true,
val doubleTapDelay: Int = DEFAULT_DELAY_MS,
val twoFingerDragEnabled: Boolean = true,
val twoFingerDragAction: String = PAN_ARROW_KEYS,
val pinchEnabled: Boolean = true,
val pinchAction: String = ZOOM_SCROLL_WHEEL,
val twoFingerTapEnabled: Boolean = true,
val twoFingerTapAction: String = ACTION_RIGHT_CLICK,
val twoFingerHoldEnabled: Boolean = true,
val twoFingerHoldAction: String = ACTION_MIDDLE_CLICK,
val twoFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val twoFingerHoldDelay: Int = DEFAULT_DELAY_MS,
val threeFingerTapEnabled: Boolean = true,
val threeFingerTapAction: String = ACTION_SHOW_KEYBOARD,
val threeFingerDragEnabled: Boolean = false,
val threeFingerDragAction: String = PAN_ARROW_KEYS,
val threeFingerHoldEnabled: Boolean = true,
val threeFingerHoldAction: String = ACTION_KEY_ESC,
val threeFingerHoldMouseBehavior: String = MOUSE_BEHAVIOR_HOLD,
val threeFingerHoldDelay: Int = DEFAULT_DELAY_MS,
val showGestureDebugOverlay: Boolean = false,
val gestureThreshold: Int = DEFAULT_GESTURE_THRESHOLD,
val mouseDragMovementMode: String = MOUSE_DRAG_MOVEMENT_DIRECT,
) {
fun toJson(): String {
return JSONObject().apply {
put(KEY_TAP_ENABLED, tapEnabled)
put(KEY_TAP_ACTION, tapAction)
put(KEY_DRAG_ENABLED, dragEnabled)
put(KEY_DRAG_ACTION, dragAction)
put(KEY_LONG_PRESS_ENABLED, longPressEnabled)
put(KEY_LONG_PRESS_ACTION, longPressAction)
put(KEY_LONG_PRESS_MOUSE_BEHAVIOR, longPressMouseBehavior)
put(KEY_LONG_PRESS_DELAY, longPressDelay)
put(KEY_DOUBLE_TAP_ENABLED, doubleTapEnabled)
put(KEY_DOUBLE_TAP_DELAY, doubleTapDelay)
put(KEY_TWO_FINGER_DRAG_ENABLED, twoFingerDragEnabled)
put(KEY_TWO_FINGER_DRAG_ACTION, twoFingerDragAction)
put(KEY_PINCH_ENABLED, pinchEnabled)
put(KEY_PINCH_ACTION, pinchAction)
put(KEY_TWO_FINGER_TAP_ENABLED, twoFingerTapEnabled)
put(KEY_TWO_FINGER_TAP_ACTION, twoFingerTapAction)
put(KEY_TWO_FINGER_HOLD_ENABLED, twoFingerHoldEnabled)
put(KEY_TWO_FINGER_HOLD_ACTION, twoFingerHoldAction)
put(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, twoFingerHoldMouseBehavior)
put(KEY_TWO_FINGER_HOLD_DELAY, twoFingerHoldDelay)
put(KEY_THREE_FINGER_TAP_ENABLED, threeFingerTapEnabled)
put(KEY_THREE_FINGER_TAP_ACTION, threeFingerTapAction)
put(KEY_THREE_FINGER_DRAG_ENABLED, threeFingerDragEnabled)
put(KEY_THREE_FINGER_DRAG_ACTION, threeFingerDragAction)
put(KEY_THREE_FINGER_HOLD_ENABLED, threeFingerHoldEnabled)
put(KEY_THREE_FINGER_HOLD_ACTION, threeFingerHoldAction)
put(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, threeFingerHoldMouseBehavior)
put(KEY_THREE_FINGER_HOLD_DELAY, threeFingerHoldDelay)
put(KEY_SHOW_GESTURE_DEBUG_OVERLAY, showGestureDebugOverlay)
put(KEY_GESTURE_THRESHOLD, gestureThreshold)
put(KEY_MOUSE_DRAG_MOVEMENT_MODE, mouseDragMovementMode)
}.toString()
}
companion object {
const val DEFAULT_DELAY_MS = 300
const val DEFAULT_GESTURE_THRESHOLD = 40
const val ACTION_LEFT_CLICK = "left_click"
const val ACTION_RIGHT_CLICK = "right_click"
const val ACTION_MIDDLE_CLICK = "middle_click"
const val MOUSE_BEHAVIOR_HOLD = "hold"
const val MOUSE_BEHAVIOR_CLICK = "click"
const val MOUSE_DRAG_MOVEMENT_DIRECT = "direct"
const val MOUSE_DRAG_MOVEMENT_RELATIVE = "relative"
const val ACTION_SHOW_KEYBOARD = "show_keyboard"
const val ACTION_KEY_ESC = "key_ESC"
const val ACTION_KEY_TILDE = "key_TILDE"
const val PAN_WASD = "wasd"
const val PAN_INVERTED_WASD = "inverted_wasd"
const val PAN_ARROW_KEYS = "arrow_keys"
const val PAN_INVERTED_ARROW_KEYS = "inverted_arrow_keys"
const val PAN_MIDDLE_MOUSE = "middle_mouse_pan"
const val PAN_INVERTED_MIDDLE_MOUSE = "inverted_middle_mouse_pan"
const val PAN_LEFT_CLICK_DRAG = "left_click_drag"
const val PAN_RIGHT_CLICK_DRAG = "right_click_drag"
const val ZOOM_SCROLL_WHEEL = "scroll_wheel"
const val ZOOM_PLUS_MINUS = "plus_minus"
const val ZOOM_PAGE_UP_DOWN = "page_up_down"
private const val KEY_TAP_ENABLED = "tapEnabled"
private const val KEY_TAP_ACTION = "tapAction"
private const val KEY_DRAG_ENABLED = "dragEnabled"
private const val KEY_DRAG_ACTION = "dragAction"
private const val KEY_LONG_PRESS_ENABLED = "longPressEnabled"
private const val KEY_LONG_PRESS_ACTION = "longPressAction"
private const val KEY_LONG_PRESS_MOUSE_BEHAVIOR = "longPressMouseBehavior"
private const val KEY_LONG_PRESS_DELAY = "longPressDelay"
private const val KEY_DOUBLE_TAP_ENABLED = "doubleTapEnabled"
private const val KEY_DOUBLE_TAP_DELAY = "doubleTapDelay"
private const val KEY_TWO_FINGER_DRAG_ENABLED = "twoFingerDragEnabled"
private const val KEY_TWO_FINGER_DRAG_ACTION = "twoFingerDragAction"
private const val KEY_PINCH_ENABLED = "pinchEnabled"
private const val KEY_PINCH_ACTION = "pinchAction"
private const val KEY_TWO_FINGER_TAP_ENABLED = "twoFingerTapEnabled"
private const val KEY_TWO_FINGER_TAP_ACTION = "twoFingerTapAction"
private const val KEY_TWO_FINGER_HOLD_ENABLED = "twoFingerHoldEnabled"
private const val KEY_TWO_FINGER_HOLD_ACTION = "twoFingerHoldAction"
private const val KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR = "twoFingerHoldMouseBehavior"
private const val KEY_TWO_FINGER_HOLD_DELAY = "twoFingerHoldDelay"
private const val KEY_THREE_FINGER_TAP_ENABLED = "threeFingerTapEnabled"
private const val KEY_THREE_FINGER_TAP_ACTION = "threeFingerTapAction"
private const val KEY_THREE_FINGER_DRAG_ENABLED = "threeFingerDragEnabled"
private const val KEY_THREE_FINGER_DRAG_ACTION = "threeFingerDragAction"
private const val KEY_THREE_FINGER_HOLD_ENABLED = "threeFingerHoldEnabled"
private const val KEY_THREE_FINGER_HOLD_ACTION = "threeFingerHoldAction"
private const val KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR = "threeFingerHoldMouseBehavior"
private const val KEY_THREE_FINGER_HOLD_DELAY = "threeFingerHoldDelay"
private const val KEY_SHOW_GESTURE_DEBUG_OVERLAY = "showGestureDebugOverlay"
private const val KEY_GESTURE_THRESHOLD = "gestureThreshold"
private const val KEY_MOUSE_DRAG_MOVEMENT_MODE = "mouseDragMovementMode"
private fun fallbackDefaults(): TouchGestureConfig = TouchGestureConfig(
twoFingerHoldEnabled = false,
threeFingerTapEnabled = false,
threeFingerHoldEnabled = false,
)
fun fromJson(json: String?): TouchGestureConfig {
if (json.isNullOrBlank()) return fallbackDefaults()
return try {
val obj = JSONObject(json)
TouchGestureConfig(
tapEnabled = obj.optBoolean(KEY_TAP_ENABLED, true),
tapAction = obj.optString(KEY_TAP_ACTION, ACTION_LEFT_CLICK),
dragEnabled = obj.optBoolean(KEY_DRAG_ENABLED, true),
dragAction = obj.optString(KEY_DRAG_ACTION, PAN_LEFT_CLICK_DRAG),
longPressEnabled = obj.optBoolean(KEY_LONG_PRESS_ENABLED, false),
longPressAction = obj.optString(KEY_LONG_PRESS_ACTION, ACTION_RIGHT_CLICK),
longPressMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_LONG_PRESS_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
longPressDelay = obj.optInt(KEY_LONG_PRESS_DELAY, DEFAULT_DELAY_MS),
doubleTapEnabled = obj.optBoolean(KEY_DOUBLE_TAP_ENABLED, true),
doubleTapDelay = obj.optInt(KEY_DOUBLE_TAP_DELAY, DEFAULT_DELAY_MS),
twoFingerDragEnabled = obj.optBoolean(KEY_TWO_FINGER_DRAG_ENABLED, true),
twoFingerDragAction = obj.optString(KEY_TWO_FINGER_DRAG_ACTION, PAN_ARROW_KEYS),
pinchEnabled = obj.optBoolean(KEY_PINCH_ENABLED, true),
pinchAction = obj.optString(KEY_PINCH_ACTION, ZOOM_SCROLL_WHEEL),
twoFingerTapEnabled = obj.optBoolean(KEY_TWO_FINGER_TAP_ENABLED, true),
twoFingerTapAction = obj.optString(KEY_TWO_FINGER_TAP_ACTION, ACTION_RIGHT_CLICK),
twoFingerHoldEnabled = obj.optBoolean(KEY_TWO_FINGER_HOLD_ENABLED, false),
twoFingerHoldAction = obj.optString(KEY_TWO_FINGER_HOLD_ACTION, ACTION_MIDDLE_CLICK),
twoFingerHoldMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_TWO_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
twoFingerHoldDelay = obj.optInt(KEY_TWO_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS),
threeFingerTapEnabled = obj.optBoolean(KEY_THREE_FINGER_TAP_ENABLED, false),
threeFingerTapAction = obj.optString(KEY_THREE_FINGER_TAP_ACTION, ACTION_SHOW_KEYBOARD),
threeFingerDragEnabled = obj.optBoolean(KEY_THREE_FINGER_DRAG_ENABLED, false),
threeFingerDragAction = obj.optString(KEY_THREE_FINGER_DRAG_ACTION, PAN_ARROW_KEYS),
threeFingerHoldEnabled = obj.optBoolean(KEY_THREE_FINGER_HOLD_ENABLED, false),
threeFingerHoldAction = obj.optString(KEY_THREE_FINGER_HOLD_ACTION, ACTION_KEY_ESC),
threeFingerHoldMouseBehavior = normalizeMouseBehavior(
obj.optString(KEY_THREE_FINGER_HOLD_MOUSE_BEHAVIOR, MOUSE_BEHAVIOR_HOLD)
),
threeFingerHoldDelay = obj.optInt(KEY_THREE_FINGER_HOLD_DELAY, DEFAULT_DELAY_MS),
showGestureDebugOverlay = obj.optBoolean(KEY_SHOW_GESTURE_DEBUG_OVERLAY, false),
gestureThreshold = obj.optInt(KEY_GESTURE_THRESHOLD, DEFAULT_GESTURE_THRESHOLD),
mouseDragMovementMode = normalizeMouseDragMovementMode(
obj.optString(KEY_MOUSE_DRAG_MOVEMENT_MODE, MOUSE_DRAG_MOVEMENT_DIRECT),
),
)
} catch (_: Exception) {
fallbackDefaults()
}
}
private fun normalizeMouseBehavior(value: String): String {
return if (value == MOUSE_BEHAVIOR_CLICK) MOUSE_BEHAVIOR_CLICK else MOUSE_BEHAVIOR_HOLD
}
private fun normalizeMouseDragMovementMode(mode: String?): String {
return if (mode == MOUSE_DRAG_MOVEMENT_RELATIVE) {
MOUSE_DRAG_MOVEMENT_RELATIVE
} else {
MOUSE_DRAG_MOVEMENT_DIRECT
}
}
val PAN_ACTIONS = listOf(
PAN_ARROW_KEYS,
PAN_INVERTED_ARROW_KEYS,
PAN_WASD,
PAN_INVERTED_WASD,
PAN_MIDDLE_MOUSE,
PAN_INVERTED_MIDDLE_MOUSE,
PAN_LEFT_CLICK_DRAG,
PAN_RIGHT_CLICK_DRAG,
)
val ZOOM_ACTIONS = listOf(
ZOOM_SCROLL_WHEEL,
ZOOM_PLUS_MINUS,
ZOOM_PAGE_UP_DOWN,
)
@JvmStatic
fun isMouseButtonAction(action: String?): Boolean =
action == ACTION_LEFT_CLICK || action == ACTION_RIGHT_CLICK || action == ACTION_MIDDLE_CLICK
}
}
@@ -38,6 +38,7 @@ class TouchpadGestures(
private var moverLastX = 0f
private var moverLastY = 0f
private var holdDrag = false
private var scrollLocked = false
private var clicked = 0
private val longPress = Runnable {
held = true
@@ -67,6 +68,7 @@ class TouchpadGestures(
dragging = false
held = false
holdDrag = false
scrollLocked = false
anchorId = -1
moverId = -1
lastTapAt = 0L
@@ -84,7 +86,7 @@ class TouchpadGestures(
lastX = event.x; lastY = event.y
downAt = event.eventTime
travelled = 0f; held = false; twoFingers = false; scrolled = false; secondWasTap = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false
anchorId = event.getPointerId(0); moverId = -1; holdDrag = false; scrollLocked = false
if (event.eventTime - lastTapAt < DOUBLE_TAP_MS) {
dragging = true
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
@@ -117,10 +119,12 @@ class TouchpadGestures(
val mi = if (moverId == -1) -1 else event.findPointerIndex(moverId)
if (ai >= 0 && mi >= 0) {
val mx = event.getX(mi); val my = event.getY(mi)
if (!holdDrag && !scrolled) {
if (!holdDrag && !scrolled && !scrollLocked) {
val anchorMoved = abs(event.getX(ai) - anchorStartX) + abs(event.getY(ai) - anchorStartY)
val moverMoved = abs(mx - moverStartX) + abs(my - moverStartY)
if (moverMoved > slopPx && anchorMoved < slopPx) {
if (anchorMoved >= slopPx) {
scrollLocked = true
} else if (moverMoved > slopPx * HOLD_DRAG_MOVER_SLOPS && anchorMoved < slopPx * HOLD_DRAG_ANCHOR_SLOPS) {
holdDrag = true
secondWasTap = false
if (!dragging) {
@@ -186,9 +190,9 @@ class TouchpadGestures(
if (gone == moverId) moverId = -1
if (gone == anchorId) {
anchorId = event.getPointerId(if (event.actionIndex == 0) 1 else 0)
event.findPointerIndex(anchorId).takeIf { it >= 0 }?.let {
lastX = event.getX(it); lastY = event.getY(it)
}
}
event.findPointerIndex(anchorId).takeIf { it >= 0 && it != event.actionIndex }?.let {
lastX = event.getX(it); lastY = event.getY(it)
}
if (twoFingers && secondWasTap && event.eventTime - secondDownAt < TAP_MS && !scrolled) {
click(PointerGestures.BTN_RIGHT)
@@ -212,7 +216,7 @@ class TouchpadGestures(
}
}
dragging = false; held = false; twoFingers = false
holdDrag = false; anchorId = -1; moverId = -1
holdDrag = false; scrollLocked = false; anchorId = -1; moverId = -1
return true
}
}
@@ -225,5 +229,7 @@ class TouchpadGestures(
private const val CLICK_MS = 50L
private const val DOUBLE_TAP_MS = 300L
private const val SCROLL_STEP_PX = 36f
private const val HOLD_DRAG_MOVER_SLOPS = 2f
private const val HOLD_DRAG_ANCHOR_SLOPS = 0.5f
}
}
File diff suppressed because it is too large. Load diff
@@ -32,7 +32,7 @@ object DeckyManager {
private set
data class Release(val tag: String, val prerelease: Boolean, val asset: String, val url: String, val digest: String?, val shaUrl: String?, val size: Long, val machine: Int)
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>, val newest: List<Release>)
data class ReleaseChannels(val stable: List<Release>, val prerelease: List<Release>)
fun installed(context: Context): String? {
val loader = loader(context)
@@ -80,13 +80,11 @@ object DeckyManager {
}
fun releaseChannels(context: Context): ReleaseChannels {
val body = Downloader.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList(), emptyList())
val body = Downloader.downloadString(RELEASES) ?: return ReleaseChannels(emptyList(), emptyList())
return try {
val array = JSONArray(body)
val stable = mutableListOf<Release>()
val prerelease = mutableListOf<Release>()
// GitHub lists releases newest first, so this keeps one ordered list across both channels.
val newest = mutableListOf<Release>()
for (i in 0 until array.length()) {
val release = array.getJSONObject(i)
if (release.optBoolean("draft", false)) continue
@@ -108,10 +106,9 @@ object DeckyManager {
if (size <= 0) continue
val row = Release(release.optString("tag_name", "unknown"), isPrerelease, name, url, digest, checksum, size, if (name == "PluginLoader-arm64") 183 else 62)
(if (isPrerelease) prerelease else stable).add(row)
newest.add(row)
}
ReleaseChannels(stable, prerelease, newest)
} catch (e: Exception) { Log.w(TAG, "release metadata", e); ReleaseChannels(emptyList(), emptyList(), emptyList()) }
ReleaseChannels(stable, prerelease)
} catch (e: Exception) { Log.w(TAG, "release metadata", e); ReleaseChannels(emptyList(), emptyList()) }
}
fun install(context: Context, release: Release, progress: (String, Int) -> Unit): String? {
@@ -47,6 +47,53 @@ object DesktopCatalog {
private fun marker(context: Context, id: String) = File(LinuxRuntime.rootDir(context), ".droiddeck-pkg-$id")
private const val JOURNAL_SUFFIX = ".installing"
private fun journal(root: File, id: String) = File(root, ".droiddeck-pkg-$id$JOURNAL_SUFFIX")
/** Packages whose extraction over the runtime started and never finished. */
fun interrupted(context: Context): List<Entry> = interrupted(LinuxRuntime.rootDir(context))
fun interrupted(root: File): List<Entry> =
root.listFiles { f -> f.name.startsWith(".droiddeck-pkg-") && f.name.endsWith(JOURNAL_SUFFIX) }.orEmpty()
.sortedBy { it.name }
.mapNotNull { f -> FileUtils.readString(f)?.let { runCatching { entryFrom(JSONObject(it)) }.getOrNull() } }
private val installLock = Any()
private fun downloadFile(context: Context, entry: Entry) = File(context.cacheDir, "pkg-${entry.id}-${entry.sha256.take(12)}.download")
/** Drops downloads kept for installs that no longer need finishing, such as after the runtime was reinstalled. */
fun pruneDownloads(context: Context) = synchronized(installLock) {
val keep = interrupted(context).map { downloadFile(context, it).name }.toSet()
context.cacheDir.listFiles { f -> f.name.startsWith("pkg-") && f.name.removeSuffix(".resume.properties").endsWith(".download") }
?.filter { it.name.removeSuffix(".resume.properties") !in keep }?.forEach { it.delete() }
}
fun resumeInterrupted(context: Context, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
for (entry in interrupted(context)) {
Log.w(TAG, "finishing the interrupted install of ${entry.id} ${entry.version}")
install(context, entry, listener)?.let { return it }
}
null
}
internal fun entryJson(e: Entry): JSONObject = JSONObject()
.put("id", e.id).put("name", e.name).put("tier", e.tier).put("version", e.version).put("kind", e.kind)
.put("url", e.url).put("sha256", e.sha256).put("size", e.size).put("notes", e.notes)
.put("icon", e.icon).put("category", e.category)
internal fun entryFrom(o: JSONObject) = Entry(
o.getString("id"), o.getString("name"), o.optInt("tier", 1), o.getString("version"), o.getString("kind"),
o.getString("url"), o.getString("sha256"), o.optLong("size", 0L), o.optString("notes", ""),
o.optString("icon", "applications-games"), o.optString("category", "Game"),
)
/** A finished download is kept until its extraction completes, so resuming does not fetch it again. */
private fun downloaded(file: File, entry: Entry): Boolean =
file.isFile && file.length() > 0 && !File(file.path + ".resume.properties").exists() &&
(entry.size <= 0 || file.length() == entry.size) && entry.sha256.equals(Hashes.sha256(file), ignoreCase = true)
fun installed(context: Context, id: String): String? =
FileUtils.readString(marker(context, id))?.trim()?.takeIf { it.isNotEmpty() }
@@ -82,22 +129,38 @@ object DesktopCatalog {
fun removeDesktop(context: Context, listener: LinuxRuntimeInstaller.ProgressListener): String? =
DesktopRemoval.remove(context, listener)
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
fun install(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? = synchronized(installLock) {
installLocked(context, entry, listener)
}
private fun installLocked(context: Context, entry: Entry, listener: LinuxRuntimeInstaller.ProgressListener?): String? {
val root = LinuxRuntime.rootDir(context)
if (!root.isDirectory) return context.getString(R.string.deskpkg_runtime_missing)
if (entry.sha256.isEmpty()) return context.getString(R.string.deskpkg_no_checksum, entry.name)
val download = File(context.cacheDir, "pkg-${entry.id}.download")
try {
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
listener?.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
val download = downloadFile(context, entry)
val journal = journal(root, entry.id)
var keepDownload = false
val release = RuntimeInstallService.holdForPackage(context, context.getString(R.string.deskpkg_installing, entry.name))
val report = object : LinuxRuntimeInstaller.ProgressListener {
override fun onProgress(stage: String, percent: Int) = onProgress(LinuxRuntimeInstaller.Step.OTHER, stage, percent)
override fun onProgress(step: LinuxRuntimeInstaller.Step, stage: String, percent: Int) {
RuntimeInstallService.packageProgress(stage, percent)
listener?.onProgress(step, stage, percent)
}
if (!ok) return context.getString(R.string.user_apps_download_failed)
listener?.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
listener?.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
}
try {
if (!downloaded(download, entry)) {
val downloading = context.getString(R.string.user_apps_downloading, entry.name)
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, 0)
val ok = Downloader.downloadFile(entry.url, download, true, entry.sha256) { f ->
report.onProgress(LinuxRuntimeInstaller.Step.DOWNLOADING, downloading, if (f < 0) -1 else Math.round(f * 100f))
}
if (!ok) return context.getString(R.string.user_apps_download_failed)
report.onProgress(LinuxRuntimeInstaller.Step.VERIFYING, context.getString(R.string.rtinst_verifying), -1)
val actual = Hashes.sha256(download)
if (!entry.sha256.equals(actual, ignoreCase = true)) return context.getString(R.string.deskpkg_checksum_mismatch)
}
report.onProgress(context.getString(R.string.deskpkg_installing, entry.name), -1)
when (entry.kind) {
"appimage" -> {
val dir = File(root, "opt/appimages").apply { mkdirs() }
@@ -112,23 +175,28 @@ object DesktopCatalog {
"Icon=${entry.icon}\nTerminal=false\nCategories=${entry.category};\n")
}
else -> {
check(FileUtils.writeString(journal, entryJson(entry).toString())) { "Could not record the package installation" }
keepDownload = true
marker(context, entry.id).delete()
if (!LinuxRuntimeInstaller.extract(context, download, root, listener)) return context.getString(R.string.deskpkg_extract_failed)
if (!LinuxRuntimeInstaller.extract(context, download, root, report)) return context.getString(R.string.deskpkg_extract_failed)
}
}
if (entry.id == DESKTOP_ID) {
cleanLegacy(context, listener)
cleanLegacy(context, report)
val pending = File(root, DesktopRemoval.PENDING)
check(!pending.exists() || pending.delete()) { "Could not finish desktop installation" }
}
FileUtils.writeString(marker(context, entry.id), entry.version)
check(installed(context, entry.id) == entry.version) { "Could not finish package installation" }
check(!journal.exists() || journal.delete()) { "Could not finish package installation" }
keepDownload = false
return null
} catch (e: Exception) {
Log.e(TAG, "install ${entry.id}", e)
return e.message ?: context.getString(R.string.deskpkg_install_failed)
} finally {
download.delete()
if (!keepDownload) download.delete()
release()
}
}
@@ -11,7 +11,7 @@ import java.io.File
object GuestCommand {
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
linkDir: File? = null, onLine: (String) -> Unit): Int {
linkDir: File? = null, extraBinds: List<String>? = null, fastPath: Boolean = false, onLine: (String) -> Unit): Int {
val log = logName?.let {
try {
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
@@ -19,13 +19,14 @@ object GuestCommand {
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
try {
return runLogged(context, argv, fakeRoot, linkDir) { line -> log?.println(line); log?.flush(); onLine(line) }
return runLogged(context, argv, fakeRoot, linkDir, extraBinds, fastPath) { line -> log?.println(line); log?.flush(); onLine(line) }
} finally {
log?.close()
}
}
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, onLine: (String) -> Unit): Int {
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, linkDir: File?, extraBinds: List<String>?,
fastPath: Boolean, onLine: (String) -> Unit): Int {
val root = LinuxRuntime.rootDir(context)
LinuxRuntime.writeAccounts(context)
SessionFiles.stage(context, root)
@@ -33,8 +34,10 @@ object GuestCommand {
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
if (linkDir != null) cmd.add(1, "--link2symlink")
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
.forEach { cmd.add("-b"); cmd.add(it) }
val binds = LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), extraBinds)
binds.forEach { cmd.add("-b"); cmd.add(it) }
// Fake root and link2symlink are emulated by proot, so their syscalls must stay on the traced path.
val fastPathKey = if (fastPath && !fakeRoot && linkDir == null && ProotFastPath.enabled(context)) ProotFastPath.key(root, binds) else null
cmd += listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
@@ -42,6 +45,7 @@ object GuestCommand {
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) }
cmd += argv
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
builder.environment().apply {
@@ -49,6 +53,7 @@ object GuestCommand {
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(context).path)
if (linkDir != null) put("PROOT_L2S_DIR", linkDir.path)
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
fastPathKey?.let { ProotFastPath.hostEnv(it).let { (k, v) -> put(k, v) } }
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
val process = builder.start()
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime;
import com.droiddeck.launcher.core.AddonPack;
import android.content.Context;
import android.os.Process;
import android.system.ErrnoException;
@@ -64,14 +65,12 @@ public final class LinuxRuntime {
private static final String HOST_DIR = "opt/android-host";
public static File prootBinary(Context context) {
File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot.so");
if (packaged.isFile()) return packaged;
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot.so");
return new File(rootDir(context), HOST_DIR + "/proot");
}
public static File prootLoader(Context context) {
File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot-loader.so");
if (packaged.isFile()) return packaged;
if (AddonPack.PROOT.installed(context)) return AddonPack.PROOT.file(context, "libproot-loader.so");
return new File(rootDir(context), HOST_DIR + "/loader");
}
@@ -187,10 +186,15 @@ public final class LinuxRuntime {
} catch (java.io.IOException e) {
}
}
File fileMax = new File(fakeProc, "file-max");
if (!fileMax.isFile()) {
String[][] seeded = {
{"file-max", "9223372036854775807\n"},
{"overcommit_memory", "0\n"},
};
for (String[] entry : seeded) {
File fake = new File(fakeProc, entry[0]);
if (fake.isFile()) continue;
try {
Files.write(fileMax.toPath(), "9223372036854775807\n".getBytes(StandardCharsets.US_ASCII));
Files.write(fake.toPath(), entry[1].getBytes(StandardCharsets.US_ASCII));
} catch (IOException e) {
}
}
@@ -205,6 +209,7 @@ public final class LinuxRuntime {
{"overflowuid", "/proc/sys/kernel/overflowuid"},
{"overflowgid", "/proc/sys/kernel/overflowgid"},
{"file-max", "/proc/sys/fs/file-max"},
{"overcommit_memory", "/proc/sys/vm/overcommit_memory"},
};
for (String[] entry : procFiles) {
File fake = new File(fakeProc, entry[0]);
@@ -214,6 +219,9 @@ public final class LinuxRuntime {
bind(cmd, fake.getPath() + ":" + entry[1]);
}
}
if (unreadablePciDevice()) {
bind(cmd, new File(root, "etc/droiddeck/empty").getPath() + ":/sys/bus/pci/devices");
}
bindGpuNode(context, cmd);
bindAdrenoStats(cmd, extraBinds);
bindCpuTemps(cmd, root);
@@ -225,6 +233,15 @@ public final class LinuxRuntime {
return specs;
}
private static boolean unreadablePciDevice() {
File[] devices = new File("/sys/bus/pci/devices").listFiles();
if (devices == null) return false;
for (File device : devices) {
if (!new File(device, "vendor").canRead()) return true;
}
return false;
}
private static void bindGpuNode(Context context, List<String> cmd) {
StructStat st;
try {
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.runtime;
import com.droiddeck.launcher.core.AddonPack;
import android.content.Context;
import android.util.Log;
@@ -261,6 +262,8 @@ public final class LinuxRuntimeInstaller {
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
if (!AddonPack.PROOT.await(context)) throw new IOException("Cannot download proot");
if (!AddonPack.DIRECTAUDIO.await(context)) throw new IOException("Cannot download DirectAudio");
boolean ok = Downloader.downloadFile(release.url, archive, true, release.sha256, (fraction) -> {
if (listener != null) {
listener.onProgress(Step.DOWNLOADING, downloading,
@@ -456,9 +459,12 @@ public final class LinuxRuntimeInstaller {
}
}
// Packages extract over the live runtime and their archives list a link before the file it
// names, so a kill mid-way must leave neither a truncated file nor a link to a missing one.
public static boolean extract(Context context, File archive, File destination, ProgressListener listener) {
long entries = 0;
String extracting = context.getString(R.string.rtinst_extracting);
java.util.LinkedHashMap<File, String> links = new java.util.LinkedHashMap<>();
try (InputStream in = new ZstdCompressorInputStream(
new BufferedInputStream(new FileInputStream(archive), 1 << 16));
TarArchiveInputStream tar = new TarArchiveInputStream(in)) {
@@ -477,29 +483,35 @@ public final class LinuxRuntimeInstaller {
if (parent != null && !parent.isDirectory()) parent.mkdirs();
if (entry.isSymbolicLink()) {
file.delete();
FileUtils.symlink(entry.getLinkName(), file.getAbsolutePath());
links.remove(file);
links.put(file, entry.getLinkName());
} else if (entry.isLink()) {
File target = ArchivePaths.inside(destination, entry.getLinkName());
if (target == null) {
Log.w(TAG, "skipping hard link outside the rootfs: " + entry.getLinkName());
continue;
}
file.delete();
links.remove(file);
File part = partFile(file);
part.delete();
try {
Files.createLink(file.toPath(), target.toPath());
Files.createLink(part.toPath(), target.toPath());
} catch (IOException | UnsupportedOperationException e) {
if (target.isFile()) {
Files.copy(target.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
Files.copy(target.toPath(), part.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
if (part.exists()) place(part, file);
} else if (entry.isFile()) {
try (OutputStream out = new FileOutputStream(file)) {
links.remove(file);
File part = partFile(file);
try (OutputStream out = new FileOutputStream(part)) {
byte[] buffer = new byte[1 << 16];
int read;
while ((read = tar.read(buffer)) != -1) out.write(buffer, 0, read);
}
if ((entry.getMode() & 0111) != 0) file.setExecutable(true, false);
if ((entry.getMode() & 0111) != 0) part.setExecutable(true, false);
place(part, file);
} else {
continue;
}
@@ -507,10 +519,32 @@ public final class LinuxRuntimeInstaller {
listener.onProgress(extracting, -1);
}
}
for (java.util.Map.Entry<File, String> link : links.entrySet()) {
File file = link.getKey();
File part = partFile(file);
try {
Files.deleteIfExists(part.toPath());
Files.createSymbolicLink(part.toPath(), java.nio.file.Paths.get(link.getValue()));
place(part, file);
} catch (IOException e) {
Log.w(TAG, "symlink " + file + " -> " + link.getValue() + ": " + e);
part.delete();
}
}
return true;
} catch (Exception e) {
Log.e(TAG, "extract", e);
return false;
}
}
static File partFile(File file) {
return new File(file.getParentFile(), ".dd-part-" + Integer.toHexString(file.getName().hashCode()));
}
private static void place(File part, File file) throws IOException {
java.nio.file.Path path = file.toPath();
if (Files.isDirectory(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.delete(path);
Files.move(part.toPath(), path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.security.MessageDigest
@@ -11,7 +12,7 @@ object ProotFastPath {
fun enabled(context: Context): Boolean =
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context) &&
LinuxRuntime.prootBinary(context).parent == context.applicationInfo.nativeLibraryDir
LinuxRuntime.prootBinary(context).parentFile == AddonPack.PROOT.dir(context)
fun key(root: File, binds: List<String>): String? {
if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null
@@ -0,0 +1,34 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
object RuntimeHealth {
private const val MAX_HOPS = 16
fun brokenLibraryLinks(context: Context): List<String> = brokenLibraryLinks(LinuxRuntime.rootDir(context))
/** A complete runtime has no library link in usr/lib whose target is missing. */
fun brokenLibraryLinks(root: File): List<String> {
val lib = File(root, "usr/lib")
val names = lib.list() ?: return emptyList()
return names.sorted().filter { name ->
val path = File(lib, name).toPath()
Files.isSymbolicLink(path) && !resolves(root, path)
}
}
private fun resolves(root: File, link: Path): Boolean = try {
var path = link
var hops = 0
while (Files.isSymbolicLink(path) && hops++ < MAX_HOPS) {
val target = Files.readSymbolicLink(path)
path = if (target.isAbsolute) File(root, target.toString()).toPath() else path.parent.resolve(target)
}
!Files.isSymbolicLink(path) && Files.exists(path)
} catch (e: Exception) {
false
}
}
@@ -19,6 +19,7 @@ class RuntimeInstallService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_PACKAGE) return holdPackage(startId)
val removal = intent?.action == ACTION_REMOVE
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (removal) R.string.rtinst_removing else R.string.user_apps_starting), -1))
@@ -44,7 +45,27 @@ class RuntimeInstallService : Service() {
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int): Notification {
private fun holdPackage(startId: Int): Int {
startForeground(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = ""
while (packageHolds.get() > 0) {
val now = "$packageStage:$packagePercent"
if (now != shown) {
shown = now
manager?.notify(NOTIFICATION_ID, notification(packageStage, packagePercent, packageTitle))
}
Thread.sleep(500)
}
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
}, "package-install").start()
return START_NOT_STICKY
}
private fun notification(stage: String, percent: Int, title: String? = null): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, getString(R.string.setup_runtime),
NotificationManager.IMPORTANCE_LOW).apply {
@@ -58,7 +79,7 @@ class RuntimeInstallService : Service() {
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentTitle(title ?: getString(if (LinuxRuntimeInstaller.isMaintaining()) R.string.desktop_removing else if (LinuxRuntimeInstaller.isRemoving()) R.string.rtinst_title_removing else R.string.rtinst_title_installing))
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
@@ -72,6 +93,11 @@ class RuntimeInstallService : Service() {
private const val CHANNEL_ID = "runtime-install"
private const val NOTIFICATION_ID = 2
private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME"
private const val ACTION_PACKAGE = "com.droiddeck.launcher.INSTALL_PACKAGE"
private val packageHolds = java.util.concurrent.atomic.AtomicInteger()
@Volatile private var packageTitle = ""
@Volatile private var packageStage = ""
@Volatile private var packagePercent = -1
private const val EXTRA_VERSION = "version"
private const val EXTRA_URL = "url"
private const val EXTRA_SHA256 = "sha256"
@@ -91,6 +117,24 @@ class RuntimeInstallService : Service() {
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
/** Keeps the process in the foreground until the returned release runs, so closing the app does not stop the install. */
fun holdForPackage(context: Context, title: String): () -> Unit {
packageTitle = title
packageStage = title
packagePercent = -1
packageHolds.incrementAndGet()
val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_PACKAGE)
runCatching { if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent) }
.onFailure { android.util.Log.w("RuntimeInstallService", "package install runs without a foreground service: $it") }
val released = java.util.concurrent.atomic.AtomicBoolean()
return { if (released.compareAndSet(false, true)) packageHolds.decrementAndGet() }
}
fun packageProgress(stage: String, percent: Int) {
packageStage = stage
packagePercent = percent
}
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
val url = intent.getStringExtra(EXTRA_URL) ?: return null
@@ -71,6 +71,9 @@ object UserApps {
private fun menuEntry(context: Context, id: String) =
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-app-$id.desktop")
fun launches(program: String?): Boolean =
program == SCRIPT_LAUNCHER || program == AppImageManager.LAUNCHER || program == FlatpakManager.LAUNCHER
fun list(context: Context): List<App> {
val scripts = root(context).listFiles()?.filter { File(it, "entry").isFile }?.map { dir ->
App(
@@ -30,7 +30,7 @@ object BootMovie {
internal val AUDIO_CODECS = setOf(MediaFormat.MIMETYPE_AUDIO_OPUS, MediaFormat.MIMETYPE_AUDIO_VORBIS)
sealed class Import {
data class Done(val name: String, val audioDropped: Boolean) : Import()
data class Done(val name: String, val audioDropped: Boolean, val id: String = "") : Import()
object TooLong : Import()
data class Unsupported(val codec: String?) : Import()
object Failed : Import()
@@ -39,6 +39,21 @@ object BootMovie {
private fun dir(context: Context) = File(context.filesDir, "boot-movie")
private fun customFile(context: Context) = File(dir(context), "custom.webm")
/** A movie imported before movies were kept in Steam's own folder. */
internal fun legacyCustomFile(context: Context) = customFile(context)
/** Unpacks one of DroidDeck's own movies on demand; null for anything else. */
internal fun assetFile(context: Context, id: String): File? = ASSETS[id]?.let { extractAsset(context, it) }
/** Picking a movie here makes it Steam's startup movie at the next session, over whatever Steam had picked. */
fun select(context: Context, id: String) {
SessionPrefs.setBootMovie(context, id)
SessionPrefs.setBootMovieApplyNext(context, true)
BootMovieReport.note(context, "picked $id here; Steam names ${SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))}")
// Whatever Steam names right now is about to be replaced, so it is not a pick to adopt later.
SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context))?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
}
fun customName(context: Context): String? =
if (customFile(context).isFile) SessionPrefs.bootMovieCustomName(context) ?: "custom.webm" else null
@@ -46,21 +61,127 @@ object BootMovie {
val chosen = SessionPrefs.bootMovie(context)
return when {
chosen == CUSTOM && customFile(context).isFile -> CUSTOM
isLibraryId(chosen) && BootMovieLibrary.file(context, chosen) != null -> chosen
chosen in ASSETS -> chosen
else -> DROIDDECK_30
}
}
/** The file the current choice plays from, before any volume or length change. */
fun currentFile(context: Context): File? = sourceOf(context, choice(context))
private fun sourceOf(context: Context, choice: String): File? = when {
choice == CUSTOM -> customFile(context)
isLibraryId(choice) -> BootMovieLibrary.file(context, choice)
else -> ASSETS[choice]?.let { extractAsset(context, it) }
}
private fun isLibraryId(id: String) = id.startsWith(BootMovieLibrary.PREFIX) || id.startsWith(BootMovieLibrary.STEAM_PREFIX)
fun prepare(context: Context): File? {
val choice = choice(context)
val source = if (choice == CUSTOM) customFile(context) else extractAsset(context, ASSETS.getValue(choice)) ?: return null
if (SessionPrefs.bootMovieSound(context)) return source
val muted = File(source.parentFile, source.nameWithoutExtension + "-muted.webm")
if (muted.isFile && muted.lastModified() >= source.lastModified()) return muted
if (remux(source, muted, audio = false)) return muted
if (BootMovieTranscoder.transcode(source, muted, keepAudio = false) {} != null) return muted
Log.e(TAG, "could not strip audio from ${source.name}; not staging it")
return null
BootMovieLibrary.restore(context)
val source = sourceOf(context, choice(context)) ?: return null
return variant(context, source)
}
/** The movie as Steam should get it: with the chosen volume and cut off after the chosen number of seconds. */
private fun variant(context: Context, source: File): File? {
val volume = if (SessionPrefs.bootMovieSound(context)) SessionPrefs.bootMovieVolume(context) else 0
val seconds = SessionPrefs.bootMovieSeconds(context)
val folder = File(dir(context), "variants")
val plan = if (isOversized(source)) oversizedPlan(volume) else plan(volume, seconds)
if (plan.passthrough) {
prune(folder, null)
return source
}
folder.mkdirs()
val limitUs = if (seconds > 0) seconds * 1_000_000L else Long.MAX_VALUE
val out = File(folder, BootMovieLibrary.variantPrefix(source) + "v$volume-s$seconds.webm")
if (out.isFile && out.length() > 0L && out.lastModified() >= source.lastModified()) {
prune(folder, out.name)
return out
}
val staged = File(folder, out.name + ".staged")
val ok = runCatching {
plan.steps.any { step ->
when (step) {
Step.REMUX_SILENT -> remux(source, staged, audio = false, limitUs)
Step.REMUX_AUDIO -> remux(source, staged, audio = true, limitUs)
Step.TRANSCODE_SILENT -> transcodeSilent(source, staged, limitUs)
Step.TRANSCODE_AUDIO -> BootMovieTranscoder.transcode(source, staged, limitUs = limitUs, gain = volume / 100f) {} != null
Step.REGAIN -> BootMovieTranscoder.regain(source, staged, volume / 100f, limitUs)
}
}
}.getOrDefault(false)
if (ok && staged.renameTo(out)) {
prune(folder, out.name)
return out
}
staged.delete()
Log.e(TAG, "could not prepare ${source.name} (volume $volume, $seconds s)")
return if (plan.sourceOnFailure) source else null
}
internal enum class Step { REMUX_SILENT, REMUX_AUDIO, TRANSCODE_SILENT, TRANSCODE_AUDIO, REGAIN }
/**
* How a movie is processed: the steps tried in order until one works, whether the source goes out untouched, and
* whether it still plays as it is when every step fails.
*/
internal class Plan(val passthrough: Boolean, val steps: List<Step>, val sourceOnFailure: Boolean)
internal fun plan(volume: Int, seconds: Int): Plan = when {
volume == 100 && seconds == 0 -> Plan(true, emptyList(), true)
volume == 0 -> Plan(false, listOf(Step.REMUX_SILENT, Step.TRANSCODE_SILENT), false)
// Only a length that could not be applied plays as it is; a volume that could not be applied plays no louder.
volume == 100 -> Plan(false, listOf(Step.REMUX_AUDIO, Step.TRANSCODE_AUDIO), true)
else -> Plan(false, listOf(Step.REGAIN, Step.REMUX_SILENT, Step.TRANSCODE_AUDIO), false)
}
/** Frames beyond 1920x1080 (4K and the like) have made Steam crash while it starts, so such a movie is always scaled down. */
internal const val MAX_LONG_SIDE = 1920
internal const val MAX_SHORT_SIDE = 1080
internal fun oversized(width: Int, height: Int) = maxOf(width, height) > MAX_LONG_SIDE || minOf(width, height) > MAX_SHORT_SIDE
private fun oversized(format: MediaFormat) =
format.containsKey(MediaFormat.KEY_WIDTH) && format.containsKey(MediaFormat.KEY_HEIGHT) &&
oversized(format.getInteger(MediaFormat.KEY_WIDTH), format.getInteger(MediaFormat.KEY_HEIGHT))
private fun isOversized(source: File): Boolean = probe(source)?.first?.let { oversized(it) } == true
/** A movie that is too large never goes out as it is: it is converted at the chosen volume, and plays nothing if that fails. */
internal fun oversizedPlan(volume: Int): Plan =
Plan(false, listOf(if (volume == 0) Step.TRANSCODE_SILENT else Step.TRANSCODE_AUDIO), false)
private fun transcodeSilent(source: File, target: File, limitUs: Long) =
BootMovieTranscoder.transcode(source, target, keepAudio = false, limitUs = limitUs) {} != null
/** Keeps at most the one processed copy in use, so trying settings never leaves movies piling up. */
private fun prune(folder: File, keep: String?) {
folder.listFiles()?.forEach { if (it.name != keep) it.delete() }
}
/**
* Takes over a startup movie chosen in Steam's own settings: when Steam's config names a movie it did not name before,
* that movie becomes the choice here. Steam already plays it, so nothing is staged over it. True when the choice changed.
*/
fun syncFromSteam(context: Context): Boolean {
val path = SessionFiles.startupMoviePath(BootMovieLibrary.steamConfig(context)) ?: return false
if (path == SessionPrefs.bootMovieSteamSeen(context)) return false
SessionPrefs.setBootMovieSteamSeen(context, path)
if (path == SessionFiles.BOOT_MOVIE_PATH) return false
val prefix = "/uioverrides/movies/"
// The list names a bought movie from the Steam folder (/config/communityitemscache/...), Steam's config from its config folder.
val id = if (path.startsWith(prefix)) BootMovieLibrary.idOf(path.removePrefix(prefix)) else {
val listed = BootMovieLibrary.list(context).firstOrNull { it.id.startsWith(BootMovieLibrary.STEAM_PREFIX) && it.id.endsWith(path) }
listed?.id ?: BootMovieLibrary.STEAM_PREFIX + path
}
if (BootMovieLibrary.file(context, id) == null) return false
val changed = SessionPrefs.bootMovie(context) != id
BootMovieReport.note(context, "Steam names $path, taken over as $id")
SessionPrefs.setBootMovie(context, id)
return changed
}
private fun extractAsset(context: Context, name: String): File? {
@@ -82,11 +203,15 @@ object BootMovie {
}
}
fun import(context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {}): Import {
fun import(
context: Context, file: File, onStart: () -> Unit = {}, onProgress: (Int) -> Unit = {},
title: String? = null, source: String = BootMovieLibrary.SOURCE_IMPORT, repoId: String? = null,
cover: ByteArray? = null, use: Boolean = true,
): Import {
val (format, audio) = probe(file) ?: return Import.Unsupported(null)
val video = format.getString(MediaFormat.KEY_MIME)
val audioPlayable = audio == null || audio in AUDIO_CODECS
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES
val playable = video in VIDEO_CODECS && file.length() <= MAX_BYTES && !oversized(format)
val copy = playable && audioPlayable && isWebm(file)
val remux = !copy && playable && video in REMUX_CODECS
if (!copy && !remux) {
@@ -95,11 +220,12 @@ object BootMovie {
if (durationUs > MAX_CONVERT_SECONDS * 1_000_000L) return Import.TooLong
onStart()
}
val target = customFile(context)
val staged = File(dir(context), "custom.webm.staged")
val shown = title?.takeIf { it.isNotBlank() } ?: file.nameWithoutExtension
val library = BootMovieLibrary.libraryDir(context)
val staged = File(library, ".import-${System.nanoTime()}.staged")
var audioDropped = remux && !audioPlayable
return try {
target.parentFile?.mkdirs()
library.mkdirs()
val ok = when {
copy -> {
file.inputStream().use { input -> staged.outputStream().use { output -> FileUtils.copy(input, output) } }
@@ -108,11 +234,10 @@ object BootMovie {
remux -> remux(file, staged, audio = true)
else -> BootMovieTranscoder.transcode(file, staged, onProgress = onProgress)?.also { audioDropped = it.audioDropped } != null
}
if (!ok || !staged.renameTo(target)) return Import.Failed
File(target.parentFile, "custom-muted.webm").delete()
SessionPrefs.setBootMovieCustomName(context, file.name)
SessionPrefs.setBootMovie(context, CUSTOM)
Import.Done(file.name, audioDropped)
if (!ok || !staged.setReadable(true, false)) return Import.Failed
val name = BootMovieLibrary.commit(context, staged, shown, source, repoId, cover) ?: return Import.Failed
if (use) select(context, BootMovieLibrary.idOf(name))
Import.Done(shown, audioDropped, BootMovieLibrary.idOf(name))
} catch (e: Exception) {
Log.w(TAG, "could not import ${file.name}", e)
Import.Failed
@@ -124,8 +249,9 @@ object BootMovie {
fun removeCustom(context: Context) {
customFile(context).delete()
File(dir(context), "custom-muted.webm").delete()
File(dir(context), "variants").listFiles { f -> f.name.startsWith(BootMovieLibrary.variantPrefix(customFile(context))) }?.forEach { it.delete() }
SessionPrefs.setBootMovieCustomName(context, null)
if (SessionPrefs.bootMovie(context) == CUSTOM) SessionPrefs.setBootMovie(context, DROIDDECK_30)
if (SessionPrefs.bootMovie(context) == CUSTOM) select(context, DROIDDECK_30)
}
private fun isWebm(file: File): Boolean {
@@ -156,7 +282,7 @@ object BootMovie {
}
}
private fun remux(source: File, target: File, audio: Boolean): Boolean {
private fun remux(source: File, target: File, audio: Boolean, limitUs: Long = Long.MAX_VALUE): Boolean {
val staged = File(target.parentFile, target.name + ".remux")
val extractor = MediaExtractor()
var muxer: MediaMuxer? = null
@@ -183,7 +309,7 @@ object BootMovie {
val info = MediaCodec.BufferInfo()
while (true) {
val size = extractor.readSampleData(buffer, 0)
if (size < 0) break
if (size < 0 || extractor.sampleTime > limitUs) break
val track = tracks[extractor.sampleTrackIndex]
if (track != null) {
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
@@ -0,0 +1,410 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat
import android.media.MediaMetadataRetriever
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
/**
* The boot movies a person can pick from: DroidDeck's own two, a movie imported before this list existed, every `.webm` in
* Steam's custom movies folder (imports, SteamDeckRepo and movies put there by hand; all of them can be removed), Steam's own
* three startup movies and the movies bought in Steam's points shop (neither can be removed).
*/
object BootMovieLibrary {
private const val TAG = "BootMovieLibrary"
const val PREFIX = "file:"
/** A movie Steam itself keeps outside its custom movies folder, such as one bought in its points shop: `steam:<path Steam names>`. */
const val STEAM_PREFIX = "steam:"
/** Held while the movie list is read and changed, so two imports at once cannot overwrite each other's entries. */
private val lock = Any()
/** Names DroidDeck stages the chosen movie under; they are copies, not movies of their own. */
internal val STAGED = setOf("bigpicture_startup.webm", "steam_os_startup.webm", "droiddeck-startup.webm")
const val SOURCE_DROIDDECK = "droiddeck"
const val SOURCE_IMPORT = "import"
const val SOURCE_REPO = "repo"
const val SOURCE_STEAM = "steam"
const val SOURCE_POINTSHOP = "pointshop"
class Entry(val id: String, val title: String, val source: String, val file: File?, val removable: Boolean)
internal fun moviesDir(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/config/uioverrides/movies")
/**
* The folders whose custom movies are listed, found and removed: [moviesDir], where imports are put and managed, then the
* x86-64 client's. The order never depends on which client is running, so a saved choice keeps meaning the same file.
*/
internal fun customMovieDirs(context: Context): List<File> =
listOf(moviesDir(context), File(SessionPrefs.steamRoot(context, "x86_64"), "config/uioverrides/movies")).distinctBy { it.path }
/** Where imported and installed movies are kept for good; Steam's folder holds a copy, which is put back if it goes missing. */
internal fun libraryDir(context: Context) = File(context.filesDir, "boot-movie/library")
private fun metaFile(context: Context) = File(context.filesDir, "boot-movie/movies.json")
internal fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
/** The running (else selected) Steam client's config, the one whose startup movie choice is read and recorded. */
internal fun steamConfig(context: Context) = File(SessionPrefs.activeSteamRoot(context), "config/config.vdf")
private fun thumbsDir(context: Context) = File(context.filesDir, "boot-movie/thumbs")
fun idOf(name: String) = PREFIX + name
/** Every Steam client folder: the usual one and the x86-64 client's. */
internal fun steamRoots(context: Context): List<File> = listOf(
steamRoot(context), File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/.local/share/Steam"),
).filter { it.isDirectory }
private fun steamFile(context: Context, id: String): File? {
val path = id.removePrefix(STEAM_PREFIX)
if (!path.startsWith("/") || path.contains("..") || path.contains('\\') || !path.endsWith(".webm", ignoreCase = true)) return null
return steamRoots(context).firstNotNullOfOrNull { root ->
listOf("config", "steamui", "").map { File(root, it + path) }.firstOrNull { it.isFile && it.length() > 0L }
}
}
fun file(context: Context, id: String): File? {
if (id.startsWith(STEAM_PREFIX)) return steamFile(context, id)
if (!id.startsWith(PREFIX)) return null
val name = id.removePrefix(PREFIX)
if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return null
return customMovieDirs(context).map { File(it, name) }.firstOrNull { it.isFile && it.length() > 0L }
}
private fun isMovie(file: File) = file.isFile && file.length() > 0L && file.name.endsWith(".webm", ignoreCase = true) &&
file.name !in STAGED && !file.name.startsWith(".")
private fun readMeta(context: Context): JSONObject =
runCatching { JSONObject(metaFile(context).readText()) }.getOrDefault(JSONObject())
/** Changes the movie list one writer at a time; [change] gets the list and returns whether to save it. */
private fun editMeta(context: Context, change: (JSONObject) -> Boolean) = synchronized(lock) {
val meta = readMeta(context)
if (change(meta)) {
val file = metaFile(context)
file.parentFile?.mkdirs()
val staged = File(file.parentFile, file.name + ".staged")
runCatching {
staged.writeText(meta.toString())
if (!staged.renameTo(file)) staged.delete()
}.onFailure { staged.delete(); Log.w(TAG, "could not write the movie list", it) }
}
}
/** The post a movie was installed from, if it came from SteamDeckRepo. */
fun repoId(context: Context, name: String): String? =
readMeta(context).optJSONObject(name)?.optString("repo")?.takeIf { it.isNotEmpty() }
fun installedRepoIds(context: Context): Set<String> {
val meta = readMeta(context)
return meta.keys().asSequence().filter { file(context, idOf(it)) != null }
.mapNotNull { meta.optJSONObject(it)?.optString("repo")?.takeIf { id -> id.isNotEmpty() } }.toSet()
}
fun nameForRepo(context: Context, repoId: String): String? {
val meta = readMeta(context)
return meta.keys().asSequence().firstOrNull { file(context, idOf(it)) != null && meta.optJSONObject(it)?.optString("repo") == repoId }
}
fun list(context: Context): List<Entry> {
val out = ArrayList<Entry>()
out.add(Entry(BootMovie.DROIDDECK_30, "DroidDeck · 30 fps", SOURCE_DROIDDECK, null, removable = false))
out.add(Entry(BootMovie.DROIDDECK_60, "DroidDeck · 60 fps", SOURCE_DROIDDECK, null, removable = false))
BootMovie.customName(context)?.let { out.add(Entry(BootMovie.CUSTOM, it, SOURCE_IMPORT, BootMovie.legacyCustomFile(context), removable = true)) }
restore(context)
val meta = readMeta(context)
val names = HashSet<String>()
customMovieDirs(context).flatMap { dir -> dir.listFiles { f -> isMovie(f) }.orEmpty().toList() }
.filter { names.add(it.name) }.sortedBy { it.name.lowercase() }.forEach { f ->
val info = meta.optJSONObject(f.name)
val source = info?.optString("source")?.takeIf { it.isNotEmpty() } ?: SOURCE_STEAM
val title = info?.optString("title")?.takeIf { it.isNotEmpty() } ?: f.nameWithoutExtension
// Everything in Steam's custom movies folder is the person's own (imported, from SteamDeckRepo or put there by hand).
out.add(Entry(idOf(f.name), title, if (source == SOURCE_STEAM) SOURCE_IMPORT else source, f, removable = true))
}
val steam = LinkedHashMap<String, File>()
SessionFiles.startupMoviePath(steamConfig(context))?.takeIf { !it.startsWith(OVERRIDES) }?.let { path ->
steamFile(context, STEAM_PREFIX + path)?.let { steam[STEAM_PREFIX + path] = it }
}
val bought = HashMap<String, String>()
for (root in steamRoots(context)) {
File(root, "steamui/movies").listFiles { f -> isMovie(f) && f.nameWithoutExtension.lowercase() in NATIVE }.orEmpty()
.sortedBy { it.name.lowercase() }.forEach { steam.putIfAbsent(STEAM_PREFIX + "/movies/" + it.name, it) }
val scan = pointShopMovies(root)
bought.putAll(scan.titles)
for (found in scan.files) steam.putIfAbsent(STEAM_PREFIX + "/" + found.relativeTo(root).invariantSeparatorsPath, found)
}
val seen = HashSet<String>()
for ((id, file) in steam) {
if (file.name in STAGED || !seen.add(file.canonicalPath)) continue
val native = file.nameWithoutExtension.lowercase() in NATIVE
// Steam names a bought movie by an item number and a hash; the points shop's own title is shown when Steam has it.
val title = if (native) NATIVE[file.nameWithoutExtension.lowercase()] ?: file.nameWithoutExtension else bought[file.path] ?: boughtId(file)?.let { "#$it" } ?: file.nameWithoutExtension
out.add(Entry(id, title, if (native) SOURCE_STEAM else SOURCE_POINTSHOP, file, removable = false))
}
return out
}
/** The three startup movies Steam ships with; Steam's movies folder holds many more videos that are not startup movies. */
private val NATIVE = mapOf("deck_startup" to "Deck startup", "oled_startup" to "Oled startup", "startup_machine" to "Machine startup")
private val skippedFolders = setOf("uioverrides", "steamapps", "librarycache", "httpcache", "logs", "compatdata", "shadercache")
private val scanLock = Any()
private class Scan(val at: Long, val files: List<File>, val titles: Map<String, String>)
private val scans = HashMap<String, Scan>()
/** Forgets the last search for Steam's own movies. */
internal fun forgetScan() = synchronized(scanLock) { scans.clear() }
/**
* Movies Steam keeps outside its custom movies folder, such as ones bought in its points shop: every `.webm` below the
* client's config, cache and user folders. The search is repeated at most every 15 seconds for each client folder.
*/
private fun pointShopMovies(root: File): Scan = synchronized(scanLock) {
val now = System.currentTimeMillis()
scans[root.path]?.let { if (now - it.at < 15_000L) return it }
val found = listOf("config", "userdata", "appcache", "steamui").flatMap { top ->
val base = File(root, top)
if (!base.isDirectory) return@flatMap emptyList()
base.walkTopDown().maxDepth(5)
.onEnter { dir -> dir.name !in skippedFolders && !(top == "steamui" && dir.parentFile == base && dir.name == "movies") }
.take(20_000).filter { isMovie(it) }.toList()
}.sortedBy { it.path.lowercase() }
val titles = HashMap<String, String>()
for (file in found) boughtId(file)?.let { id -> boughtTitle(root, id)?.let { titles[file.path] = it } }
val scan = Scan(now, found, titles)
scans[root.path] = scan
scan
}
/** The item number in the name Steam gives a bought movie, `<item number>_<hash>.webm`; null for any other name. */
private fun boughtId(file: File) = Regex("^(\\d+)_").find(file.nameWithoutExtension)?.groupValues?.get(1)
/** What the points shop calls a bought movie, from the list of startup movies Steam keeps in each user's local config. */
private fun boughtTitle(root: File, id: String): String? {
val key = "communityitemid\\\":\\\"$id\\\""
val title = Regex("item_title\\\\\":\\\\\"([^\\\\\"]*)")
for (user in File(root, "userdata").listFiles { f -> f.isDirectory }.orEmpty()) {
val text = runCatching { File(user, "config/localconfig.vdf").readText() }.getOrNull() ?: continue
val at = text.indexOf(key)
if (at < 0) continue
title.find(text.substring(at, (at + 3000).coerceAtMost(text.length)))?.groupValues?.get(1)?.takeIf { it.isNotBlank() }?.let { return it }
}
return null
}
private const val OVERRIDES = "/uioverrides/movies/"
/** Puts back, in Steam's folder, any movie of DroidDeck's that went missing there, such as after the runtime was reset. */
internal fun restore(context: Context) = synchronized(lock) {
migrateLegacyMeta(context)
val meta = readMeta(context)
val library = libraryDir(context)
meta.keys().asSequence().toList().forEach { name ->
val kept = File(library, name)
if (name.contains('/') || !kept.isFile || kept.length() == 0L) return@forEach
val copy = File(moviesDir(context), name)
if (!copy.isFile || copy.length() != kept.length()) mirror(kept, copy)
}
}
/** Takes in the movie notes an earlier version kept inside Steam's movies folder, so those movies keep their title and source. */
private fun migrateLegacyMeta(context: Context) {
val legacy = File(moviesDir(context), ".droiddeck-movies.json")
if (!legacy.isFile) return
val old = runCatching { JSONObject(legacy.readText()) }.getOrNull() ?: return
editMeta(context) { meta ->
var changed = false
old.keys().asSequence().toList().forEach { name ->
val info = old.optJSONObject(name) ?: return@forEach
if (meta.optJSONObject(name) == null && File(moviesDir(context), name).isFile) { meta.put(name, info); changed = true }
}
changed
}
legacy.delete()
}
/** Copies [kept] to [copy] through a staging name, so Steam never sees half a movie. */
private fun mirror(kept: File, copy: File): Boolean {
val staged = File(copy.parentFile, copy.name + ".${System.nanoTime()}.staged")
return try {
copy.parentFile?.mkdirs()
kept.inputStream().use { input -> staged.outputStream().use { output -> input.copyTo(output) } }
staged.setReadable(true, false) && staged.renameTo(copy)
} catch (e: Exception) {
Log.w(TAG, "could not copy ${kept.name} into Steam's movies", e)
false
} finally {
staged.delete()
}
}
fun title(context: Context, id: String): String = list(context).firstOrNull { it.id == id }?.title ?: id
/** The file a movie id plays from; DroidDeck's own movies live in the app and are unpacked on demand. */
fun fileOf(context: Context, entry: Entry): File? = entry.file ?: BootMovie.assetFile(context, entry.id)
/** A free `.webm` name for a movie called [title]; the same title twice gets " (2)", " (3)"… */
fun freeName(context: Context, title: String): String {
val base = title.replace(Regex("[^\\p{L}\\p{N} ._()\\-]"), "").replace(Regex("\\s+"), " ").trim().trim('.').take(60).ifEmpty { "movie" }
val dirs = customMovieDirs(context)
val library = libraryDir(context)
var name = "$base.webm"
var n = 2
while (dirs.any { File(it, name).exists() } || File(library, name).exists() || name in STAGED) name = "$base ($n).webm".also { n++ }
return name
}
/**
* Keeps the finished movie [staged] (a file in [libraryDir]) under a free name, copies it into Steam's folder and notes its
* title, source and cover. Returns the name, or null when it could not be kept. Runs one movie at a time.
*/
internal fun commit(context: Context, staged: File, title: String, source: String, repoId: String? = null, cover: ByteArray? = null): String? =
synchronized(lock) {
val name = freeName(context, title)
val kept = File(libraryDir(context), name)
if (!staged.renameTo(kept)) return null
if (!mirror(kept, File(moviesDir(context), name))) { kept.delete(); return null }
register(context, name, title, source, repoId, cover)
name
}
/** Notes a movie that was just put into Steam's folder: what to call it, where it came from and its cover. */
fun register(context: Context, name: String, title: String, source: String, repoId: String? = null, cover: ByteArray? = null) {
editMeta(context) { meta ->
val info = JSONObject().put("title", title).put("source", source)
if (repoId != null) info.put("repo", repoId)
meta.put(name, info)
true
}
if (cover != null) runCatching {
thumbsDir(context).mkdirs()
File(thumbsDir(context), coverName(name)).writeBytes(cover)
}
}
fun remove(context: Context, id: String): Boolean {
val removed = when {
id == BootMovie.CUSTOM -> { BootMovie.removeCustom(context); true }
else -> {
// Only movies in Steam's custom movies folder are ever deleted; Steam's own and points shop movies are left alone.
if (!id.startsWith(PREFIX)) return false
val name = id.removePrefix(PREFIX)
if (name.isEmpty() || name.contains('/') || name.contains('\\') || name in STAGED) return false
val copies = customMovieDirs(context).map { File(it, name) }
// A name can exist in more than one client's folder; the entry is the first one found (see customMovieDirs), and only that file is deleted.
val target = copies.firstOrNull { it.exists() }
val managed = File(libraryDir(context), name)
if (readMeta(context).optJSONObject(name) == null && target == null && !managed.isFile) return false
synchronized(lock) {
val main = File(moviesDir(context), name)
val another = copies.any { it != target && it.exists() }
val ownsRecord = target == null || target.path == main.path || (!another && !managed.isFile)
var gone = target == null || target.delete()
if (gone && ownsRecord) {
gone = !managed.exists() || managed.delete()
if (gone) {
editMeta(context) { it.remove(name) != null }
File(thumbsDir(context), coverName(name)).delete()
thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() }
}
}
if (gone) {
val prefixes = listOfNotNull(target, main.takeIf { ownsRecord }).map { variantPrefix(it) }
File(context.filesDir, "boot-movie/variants").listFiles { f -> prefixes.any { f.name.startsWith(it) } }?.forEach { it.delete() }
}
gone
}
}
}
if (removed && SessionPrefs.bootMovie(context) == id) BootMovie.select(context, BootMovie.DROIDDECK_30)
return removed
}
/** What every processed copy of [source] starts with; the hash of its full path keeps same-named files in different places apart. */
internal fun variantPrefix(source: File) = safe(source.name) + "." + Integer.toHexString(source.absolutePath.hashCode()) + "-"
private fun safe(name: String) = name.replace(Regex("[^A-Za-z0-9._-]"), "_")
private fun coverName(name: String) = "cover-" + safe(name)
/** How long a movie runs, in whole seconds; 0 when it cannot be read. */
fun durationSeconds(file: File?): Int {
if (file == null || !file.isFile) return 0
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(file.path)
val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
((ms + 500) / 1000).toInt()
} catch (e: Exception) {
0
} finally {
runCatching { retriever.release() }
}
}
/** The first frame that is not nearly black out of several taken through the movie, else the brightest; a movie may open dark. */
private fun brightestFrame(retriever: MediaMetadataRetriever, durationMs: Long): Bitmap? {
var best: Bitmap? = null
var bestLuma = -1
for (fraction in doubleArrayOf(0.33, 0.5, 0.66, 0.2, 0.8, 0.1, 0.9)) {
val frame = runCatching { retriever.getFrameAtTime((durationMs * 1000L * fraction).toLong(), MediaMetadataRetriever.OPTION_CLOSEST) }.getOrNull() ?: continue
val luma = averageLuma(frame)
if (luma >= MIN_COVER_LUMA) return frame
if (luma > bestLuma) { best = frame; bestLuma = luma }
}
return best ?: runCatching { retriever.frameAtTime }.getOrNull()
}
private fun averageLuma(bitmap: Bitmap): Int {
val small = Bitmap.createScaledBitmap(bitmap, 16, 9, true)
var sum = 0L
for (y in 0 until small.height) for (x in 0 until small.width) {
val c = small.getPixel(x, y)
sum += (((c shr 16) and 0xFF) * 3 + ((c shr 8) and 0xFF) * 6 + (c and 0xFF)) / 10
}
return (sum / (small.width * small.height)).toInt()
}
private const val MIN_COVER_LUMA = 24
/** A cover for a movie: the picture SteamDeckRepo gave it, or one frame taken from the video and remembered. */
fun cover(context: Context, entry: Entry): File? {
val file = fileOf(context, entry) ?: return null
val name = if (entry.id.startsWith(PREFIX)) file.name else entry.id
// Points Shop covers once kept a picture that was not always the movie's own; those files are not used.
val given = File(thumbsDir(context), if (entry.source == SOURCE_POINTSHOP) "v2-" + coverName(name) else coverName(name))
if (given.isFile && given.length() > 0L) return given
val key = "frame-" + safe(name) + "-v2-" + file.length() + "-" + file.lastModified() + ".jpg"
val frame = File(thumbsDir(context), key)
if (frame.isFile && frame.length() > 0L) return frame
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(file.path)
val ms = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
val bitmap = brightestFrame(retriever, ms) ?: return null
val width = 480
val scaled = if (bitmap.width > width) Bitmap.createScaledBitmap(bitmap, width, (bitmap.height * width / bitmap.width).coerceAtLeast(1), true) else bitmap
thumbsDir(context).mkdirs()
thumbsDir(context).listFiles { f -> f.name.startsWith("frame-" + safe(name) + "-") }?.forEach { it.delete() }
val staged = File(thumbsDir(context), "$key.staged")
staged.outputStream().use { scaled.compress(CompressFormat.JPEG, 82, it) }
if (staged.renameTo(frame)) frame else null
} catch (e: Exception) {
Log.w(TAG, "no cover for ${file.name}", e)
null
} finally {
runCatching { retriever.release() }
}
}
}
@@ -0,0 +1,160 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.os.Environment
import android.util.Log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* What DroidDeck and Steam know about boot movies, in one text file: Steam's startup movie setting, every movie file and
* where it lives, DroidDeck's own state, and a short trace of what each session start did to Steam's config.
*/
object BootMovieReport {
private const val TAG = "BootMovieReport"
private const val MAX_TRACE_LINES = 200
private const val MAX_HITS = 60
private val KNOWN_TITLES = listOf("Turret Factory")
private val SKIPPED = setOf("steamapps", "compatdata", "shadercache", "logs")
private val BINARY = setOf("webm", "jpg", "jpeg", "png", "ico", "mp4", "ogg", "mp3", "wav", "ttf", "woff", "woff2", "gif", "webp", "bin", "so", "dll", "exe", "zip", "pak")
private val lock = Any()
private fun traceFile(context: Context) = File(context.filesDir, "boot-movie/apply.log")
/** Adds a line to the short trace kept for the report; only the latest lines are kept. */
fun note(context: Context, line: String) {
synchronized(lock) {
runCatching {
val file = traceFile(context)
file.parentFile?.mkdirs()
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
val lines = (if (file.isFile) file.readLines() else emptyList()) + "$stamp $line"
file.writeText(lines.takeLast(MAX_TRACE_LINES).joinToString("\n") + "\n")
}
}
}
/** Writes the report to Downloads/DroidDeck, or to the app's own folder when that is not possible; null when the report cannot be built or neither folder works. */
fun write(context: Context): File? {
val text = runCatching { build(context) }.getOrElse {
Log.w(TAG, "could not build the report", it)
return null
}
val folders = listOfNotNull(
File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck"),
context.getExternalFilesDir(null),
)
for (folder in folders) {
val file = File(folder, "boot-movie-report.txt")
val saved = runCatching { folder.mkdirs(); file.writeText(text); true }.getOrDefault(false)
if (saved) return file
Log.w(TAG, "could not write the report to ${file.path}")
}
return null
}
private fun describe(file: File) =
"${file.path} ${file.length()} bytes ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(file.lastModified()))}"
internal fun build(context: Context): String {
val out = StringBuilder()
fun line(text: String = "") { out.append(text).append('\n') }
fun listing(title: String, folder: File) {
line("-- $title: ${folder.path}")
val files = folder.listFiles()?.sortedBy { it.name.lowercase() }
if (files == null) line(" (missing)") else if (files.isEmpty()) line(" (empty)")
else files.forEach { line(" " + describe(it)) }
}
line("DroidDeck boot movie report, ${Date()}")
line()
line("== DroidDeck state")
line("choice=${BootMovie.choice(context)} saved choice=${SessionPrefs.bootMovie(context)} pending pick=${SessionPrefs.bootMovieApplyNext(context)}")
line("last Steam path seen=${SessionPrefs.bootMovieSteamSeen(context)}")
line("sound=${SessionPrefs.bootMovieSound(context)} volume=${SessionPrefs.bootMovieVolume(context)} seconds=${SessionPrefs.bootMovieSeconds(context)}")
line("session running=${SessionState.running}")
line()
line("== The movie list as shown")
BootMovieLibrary.list(context).forEach { line("${it.id} title=${it.title} source=${it.source} removable=${it.removable} file=${it.file?.path}") }
line()
line("== DroidDeck folders")
listing("library", BootMovieLibrary.libraryDir(context))
listing("processed copies", File(context.filesDir, "boot-movie/variants"))
val meta = File(context.filesDir, "boot-movie/movies.json")
line("-- movie list file: ${if (meta.isFile) meta.readText().take(8000) else "(missing)"}")
val roots = BootMovieLibrary.steamRoots(context)
val movieIds = HashSet<String>()
for (root in roots) {
line()
line("== Steam folder ${root.path}")
val config = File(root, "config/config.vdf")
line("config.vdf: ${if (config.isFile) describe(config) else "(missing)"}")
val text = runCatching { config.readText() }.getOrNull()
if (text != null) {
line("-- StartupMovie block:")
(SessionFiles.startupMovieBlock(config) ?: " (none)").lines().forEach { line(" $it") }
line("-- Customization section with the lines before it:")
(SessionFiles.customizationBlock(config) ?: " (none)").lines().forEach { line(" $it") }
line("-- every line of config.vdf that mentions a movie:")
text.lines().filter { it.contains("movie", ignoreCase = true) }.take(40).forEach { line(" ${it.trim()}") }
Regex("\"MovieID\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)?.takeIf { it.isNotEmpty() && it != "0" }?.let { movieIds.add(it) }
}
listing("custom movies", File(root, "config/uioverrides/movies"))
listing("Steam's own movies", File(root, "steamui/movies"))
listing("config/movies", File(root, "config/movies"))
line("-- every .webm below config, userdata, appcache and steamui:")
var shown = 0
for (top in listOf("config", "userdata", "appcache", "steamui")) {
val base = File(root, top)
if (!base.isDirectory) continue
for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) {
if (!file.isFile || !file.name.endsWith(".webm", ignoreCase = true)) continue
if (shown++ >= 300) break
line(" " + describe(file))
Regex("^(\\d+)_[0-9a-fA-F]+").find(file.name)?.groupValues?.get(1)?.let { movieIds.add(it) }
}
}
}
line()
line("== Where Steam keeps movie names (text found in Steam's files)")
val needles = (KNOWN_TITLES + movieIds).distinct()
line("looking for: ${needles.joinToString(", ")}")
search(roots, needles) { line(" $it") }
line()
line("== What each session start did to Steam's config (latest last)")
val trace = traceFile(context)
if (trace.isFile) trace.readLines().forEach { line(" $it") } else line(" (nothing yet: start a session after picking a movie)")
return out.toString()
}
private fun search(roots: List<File>, needles: List<String>, report: (String) -> Unit) {
if (needles.isEmpty()) return
val deadline = System.currentTimeMillis() + 25_000L
var scanned = 0
var hits = 0
outer@ for (root in roots) {
for (top in listOf("config", "userdata", "appcache", "steamui")) {
val base = File(root, top)
if (!base.isDirectory) continue
for (file in base.walkTopDown().maxDepth(8).onEnter { it.name !in SKIPPED }) {
if (!file.isFile || file.length() > (2L shl 20) || file.extension.lowercase() in BINARY) continue
if (++scanned > 20_000 || System.currentTimeMillis() > deadline || hits >= MAX_HITS) break@outer
val text = runCatching { String(file.readBytes(), Charsets.ISO_8859_1) }.getOrNull() ?: continue
for (needle in needles) {
val at = text.indexOf(needle)
if (at < 0) continue
hits++
val around = text.substring((at - 60).coerceAtLeast(0), (at + needle.length + 100).coerceAtMost(text.length))
report("$needle in ${file.path}: " + around.map { if (it in ' '..'~') it else '.' }.joinToString(""))
}
}
}
}
report("(searched $scanned files, $hits matches)")
}
}
@@ -37,15 +37,77 @@ internal object BootMovieTranscoder {
MediaCodecList(MediaCodecList.REGULAR_CODECS).findDecoderForFormat(format) != null
}.getOrDefault(false)
fun transcode(source: File, target: File, keepAudio: Boolean = true, onProgress: (Int) -> Unit): Result? {
fun transcode(source: File, target: File, keepAudio: Boolean = true, limitUs: Long = Long.MAX_VALUE, gain: Float = 1f, onProgress: (Int) -> Unit): Result? {
val hadAudio = keepAudio && trackFormat(source, "audio/") != null
val audio = if (hadAudio) runCatching { encodeAudio(source) }
val audio = if (hadAudio) runCatching { encodeAudio(source, gain, limitUs) }
.onFailure { Log.w(TAG, "could not convert the audio of ${source.name}", it) }.getOrNull() else null
val muxedAudio = runCatching { encodeVideo(source, target, audio, onProgress) }
val muxedAudio = runCatching { encodeVideo(source, target, audio, limitUs, onProgress) }
.onFailure { Log.w(TAG, "could not convert ${source.name}", it) }.getOrNull() ?: return null
return Result(audioDropped = hadAudio && !muxedAudio)
}
/** Copies the video of [source] untouched and gives it its sound again at [gain] of its level, cut to [limitUs]. */
fun regain(source: File, target: File, gain: Float, limitUs: Long): Boolean {
val audio = runCatching { encodeAudio(source, gain, limitUs) }
.onFailure { Log.w(TAG, "could not change the volume of ${source.name}", it) }.getOrNull() ?: return false
val extractor = MediaExtractor()
var muxer: MediaMuxer? = null
val staged = File(target.parentFile, target.name + ".regain")
return try {
extractor.setDataSource(source.path)
var videoTrack = -1
var videoFormat: MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (videoTrack < 0 && (mime == MediaFormat.MIMETYPE_VIDEO_VP8 || mime == MediaFormat.MIMETYPE_VIDEO_VP9)) {
videoTrack = i
videoFormat = format
}
}
if (videoTrack < 0 || videoFormat == null) return false
extractor.selectTrack(videoTrack)
val mux = MediaMuxer(staged.path, MediaMuxer.OutputFormat.MUXER_OUTPUT_WEBM).also { muxer = it }
val outVideo = mux.addTrack(videoFormat)
val outAudio = mux.addTrack(audio.format)
mux.start()
val info = MediaCodec.BufferInfo()
var audioIndex = 0
fun writeAudioUpTo(timeUs: Long) {
while (audioIndex < audio.samples.size && audio.samples[audioIndex].timeUs <= timeUs) {
val sample = audio.samples[audioIndex++]
info.set(0, sample.data.size, sample.timeUs, 0)
mux.writeSampleData(outAudio, ByteBuffer.wrap(sample.data), info)
}
}
var bufferSize = 8 shl 20
if (videoFormat.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) bufferSize = maxOf(bufferSize, videoFormat.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
val buffer = ByteBuffer.allocate(bufferSize)
while (true) {
val size = extractor.readSampleData(buffer, 0)
if (size < 0 || extractor.sampleTime > limitUs) break
val time = extractor.sampleTime
writeAudioUpTo(time)
val key = extractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
info.set(0, size, time, if (key) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0)
mux.writeSampleData(outVideo, buffer, info)
extractor.advance()
}
writeAudioUpTo(Long.MAX_VALUE)
mux.stop()
mux.release()
muxer = null
staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "could not change the volume of ${source.name}", e)
false
} finally {
runCatching { muxer?.release() }
extractor.release()
staged.delete()
}
}
private fun trackFormat(source: File, prefix: String): Pair<Int, MediaFormat>? {
val extractor = MediaExtractor()
return try {
@@ -75,6 +137,14 @@ internal object BootMovieTranscoder {
return Layout(canvas, ch, (canvas - cw) / 2, 0, cw, ch)
}
/** [pcm] made quieter by [gain] and cut to its first [limitUs] microseconds. */
internal fun scale(pcm: ShortArray, channels: Int, gain: Float, limitUs: Long): ShortArray {
val frames = pcm.size / channels
val kept = if (limitUs == Long.MAX_VALUE) frames else minOf(frames.toLong(), limitUs * OPUS_RATE / 1_000_000L).toInt()
if (gain == 1f && kept == frames) return pcm
return ShortArray(kept * channels) { (pcm[it] * gain).toInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort() }
}
internal fun resample(pcm: ShortArray, channels: Int, rate: Int): ShortArray {
if (rate == OPUS_RATE) return pcm
val frames = pcm.size / channels
@@ -94,7 +164,10 @@ internal object BootMovieTranscoder {
return out
}
private fun encodeAudio(source: File): Audio? {
/** Most decoded audio held in memory at once: about six minutes of 48 kHz stereo. */
private const val MAX_PCM_BYTES = 64 shl 20
private fun encodeAudio(source: File, gain: Float = 1f, limitUs: Long = Long.MAX_VALUE): Audio? {
val (track, inFormat) = trackFormat(source, "audio/") ?: return null
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
@@ -139,13 +212,16 @@ internal object BootMovieTranscoder {
}
dec.releaseOutputBuffer(index, false)
if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
// Past the length that will be kept there is nothing more to decode; without a length, a track too big to hold is dropped.
if (limitUs != Long.MAX_VALUE && info.presentationTimeUs > limitUs) break
if (pcm.size() > MAX_PCM_BYTES) { if (limitUs == Long.MAX_VALUE) return null else break }
}
}
if (channels !in 1..2) return null
val raw = pcm.toByteArray()
val shorts = ShortArray(raw.size / 2)
ByteBuffer.wrap(raw).order(ByteOrder.nativeOrder()).asShortBuffer().get(shorts)
val samples = resample(shorts, channels, rate)
val samples = scale(resample(shorts, channels, rate), channels, gain, limitUs)
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_OPUS, OPUS_RATE, channels)
format.setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
@@ -201,7 +277,7 @@ internal object BootMovieTranscoder {
private fun MediaFormat.intOrNull(key: String): Int? = if (!containsKey(key)) null else
runCatching { getInteger(key) }.getOrElse { runCatching { getFloat(key).toInt() }.getOrNull() }
private fun encodeVideo(source: File, target: File, audio: Audio?, onProgress: (Int) -> Unit): Boolean {
private fun encodeVideo(source: File, target: File, audio: Audio?, limitUs: Long, onProgress: (Int) -> Unit): Boolean {
val (track, inFormat) = trackFormat(source, "video/") ?: throw IllegalStateException("no video track")
val rotation = (inFormat.intOrNull(MediaFormat.KEY_ROTATION) ?: 0).mod(360)
val layout = layout(inFormat.getInteger(MediaFormat.KEY_WIDTH), inFormat.getInteger(MediaFormat.KEY_HEIGHT), rotation)
@@ -278,7 +354,8 @@ internal object BootMovieTranscoder {
val index = dec.dequeueOutputBuffer(decInfo, TIMEOUT_US)
if (index >= 0) {
val timeUs = decInfo.presentationTimeUs
val keep = decInfo.size > 0 && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
val pastLimit = timeUs > limitUs
val keep = decInfo.size > 0 && !pastLimit && (lastKept == Long.MIN_VALUE || timeUs - lastKept >= minIntervalUs)
dec.releaseOutputBuffer(index, keep)
if (keep) {
if (!frames.awaitFrame()) throw IllegalStateException("decoder frame timed out")
@@ -290,9 +367,10 @@ internal object BootMovieTranscoder {
if (percent != lastProgress) { lastProgress = percent; onProgress(percent) }
}
}
if (decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
if (pastLimit || decInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
enc.signalEndOfInputStream()
decoderDone = true
inputDone = true
}
}
}
@@ -0,0 +1,179 @@
package com.droiddeck.launcher.session
import android.os.FileObserver
import android.os.SystemClock
import android.system.Os
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.input.FakeInputWriter
import java.io.File
class InputSysfsComponent(
private val nodes: File,
base: File,
private val udevData: File,
private val deckPad: Boolean,
private val steamVirtual: Boolean,
) : SessionPart() {
private val devices = File(base, "devices")
private val classDir = File(base, "class")
private val shown = HashSet<Int>()
private var observer: FileObserver? = null
private var running = false
fun prepare(): Boolean = try {
FileUtils.clear(devices)
FileUtils.clear(classDir)
devices.mkdirs()
classDir.mkdirs()
udevData.mkdirs()
udevData.listFiles()?.forEach { if (isPadRecord(it.name)) it.delete() }
devices.isDirectory && classDir.isDirectory
} catch (e: Exception) {
Log.w(TAG, "pads: could not prepare the input sysfs: $e")
false
}
fun binds() = listOf(devices.path + ":" + GUEST_DEVICES, classDir.path + ":" + GUEST_CLASS)
override fun start() {
synchronized(this) { running = true }
active = this
@Suppress("DEPRECATION")
observer = object : FileObserver(nodes.path, CREATE or DELETE or MOVED_TO or MOVED_FROM or CLOSE_WRITE) {
override fun onEvent(event: Int, path: String?) {
if (path != null && NODE_FILE.matches(path)) sync()
}
}.also { it.startWatching() }
sync()
}
override fun stop() {
observer?.stopWatching()
observer = null
if (active === this) active = null
synchronized(this) {
running = false
for (node in shown.toList()) hide(node)
}
}
@Synchronized
fun sync() {
if (!running) return
val wanted = (0 until NODE_LIMIT).filter { node ->
File(nodes, "event$node").exists() && if (node >= FIRST_VIRTUAL) File(nodes, ".uinput-event$node.uevent").isFile
else !ringWithdrawn(deckPad, padType)
}.toSet()
for (node in shown - wanted) hide(node)
for (node in wanted - shown) show(node)
}
private fun show(node: Int) {
val pad = (if (node >= FIRST_VIRTUAL) runCatching { File(nodes, ".uinput-event$node.uevent").readText() }.getOrNull()?.let(::virtualPad)
else ringPad(node, steamVirtual)) ?: return
try {
val input = File(devices, "input$node")
val event = File(input, "event$node")
event.mkdirs()
File(input, "uevent").writeText(pad.uevent())
File(input, "name").writeText(pad.name + "\n")
File(input, "phys").writeText(pad.phys + "\n")
File(input, "uniq").writeText(pad.uniq + "\n")
File(input, "properties").writeText("0\n")
File(input, "id").mkdirs()
File(input, "id/bustype").writeText("%04x\n".format(pad.bus))
File(input, "id/vendor").writeText("%04x\n".format(pad.vendor))
File(input, "id/product").writeText("%04x\n".format(pad.product))
File(input, "id/version").writeText("%04x\n".format(pad.version))
File(input, "capabilities").mkdirs()
for (name in CAPABILITIES) File(input, "capabilities/$name").writeText("0\n")
link(File(input, "subsystem"), GUEST_CLASS)
File(event, "uevent").writeText("MAJOR=$MAJOR\nMINOR=${MINOR_BASE + node}\nDEVNAME=input/event$node\n")
File(event, "dev").writeText("$MAJOR:${MINOR_BASE + node}\n")
link(File(event, "subsystem"), GUEST_CLASS)
link(File(event, "device"), "$GUEST_DEVICES/input$node")
val record = udevRecord(SystemClock.elapsedRealtimeNanos() / 1000)
File(udevData, "+input:input$node").writeText(record)
File(udevData, "c$MAJOR:${MINOR_BASE + node}").writeText(record)
link(File(classDir, "input$node"), "$GUEST_DEVICES/input$node")
link(File(classDir, "event$node"), "$GUEST_DEVICES/input$node/event$node")
shown.add(node)
Log.i(TAG, "pads: /dev/input/event$node listed for udev as \"${pad.name}\" (%04x:%04x)".format(pad.vendor, pad.product))
} catch (e: Exception) {
Log.w(TAG, "pads: could not list /dev/input/event$node for udev: $e")
hide(node)
}
}
private fun hide(node: Int) {
File(classDir, "event$node").delete()
File(classDir, "input$node").delete()
File(udevData, "c$MAJOR:${MINOR_BASE + node}").delete()
File(udevData, "+input:input$node").delete()
File(devices, "input$node").let { FileUtils.clear(it); it.delete() }
if (shown.remove(node)) Log.i(TAG, "pads: /dev/input/event$node no longer listed for udev")
}
private fun link(file: File, target: String) {
file.delete()
Os.symlink(target, file.path)
}
data class Pad(
val bus: Int,
val vendor: Int,
val product: Int,
val version: Int,
val name: String,
val phys: String,
val uniq: String,
) {
fun uevent(): String =
"PRODUCT=%x/%x/%x/%x\nNAME=\"$name\"\nPHYS=\"$phys\"\nUNIQ=\"$uniq\"\nPROP=0\n".format(bus, vendor, product, version)
}
companion object {
private const val TAG = "SessionService"
const val GUEST_DEVICES = "/sys/devices/virtual/input"
const val GUEST_CLASS = "/sys/class/input"
private const val MAJOR = 13
private const val MINOR_BASE = 64
private const val FIRST_VIRTUAL = 16
private const val NODE_LIMIT = 64
private val NODE_FILE = Regex("(\\.uinput-)?event\\d+(\\.uevent)?")
private val CAPABILITIES = listOf("ev", "key", "rel", "abs", "msc", "sw", "led", "snd", "ff")
@Volatile private var active: InputSysfsComponent? = null
@Volatile private var padType = -1
fun padTypeChanged(type: Int) {
padType = type
active?.let { Thread(it::sync, "input-sysfs").start() }
}
internal fun ringWithdrawn(deckPad: Boolean, type: Int) = deckPad && type != FakeInputWriter.PAD_TYPE_XBOX
internal fun isPadRecord(name: String) =
Regex("c$MAJOR:(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it in MINOR_BASE until MINOR_BASE + NODE_LIMIT } == true ||
Regex("\\+input:input(\\d+)").matchEntire(name)?.groupValues?.get(1)?.toInt()?.let { it < NODE_LIMIT } == true
internal fun udevRecord(initializedUs: Long) =
"I:$initializedUs\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\nE:ID_SERIAL=noserial\nG:seat\nG:uaccess\nQ:seat\nQ:uaccess\nV:1\n"
internal fun ringPad(node: Int, steamVirtual: Boolean) = if (steamVirtual) {
Pad(3, 0x28de, 0x11ff, 0x110, "Microsoft X-Box 360 pad $node", "usb-fakeinput/input$node", "0000000000%02d".format(node))
} else {
Pad(3, 0x045e, 0x028e, 0x110, "Xbox 360 Controller ($node)", "usb-fakeinput/input$node", "0000000000%02d".format(node))
}
internal fun virtualPad(uevent: String): Pad? {
val values = uevent.lines().mapNotNull { line ->
line.indexOf('=').takeIf { it > 0 }?.let { line.substring(0, it) to line.substring(it + 1).trim('"') }
}.toMap()
val id = values["PRODUCT"]?.split('/')?.mapNotNull { it.toIntOrNull(16) }?.takeIf { it.size == 4 } ?: return null
return Pad(id[0], id[1], id[2], id[3], values["NAME"] ?: return null, values["PHYS"].orEmpty(), values["UNIQ"].orEmpty())
}
}
}
@@ -77,7 +77,7 @@ class PerfHud(context: 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 name = if (lsfg) "LSFG" else "DD-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 }
@@ -118,7 +118,9 @@ object SessionArtifacts {
private fun scrubFolder(dir: File, record: Record) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f ->
if (!LogRedactor.isText(f)) return@forEach
if (LogRedactor.isBinary(f)) return@forEach
// Too large to rewrite in memory: the folder must not be recorded as scrubbed.
if (!LogRedactor.isText(f)) { record.failed = true; return@forEach }
val done = scrubFile(dir, f)
if (done == null) record.failed = true else record.files[f] = done
}
@@ -1,10 +1,12 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.AddonPack
import android.content.Context
import android.os.Environment
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.BwrapSpawner
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LegacyNames
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -15,6 +17,7 @@ object SessionFiles {
private const val TAG = "SessionFiles"
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
const val DIRECTAUDIO_DIR = "usr/local/lib/directaudio"
private const val STAGED_LEDGER = "usr/local/.droiddeck-staged"
val DIRECTAUDIO_SETS = arrayOf("linux-wine11", "linux-wine11-systhread")
internal val DESKTOP_FILES = arrayOf(
@@ -27,7 +30,8 @@ object SessionFiles {
"etc/xdg/autostart/droiddeck-clipboard.desktop" to "etc/xdg/autostart/droiddeck-clipboard.desktop",
)
fun stage(context: Context, root: File) {
/** Puts the session's files in place; [startsSession] is true only for the call that starts the Steam session. */
fun stage(context: Context, root: File, startsSession: Boolean = false) {
if (DesktopCatalog.desktopInstalled(root)) try { DesktopCatalog.cleanLegacy(context) }
catch (e: Exception) { Log.w(TAG, "LXQt cleanup will retry next session", e) }
GameEnvironmentStore.publish(context)
@@ -66,6 +70,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks",
"usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim",
"usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts",
"usr/local/bin/droiddeck-xbox" to "usr/local/bin/droiddeck-xbox",
"usr/local/bin/droiddeck-store-launch" to "usr/local/bin/droiddeck-store-launch",
"usr/local/bin/droiddeck-epic" to "usr/local/bin/droiddeck-epic",
"usr/local/bin/droiddeck-epic-steam" to "usr/local/bin/droiddeck-epic-steam",
@@ -133,13 +138,15 @@ object SessionFiles {
"usr/local/bin/gamescope" to "usr/local/bin/gamescope",
"usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime",
"usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE",
) + mangoapp + listOf("comet", "GalaxyCommunication.exe", "update-permissions.exe", "LICENSE", "version").map { "usr/local/lib/droiddeck-comet/$it" to "usr/local/lib/droiddeck-comet/$it" } + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
) + mangoapp + msitools.map { it to it } + vr + fexPreloads).filter { (asset, _) ->
val dir = asset.substringBeforeLast('/')
runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false)
}
val all = (if (DesktopCatalog.desktopInstalled(root)) files + DESKTOP_FILES else files) + optional
val ledger = StageLedger(File(root, STAGED_LEDGER), apkIdentity(context))
for ((asset, relative) in all) {
val target = File(root, relative)
if (ledger.current(relative, target)) continue
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
@@ -153,7 +160,7 @@ object SessionFiles {
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "$relative NOT staged")
if (installed) ledger.staged(relative, target) else Log.e(TAG, "$relative NOT staged")
}
File(root, "usr/local/bin/bannerlator-steam-compat").delete()
LegacyNames.migrateRootfs(root)
@@ -165,13 +172,17 @@ object SessionFiles {
"i386-windows/winedirectaudio.drv",
"version.txt",
)
for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
val target = File(root, "$DIRECTAUDIO_DIR/$set/lib/wine/$relative")
val directAudioPack = AddonPack.DIRECTAUDIO.takeIf { it.installed(context) }?.dir(context)
if (directAudioPack == null) Log.w(TAG, "DirectAudio pack is not installed; drivers not staged")
if (directAudioPack != null) for (set in DIRECTAUDIO_SETS) for (relative in directAudio) {
val key = "$DIRECTAUDIO_DIR/$set/lib/wine/$relative"
val target = File(root, key)
if (ledger.current(key, target)) continue
val staged = File(target.parentFile, target.name + ".staged")
var installed = false
try {
target.parentFile?.mkdirs()
context.assets.open("directaudio/$set/$relative").use { input ->
File(directAudioPack, "$set/$relative").inputStream().use { input ->
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(target)
@@ -180,8 +191,9 @@ object SessionFiles {
} finally {
if (!installed) staged.delete()
}
if (!installed) Log.e(TAG, "DirectAudio $set/$relative NOT staged")
if (installed) ledger.staged(key, target) else Log.e(TAG, "DirectAudio $set/$relative NOT staged")
}
ledger.save()
for (old in arrayOf("aarch64-unix", "aarch64-windows", "i386-windows")) File(root, "$DIRECTAUDIO_DIR/lib/wine/$old").deleteRecursively()
val preload = StringBuilder("/usr/local/lib/libblsession.so\n")
if (!File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()) {
@@ -198,20 +210,118 @@ object SessionFiles {
if (!FileUtils.writeString(File(etc, "steam-compatibility-label"), context.getString(R.string.steam_compat_label))) {
Log.e(TAG, "could not write steam-compatibility-label")
}
val droiddeckEtc = File(etc, "droiddeck").apply { mkdirs() }
val stagedSocket = File(droiddeckEtc, "bwrap-socket.staged")
if (!FileUtils.writeString(stagedSocket, BwrapSpawner.socket(context) + "\n")
|| !stagedSocket.renameTo(File(droiddeckEtc, "bwrap-socket"))) {
stagedSocket.delete()
Log.e(TAG, "could not write bwrap-socket")
}
BootMovie.syncFromSteam(context)
val startupMovie = BootMovie.prepare(context)
// The x86-64 client's config is left alone until its first start creates it.
val steamRoots = listOf(File(root, "root/.local/share/Steam"), File(root, "root/.droiddeck-x64/.local/share/Steam"))
.filterIndexed { i, steam -> i == 0 || File(steam, "config").isDirectory }
for (steam in steamRoots) {
val startupMovieDir = File(steam, "config/uioverrides/movies")
val bigPictureMovieInstalled = startupMovie != null &&
stageStartupMovie(startupMovie, startupMovieDir, "bigpicture_startup.webm")
if (startupMovie != null) stageStartupMovie(startupMovie, startupMovieDir, "steam_os_startup.webm")
if (bigPictureMovieInstalled) {
ensureStartupMovieDefault(File(steam, "config/config.vdf"))
val force = SessionPrefs.bootMovieApplyNext(context)
val steamHomes = listOf(File(root, "root/.local/share/Steam")) +
listOf(File(root, "root/.droiddeck-x64/.local/share/Steam")).filter { File(it, "config").isDirectory }
var allDone = true
var allInstalled = true
var seen: String? = null
val activeIsX64 = SessionPrefs.activeSteamClientArch(context) == "x86_64"
for (home in steamHomes) {
val movieDir = File(home, "config/uioverrides/movies")
val config = File(home, "config/config.vdf")
if (startupMovie == null) {
// Nothing to play (a movie whose volume could not be set is never played at another volume), so no earlier copy plays either.
for (name in listOf("bigpicture_startup.webm", "steam_os_startup.webm")) {
File(movieDir, name).delete()
File(movieDir, ".$name.source").delete()
}
}
val bigPictureMovieInstalled = startupMovie != null && stageStartupMovie(startupMovie, movieDir, "bigpicture_startup.webm")
if (startupMovie != null) stageStartupMovie(startupMovie, movieDir, "steam_os_startup.webm")
val before = startupMoviePath(config)
val done = if (bigPictureMovieInstalled) ensureStartupMovieDefault(config, force) else resetMissingStartupMovie(config, force)
BootMovieReport.note(
context,
"stage session=$startsSession pending=$force choice=${SessionPrefs.bootMovie(context)} movie=${startupMovie?.path} " +
"staged=$bigPictureMovieInstalled done=$done steam=${home.path} config: $before -> ${startupMoviePath(config)}",
)
if (!done) allDone = false
if (!bigPictureMovieInstalled) allInstalled = false
if (done && home.path.contains(".droiddeck-x64") == activeIsX64) seen = if (bigPictureMovieInstalled) BOOT_MOVIE_PATH else ""
}
// A pick made in the app is done once a session started with every Steam client's config having it; guest commands run
// before that, and a Steam that is already running writes its own choice back when it exits.
if (force && allDone && allInstalled && startsSession) SessionPrefs.setBootMovieApplyNext(context, false)
seen?.let { SessionPrefs.setBootMovieSteamSeen(context, it) }
}
/**
* Points Steam's startup movie back at its own default when it names a movie that is gone: DroidDeck's staged copy that
* could not be prepared, or (when a pick here is waiting) a movie removed from Steam's folder. True when Steam's config
* no longer names a missing movie.
*/
internal fun resetMissingStartupMovie(config: File, force: Boolean): Boolean {
if (!config.isFile) return true
val text = runCatching { config.readText() }.getOrElse {
Log.w(TAG, "could not read Steam config to reset the startup movie", it)
return false
}
val movie = activeStartupMovie(text) ?: return true
val block = text.substring(movie.keyStart, movie.close + 1)
val path = scalar(block, "LocalPath").orEmpty()
if (path.isEmpty()) return true
val missing = File(config.parentFile, "uioverrides/movies/" + path.removePrefix(BOOT_MOVIES_DIR))
val gone = path.startsWith(BOOT_MOVIES_DIR) && !missing.isFile && (force || path == BOOT_MOVIE_PATH)
if (!gone) return false
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
val updated = setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", "", newline)
val staged = File(config.parentFile, config.name + ".staged")
return try {
staged.writeText(text.replaceRange(movie.keyStart, movie.close + 1, updated))
(staged.renameTo(config)).also { if (!it) staged.delete() }
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not reset Steam startup movie selection", e)
false
}
}
private fun apkIdentity(context: Context): String {
val apk = File(context.applicationInfo.sourceDir)
return "${apk.path} ${apk.length()} ${apk.lastModified()}"
}
private class StageLedger(private val file: File, private val identity: String) {
private val before: Map<String, String> = FileUtils.readString(file)?.lines()
?.takeIf { it.firstOrNull() == identity }
?.drop(1)?.mapNotNull { line -> line.split('\t').takeIf { it.size == 2 }?.let { it[0] to it[1] } }?.toMap()
.orEmpty()
private val after = LinkedHashMap<String, String>()
fun current(relative: String, target: File): Boolean {
val mark = mark(target) ?: return false
if (before[relative] != mark) return false
after[relative] = mark
return true
}
fun staged(relative: String, target: File) {
mark(target)?.let { after[relative] = it }
}
fun save() {
if (after == before) return
val text = (listOf(identity) + after.map { (relative, mark) -> "$relative\t$mark" }).joinToString("\n")
val staged = File(file.parentFile, file.name + ".staged")
if (!FileUtils.writeString(staged, text) || !staged.renameTo(file)) {
staged.delete()
file.delete()
}
}
private fun mark(target: File): String? =
if (target.isFile) "${target.length()} ${target.lastModified()} ${target.canExecute()}" else null
}
private fun stageStartupMovie(source: File, directory: File, name: String): Boolean {
@@ -227,7 +337,11 @@ object SessionFiles {
staged.outputStream().use { output -> FileUtils.copy(input, output) }
}
installed = staged.setReadable(true, false) && staged.renameTo(movie)
if (installed) FileUtils.writeString(stamp, identity)
if (installed) {
FileUtils.writeString(stamp, identity)
// Steam's picker draws its preview from this picture and does not redraw it when the movie changes.
File(directory, name.removeSuffix(".webm") + ".jpg").delete()
}
} catch (e: Exception) {
Log.w(TAG, "could not stage Steam startup movie $name", e)
} finally {
@@ -239,43 +353,108 @@ object SessionFiles {
private data class VdfBlock(val keyStart: Int, val open: Int, val close: Int)
private fun ensureStartupMovieDefault(config: File) {
val path = "/uioverrides/movies/bigpicture_startup.webm"
internal const val BOOT_MOVIES_DIR = "/uioverrides/movies/"
internal const val BOOT_MOVIE_PATH = "/uioverrides/movies/bigpicture_startup.webm"
/**
* Steam's startup movie setting is the `StartupMovie` section inside its `Customization` section; the one under the Steam
* settings is where older clients kept it, and DroidDeck still keeps it in step.
*/
private fun customizationMovie(text: String): VdfBlock? {
var from = 0
while (true) {
val customization = findObject(text, "Customization", from, text.length) ?: return null
findObject(text, "StartupMovie", customization.open + 1, customization.close)?.let { return it }
from = customization.close + 1
}
}
/** The older `StartupMovie` section: the first one that is not inside `Customization`. */
private fun olderMovie(text: String): VdfBlock? {
val inside = customizationMovie(text)
var from = 0
while (true) {
val movie = findObject(text, "StartupMovie", from, text.length) ?: return null
if (inside == null || movie.keyStart != inside.keyStart) return movie
from = movie.close + 1
}
}
/** The section Steam reads: the one under `Customization` when there is one. */
private fun activeStartupMovie(text: String): VdfBlock? = customizationMovie(text) ?: olderMovie(text)
/** The movie Steam's config selects, as a path like `/uioverrides/movies/x.webm`; null when none is named. */
internal fun startupMoviePath(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val movie = activeStartupMovie(text) ?: return null
return scalar(text.substring(movie.keyStart, movie.close + 1), "LocalPath")?.takeIf { it.isNotEmpty() }
}
/** Both `StartupMovie` sections of Steam's config as written there, for the report; null when there is none. */
internal fun startupMovieBlock(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val parts = listOfNotNull(
customizationMovie(text)?.let { "under Customization:\n" + text.substring(it.keyStart, it.close + 1) },
olderMovie(text)?.let { "older place:\n" + text.substring(it.keyStart, it.close + 1) },
)
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
}
/** The `Customization` section of Steam's config with the lines before it, for the report; null when there is none. */
internal fun customizationBlock(config: File): String? {
val text = runCatching { config.readText() }.getOrNull() ?: return null
val customization = findObject(text, "Customization", 0, text.length) ?: return null
val before = text.substring(0, customization.keyStart).lines().takeLast(4).joinToString("\n")
return (before + "\n" + text.substring(customization.keyStart, customization.close + 1)).take(4000)
}
private fun pointAt(text: String, movie: VdfBlock, path: String, newline: String): String {
val block = text.substring(movie.keyStart, movie.close + 1)
return text.replaceRange(movie.keyStart, movie.close + 1, setScalar(setScalar(block, "MovieID", "0", newline), "LocalPath", path, newline))
}
/** True when Steam's config selects DroidDeck's staged movie once this returns. */
internal fun ensureStartupMovieDefault(config: File, force: Boolean = false): Boolean {
val path = BOOT_MOVIE_PATH
val legacyPath = "/uioverrides/movies/droiddeck-startup.webm"
var text = if (config.isFile) runCatching { config.readText() }.getOrElse {
Log.w(TAG, "could not read Steam config for startup movie", it)
return
return false
} else ""
val newline = if (text.contains("\r\n")) "\r\n" else "\n"
val movie = findObject(text, "StartupMovie", 0, text.length)
if (movie != null) {
val block = text.substring(movie.keyStart, movie.close + 1)
val custom = customizationMovie(text)
val active = custom ?: olderMovie(text)
if (active != null) {
val block = text.substring(active.keyStart, active.close + 1)
val selectedId = scalar(block, "MovieID")
val selectedPath = scalar(block, "LocalPath")
if (selectedPath == path) {
if (selectedPath == path && custom != null) {
Log.i(TAG, "Steam startup movie is set to DroidDeck")
return
return true
}
val legacyDefault = selectedId == "0" && selectedPath == legacyPath
val hasExplicitSelection =
(!selectedId.isNullOrEmpty() && selectedId != "0") || !selectedPath.isNullOrEmpty()
if (!legacyDefault && hasExplicitSelection) {
if (!force && !legacyDefault && hasExplicitSelection && selectedPath != path) {
Log.i(TAG, "Steam startup movie selection preserved")
return
return false
}
val updated = setScalar(block, "MovieID", "0", newline)
.let { setScalar(it, "LocalPath", path, newline) }
text = text.replaceRange(movie.keyStart, movie.close + 1, updated)
} else {
}
// Later sections first, so the positions of earlier ones stay valid.
val targets = listOfNotNull(customizationMovie(text), olderMovie(text)).sortedByDescending { it.keyStart }
for (target in targets) text = pointAt(text, target, path, newline)
if (olderMovie(text) == null) {
val steam = findSteamBlock(text)
if (steam == null) {
if (text.isNotBlank()) {
if (text.isNotBlank() && customizationMovie(text) == null) {
Log.w(TAG, "Steam config has no Steam settings block; startup movie default not set")
return
return false
}
if (text.isBlank()) {
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
val section = startupMovieSection("\t\t\t\t", path, newline)
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
}
text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
val section = startupMovieSection("\t\t\t\t", path, newline)
text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
} else {
val lineStart = text.lastIndexOf('\n', steam.close).let { if (it < 0) 0 else it + 1 }
val indent = text.substring(lineStart, steam.close).takeWhile { it == '\t' || it == ' ' }
@@ -283,22 +462,35 @@ object SessionFiles {
text = text.substring(0, lineStart) + section + newline + indent + text.substring(lineStart)
}
}
if (customizationMovie(text) == null) text = withCustomization(text, path, newline)
val staged = File(config.parentFile, config.name + ".staged")
try {
return try {
config.parentFile?.mkdirs()
staged.writeText(text)
if (!staged.renameTo(config)) {
staged.delete()
Log.e(TAG, "could not write Steam startup movie selection")
false
} else {
Log.i(TAG, "Steam startup movie default selected")
true
}
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not write Steam startup movie selection", e)
false
}
}
/** Adds the `Customization` section Steam reads, next to the `Software` section, when the config has none. */
private fun withCustomization(text: String, path: String, newline: String): String {
val root = findObject(text, "InstallConfigStore", 0, text.length) ?: return text
val lineStart = text.lastIndexOf('\n', root.close).let { if (it < 0) 0 else it + 1 }
val indent = text.substring(lineStart, root.close).takeWhile { it == '\t' || it == ' ' }
val section = listOf(indent + "\t\"Customization\"", indent + "\t{", startupMovieSection(indent + "\t\t", path, newline), indent + "\t}").joinToString(newline)
return text.substring(0, lineStart) + section + newline + text.substring(lineStart)
}
private fun findSteamBlock(text: String): VdfBlock? {
val install = findObject(text, "InstallConfigStore", 0, text.length) ?: return null
val software = findObject(text, "Software", install.open + 1, install.close) ?: return null
@@ -66,15 +66,12 @@ object SessionLogShare {
return zip
}
/** Every entry is scrubbed line by line, whatever its size: a raw copy can carry tokens out of the device. */
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
z.putNextEntry(ZipEntry(name))
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubForShare(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
}
val w = z.bufferedWriter()
LogRedactor.scrubForShare(f, w)
w.flush()
z.closeEntry()
}
@@ -118,8 +118,22 @@ object SessionPrefs {
prefs(context).edit().putString("storesView", if (view in STORES_VIEWS) view else STORES_VIEWS.first()).apply()
}
fun storesTabOrder(context: Context): List<String> = idList(prefs(context).getString("storesTabOrder", null))
fun setStoresTabOrder(context: Context, order: List<String>) {
prefs(context).edit().putString("storesTabOrder", order.joinToString(",")).apply()
}
fun storesTabHidden(context: Context): Set<String> = idList(prefs(context).getString("storesTabHidden", null)).toSet()
fun setStoresTabHidden(context: Context, hidden: Set<String>) {
prefs(context).edit().putString("storesTabHidden", hidden.joinToString(",")).apply()
}
private fun idList(raw: String?): List<String> = raw.orEmpty().split(',').map { it.trim() }.filter { it.isNotEmpty() }
val STORES_SORTS = listOf("lastPlayed", "recentlyInstalled", "titleAsc", "titleDesc", "sizeDesc", "sizeAsc", "releaseDateDesc")
val STORES_VIEWS = listOf("cards", "list")
val STORES_VIEWS = listOf("cards", "capsules", "list")
const val STORES_OPEN_LIBRARY = "library"
const val STORES_OPEN_STORE = "store"
@@ -168,6 +182,7 @@ object SessionPrefs {
const val TOUCH_AUTO = "auto"
const val TOUCH_PAD = "touchpad"
const val TOUCH_DIRECT = "direct"
const val TOUCH_GESTURES = "gestures"
const val TOUCH_OFF = "off"
fun touchMode(context: Context): String = prefs(context).getString("touch", TOUCH_AUTO) ?: TOUCH_AUTO
@@ -527,6 +542,17 @@ object SessionPrefs {
fun setBootMovie(context: Context, id: String) { prefs(context).edit().putString("bootMovie", id).apply() }
fun bootMovieSound(context: Context): Boolean = prefs(context).getBoolean("bootMovieSound", true)
fun setBootMovieSound(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieSound", on).apply() }
fun bootMovieVolume(context: Context): Int = prefs(context).getInt("bootMovieVolume", 100).coerceIn(0, 100)
fun setBootMovieVolume(context: Context, percent: Int) { prefs(context).edit().putInt("bootMovieVolume", percent.coerceIn(0, 100)).apply() }
/** How long the boot movie plays, in seconds; 0 plays all of it. */
fun bootMovieSeconds(context: Context): Int = prefs(context).getInt("bootMovieSeconds", 0).coerceAtLeast(0)
fun setBootMovieSeconds(context: Context, seconds: Int) { prefs(context).edit().putInt("bootMovieSeconds", seconds.coerceAtLeast(0)).apply() }
/** Set when a movie was picked here, so the next session makes it Steam's startup movie even if Steam had another one. */
fun bootMovieApplyNext(context: Context): Boolean = prefs(context).getBoolean("bootMovieApplyNext", false)
fun setBootMovieApplyNext(context: Context, on: Boolean) { prefs(context).edit().putBoolean("bootMovieApplyNext", on).apply() }
/** The startup movie path Steam's config held when it was last looked at, so only a change made in Steam is taken over. */
fun bootMovieSteamSeen(context: Context): String? = prefs(context).getString("bootMovieSteamSeen", null)
fun setBootMovieSteamSeen(context: Context, path: String) { prefs(context).edit().putString("bootMovieSteamSeen", path).apply() }
fun bootMovieCustomName(context: Context): String? = prefs(context).getString("bootMovieCustomName", null)
fun setBootMovieCustomName(context: Context, name: String?) {
prefs(context).edit().apply { if (name == null) remove("bootMovieCustomName") else putString("bootMovieCustomName", name) }.apply()
@@ -878,6 +904,11 @@ object SessionPrefs {
fun prefMode(mode: String): String = if (mode == SessionService.MODE_RUN) SessionService.MODE_STEAM else mode
fun displayMode(context: Context, mode: String, program: String?): String =
if (mode == SessionService.MODE_RUN && com.droiddeck.launcher.runtime.UserApps.launches(program) &&
resolutionChosen(context, SessionService.MODE_DESKTOP)) SessionService.MODE_DESKTOP
else prefMode(mode)
fun pipAutoEnter(context: Context): Boolean = prefs(context).getBoolean("pipAutoEnter", false)
fun setPipAutoEnter(context: Context, enabled: Boolean) {
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.AddonPack
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
@@ -21,6 +22,8 @@ import android.os.IBinder
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.hardware.display.DisplayManager
import android.view.Display
import android.util.Log
import com.droiddeck.launcher.R
import com.droiddeck.launcher.SessionActivity
@@ -56,6 +59,7 @@ class SessionService : Service() {
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
private var deckBinds: List<String> = emptyList()
private var inputSysfs: InputSysfsComponent? = null
private val stopLock = Any()
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
@@ -75,11 +79,13 @@ class SessionService : Service() {
private var signInHold = false
private val hasVisibleSurface get() = sessionHasVisibleSurface(activityVisible || signInHold, screenOn, xrActivityVisible)
private var screenOn = true
private var sessionDisplayId = Display.DEFAULT_DISPLAY
private var manualPauseRequested = false
private var steamSleepToken: String? = null
private var nativeSleepToken: String? = null
private var nativeSleepReady = false
private var nativeSleepFallback = false
private var nativeSleepIdle = false
private var nativeSleepTimeout: Runnable? = null
private var suspendOperationPending = false
private var pipTask = false
@@ -93,8 +99,8 @@ class SessionService : Service() {
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> screenOn = false
Intent.ACTION_SCREEN_ON -> screenOn = true
Intent.ACTION_SCREEN_OFF -> screenOn = readScreenOn(false)
Intent.ACTION_SCREEN_ON -> screenOn = readScreenOn(true)
else -> return
}
if (screenOn) resumeSteamSleepOnReturn()
@@ -102,17 +108,49 @@ class SessionService : Service() {
updateSuspendPolicy()
}
}
private var displayListenerRegistered = false
private val displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
override fun onDisplayRemoved(displayId: Int) {
if (displayId != sessionDisplayId) return
sessionDisplayId = Display.DEFAULT_DISPLAY
refreshScreenOn()
}
override fun onDisplayChanged(displayId: Int) {
if (displayId == sessionDisplayId && displayId != Display.DEFAULT_DISPLAY) refreshScreenOn()
}
}
private fun readScreenOn(interactive: Boolean? = null): Boolean {
val id = sessionDisplayId
val state = if (id == Display.DEFAULT_DISPLAY) null
else (getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.getDisplay(id)?.state
return sessionScreenOn(id == Display.DEFAULT_DISPLAY,
interactive ?: (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true, state)
}
private fun refreshScreenOn() {
val on = readScreenOn()
if (on == screenOn) return
screenOn = on
if (on) resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
}
@Volatile private var sessionGen = 0
override fun onCreate() {
super.onCreate()
val power = getSystemService(Context.POWER_SERVICE) as? PowerManager
screenOn = power?.isInteractive ?: true
screenOn = readScreenOn()
registerReceiver(screenReceiver, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON)
})
screenReceiverRegistered = true
(getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.let {
it.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
displayListenerRegistered = true
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -161,12 +199,19 @@ class SessionService : Service() {
ACTION_ACTIVITY_VISIBLE -> {
if (!SessionState.pipActive) pipTask = false
activityVisible = true
sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId)
screenOn = readScreenOn()
suspendDownloadNow = false
resumeSteamSleepOnReturn()
suspendAttemptFailed = false
updateSuspendPolicy()
return START_NOT_STICKY
}
ACTION_ACTIVITY_DISPLAY -> {
sessionDisplayId = intent.getIntExtra(EXTRA_DISPLAY_ID, sessionDisplayId)
refreshScreenOn()
return START_NOT_STICKY
}
ACTION_ACTIVITY_HIDDEN -> {
activityVisible = false
suspendAttemptFailed = false
@@ -230,7 +275,8 @@ class SessionService : Service() {
activityVisible = true
xrActivityVisible = false
signInHold = false
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
sessionDisplayId = intent?.getIntExtra(EXTRA_DISPLAY_ID, Display.DEFAULT_DISPLAY) ?: Display.DEFAULT_DISPLAY
screenOn = readScreenOn()
manualPauseRequested = false
suspendDownloadNow = false
steamDownloadMonitor.reset()
@@ -293,14 +339,13 @@ class SessionService : Service() {
return lines
}
private fun tuDebug(linuxDriverId: String): String? {
val override = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH)
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
if (!override.isNullOrEmpty()) return override
if (SessionPrefs.tuSysmem(this)) return "sysmem"
if (linuxDriverId.isEmpty()) return null
private fun tuDebugOverride(): String? = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH)
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }?.takeIf { it.isNotEmpty() }
private fun sysmemDriver(linuxDriverId: String): Boolean {
if (linuxDriverId.isEmpty()) return false
val name = LinuxVulkanDriverManager(this).getDriverName(linuxDriverId).lowercase()
return if (name.contains("710-720") || name.contains("710_720")) "sysmem" else null
return name.contains("710-720") || name.contains("710_720")
}
@@ -319,7 +364,9 @@ class SessionService : Service() {
val sessionRoot = LinuxRuntime.sessionRoot(this).apply { mkdirs() }
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
SessionFiles.stage(this, root)
for (pack in listOf(AddonPack.PROOT, AddonPack.DIRECTAUDIO))
if (!pack.await(this, { gen == sessionGen && SessionState.running })) Log.w(TAG, "${pack.name} pack unavailable")
SessionFiles.stage(this, root, startsSession = true)
// Earlier sessions' reports still read the Steam logs and VAC marker this session replaces.
SessionArtifacts.awaitCollections()
SessionArtifacts.finishAbandoned(this)
@@ -365,6 +412,7 @@ class SessionService : Service() {
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
SessionState.deckPad = false
deckBinds = emptyList()
inputSysfs = null
if (controllersOn) {
addControllerEnvironment(guest, fakeInputDir, sessionDir)
SessionState.padBridge?.syncConnectedControllers()
@@ -575,7 +623,9 @@ class SessionService : Service() {
)
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") }
val tuOverride = tuDebugOverride()
val tuDriver = sysmemDriver(linuxDriverId)
(tuOverride ?: "sysmem".takeIf { tuDriver || SessionPrefs.tuSysmem(this) })?.let { guest.add("TU_DEBUG=$it") }
com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
if (SessionPrefs.zinkLazy(this)) {
guest.add("ZINK_DESCRIPTORS=lazy")
@@ -603,8 +653,9 @@ class SessionService : Service() {
if (SessionState.mode == MODE_STEAM && SessionPrefs.zramCompression(this) &&
com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1")
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
extraEnv().forEach { guest.add(it) }
com.droiddeck.launcher.agent.AgentEnv.beginSession(this).forEach { guest.add(it) }
val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this)
if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else ""))
extra.forEach { guest.add(it) }
val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this)
if (clientPinned) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
@@ -680,6 +731,7 @@ class SessionService : Service() {
val padType = SessionPrefs.steamController(this)
guest.add("FAKE_EVDEV_PAD_TYPE=$padType")
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(padType))
InputSysfsComponent.padTypeChanged(SessionPrefs.padType(padType))
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
}
@@ -693,6 +745,9 @@ class SessionService : Service() {
if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0")
guest.add("FAKE_EVDEV_LOG=1")
guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path)
inputSysfs = InputSysfsComponent(fakeInputDir, File(fakeInputDir.parentFile!!.parentFile!!, "sys/input"),
File(LinuxRuntime.rootDir(this), "run/udev/data"), SessionState.deckPad, !uinput && SessionState.mode == MODE_STEAM)
.takeIf { it.prepare() }
SessionState.fakeInputDir = fakeInputDir
}
@@ -707,6 +762,11 @@ class SessionService : Service() {
val binds = ArrayList<String>()
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
if (controllersOn) binds.addAll(deckBinds)
if (controllersOn) inputSysfs?.let {
it.attach(this)
components.add(it)
binds.addAll(it.binds())
}
val battery = BatteryComponent(
File(filesDir, "session/sys/power_supply"),
File(LinuxRuntime.rootDir(this), "run/vpower"),
@@ -1016,10 +1076,10 @@ class SessionService : Service() {
private fun resumeSession(panelVisible: Boolean = true) {
if (panelVisible) activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
screenOn = readScreenOn()
manualPauseRequested = false
suspendDownloadNow = false
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
suspendAttemptFailed = false
@@ -1219,16 +1279,22 @@ class SessionService : Service() {
val downloadsBlockingSuspend = steamDownloadsInBackground && SessionState.mode == MODE_STEAM &&
suspendPolicy != SessionPrefs.SUSPEND_NEVER && hidden && downloadActive && !suspendDownloadNow
if (downloadsBlockingSuspend) acquireLocks()
if (suspendPolicy != SessionPrefs.SUSPEND_NATIVE || !hidden || downloadsBlockingSuspend) {
val steamPause = SessionState.mode == MODE_STEAM && !downloadsBlockingSuspend && when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE -> hidden
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested
else -> false
}
if (!steamPause) {
completeNativeSleep()
} else if (nativeSleepToken == null && steamSleepToken == null &&
!SessionState.suspended && !suspendAttemptFailed) {
prepareNativeSleep()
}
val prepared = SessionState.mode != MODE_STEAM || nativeSleepReady
val shouldSuspend = steamSleepToken != null || when (suspendPolicy) {
SessionPrefs.SUSPEND_AUTO -> hidden && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_AUTO -> hidden && prepared && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_NATIVE -> hidden && nativeSleepReady && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && !downloadsBlockingSuspend
SessionPrefs.SUSPEND_MANUAL -> manualPauseRequested && prepared && !downloadsBlockingSuspend
else -> false
}
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
@@ -1236,7 +1302,7 @@ class SessionService : Service() {
suspendOperationPending = true
SessionEvents.record(if (shouldSuspend) "session.suspend_requested" else "session.resume_requested", mapOf(
"policy" to suspendPolicy, "activityVisible" to activityVisible,
"screenOn" to screenOn, "steamSleep" to (steamSleepToken != null),
"screenOn" to screenOn, "display" to sessionDisplayId, "steamSleep" to (steamSleepToken != null),
"xrActivityVisible" to xrActivityVisible,
"nativeSleep" to (nativeSleepToken != null),
))
@@ -1296,7 +1362,7 @@ class SessionService : Service() {
private fun resumeSteamSleepOnReturn() {
if (suspendPolicy in setOf(SessionPrefs.SUSPEND_AUTO, SessionPrefs.SUSPEND_NATIVE) && hasVisibleSurface) {
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback)) requestSteamWake()
if (steamSleepToken == null && (nativeSleepToken == null || nativeSleepFallback || nativeSleepIdle)) requestSteamWake()
completeNativeSleep()
completeSteamSleep()
}
@@ -1307,13 +1373,14 @@ class SessionService : Service() {
nativeSleepToken = token
nativeSleepReady = false
nativeSleepFallback = false
nativeSleepIdle = suspendPolicy != SessionPrefs.SUSPEND_NATIVE
val dir = LinuxRuntime.sessionRoot(this)
runCatching {
val staged = File(dir, "steam-native-sleep.tmp")
staged.writeText("$token\n")
staged.writeText(if (nativeSleepIdle) "$token idle\n" else "$token\n")
check(staged.renameTo(File(dir, "steam-native-sleep")))
}.onFailure { Log.w(TAG, "could not request Native sleep; using timed pause", it) }
SessionEvents.record("steam.native_sleep_requested")
SessionEvents.record("steam.native_sleep_requested", mapOf("idle" to nativeSleepIdle))
val gen = sessionGen
val timeout = Runnable {
if (gen != sessionGen || !SessionState.running || nativeSleepToken != token) return@Runnable
@@ -1339,6 +1406,7 @@ class SessionService : Service() {
nativeSleepToken = null
nativeSleepReady = false
nativeSleepFallback = false
nativeSleepIdle = false
}
private fun requestSteamWake() {
@@ -1498,6 +1566,10 @@ class SessionService : Service() {
unregisterReceiver(screenReceiver)
screenReceiverRegistered = false
}
if (displayListenerRegistered) {
(getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.unregisterDisplayListener(displayListener)
displayListenerRegistered = false
}
super.onDestroy()
}
@@ -1665,6 +1737,7 @@ class SessionService : Service() {
private const val TAG = "SessionService"
private const val TRACER_NICE = -6
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
private const val GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG"
private const val LEGACY_ENV_SWITCH = "Download/droiddeck-env"
private const val ENV_SWITCH_NAME = "droiddeck-env"
@@ -1684,6 +1757,8 @@ class SessionService : Service() {
private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED"
private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE"
private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN"
private const val ACTION_ACTIVITY_DISPLAY = "com.droiddeck.launcher.ACTIVITY_DISPLAY"
private const val EXTRA_DISPLAY_ID = "displayId"
private const val ACTION_XR_ACTIVITY_VISIBLE = "com.droiddeck.launcher.XR_ACTIVITY_VISIBLE"
private const val ACTION_XR_ACTIVITY_HIDDEN = "com.droiddeck.launcher.XR_ACTIVITY_HIDDEN"
private const val ACTION_SIGN_IN_HOLD = "com.droiddeck.launcher.SIGN_IN_HOLD"
@@ -1720,8 +1795,10 @@ class SessionService : Service() {
fun start(
context: Context, mode: String = MODE_STEAM, program: String? = null,
steamUi: String? = null, steamUrl: String? = null, programArgs: Array<String>? = null,
displayId: Int = Display.DEFAULT_DISPLAY,
) {
val intent = Intent(context, SessionService::class.java).putExtra(EXTRA_MODE, mode)
.putExtra(EXTRA_DISPLAY_ID, displayId)
if (program != null) intent.putExtra(EXTRA_PROGRAM, program)
if (programArgs != null) intent.putExtra(EXTRA_PROGRAM_ARGS, programArgs)
if (steamUi != null) intent.putExtra(EXTRA_STEAM_UI, steamUi)
@@ -1742,10 +1819,15 @@ class SessionService : Service() {
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_PIP_BEGIN))
}
fun setActivityVisible(context: Context, visible: Boolean) {
fun setActivityVisible(context: Context, visible: Boolean, displayId: Int = Display.DEFAULT_DISPLAY) {
if (!SessionState.running) return
val action = if (visible) ACTION_ACTIVITY_VISIBLE else ACTION_ACTIVITY_HIDDEN
context.startService(Intent(context, SessionService::class.java).setAction(action))
context.startService(Intent(context, SessionService::class.java).setAction(action).putExtra(EXTRA_DISPLAY_ID, displayId))
}
fun setActivityDisplay(context: Context, displayId: Int) {
if (!SessionState.running) return
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_ACTIVITY_DISPLAY).putExtra(EXTRA_DISPLAY_ID, displayId))
}
fun setXrActivityVisible(context: Context, visible: Boolean) {
@@ -2,14 +2,22 @@ package com.droiddeck.launcher.session
import android.os.Process
import java.io.File
import java.io.IOException
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
internal class SessionSuspendController(
private val sessionRoot: () -> Int,
private val helperRoots: () -> List<Int>,
private val suspendAudio: () -> Unit,
private val resumeAudio: () -> Unit,
private val procRoot: File = File("/proc"),
private val signal: (Int, Int) -> Unit = Process::sendSignal,
private val threadSignal: (Int, Int, Int) -> Unit = { pid, tid, signal -> ThreadSignals.tgkill(pid, tid, signal) },
private val sleep: (Long) -> Unit = Thread::sleep,
private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 },
private val executor: ExecutorService = Executors.newSingleThreadExecutor(),
) {
private enum class Role { SESSION_ROOT, SESSION_CHILD, HELPER_ROOT, HELPER_CHILD }
@@ -24,96 +32,95 @@ internal class SessionSuspendController(
val parent: Int,
val started: Long,
val state: Char,
val tracer: Int,
)
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
private data class TaskIdentity(val process: ProcessRecord, val tid: Int, val started: Long)
@Volatile private var closing = false
private var frozen = emptyList<ProcessRecord>()
fun freeze(completion: (Boolean) -> Unit) {
submit {
if (closing) {
completion(false)
return@submit
}
if (frozen.isNotEmpty()) {
val fullyFrozen = frozen.any { it.role == Role.SESSION_ROOT } && frozen.all(::isStoppedOrGone)
if (!fullyFrozen && restore(frozen)) frozen = emptyList()
completion(fullyFrozen)
return@submit
}
val records = LinkedHashMap<Pair<Int, Long>, ProcessRecord>()
val root = sessionRoot()
if (root <= 1) {
completion(false)
return@submit
}
runAudio(suspendAudio)
val helpers = helperRoots().filter { it > 1 && it != root }.distinct()
var stablePasses = 0
submit({
val records = frozen.associateByTo(LinkedHashMap()) { it.pid to it.started }
var complete = false
repeat(10) { pass ->
if (closing) {
restore(records.values.toList())
runAudio(resumeAudio)
completion(false)
return@submit
var interrupted = false
try {
val root = records.values.firstOrNull { it.role == Role.SESSION_ROOT }
?: readProcess(sessionRoot())?.takeIf { it.pid > 1 }?.let {
ProcessRecord(it.pid, it.started, Role.SESSION_ROOT)
} ?: throw IOException("Session root is unavailable")
records[root.pid to root.started] = root
runAudio(suspendAudio)
val helpers = helperRoots().filter { it > 1 && it != root.pid }.distinct()
for (helper in helpers) {
if (records.values.any { it.pid == helper && it.role == Role.HELPER_ROOT }) continue
readProcess(helper)?.let {
records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT)
}
}
val includeRoots = pass >= 2
addSnapshot(records, root, helpers, includeRoots)
stop(records.values.toList(), includeRoots)
Thread.sleep(if (pass == 0) 70L else 35L)
val rootCaptured = records.values.any { it.role == Role.SESSION_ROOT }
if (rootCaptured && records.isNotEmpty() && records.values.all(::isStoppedOrGone)) {
stablePasses++
if (stablePasses >= 2) complete = true
} else {
stablePasses = 0
val deadline = nowMillis() + FREEZE_TIMEOUT_MS
var previous: Map<TaskIdentity, Long>? = null
for (pass in 0 until FREEZE_MAX_PASSES) {
if (closing || nowMillis() >= deadline) break
try {
if (readProcess(root.pid)?.started != root.started) break
addSnapshot(records, root.pid, helpers)
if (readProcess(root.pid)?.started != root.started) break
stop(records.values.toList())
sleep(if (pass == 0) 70L else 35L)
if (closing || nowMillis() >= deadline) break
val current = stoppedSnapshot(records.values.toList(), root)
if (current != null && current == previous) {
complete = true
break
}
previous = current
} catch (_: IOException) {
previous = null
sleep(35L)
}
}
}
if (complete) {
} catch (_: InterruptedException) {
interrupted = true
Thread.interrupted()
} catch (_: Exception) {
} finally {
frozen = records.values.toList()
completion(true)
} else {
val restored = restore(records.values.toList())
runAudio(resumeAudio)
if (!restored) frozen = records.values.toList()
completion(false)
if (!complete) {
if (restoreSafely(frozen)) frozen = emptyList()
runAudio(resumeAudio)
}
if (interrupted) Thread.currentThread().interrupt()
}
}
completion(complete)
}, { completion(false) })
}
fun resume(completion: (Boolean) -> Unit) {
submit {
submit({
val records = frozen
runAudio(resumeAudio)
val resumed = restore(records)
val resumed = restoreSafely(records)
if (resumed) frozen = emptyList()
completion(resumed)
}
}, { completion(false) })
}
fun closeAndResume(completion: () -> Unit) {
closing = true
submit {
restore(frozen)
frozen = emptyList()
submit({
if (restoreSafely(frozen)) frozen = emptyList()
completion()
executor.shutdown()
}
}, completion)
}
private fun submit(task: () -> Unit) {
private fun submit(task: () -> Unit, rejected: () -> Unit) {
try {
executor.execute {
try {
task()
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
} catch (_: RuntimeException) {
task()
executor.execute(task)
} catch (_: RejectedExecutionException) {
rejected()
}
}
@@ -128,23 +135,25 @@ internal class SessionSuspendController(
records: MutableMap<Pair<Int, Long>, ProcessRecord>,
root: Int,
helpers: List<Int>,
includeRoots: Boolean,
) {
val processes = readProcesses()
val byPid = processes.associateBy { it.pid }
for (process in descendants(root, processes, byPid)) {
records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.SESSION_CHILD)
val rootRecord = records.values.first { it.role == Role.SESSION_ROOT }
if (byPid[root]?.started != rootRecord.started) throw IOException("Session root exited or changed")
val liveHelpers = helpers.filter { pid ->
records.values.any { it.pid == pid && it.role == Role.HELPER_ROOT && it.started == byPid[pid]?.started }
}
for (helper in helpers) {
for (process in descendants(root, processes, byPid) + processes.filter { it.tracer == root }) {
records.putIfAbsent(process.pid to process.started, ProcessRecord(process.pid, process.started, Role.SESSION_CHILD))
}
for (helper in liveHelpers) {
for (process in descendants(helper, processes, byPid)) {
records[process.pid to process.started] = ProcessRecord(process.pid, process.started, Role.HELPER_CHILD)
records.putIfAbsent(process.pid to process.started, ProcessRecord(process.pid, process.started, Role.HELPER_CHILD))
}
}
if (includeRoots) {
byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) }
for (helper in helpers) {
byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) }
}
byPid[root]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.SESSION_ROOT) }
for (helper in liveHelpers) {
byPid[helper]?.let { records[it.pid to it.started] = ProcessRecord(it.pid, it.started, Role.HELPER_ROOT) }
}
}
@@ -168,36 +177,84 @@ internal class SessionSuspendController(
}
private fun readProcesses(): List<ProcessInfo> =
File("/proc").listFiles().orEmpty().mapNotNull { entry ->
(procRoot.listFiles() ?: throw IOException("Cannot enumerate processes")).mapNotNull { entry ->
val pid = entry.name.toIntOrNull() ?: return@mapNotNull null
readProcess(pid)
try { readProcess(pid) } catch (_: IOException) { null }
}
private fun readProcess(pid: Int): ProcessInfo? {
val stat = try {
File("/proc/$pid/stat").readText()
} catch (_: Exception) {
return null
}
val close = stat.lastIndexOf(')')
if (close < 0 || close + 2 >= stat.length) return null
val fields = stat.substring(close + 2).trim().split(Regex("\\s+"))
if (fields.size < 20) return null
return try {
ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first())
} catch (_: NumberFormatException) {
null
val base = File(procRoot, pid.toString())
try {
val fields = readStat(File(base, "stat"))
val tracer = File(base, "status").useLines { lines ->
lines.firstOrNull { it.startsWith("TracerPid:") }?.substringAfter(':')?.trim()?.toIntOrNull() ?: 0
}
return ProcessInfo(pid, fields[1].toInt(), fields[19].toLong(), fields[0].first(), tracer)
} catch (error: IOException) {
if (procRoot.list()?.contains(pid.toString()) == false) return null
throw error
}
}
private fun stop(records: List<ProcessRecord>, includeRoots: Boolean) {
val descendants = records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD }
val session = records.filter { it.role == Role.SESSION_ROOT }
val helpers = records.filter { it.role == Role.HELPER_ROOT }
for (record in descendants) send(record, SIGSTOP)
if (includeRoots) {
for (record in session) send(record, SIGSTOP)
for (record in helpers) send(record, SIGSTOP)
private fun readStat(file: File): List<String> {
val stat = file.readText()
val close = stat.lastIndexOf(')')
if (close < 0 || close + 2 >= stat.length) throw IOException("Invalid process stat: $file")
val fields = stat.substring(close + 2).trim().split(Regex("\\s+"))
if (fields.size < 20) throw IOException("Incomplete process stat: $file")
return fields
}
private fun stoppedSnapshot(records: List<ProcessRecord>, root: ProcessRecord): Map<TaskIdentity, Long>? {
// PRoot can leave the leader stopped while native worker threads keep running.
val tasks = LinkedHashMap<TaskIdentity, Long>()
var stopped = true
for (record in records) {
val current = readProcess(record.pid)
if (current == null || current.started != record.started) {
if (record == root) throw IOException("Session root exited or changed")
continue
}
val entries = File(procRoot, "${record.pid}/task").listFiles()
?: throw IOException("Cannot enumerate threads: ${record.pid}")
if (entries.none { it.name.toIntOrNull() != null }) throw IOException("Empty thread inventory: ${record.pid}")
var liveRoot = false
for (entry in entries) {
val tid = entry.name.toIntOrNull() ?: continue
val fields = readStat(File(entry, "stat"))
val state = fields[0].first()
if (state != 'Z' && state != 'X') liveRoot = true
if (state != 'T' && state != 't' && state != 'Z' && state != 'X') stopped = false
tasks[TaskIdentity(record, tid, fields[19].toLong())] = fields[11].toLong() + fields[12].toLong()
}
if (record == root && !liveRoot) throw IOException("Session root has no live threads")
if (readProcess(record.pid)?.started != record.started) throw IOException("Process changed during inspection")
}
return tasks.takeIf { stopped && it.isNotEmpty() }
}
// PRoot traces every guest. A stop sent while it runs becomes a group stop that it restarts
// thread by thread; once it is stopped, each thread that takes a SIGSTOP stays parked in its
// signal-delivery stop. So the roots stop first and every guest thread gets its own SIGSTOP.
private fun stop(records: List<ProcessRecord>) {
for (record in records.filter { it.role == Role.SESSION_ROOT }) send(record, SIGSTOP)
for (record in records.filter { it.role == Role.HELPER_ROOT }) send(record, SIGSTOP)
for (record in records.filter { it.role == Role.SESSION_CHILD || it.role == Role.HELPER_CHILD }) {
stopThreads(record)
}
}
private fun stopThreads(record: ProcessRecord) {
val current = try { readProcess(record.pid) } catch (_: Exception) { return }
if (current == null || current.started != record.started) return
for (entry in File(procRoot, "${record.pid}/task").listFiles().orEmpty()) {
val tid = entry.name.toIntOrNull() ?: continue
val state = try { readStat(File(entry, "stat"))[0].first() } catch (_: IOException) { continue }
if (state == 'T' || state == 't' || state == 'Z' || state == 'X') continue
try {
threadSignal(record.pid, tid, SIGSTOP)
} catch (_: Throwable) {
}
}
}
@@ -213,7 +270,7 @@ internal class SessionSuspendController(
for (record in ordered) send(record, SIGCONT)
val waiting = ordered.toMutableList()
repeat(RESTORE_CHECKS) { check ->
Thread.sleep(25L)
sleep(25L)
waiting.removeAll { isRunningOrGone(it) }
if (waiting.isEmpty()) return true
if (check % 8 == 7) for (record in waiting) send(record, SIGCONT)
@@ -221,30 +278,40 @@ internal class SessionSuspendController(
return false
}
private fun restoreSafely(records: List<ProcessRecord>): Boolean = try {
restore(records)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
false
} catch (_: Exception) {
false
}
private fun send(record: ProcessRecord, signal: Int) {
val current = readProcess(record.pid) ?: return
if (current.started != record.started) return
val current = try { readProcess(record.pid) } catch (_: Exception) { return }
if (current == null || current.started != record.started) return
try {
Process.sendSignal(record.pid, signal)
this.signal(record.pid, signal)
} catch (_: Throwable) {
}
}
private fun isStoppedOrGone(record: ProcessRecord): Boolean {
val current = readProcess(record.pid) ?: return true
if (current.started != record.started) return true
return current.state == 'T' || current.state == 't' || current.state == 'Z' || current.state == 'X'
}
private fun isRunningOrGone(record: ProcessRecord): Boolean {
val current = readProcess(record.pid) ?: return true
if (current.started != record.started) return true
return current.state != 'T' && current.state != 't'
val current = try { readProcess(record.pid) } catch (_: Exception) { return false }
if (current == null || current.started != record.started) return true
if (current.state == 'T' || current.state == 't') return false
// A traced worker in 't' is usually PRoot handling a system call; 'T' is a job-control stop.
return File(procRoot, "${record.pid}/task").listFiles().orEmpty().none { entry ->
entry.name.toIntOrNull() != null &&
try { readStat(File(entry, "stat"))[0].first() == 'T' } catch (_: IOException) { false }
}
}
private companion object {
const val SIGSTOP = 19
const val SIGCONT = 18
const val RESTORE_CHECKS = 80
const val FREEZE_TIMEOUT_MS = 8_000L
const val FREEZE_MAX_PASSES = 256
}
}
@@ -1,4 +1,10 @@
package com.droiddeck.launcher.session
import android.view.Display
internal fun sessionHasVisibleSurface(panelVisible: Boolean, screenOn: Boolean, xrVisible: Boolean): Boolean =
xrVisible || (panelVisible && screenOn)
internal fun sessionScreenOn(onDefaultDisplay: Boolean, interactive: Boolean, displayState: Int?): Boolean =
if (onDefaultDisplay || displayState == null || displayState == Display.STATE_UNKNOWN) interactive
else displayState == Display.STATE_ON || displayState == Display.STATE_VR || displayState == Display.STATE_ON_SUSPEND
@@ -21,7 +21,8 @@ object SettingsReset {
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath",
"guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics",
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
"bootMovieApplyNext", "bootMovieSteamSeen",
"questDefaultsV1",
)
@@ -0,0 +1,218 @@
package com.droiddeck.launcher.session
import android.util.Log
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/** steamdeckrepo.com's public boot video list, the same one its website and its own manager app read. */
object SteamDeckRepo {
private const val TAG = "SteamDeckRepo"
const val BASE = "https://steamdeckrepo.com"
/** The site shows twelve movies a page. */
const val PAGE_SIZE = 12
const val MAX_DOWNLOAD_BYTES = 120L shl 20
private const val MAX_JSON_BYTES = 1 shl 20
private const val USER_AGENT = "DroidDeck (+https://github.com/Droid-Deck/DroidDeck)"
private const val CACHE_MS = 10 * 60 * 1000L
enum class Sort(val key: String) {
TRENDING("trending"), DOWNLOADS("downloads-desc"), LIKES("likes-desc"), NEWEST("created_at-desc"),
}
/** `null` is every device. */
val DEVICES = listOf("steam_deck", "steam_machine")
class Post(
val id: String, val title: String, val content: String, val author: String, val thumbnail: String, val video: String,
val seconds: Int, val likes: Int, val downloads: Int, val devices: List<String>, val url: String,
)
sealed class Page {
class Ok(val posts: List<Post>) : Page()
object Failed : Page()
}
/** The text of [key], empty when it is missing or JSON null, which `optString` would turn into the word "null". */
private fun JSONObject.text(key: String): String = if (isNull(key)) "" else optString(key)
fun parse(json: String): List<Post>? {
val array = runCatching { JSONObject(json).optJSONArray("posts") }.getOrNull() ?: return null
val out = ArrayList<Post>()
for (i in 0 until array.length()) {
val o = array.optJSONObject(i) ?: continue
val id = o.text("id")
val video = o.text("video")
if (id.isEmpty() || !video.startsWith("https://")) continue
val devices = o.optJSONArray("devices")?.let { d -> (0 until d.length()).map { d.optString(it) } }.orEmpty()
out.add(
Post(
id = id, title = o.text("title").ifBlank { id }, content = o.text("content"),
author = o.optJSONObject("user")?.text("steam_name").orEmpty(),
thumbnail = o.text("thumbnail").takeIf { it.startsWith("https://") }.orEmpty(), video = video,
seconds = o.optInt("video_duration"), likes = o.optInt("likes"), downloads = o.optInt("downloads"),
devices = devices, url = o.text("url"),
),
)
}
return out
}
fun url(sort: Sort, device: String?, search: String, page: Int = 1): String {
val query = StringBuilder("sort=").append(sort.key)
if (device != null) query.append("&device=").append(URLEncoder.encode(device, "UTF-8"))
val term = search.trim()
if (term.isNotEmpty()) query.append("&search=").append(URLEncoder.encode(term, "UTF-8").replace("+", "%20"))
if (page > 1) query.append("&page=").append(page)
return "$BASE/api/posts?$query"
}
private class Cached(val at: Long, val page: Page.Ok)
private val cache = HashMap<String, Cached>()
fun fetch(sort: Sort, device: String?, search: String, page: Int = 1, now: Long = System.currentTimeMillis()): Page {
val url = url(sort, device, search, page)
synchronized(cache) { cache[url]?.takeIf { now - it.at < CACHE_MS }?.let { return it.page } }
val body = get(url) ?: return Page.Failed
val posts = parse(body) ?: return Page.Failed
val result = Page.Ok(posts.take(PAGE_SIZE))
synchronized(cache) { cache[url] = Cached(now, result) }
return result
}
/**
* The number of the last page for a search, found by asking for pages: the site does not say how many there are, but a
* page past the end is empty and the last one is short. Null when the site cannot be reached.
*/
fun lastPage(sort: Sort, device: String?, search: String, cap: Int = 512, count: (Int) -> Int? = { p ->
(fetch(sort, device, search, p) as? Page.Ok)?.posts?.size
}): Int? {
val first = count(1) ?: return null
if (first < PAGE_SIZE) return 1
var good = 1
var bad = -1
var probe = 2
while (probe <= cap) {
val n = count(probe) ?: return null
if (n == 0) { bad = probe; break }
if (n < PAGE_SIZE) return probe
good = probe
probe *= 2
}
if (bad < 0) return good
while (bad - good > 1) {
val mid = (good + bad) / 2
val n = count(mid) ?: return null
if (n == 0) bad = mid else if (n < PAGE_SIZE) return mid else good = mid
}
return good
}
/** Reads [input] to its end, or null as soon as it holds more than [limit] bytes, so a huge answer is never kept whole. */
internal fun readLimited(input: java.io.InputStream, limit: Int): ByteArray? {
val out = java.io.ByteArrayOutputStream()
val buffer = ByteArray(16 * 1024)
while (true) {
val n = input.read(buffer)
if (n < 0) break
if (out.size() + n > limit) return null
out.write(buffer, 0, n)
}
return out.toByteArray()
}
private fun get(url: String): String? {
var connection: HttpURLConnection? = null
return try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 20_000
setRequestProperty("User-Agent", USER_AGENT)
setRequestProperty("Accept", "application/json")
}
if (connection.responseCode !in 200..299) null else connection.inputStream.use { readLimited(it, MAX_JSON_BYTES)?.toString(Charsets.UTF_8) }
} catch (e: Exception) {
Log.w(TAG, "request failed: ${e.javaClass.simpleName}")
null
} finally {
runCatching { connection?.disconnect() }
}
}
/** Fetches a small picture such as a cover; null when it cannot be had. */
fun bytes(url: String, limit: Int = 4 shl 20): ByteArray? {
if (!url.startsWith("https://")) return null
var connection: HttpURLConnection? = null
return try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 20_000
setRequestProperty("User-Agent", USER_AGENT)
}
if (connection.responseCode !in 200..299 || connection.contentLengthLong > limit) null
else connection.inputStream.use { input -> readLimited(input, limit)?.takeIf { it.isNotEmpty() } }
} catch (e: Exception) {
null
} finally {
runCatching { connection?.disconnect() }
}
}
/**
* Downloads a movie to [target] through the site's own download address, so the site counts it like a download from
* its website, and falls back to the video's address. True when a file was saved.
*/
fun download(post: Post, target: File, onProgress: (Int) -> Unit = {}): Boolean {
for (url in listOf("$BASE/post/download/${post.id}", post.video)) {
if (save(url, target, onProgress)) return true
}
return false
}
private fun save(url: String, target: File, onProgress: (Int) -> Unit): Boolean {
var connection: HttpURLConnection? = null
val staged = File(target.parentFile, target.name + ".part")
return try {
target.parentFile?.mkdirs()
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", USER_AGENT)
}
if (connection.responseCode !in 200..299) return false
val type = connection.contentType.orEmpty().lowercase()
if (type.startsWith("text/") || type.contains("json")) return false
val total = connection.contentLengthLong
if (total > MAX_DOWNLOAD_BYTES) return false
var done = 0L
var lastPercent = -1
connection.inputStream.use { input ->
staged.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
while (true) {
val n = input.read(buffer)
if (n < 0) break
done += n
if (done > MAX_DOWNLOAD_BYTES) return false
output.write(buffer, 0, n)
if (total > 0) {
val percent = (done * 100 / total).toInt()
if (percent != lastPercent) { lastPercent = percent; onProgress(percent) }
}
}
}
}
done > 0 && (total <= 0 || done == total) && staged.renameTo(target)
} catch (e: Exception) {
Log.w(TAG, "download failed: ${e.javaClass.simpleName}")
false
} finally {
runCatching { connection?.disconnect() }
staged.delete()
}
}
}
Loaded 100 of 384 files, more files were not shown because too many files have changed in this diff. Show more